学完这篇,你就能在自己的 PHP 项目里认出哪些正则可能引发灾难性回溯,并且知道该改哪一行、该加哪道后备保护。
第一步:先复现一次「一行代码拖住整个进程」
在本机新建一个测试文件跑一下:
$subject = str_repeat('a', 30) . '!';
$t = microtime(true);
$r = preg_match('/^(\w+\s?)*$/', $subject);
var_dump($r, microtime(true) - $t, preg_last_error_msg());
输入只有 31 个字符,返回 false(超过回溯上限),耗时几百毫秒到几秒不等。把 30 改成 40,进程可能直接卡住或触发 502。这就是 ReDoS——不需要任何漏洞利用工具,一个普通表单字段就能打。
注意:这不是「正则写错了」,正则逻辑完全正确,它只是在意料之外的输入上退化成指数级。
第二步:理解回溯为什么会指数级增长
PCRE 是回溯型引擎,匹配失败时会逐个换一种切分方式重试。危险信号是同一个字符能被多种方式切分:
- 嵌套量词:
(a+)+、(a*)*、(\w+\s?)*
- 分支重叠:
(a|a)*、(\d|\.)+
- 两端都能吞:
^.*(\d+).*$、preg_replace('/<.*>/', ...)
一旦匹配失败,引擎要把所有切分组合走一遍,代价是 2^n。
注意:判断标准不是「有没有嵌套量词」,而是「子模式之间能不能匹配到同一段字符」。/^\w+(?:\s+\w+)*$/ 看着也嵌套,但切分唯一,是线性的。
第三步:加后备保护,把问题控制在一个请求内
在 php.ini 或面板的 PHP 配置里确认(默认值就是下面这些):
pcre.backtrack_limit = 100000
pcre.recursion_limit = 100000
pcre.jit = 1
临时改可以 ini_set('pcre.backtrack_limit', '100000'),但只对当前请求生效,不要指望它兜住所有入口。
关键是必须判断 false:
$r = preg_match($pattern, $subject);
if ($r === false) {
error_log('regex aborted: ' . preg_last_error_msg());
// 按业务决定:拒绝输入 / 降级为不匹配
}
注意:超限时 preg_match 返回的是 false 而不是 0。写成 if (!preg_match(...)) { echo '格式不正确'; } 会把「超限」当成「格式错」,校验类场景可能正好放行了不该放行的内容。
第四步:改写正则,把它从指数级改成线性
按优先级依次尝试:
- 占有量词:
/^(\w+\s?)++$/,++ *+ ?+ 表示吃掉就不吐,禁止回溯。
- 原子组:
(?>...),同上有去无回。
- 消除歧义:把
(\w+\s?)* 改成 \w+(?:\s+\w+)*,明确「词 + 空格 + 词」的结构。
- 否定字符类替换
.*:解析标签用 <[^>]*> 而不是 <.*>,匹配链接用 [^"\s]+ 而不是 .+。
- 能不用正则就不用:
str_contains / strpos 找子串,filter_var($email, FILTER_VALIDATE_EMAIL) 校验邮箱,parse_url 拆链接,htmlspecialchars 转义输出。
第五步:在输入侧限长,别让别人替你决定输入规模
所有走正则的用户输入先卡长度:
if (mb_strlen($title) > 80) { /* 直接拒绝 */ }
尤其是:
- 帖子标题、标签、搜索关键词(搜索词经常被拼进正则)
- 敏感词/违禁词过滤,词表大时不要拼成一条巨型正则,逐个
strpos 或先用长度阈值筛
- Markdown 解析里的嵌套列表、强调符号
- 上传文件名的清洗、URL 白名单校验
注意:在 Clara BBS 这类无框架纯 PHP 系统里,主程序已经做了上传类型白名单、输出转义、敏感词拦截,但插件和主题里你自己写的正则一样吃主程序的 pcre.backtrack_limit,出事是整站一起卡。
第六步:验证改对了没有
- 把正则贴进 regex101(选 PCRE2),看 Debugger 里的 steps 数量:改前几百万步、改后几百步,就是对了。
- 本地写个脚本,用
str_repeat('a', 20..40) . '!' 循环压一遍,确认耗时稳定在毫秒级。
- 上线前把
pcre.backtrack_limit 临时调到 10000 再跑一遍业务流程,能提前暴露隐藏的慢正则。
小结
- ReDoS 的根因是回溯型引擎在歧义切分上做指数枚举,不是「正则语法错」。
- 三个后备保护:
pcre.backtrack_limit、pcre.recursion_limit、以及永远用 === false 判断返回值。
- 三个改法:占有量词
++、原子组 (?>...)、消除嵌套歧义;能换字符串函数就换。
- 两道外层防护:输入限长、别把用户输入直接拼进正则。
- 用 regex101 的 steps 数验证,比肉眼审查可靠得多。