正则表达式
正则表达式是一个可以被「有限状态自动机」接受的语言类。
「有限状态自动机」,其拥有有限数量的状态,每个状态可以迁移到零个或多个状态,输入字串决定执行哪个状态的迁移。
而常见的正则引擎,又被细分为 DFA(确定性有限状态自动机)与 NFA(非确定性有限状态自动机)。他们匹配输入的过程分别是:
DFA: 从起始状态开始,一个字符一个字符地读取输入串,并根据正则来一步步确定至下一个转移状态,直到匹配不上或走完整个输入
NFA:从起始状态开始,一个字符一个字符地读取输入串,并与正则表达式进行匹配,如果匹配不上,则进行回溯,尝试其他状态
由于 NFA 的执行过程存在回溯,所以其性能会劣于 DFA,但它支持更多功能。大多数程序语言都使用了 NFA 作为正则引擎,其中也包括 PHP 使用的 PCRE 库。
PHP 的preg_match、preg_replace底层是 PCRE 库( NFA 非确定有限状态机) ,会发生回溯。NFA 引擎回溯漏洞属于 ReDOS (正则拒绝服务),大量回溯会消耗服务器 CPU。因此有了最大回溯次数为100万次(查看官方文档)。
核心特点:回溯 (backtrack)
正则符号规则
| 符号 | 功能详解(通俗版) | 示例 | 匹配说明 / 控制对象 | 可匹配 | 不可匹配 |
|---|---|---|---|---|---|
| ^ | 匹配行首/字符串开头 | ^abc |
匹配以abc开头的内容 | abc123 | 123abc |
| $ | 匹配行尾/字符串结尾 | abc$ |
匹配以abc结尾的内容 | 123abc | abc123 |
| . | 匹配任意单个字符(除换行\n) | a.b |
中间必须有1个任意字符 | aab、acb、a1b | ab、a\nb |
| \[\] | 匹配括号内任意一个字符 | [abc] |
只匹配 a / b / c 其中一个 | a、b、c | d、e、f |
| \^ | 匹配除括号内的任意字符(取反) | 123[^45] |
禁止后面是4、5,其他都可以 | 1236、1237 | 1234、1235 |
| - | 匹配递增区间内任意字符 | [0-9] |
匹配任意一位数字 | 0~9任意数字 | 字母、符号 |
| ? | 控制左边字符:出现0次或1次(最多1次) | colou?r |
? 专门控制左边的 u:u可以没有、可以有1个,不能多 | color(无u)、colour(1个u) | colouur(2个u) |
| + | 控制左边字符:出现1次或多次(至少1次) | sa-6+ |
+ 专门控制左边的 6:6必须至少有1个 | sa-6、sa-666 | sa-(没有6) |
| * | 控制左边字符:出现0次或多次(有无都行) | co*l |
* 专门控制左边的 o:o可以没有、可以无数个 | cl、col、cool、coool | 几乎无限制 |
| () | 分组,把括号内容当成一个整体 | max(tri)? |
? 控制整体 tri 单元 | max、maxtri | maxtritri |
| {n} | 左边字符精准匹配n次 | [0-9]{3} |
必须刚好3个数字 | 123、456 | 12、1234 |
| {n,} | 左边字符最少n次,无上限 | [0-9]{2,} |
至少2位数字 | 12、123、9999 | 1 |
| {n,m} | 左边字符最少n次、最多m次 | [0-9]{2,5} |
2~5位数字 | 12、12345 | 1、123456 |
| | | 或逻辑,匹配左右任意一项 | `ab(c | d)` | 匹配 c 或者 d | abc、abd |
贪婪:尽可能多的去匹配(大前提:匹配成功)
a*: 0-任意多
a?:0-1
a+:1-任意
a{n,m}: n-m
1./<\?.*(\`;?\>.*/
在了解了正则相关规则后查看以下一个代码中的正则
<?php
function is_php($data){
return preg_match('/<\?.*[(`;?>].*/is', $data);
}
<?php eval()
if(!is_php($input)) {
fwrite($f, $input); ...
}
测试样例文本:<?php eval($_GETcmd);?>aaaaaaaaa//
步骤 1:<\?匹配
引擎从字符串开头,匹配字面<紧接着?。
成功匹配<?,指针移动到p(<?之后)。
步骤 2:第一个.*贪婪吞噬
(关键,回溯的源头).代表任意一个字符,而*代表当前左边字符能出现一次或任意次
.*是贪婪量词,它的本能:能拿多少拿多少。直接把从p一直到整个字符串末尾全部吃掉。现在指针直接跳到字符串最后面。
当前状态:.*把后面所有字符全部占完了;此时后面还有模式:(;?\>需要匹配一个字符,但是已经没有字符剩下。 (;?\>拿不到任何字符,匹配失败。

为什么要回溯:
NFA 不会直接宣告整体匹配失败。 它认为是.*吃太多了,霸占了本该留给后面(;?\>的那一个字符。 于是触发回溯:.*吐出 (归还)最后一个字符 ,把指针往回退一位,再交给(;?\>尝试匹配。

步骤 3:循环回溯
吐出 1 个字符 交给(;?\>` 测试,不是集合里的字符 → 继续回溯;再吐出一个字符,继续测试......
不断往回退,直到退到字符 > 的位置。此时(属于(;?\>` 集合,匹配成功!停止回溯。
现在:.*占有php eval,让出了>,指针停在(这里。

步骤 4:
匹配(;?\>** 命中>,匹配成功。指针向后移动。

步骤 5:最后的.*
剩余的交给末尾.*贪婪全部吃掉。整个正则全部子模式全部匹配,返回匹配成功。
PHP 为了防止正则表达式的拒绝服务攻击(reDOS),给 pcre 设定了一个回溯次数上限 pcre.backtrack_limit。查阅文档可得知为100万次。
如何绕过?
通过POST方式发送超长字符串的方式,使正则执行失败,最后绕过目标对 PHP 语言的限制。 preg_match 函数返回 false 表示此次执行失败了
同样的含.+的正则也可以通过这样的方式实现绕过
另一种.+?
<?php
if(preg_match('/UNION.+?SELECT/is', $input)) {
die('SQL Injection');
}
测试正则: UNION/aaaaa/SELECT
1)匹配 UNION
引擎从文本开头,依次匹配 U‑N‑I‑O‑N。
UNION匹配成功。
指针移动到 UNION 的后面,指向第一个字符:/。

2)进入 .+?(非贪婪)
规则:先拿最少 1 个字符,马上尝试后面的SELECT。
第1轮:.+?吃掉第 1 个字符 /。
现在指针走到a。立刻尝试匹配后面是否是SELECT。
当前位置是a,不是 S,匹配失败。

回溯(向前多吞一个字符)
第2轮:.+?再多吃下一个a,指针移到下一个a。
尝试匹配SELECT,这里还是a,失败。👉继续多吃字符。
重复循环:
不断吃字符:a →a→a→a→a→/
每吃一个字符,就停下来试一次SELECT,全部失败。
直到:.+?吃完 /aaaaa/,指针停到字符 S 的位置。

尝试匹配SELECT
当前指针正好指向S,依次匹配 S‑E‑L‑E‑C‑T,全部匹配成功。
整个正则全部片段完成匹配。
整体匹配成功。
匹配捕获的完整字符串:UNION/aaaaa/SELECT

php preg_match 返回值:int(1)
绕过方法
回溯次数随着 a 的数量增加而增加。所以,我们仍然可以通过发送大量 a,来使回溯次数超出 pcre.backtrack_limit 限制,进而绕过 WAF
新的sql绕过思想
使用百万次回溯绕过正则过滤的select等关键字
防御思路
-
不使用贪婪匹配
.* -
正则执行时间限制
-
请求体大小限制
-
不使用单层检测,容易绕过
-
使用===号判断
思考如何绕过

die() 等价于 exit(),输出字符串并直接终止整个 PHP 脚本,后面代码不再执行。
preg_match------执行匹配正则表达式
strpos()
function strpos(string haystack, [string](https://www.php.net/manual/zh/language.types.string.php "string") needle, int $offset = 0): int|false
返回 needle 在 haystack 中首次出现的数字位置。
haystack
在该字符串中进行查找。
needle
要搜索的字符串
isset ()--- 检测变量是否已声明并且其值不为空
使用传入数组 绕过
PHP 特性: preg_match 传入数组参数,直接返回 null ,不会报错
strpos接收数组,返回null
null !== false → true,条件成立,输出 flag

正则回溯绕过
is_array():检测变量是否是数组,是则返回ture
