ThinkPHP5 远程代码执行漏洞分析

作者:启明星辰ADLab

公众号:ADLab

漏洞介绍

2018年12月9日,ThinkPHP团队发布了一个补丁更新,修复了一处由于路由解析缺陷导致的代码执行漏洞。该漏洞危害程度非常高,默认环境配置即可导致远程代码执行。经过启明星辰ADLab安全研究员对ThinkPHP的56个小版本的源码分析和验证,确定具体受影响的版本为:

  • ThinkPHP 5.0.5-5.0.22
  • ThinkPHP 5.1.0-5.1.30

漏洞复现

本地环境采用ThinkPHP 5.1.29+PHP7+Apache进行复现。安装环境后直接访问POC给定的URL即可执行phpinfo(),如图所示:

漏洞分析

以5.1.29版本进行分析,首先看取路由的函数pathinfo:

library/think/Request.php:678

public function pathinfo()

{

if (is_null($this->pathinfo)) {

if (isset($_GET[$this->config['var_pathinfo']])) {

// 判断URL里面是否有兼容模式参数

$pathinfo = $_GET[$this->config['var_pathinfo']];

unset($_GET[$this->config['var_pathinfo']]);

} elseif ($this->isCli()) {

// CLI模式下 index.php module/controller/action/params/...

$pathinfo = isset($_SERVER['argv'][1]) ? $_SERVER['argv'][1] : '';

} elseif ('cli-server' == PHP_SAPI) {

$pathinfo = strpos($this->server('REQUEST_URI'), '?') ? strstr($this->server('REQUEST_URI'), '?', true) : $this->server('REQUEST_URI');

} elseif ($this->server('PATH_INFO')) {

$pathinfo = $this->server('PATH_INFO');

}

// 分析PATHINFO信息

if (!isset($pathinfo)) {

foreach ($this->config['pathinfo_fetch'] as $type) {

if ($this->server($type)) {

$pathinfo = (0 === strpos($this->server($type), $this->server('SCRIPT_NAME'))) ?

substr($this->server($type), strlen($this->server('SCRIPT_NAME'))) : $this->server($type);

break;

}

}

}

$this->pathinfo = empty($pathinfo) || '/' == $pathinfo ? '' : ltrim($pathinfo, '/');

}

return $this->pathinfo;

}

该路由函数中$this->config['var_pathinfo']是配置文件的默认值,其初始化代码如下,值为’s’:

当请求报文包含$_GET['s'],就取其值作为pathinfo,并返回pathinfo给调用函数。

分析发现pathinfo函数被library/think/Request.php:716中的path函数调用:

public function path()

{

   if (is_null($this->path)) {

       $suffix   = $this->config['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;

}

显然,这里$this->path源自pathinfo,因此可以被攻击者控制。继续分析该变量的传递,在library/think/App.php:597中被引用:

//public function routecheck()

$path = $this->request->path();

// 是否强制路由模式

$must = !is_null($this->routeMust) ? $this->routeMust : $this->route->config('url_route_must');

// 路由检测 返回一个Dispatch对象

$dispatch = $this->route->check($path, $must);

if (!empty($routeKey)) {

try {

if ($option) {

$this->cache->connect($option)->tag('route_cache')->set($routeKey, $dispatch);

} else {

$this->cache->tag('route_cache')->set($routeKey, $dispatch);

}

} catch (\Exception $e) {

// 存在闭包的时候缓存无效

}

}

return $dispatch;

这里是进行路由检测,攻击者可控的$path被传递给了如下的check函数:

public function check($url, $must = false)

{

// 自动检测域名路由

$domain = $this->checkDomain();

$url = str_replace($this->config['pathinfo_depr'], '|', $url);

$completeMatch = $this->config['route_complete_match'];

$result = $domain->check($this->request, $url, $completeMatch);

if (false === $result && !empty($this->cross)) {

// 检测跨域路由

$result = $this->cross->check($this->request, $url, $completeMatch);

}

if (false !== $result) {

// 路由匹配

return $result;

} elseif ($must) {

// 强制路由不匹配则抛出异常

throw new RouteNotFoundException();

}

// 默认路由解析

return new UrlDispatch($this->request, $this->group, $url, [

'auto_search' => $this->autoSearchController,

]);

}

分析代码可知,如果开启了强制路由则会抛出异常,也就是官方所说的该漏洞在开启强制路由的情况下不受影响(默认不开启)。

Check函数最后实例化一个UrlDispatch对象,将$url传递给了构造函数。继续分析UrlDispatch的父类也就是Dispatch类的构造函数:

library/think/route/Dispatch.php:64

 public function __construct(Request $request, Rule $rule, $dispatch, $param = [], $code = null)

{

$this->request = $request;

$this->rule = $rule;

$this->app = Container::get('app');

$this->dispatch = $dispatch;

$this->param = $param;

$this->code = $code;

if (isset($param['convert'])) {

$this->convert = $param['convert'];

}

}

$dispatch变量可控并赋值给了$this->dispatch,经过多次函数调用返回,最后如下的Url类的init 函数将会被调用来处理$this->dispatch

class Url extends Dispatch

{

public function init()

{

// 解析默认的URL规则

$result = $this->parseUrl($this->dispatch);

return (new Module($this->request, $this->rule, $result))->init();

}

public function exec()

{}

这里调用parseUrl对$this->dispatch进行解析,这是该漏洞的核心点之一:

protected function parseUrl($url)

{

$depr = $this->rule->getConfig('pathinfo_depr');

$bind = $this->rule->getRouter()->getBind();

if (!empty($bind) && preg_match('/^[a-z]/is', $bind)) {

$bind = str_replace('/', $depr, $bind);

// 如果有模块/控制器绑定

$url = $bind . ('.' != substr($bind, -1) ? $depr : '') . ltrim($url, $depr);

}

list($path, $var) = $this->rule->parseUrlPath($url);

if (empty($path)) {

这里调用parseUrlPath函数对$url进行解析,继续分析该函数:

public function parseUrlPath($url)

{

....

....

} elseif (strpos($url, '/')) {

// [模块/控制器/操作]

$path = explode('/', $url);

} elseif (false !== strpos($url, '=')) {

// 参数1=值1&参数2=值2...

$path = [];

parse_str($url, $var);

} else {

$path = [$url];

}

return [$path, $var];

}

显然,url的格式为“模块/控制器/操作”,url分割形成一个数组存到$path变量中并返回到调用者。

继续分析封装路由的代码:

library/think/route/dispatch/Url.php:48

 list($path, $var) = $this->rule->parseUrlPath($url);

...

...

// 解析模块

$module = $this->rule->getConfig('app_multi_module') ? array_shift($path) : null;

if ($this->param['auto_search']) {

$controller = $this->autoFindController($module, $path);

} else {

// 解析控制器

$controller = !empty($path) ? array_shift($path) : null;

}

// 解析操作

$action = !empty($path) ? array_shift($path) : null;

...

...

// 设置当前请求的参数

$this->request->setRouteVars($var);

// 封装路由

$route = [$module, $controller, $action];

return $route;

路由封装返回到library/think/route/dispatch/Url.php:20

class Url extends Dispatch

{

public function init()

{

// 解析默认的URL规则

$result = $this->parseUrl($this->dispatch);

return (new Module($this->request, $this->rule, $result))->init();

}

$result就是封装好的路由数组,传递给了Module的构造函数。

由于Module也是继承自Dispatch类,直接看Dispatch的构造函数:

public function __construct(Request $request, Rule $rule, $dispatch, $param = [], $code = null)

{

$this->request = $request;

$this->rule = $rule;

$this->app = Container::get('app');

$this->dispatch = $dispatch;

$this->param = $param;

$this->code = $code;

if (isset($param['convert'])) {

$this->convert = $param['convert'];

}

}

$result赋值给了$this->dispatch。然后调用Module类的init函数:

public function init()

{

parent::init();

$result = $this->dispatch;

if ($this->rule->getConfig('app_multi_module')) {

// 多模块部署

$module = strip_tags(strtolower($result[0] ?: $this->rule->getConfig('default_module')));

...

...

} elseif (!in_array($module, $this->rule->getConfig('deny_module_list')) && is_dir($this->app->getAppPath() . $module)) {

$available = true;

}

...

...

// 模块初始化

if ($module && $available) {

// 初始化模块

$this->request->setModule($module);

$this->app->init($module);

} else {

throw new HttpException(404, 'module not exists:' . $module);

}

}

// 获取控制器名

$controller = strip_tags($result[1] ?: $this->rule->getConfig('default_controller'));

$this->controller = $convert ? strtolower($controller) : $controller;

// 获取操作名

$this->actionName = strip_tags($result[2] ?: $this->rule->getConfig('default_action'));

// 设置当前请求的控制器、操作

$this->request

->setController(Loader::parseName($this->controller, 1))

->setAction($this->actionName);

return $this;

}

这里存在第一个对$module的判断,需要让$available等于true,这就需要is_dir($this->app->getAppPath() . $module)成立。官方demo给出的模块是index,而实际开发程序不一定存在该模块名,所以构造payload时这里是一个注意点。

满足这个判断条件后,继续分析后续的控制流会进入如下module的exec函数:

library/think/route/dispatch/Module.php:80

public function exec()

{

// 监听module_init

$this->app['hook']->listen('module_init');

try {

// 实例化控制器

$instance = $this->app->controller($this->controller,

$this->rule->getConfig('url_controller_layer'),

$this->rule->getConfig('controller_suffix'),

$this->rule->getConfig('empty_controller'));

if ($instance instanceof Controller) {

$instance->registerMiddleware();

}

} catch (ClassNotFoundException $e) {

throw new HttpException(404, 'controller not exists:' . $e->getClass());

}

分析发现,$this->controller是攻击者可控的,并传递给了如下的controller函数,继续分析该函数:

  public function controller($name, $layer = 'controller', $appendSuffix = false, $empty = '')

{

list($module, $class) = $this->parseModuleAndClass($name, $layer, $appendSuffix);

if (class_exists($class)) {

return $this->__get($class);

} elseif ($empty && class_exists($emptyClass = $this->parseClass($module, $layer, $empty, $appendSuffix))) {

return $this->__get($emptyClass);

}

throw new ClassNotFoundException('class not exists:' . $class, $class);

}

在这里,name是攻击者可控的,并传递给了如下的parseModuleAndClass函数:

protected function parseModuleAndClass($name, $layer, $appendSuffix)

{

if (false !== strpos($name, '\\')) {

$class = $name;

$module = $this->request->module();

} else {

if (strpos($name, '/')) {

list($module, $name) = explode('/', $name, 2);

} else {

$module = $this->request->module();

}

$class = $this->parseClass($module, $layer, $name, $appendSuffix);

}

return [$module, $class];

}

分析发现,当$name存在反斜杠时就直接将$name赋值给$class并返回。显然,攻击者通过控制输入就可以操控类的实例化过程,从而造成代码执行漏洞。

补丁分析

在ThinkPHP5.0.23以及5.1.31版本中,增加了对$controller的正则过滤:

导致无法再传入\think\app这种形式的控制器。

结论

此漏洞是因为框架对传入的路由参数过滤不严格,导致攻击者可以操作非预期的控制器类来远程执行代码。进一步分析发现,某些ThinkPHP版本不受已公开的POC的影响,这是由于该POC缺乏完备性考虑。因此,强烈建议用户及时将5.0.x版本升级到5.0.23,将5.1.x版本升级到5.1.31,以免遭受攻击。

以上是 ThinkPHP5 远程代码执行漏洞分析 的全部内容, 来源链接: utcz.com/p/199174.html

回到顶部