一. 正则表达式(regular expression)描述了一种字符串匹配的模式(pattern),可以用来检查一个串是否含有某种子串、将匹配的子串替换或者从某个串中取出符合某个条件的子串等。
正则表达式是一个可以被“有限状态自动机”接受的语言类(“有限状态自动机”,其拥有有限数量的状态,每个状态可以迁移到零个或多个状态,输入字串决定执行哪个状态的迁移。)。
构造正则表达式的方法是用多种元字符与运算符可以将小的表达式结合在一起来创建更大的表达式。正则表达式的组件可以是单个的字符、字符集合、字符范围、字符间的选择或者所有这些组件的任意组合。
正则表达式是由普通字符(例如字符 a 到 z)以及特殊字符(称为"元字符")组成的文字模式。
案例分析:
(1) 需要转义的特殊字符
$ ( ) * + . ? [ \ ^ {
在文本中遇到 这几种特殊字符想转为文本,需要通过反斜杠\转义:
/$ ( ) * + . ? [ \ ^ {/g
关于 -减号符,}右花括号,]右中括号为何不在内?首先减号符是在[]中的,在前面没遇到转义的[左方括号时,减号符及右方括号-]是当普通文本处理,无需转义,而花括号也如此
- Regexp : /{\w+}[hello-world]/
- Text : {abc}[hello-world] hey! hey! hey!
(2)再次匹配先前匹配的文本
如果需要匹配一个 yyyy-mm-dd 格式的日期,其中月份和日期都是年份的个十位
/\b\d\d(\d\d)-\1-\1\b/
反斜杠\1-9可以得到前面分组(\d\d)捕获到的结果,如果是10-99呢?那就\10至\99
- Regexp : /\b\d\d(\d\d)-\1-\1\b/
- Text : `2008-08-08
(3)单词边界
在单词边界匹配的位置,单词字符后面或前面不与另一个单词字符直接相邻。
“That dang-tootin’ #!@#$ varmint’s cost me $199.95!”.replace(/\b/g,function(){
console.log(arguments)
});
通过控制台输出,我们可以发现\b匹配的位置如下:
小数点的左右都会有单词边界,这意味着单词边界不仅仅是英文字母,还包括数字。
\b属于匹配位置的元字符,一般作占位作用,而不被捕获,同属于匹配位置的还有匹配行起始位 ^ 和行结束位 $
(4)处理24小时制时间
24小时制可以分为三段:
- 00 - 09
- 10 - 19
- 20 - 23
先匹配第一阶段: /[0][0-9]/
再接着匹配第二阶段:/[01][0-9]/
第三阶段以此类推?/[012][0-9]/
明显不合适,因为不可能出现大于23以上的数字,那么只能开分支了 /[01][0-9]|2[0-3]/
00-09如果要匹配“没有补零”的情况?(即:0,1,2,3,4,5…)
可以借助一下量词? 匹配 /[01]?[0-9]|2[0-3]/
- Regexp : /([01]?[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]/
(5) 千分位
我们在一些场景里需要将7654321输出成7,654,321这样的格式,这就是千分位,用正则表达式去处理的话,关键是获取位置,那么首先想到的就是要利用非单词边界\B,下面这条正则是能成功取得千分位的位置的:
/\B(?=(\d{3})+(?!\d))/g
先将这个正则分解成三部分:
1 )、 /\B(?=\d)/ 这是\B是为了防止出现,123起始位置被匹配的问题,(?=\d)是非单词边界后紧跟数字
2 )、 尝试一下8位数的数字: ‘12345678’ 在 /\B(?=(\d{3})+)/匹配得到什么结果呢?
‘12345678’.replace(/\B(?=(\d{3})+)/g,function(){console.log(arguments);return ‘|’})
["", “567”, 1, “12345678”]
["", “678”, 2, “12345678”]
["", “456”, 3, “12345678”]
["", “567”, 4, “12345678”]
["", “678”, 5, “12345678”]
“1|2|3|4|5|678”
首先符合非单词边界\B的有1,2,3,4,5,6,7的右边位置,而后面也同样紧跟数字2,3,4,5,6,7
其次符合\d{3}的有234,345,456,567,678,但后面跟个加号+
结果就不一样了
那为什么会得到567,678,456,567,678,这样奇怪的匹配?原理如下:
- 匹配\B第1个非单词边界 1的右边位置,则后面(\d{3})+的结果为:234、567,8后面无法补齐3位,匹配得到567
- 匹配\B第2个非单词边界 2的右边位置,则后面(\d{3})+的结果为:345、678,匹配得到678
- 匹配\B第3个非单词边界 3的右边位置,则后面(\d{3})+的结果为:456、78后面无法补齐3位,匹配得到456
- 匹配\B第4个非单词边界 4的右边位置,则后面(\d{3})+的结果为:567、8后面无法补齐3位,匹配得到567
- 匹配\B第5个非单词边界 5的右边位置,则后面(\d{3})+的结果为:678
- 匹配\B第6个非单词边界 6的右边位置,但78无法补齐3位,
- 同6
- 最终小括号分组匹配得到的分别是:567,678,456,567,678
3)、最后 (?!\d) 是前面匹配成功后跟的非数字,那连起来就是:
12,345,678
- 匹配\B第1个非单词边界 1的右边位置,则后面(\d{3})+的结果为:234、567,后面跟着8,不匹配
- 匹配\B第2个非单词边界 2的右边位置,则后面(\d{3})+的结果为:345、678,后面跟着非数字,位置匹配成功
- 匹配\B第3个非单词边界 3的右边位置,则后面(\d{3})+的结果为:456,后面跟着7、8不匹配
- 匹配\B第4个非单词边界 4的右边位置,则后面(\d{3})+的结果为:567,后面跟着8,不匹配
- 匹配\B第5个非单词边界 5的右边位置,则后面(\d{3})+的结果为:678,后面跟着非数字,位置匹配成功
- 最终得到得到可插入逗号的位置为2,5
(6)重复分组
假定用这个正则去匹配 1234567890,开始我以为分组小括号(\d\d\d)的最终结果是 123,456,789都能拿到,但结果却只有789
重复分组的匹配在每次引擎退出该分组的时候被捕获,并会覆盖该分组在之前匹配的任何文本
模拟一下引擎工作的步骤:
- 第一次匹配,捕获到 123,退出分组
- 第二次匹配,捕获到 456, 覆盖上一次捕获的123,退出分组
- 第三次匹配,捕获到 789,覆盖上一次捕获的456,退出分组
- 退出重复分组,结束
因为重复分组最后一次循环存储的是789,另外两次分组匹配,也就是123,456是无法被获取的。
如果想要获得所有结果,就要把重复匹配放进分组中 /((\d\d\d){3})/
(7) 获取<p>...</p>
中的内容
/<p>(.*?)<\/p>/g.exec('<p>Hello,<em>world</em></p><p>Hello,Janking</p>')
运行结果
[ "<p>Hello,<em>world</em></p>", "Hello,<em>world</em>", index: 0, input: "<p>Hello,<em>world</em></p><p>Hello,Janking</p>" ]
贪婪匹配
属于贪婪模式的量词,也叫做匹配优先量词,包括:{m,n},{m,},?,* 和 + 。
惰性匹配
在匹配优先量词后加上?,即变成属于惰性匹配的量词,也叫做忽略优先量词,包括:{m,n}?,{m,}?,??,*? 和 +? 。
回溯
当前前面分支/重复匹配成功后,没有多余的文本可被正则后半部分匹配时,会产生回溯
用一个简单的例子来解释一下贪婪匹配和惰性匹配!
贪婪 : /\d+\b/
惰性 : /d+?\b/
文本 : 1234a
贪婪正则匹配 1234a 时的过程是这样的:
- \d+ 匹配得到 1234
- \b 却匹配失败(\b 是分词边界匹配,用来获取位置,而不是文本,上一节有讲到)
- 这个时候,\d+会尝试回吐一个字符,即匹配结果为 123 ,可\b还是匹配失败!
- 那就继续回吐,一直到 1,还是匹配失败,那么这个正则就整体匹配失败了
- 这个回吐匹配结果的过程就是回溯
惰性正则匹配 1234a 时的过程是这样的:
- \d+? 首先匹配,结果是1 ,紧接着 \b 匹配失败
- 那就 \d+? 继续匹配,结果是 12 ,紧接着 \b 还是匹配失败
- \d+? 一直匹配到1234,紧接着的 \b 依然匹配失败
- 结果整个正则匹配不成功
通过这两个例子的比较,能够发现回溯会影响匹配速度,回溯的过程慢那是相对那些DFA引擎。
而JS的正则引擎是NFA(非确定型有限自动机),匹配慢,编译快。
二. PCRE绕过
常见的正则引擎,被细分为DFA(确定性有限状态自动机)与NFA(非确定性有限状态自动机)。他们匹配输入的过程分别是:
- DFA: 从起始状态开始,一个字符一个字符地读取输入串,并根据正则来一步步确定至下一个转移状态,直到匹配不上或走完整个输入
- NFA:从起始状态开始,一个字符一个字符地读取输入串,并与正则表达式进行匹配,如果匹配不上,则进行回溯,尝试其他状态
由于NFA的执行过程存在回溯,所以其性能会劣于DFA,但它支持更多功能。大多数程序语言都使用了NFA作为正则引擎,其中也包括PHP使用的PCRE库。
(1)回溯
题目中的正则
<\?.*[(
;?>].*,假设匹配的输入是<?php phpinfo();//aaaaa,实际执行流程是这样的:
见上图,可见第4步的时候,因为第一个.*可以匹配任何字符,所以最终匹配到了输入串的结尾,也就是//aaaaa。但此时显然是不对的,因为正则显示.*
后面还应该有一个字符[(`;?>]。
所以NFA就开始回溯,先吐出一个a,输入变成第5步显示的//aaaa,但仍然匹配不上正则,继续吐出a,变成//aaa,仍然匹配不上……
最终直到吐出;,输入变成第12步显示的<?php phpinfo(),此时,`.*`匹配的是php phpinfo(),而后面的;则匹配上`[(`;?>],这个结果满足正则表达式的要求,于是不再回溯。13步开始向后匹配;,14步匹配.*,第二个.*匹配到了字符串末尾,最后结束匹配。
(2)PHP的pcre.backtrack_limit限制利用
PHP为了防止正则表达式的拒绝服务攻击(reDOS),给pcre设定了一个回溯次数上限pcre.backtrack_limit。我们可以通过var_dump(ini_get(‘pcre.backtrack_limit’));的方式查看当前环境下的上限:
通过发送超长字符串的方式,使正则执行失败,最后绕过目标对PHP语言的限制。
(3)PCRE另一种错误的用法
很多基于PHP的WAF,如:
if(preg_match(’/SELECT.+FROM.+/is’, $input)) {
die(‘SQL Injection’);
}
均存在上述问题,通过大量回溯可以进行绕过。
另外,我遇到更常见的一种WAF是:
if(preg_match(’/UNION.+?SELECT/is’, $input)) {
die(‘SQL Injection’);
}
这里涉及到了正则表达式的“非贪婪模式”。在NFA中,如果我输入UNION/aaaaaaaaa/SELECT,这个正则表达式执行流程如下:
- .+?匹配到/
- 因为非贪婪模式,所以.+?停止匹配,而由S匹配*
- S匹配失败,回溯,再由.+?匹配
- 因为非贪婪模式,所以.+?停止匹配,而由S匹配a
- S匹配a失败,回溯,再由.+?匹配a
回溯次数随着a的数量增加而增加。所以,我们仍然可以通过发送大量a,来使回溯次数超出pcre.backtrack_limit限制,进而绕过WAF
(4)修复方法
如果用preg_match对字符串进行匹配,一定要使用===全等号来判断返回值,如:
function is_php($data){
return preg_match(’/<?.[(`;?>]./is’, $data);
}
if(is_php(KaTeX parse error: Expected '}', got 'EOF' at end of input: … // fwrite(f, $input); …
}
这样,即使正则执行失败返回false,也不会进入if语句。