作者:啟明星辰ADLab
公眾號:https://mp.weixin.qq.com/s/DGWuSdB2DvJszom0C_dkoQ
漏洞介紹
2019年1月11日,ThinkPHP團隊發布了一個補丁更新,修復了一處由于不安全的動態函數調用導致的遠程代碼執行漏洞。該漏洞危害程度非常高,默認條件下即可執行遠程代碼。啟明星辰ADLab安全研究員對ThinkPHP的多個版本進行源碼分析和驗證后,確認具體受影響的版本為ThinkPHP5.0-5.0.23完整版。
漏洞復現
本地環境采用ThinkPHP 5.0.22完整版+PHP5.5.38+Apache進行復現。安裝環境后執行POC即可執行系統命令,如圖:
漏洞分析
以官網下載的5.0.22完整版進行分析,首先定位到漏洞關鍵點:
thinkphp/library/think/Request.php:518
public function method($method = false)
{
if (true === $method) {
// 獲取原始請求類型
return $this->server('REQUEST_METHOD') ?: 'GET';
} elseif (!$this->method) {
if (isset($_POST[Config::get('var_method')])) {
$this->method = strtoupper($_POST[Config::get('var_method')]);
$this->{$this->method}($_POST);
} elseif (isset($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'])) {
$this->method = strtoupper($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE']);
} else {
$this->method = $this->server('REQUEST_METHOD') ?: 'GET';
}
}
return $this->method;
}
在method函數的第二個if分支中,引入了一個外部可控的數據$_POST[Config::get[‘var_method’]。而var_method的值為_method。

取得$_POST[‘_method’]的值并將其賦值給$this->method,然后動態調用$this->{$this->method}($_POST)。這意味著攻擊者可以調用該類任意函數并以$_POST作為第一個參數。如果動態調用__construct函數,則會導致代碼執行。
Request類的__construct函數如下:
protected function __construct($options = [])
{
foreach ($options as $name => $item) {
if (property_exists($this, $name)) {
$this->$name = $item;
}
}
if (is_null($this->filter)) {
$this->filter = Config::get('default_filter');
}
// 保存 php://input
$this->input = file_get_contents('php://input');
}
由于$options參數可控,攻擊者可以覆蓋該類的filter屬性、method屬性以及get屬性的值。而在Request類的param函數中:
public function param($name = '', $default = null, $filter = '')
{
if (empty($this->mergeParam)) {
$method = $this->method(true);
// 自動獲取請求變量
switch ($method) {
case 'POST':
$vars = $this->post(false);
break;
case 'PUT':
case 'DELETE':
case 'PATCH':
$vars = $this->put(false);
break;
default:
$vars = [];
}
// 當前請求參數和URL地址中的參數合并
$this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false));
$this->mergeParam = true;
}
if (true === $name) {
// 獲取包含文件上傳信息的數組
$file = $this->file();
$data = is_array($file) ? array_merge($this->param, $file) : $this->param;
return $this->input($data, '', $default, $filter);
}
return $this->input($this->param, $name, $default, $filter);
}
當$this->mergeParam為空時,這里會調用$this->get(false)。跟蹤$this->get函數:
public function get($name = '', $default = null, $filter = null)
{
if (empty($this->get)) {
$this->get = $_GET;
}
if (is_array($name)) {
$this->param = [];
return $this->get = array_merge($this->get, $name);
}
return $this->input($this->get, $name, $default, $filter);
}
該函數末尾調用了$this->input函數,并將$this->get傳入,而$this->get的值是攻擊者可控的。跟蹤$this->input函數:
public function input($data = [], $name = '', $default = null, $filter = '')
{
if (false === $name) {
// 獲取原始數據
return $data;
}
$name = (string) $name;
if ('' != $name) {
// 解析name
if (strpos($name, '/')) {
list($name, $type) = explode('/', $name);
} else {
$type = 's';
}
// 按.拆分成多維數組進行判斷
foreach (explode('.', $name) as $val) {
if (isset($data[$val])) {
$data = $data[$val];
} else {
// 無輸入數據,返回默認值
return $default;
}
}
if (is_object($data)) {
return $data;
}
}
// 解析過濾器
$filter = $this->getFilter($filter, $default);
if (is_array($data)) {
array_walk_recursive($data, [$this, 'filterValue'], $filter);
reset($data);
} else {
$this->filterValue($data, $name, $filter);
}
if (isset($type) && $data !== $default) {
// 強制類型轉換
$this->typeCast($data, $type);
}
return $data;
}
該函數調用了$this->getFileter取得過濾器。函數體如下:
protected function getFilter($filter, $default)
{
if (is_null($filter)) {
$filter = [];
} else {
$filter = $filter ?: $this->filter;
if (is_string($filter) && false === strpos($filter, '/')) {
$filter = explode(',', $filter);
} else {
$filter = (array) $filter;
}
}
$filter[] = $default;
return $filter;
}
$this->filter的值是攻擊者通過調用構造函數覆蓋控制的,將該值返回后將進入到input函數:
if (is_array($data)) {
array_walk_recursive($data, [$this, 'filterValue'], $filter);
reset($data);
}
查看filterValue函數如下:
private function filterValue(&$value, $key, $filters)
{
$default = array_pop($filters);
foreach ($filters as $filter) {
if (is_callable($filter)) {
// 調用函數或者方法過濾
$value = call_user_func($filter, $value);
} elseif (is_scalar($value)) {
if (false !== strpos($filter, '/')) {
// 正則過濾
if (!preg_match($filter, $value)) {
// 匹配不成功返回默認值
$value = $default;
break;
}
} elseif (!empty($filter)) {
// filter函數不存在時, 則使用filter_var進行過濾
// filter為非整形值時, 調用filter_id取得過濾id
$value = filter_var($value, is_int($filter) ? $filter : filter_id($filter));
if (false === $value) {
$value = $default;
break;
}
}
}
}
return $this->filterExp($value);
}
在call_user_func函數的調用中,$filter可控,$value可控。因此,可致代碼執行。
漏洞觸發流程:
從ThinkPHP5的入口點開始分析:
thinkphp/library/think/App.php:77
public static function run(Request $request = null)
{
$request = is_null($request) ? Request::instance() : $request;
try {
$config = self::initCommon();
// 模塊/控制器綁定
if (defined('BIND_MODULE')) {
BIND_MODULE && Route::bind(BIND_MODULE);
} elseif ($config['auto_bind_module']) {
// 入口自動綁定
$name = pathinfo($request->baseFile(), PATHINFO_FILENAME);
if ($name && 'index' != $name && is_dir(APP_PATH . $name)) {
Route::bind($name);
}
}
$request->filter($config['default_filter']);
// 默認語言
Lang::range($config['default_lang']);
// 開啟多語言機制 檢測當前語言
$config['lang_switch_on'] && Lang::detect();
$request->langset(Lang::range());
// 加載系統語言包
Lang::load([
THINK_PATH . 'lang' . DS . $request->langset() . EXT,
APP_PATH . 'lang' . DS . $request->langset() . EXT,
]);
// 監聽 app_dispatch
Hook::listen('app_dispatch', self::$dispatch);
// 獲取應用調度信息
$dispatch = self::$dispatch;
// 未設置調度信息則進行 URL 路由檢測
if (empty($dispatch)) {
$dispatch = self::routeCheck($request, $config);
}
// 記錄當前調度信息
$request->dispatch($dispatch);
// 記錄路由和請求信息
if (self::$debug) {
Log::record('[ ROUTE ] ' . var_export($dispatch, true), 'info');
Log::record('[ HEADER ] ' . var_export($request->header(), true), 'info');
Log::record('[ PARAM ] ' . var_export($request->param(), true), 'info');
}
// 監聽 app_begin
Hook::listen('app_begin', $dispatch);
// 請求緩存檢查
$request->cache(
$config['request_cache'],
$config['request_cache_expire'],
$config['request_cache_except']
);
$data = self::exec($dispatch, $config);
run函數第一行便實例化了一個Request類,并賦值給了$request。然后調用routeCheck($request,$config):
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::check進行路由檢測。函數如下:
public static function check($request, $url, $depr = '/', $checkDomain = false)
{
//檢查解析緩存
if (!App::$debug && Config::get('route_check_cache')) {
$key = self::getCheckCacheKey($request);
if (Cache::has($key)) {
list($rule, $route, $pathinfo, $option, $matches) = Cache::get($key);
return self::parseRule($rule, $route, $pathinfo, $option, $matches, true);
}
}
// 分隔符替換 確保路由定義使用統一的分隔符
$url = str_replace($depr, '|', $url);
if (isset(self::$rules['alias'][$url]) || isset(self::$rules['alias'][strstr($url, '|', true)])) {
// 檢測路由別名
$result = self::checkRouteAlias($request, $url, $depr);
if (false !== $result) {
return $result;
}
}
$method = strtolower($request->method());
// 獲取當前請求類型的路由規則
$rules = isset(self::$rules[$method]) ? self::$rules[$method] : [];
// 檢測域名部署
if ($checkDomain) {
self::checkDomain($request, $rules, $method);
}
// 檢測URL綁定
$return = self::checkUrlBind($url, $rules, $depr);
if (false !== $return) {
return $return;
}
if ('|' != $url) {
$url = rtrim($url, '|');
}
$item = str_replace('|', '/', $url);
if (isset($rules[$item])) {
// 靜態路由規則檢測
$rule = $rules[$item];
if (true === $rule) {
$rule = self::getRouteExpress($item);
}
if (!empty($rule['route']) && self::checkOption($rule['option'], $request)) {
self::setOption($rule['option']);
return self::parseRule($item, $rule['route'], $url, $rule['option']);
}
}
// 路由規則檢測
if (!empty($rules)) {
return self::checkRoute($request, $rules, $url, $depr);
}
return false;
}
注意紅色字體部分。對應開頭的第一個步驟,也就是調用method函數進行變量覆蓋。這里需要覆蓋的屬性有$this->filter,$this->method,$this->get。因為$request->method()的返回值為$this->method,所以該值也需要被控制。這里返回值賦值給了$method,然后取出self::$rules[$method]的值給$rules。這里需要注意:THINKPHP5有自動類加載機制,會自動加載vendor目錄下的一些文件。但是完整版跟核心版的vendor目錄結構是不一樣的。
完整版的目錄結構如下:
而核心版的目錄結構如下:
可以看到完整版比核心版多出了幾個文件夾。特別需要注意的就是think-captcha/src這個文件夾里有一個helper.php文件:
這里調用\think\Route::get函數進行路由注冊的操作。而這步操作的影響就是改變了上文提到的self::$rules的值。有了這個路由,才能進行RCE,否則不成功。這也就是為什么只影響完整版,而不影響核心版的原因。此時的self::$rules的值為:
那么,當攻擊者控制返回的$method的值為get的時候,$rules的值就是這條路由的規則。然后回到上文取到$rules之后,根據傳入的URL取得$item的值,使得$rules[$item]的值為captcha路由數組,就可以進一步調用到self::parseRule函數。函數體略長,這里取關鍵點:
private static function parseRule($rule, $route, $pathinfo, $option = [], $matches = [], $merge = false)
{
// 解析路由規則
......
......
if ($route instanceof \Closure) {
// 執行閉包
$result = ['type' => 'function', 'function' => $route];
} elseif (0 === strpos($route, '/') || 0 === strpos($route, 'http')) {
// 路由到重定向地址
$result = ['type' => 'redirect', 'url' => $route, 'status' => isset($option['status']) ? $option['status'] : 301];
} elseif (0 === strpos($route, '\\')) {
// 路由到方法
$method = strpos($route, '@') ? explode('@', $route) : $route;
$result = ['type' => 'method', 'method' => $method];
} elseif (0 === strpos($route, '@')) {
// 路由到控制器
$result = ['type' => 'controller', 'controller' => substr($route, 1)];
} else {
// 路由到模塊/控制器/操作
$result = self::parseModule($route);
}
return $result;
}
此時傳遞進來的$route的值為\think\captcha\CaptchaController@index。因此進入的是標注紅色的if分支中。在這個分支中,$result的’type’鍵對應的值為‘method’。然后將$result層層返回到run函數中,并賦值給了$dispatch。
// 未設置調度信息則進行 URL 路由檢測
if (empty($dispatch)) {
$dispatch = self::routeCheck($request, $config);
}
// 記錄當前調度信息
$request->dispatch($dispatch);
// 記錄路由和請求信息
if (self::$debug) {
Log::record('[ ROUTE ] ' . var_export($dispatch, true), 'info');
Log::record('[ HEADER ] ' . var_export($request->header(), true), 'info');
Log::record('[ PARAM ] ' . var_export($request->param(), true), 'info');
}
// 監聽 app_begin
Hook::listen('app_begin', $dispatch);
// 請求緩存檢查
$request->cache(
$config['request_cache'],
$config['request_cache_expire'],
$config['request_cache_except']
);
$data = self::exec($dispatch, $config);
然后將$dispatch帶入到self::exec函數中:
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;
case 'controller': // 執行控制器操作
$vars = array_merge(Request::instance()->param(), $dispatch['var']);
$data = Loader::action(
$dispatch['controller'],
$vars,
$config['url_controller_layer'],
$config['controller_suffix']
);
break;
case 'method': // 回調方法
$vars = array_merge(Request::instance()->param(), $dispatch['var']);
$data = self::invokeMethod($dispatch['method'], $vars);
break;
case 'function': // 閉包
$data = self::invokeFunction($dispatch['function']);
break;
case 'response': // Response 實例
$data = $dispatch['response'];
break;
default:
throw new \InvalidArgumentException('dispatch type not support');
}
return $data;
}
進入到紅色標注的分支,該分支調用Request類的param方法。因此,滿足了利用鏈的第三步,造成命令執行。
啟明星辰ADLab安全研究員對ThinkPHP5.0-5.0.23每個版本都進行了分析,發現ThinkPHP5.0.2-5.0.23可以使用同一個POC,而ThinkPHP5.0-5.0.1需要更改一下POC,原因在于Route.php的rule函數的一個實現小差異。
ThinkPHP5.0-5.0.1版本的thinkphp/library/think/Route.php:235,將$type轉換成了大寫:
在ThinkPHP5.0.2-5.0.23版本中,rule函數中卻將$type轉換成了小寫:
補丁分析
在ThinkPHP5.0.24中,增加了對$this->method的判斷,不允許再自由調用類函數。
結論
強烈建議用戶升級到ThinkPHP5.0.24版本,并且不要開啟debug模式,以免遭受攻擊。
啟明星辰積極防御實驗室(ADLab)
ADLab成立于1999年,是中國安全行業最早成立的攻防技術研究實驗室之一,微軟MAPP計劃核心成員。截止目前,ADLab通過CVE發布Windows、Linux、Unix等操作系統安全或軟件漏洞近400個,持續保持國際網絡安全領域一流水準。實驗室研究方向涵蓋操作系統與應用系統安全研究、移動智能終端安全研究、物聯網智能設備安全研究、Web安全研究、工控系統安全研究、云安全研究。研究成果應用于產品核心技術研究、國家重點科技項目攻關、專業安全服務等。

本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/787/
暫無評論