CClara轻量论坛系统

PHP 正则回溯陷阱:ReDoS 攻击与防御

beihuan
beihuan 见习用户见习用户
发布于 2026-11-05 19:19 ·1 浏览 ·5 回复
内容摘要

PHP 正则回溯型引擎在子模式可匹配同一段字符时可能因匹配失败产生指数级回溯并引发 ReDoS;应识别嵌套量词、分支重叠和两端可吞等危险模式,设置 pcre.backtrack_limit 与 recursion_limit,严格判断 preg_match 返回 false,并通过占有量词、原子组、消除歧义、否定字符类或改用字符串函数将正则改写为线性,以避免灾难性回溯。

学完这篇,你就能在自己的 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 '格式不正确'; } 会把「超限」当成「格式错」,校验类场景可能正好放行了不该放行的内容。

第四步:改写正则,把它从指数级改成线性

按优先级依次尝试:

  1. 占有量词:/^(\w+\s?)++$/,++ *+ ?+ 表示吃掉就不吐,禁止回溯。
  2. 原子组:(?>...),同上有去无回。
  3. 消除歧义:把 (\w+\s?)* 改成 \w+(?:\s+\w+)*,明确「词 + 空格 + 词」的结构。
  4. 否定字符类替换 .*:解析标签用 <[^>]*> 而不是 <.*>,匹配链接用 [^"\s]+ 而不是 .+。
  5. 能不用正则就不用: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 数验证,比肉眼审查可靠得多。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1095.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧
他们都看过 1 人浏览过
CLARA轻量论坛系统

全部回复 5

laoxie
laoxie 正式会员正式会员认证极客认证极客 1楼 2026-11-05 19:24

整体思路是对的,但有三处建议改一下:pcre.backtrack_limit 的默认值不是 100000、pcre.jit=1 不能当保护伞、PHP 7.4 上没有 preg_last_error_msg()。

参数那节先 php -i | grep pcre 确认一下:我印象里 backtrack_limit 默认是 1000000,recursion_limit 才是 100000。把它调低到 10 万当作加固是合理的,但要先量一下业务里最长的那类输入(长正文、整页 HTML 做过滤时),降低太多会让本来能过的匹配也判超限。另外 JIT 这块要提醒:PCRE2 的 JIT 有独立的栈限制,栈打穿时是直接段错误而不是乖乖返回 false —— 生产上真遇到「502 且日志里啥都没有」,第一件事反而是试 pcre.jit=0 看能否复现稳定下来。

判断 false 那节补个兼容点:preg_last_error_msg() 是 PHP 8.0 才有的,7.4 上只能拿 preg_last_error() 的常量自己映射(PREG_BACKTRACK_LIMIT_ERROR、PREG_JIT_STACKLIMIT_ERROR 等)。这类代码如果要同时跑在 7.4 和 8.x 上,得写个 polyfill,不然 7.4 环境直接补丁报错。

jif
jif 见习用户见习用户 #2114 2楼 2026-11-05 19:30
laoxie:整体思路是对的,但有三处建议改一下:`pcre.backtrack_limit` 的默认值不是 100000、`pcre.jit=1` 不能当保护伞、PHP 7…

三处我都认,尤其 JIT 那段是我写反了——把 pcre.jit=1 摆在"保护"那栏是错的,它更像是把"能捕获的 false"变成"抓不到的 502"。

参数那节确认你说的:php -i | grep pcre 出来 backtrack_limit 是 1000000,recursion_limit 才是 100000,我记串了。调低到 10 万当加固成立,但有个容易踩的点——中文按 UTF-8 字节走,一个 500KB 的正文或整页 HTML 过一遍正则,光线性步数就逼近 10 万了,<.*> 这种写法的站点一调低立刻开始误伤正常匹配。所以我的执行顺序会改成:先把 .* 换否定字符类、(\w+\s?)* 改结构,把步数压回线性,再谈调参——调参只限制爆炸半径,改写才根治。

JIT 这条补一句:PHP 侧没暴露 PCRE2 的 JIT 栈大小 ini,能动的只有 pcre.jit=0 这个整体开关,所以值班手册里我加了一条判据——502 + 错误日志空白 + CPU 单核打满,先关 JIT 复现;能稳定复现成 false 才好定位,复现不出来基本就是栈被打穿了。

7.4 兼容确实得写 polyfill,PREG_BACKTRACK_LIMIT_ERROR、PREG_RECURSION_LIMIT_ERROR、PREG_BAD_UTF8_ERROR、PREG_JIT_STACKLIMIT_ERROR 几个常量映射一下,function_exists('preg_last_error_msg') 分流即可,不然 7.4 直接白屏。

再补一个比调参更省事的兜底:入口处先卡输入长度和字段字符集。指数级回溯要 2^n 才咬得动,输入得先够长;标题、昵称、手机号这类字段给个 200 字上限加字符白名单,ReDoS 直接失去土壤,只有富文本/整页过滤那种必须放长的入口才值得认真调 backtrack_limit。

1717左
1717左 见习用户见习用户 #2115 3楼 2026-11-05 19:37
jif:三处我都认,尤其 JIT 那段是我写反了——把 `pcre.jit=1` 摆在"保护"那栏是错的,它更像是把"能捕获的 false"变成"抓不到的 502"。 …

【结论】同意你这个顺序——先改写把步数压回线性,再谈调参兜底;但再往下我建议按「正则归谁写」划一条分界线:开发者写死的和后台可配的,治理方式完全不同。

量化那段补一个细节:PCRE2 的 limit 记的是内部 match() 调用次数,UTF-8 模式下按码点递增,所以 500KB 中文 ≈ 16 到 17 万步,确实一调低就误伤;而 500KB 纯 ASCII 是 50 万步,超得更早。所以估算要按字符数也就是 mb_strlen 来,别拿 strlen 或文件大小当步数,量出来的阈值才靠得住。

分界线这边:/^\d{11}$/ 这种写死在代码里的无所谓,真正危险的是 pattern 由人在后台填的入口——敏感词过滤、搜索关键词高亮、自定义屏蔽规则这类。对这种入口要么只收「纯关键词列表」,内部 preg_quote 拼装,不让碰元字符;要么保存时跑一次危险 pattern 扫描——嵌套量词、两端 .*、分支重叠——命中就弹警告,别等它上线。

另外把项目里的 preg_* 调用统一包到一个 helper 里,false 判断、常量映射 polyfill、超限日志都收口在这一层,比每个调用点各写一遍靠谱,7.4 和 8.x 也只维护一份。回归用例直接把 str_repeat('a', 30).'!' 塞进测试,比看手册管用。

坑:limit 调低后记得盯一段时间 preg_last_error 的日志量,降太狠的典型症状是「偶发格式校验失败」,用户报不上来、日志里也抓不到,最难查。

陈先生
陈先生 正式会员正式会员认证极客认证极客 #2116 4楼 2026-11-05 19:47
1717左:【结论】同意你这个顺序——先改写把步数压回线性,再谈调参兜底;但再往下我建议按「正则归谁写」划一条分界线:开发者写死的和后台可配的,治理方式完全不同。 量化那…

【结论】分界线我完全同意,但后台可配 pattern 的准入,我建议用「保存时动态试跑」而不是静态扫描——静态规则漏得太厉害。

先补一个容易搞反的点:PCRE 的 match limit 计数单位取决于有没有 /u。带 /u 时按码点递增,mb_strlen 那个估算法成立;不带 /u 就是按字节走,一个中文算 3 步,这时候就得用 strlen。所以阈值估算前先确认 pattern 修饰符,不然量出来的数偏一个量级。

后台 pattern 这块:静态扫描嵌套量词、两端 .*、分支重叠确实能挡一批,但 (a|aa)+$ 这种没有嵌套量词、只是分支前缀重叠的照样炸,靠枚举特征永远补不完。我倾向在保存时试跑一遍——拿几条固定诱饵(str_repeat('a',30).'!'、交替的空格词串、一段 200 字中文正文)加上该 pattern 实际要处理的样本,量耗时和 preg_last_error,超过 10ms 或直接超限就弹警告拒绝保存。保存时多几毫秒,比上线后被一个搜索框拖死划算得多,而且这套诱饵同时就是回归用例。

helper 收口补两个设计细节:返回值得是三态(匹配 / 不匹配 / 超限),别退化成 bool,否则调用方又回到 if (!preg_match()) 的老坑;另外加耗时采样,超过阈值(5ms 左右)就记一条带 pattern 和输入长度的日志。慢正则真正爆掉之前会先长期拖慢,这条日志是提前预警,比事后排查 502 有用。

坑:超限日志只记 pattern 不够,同一个 pattern 一天出现上百次你也不知道是哪个字段喂进去的。建议连输入长度 + 前 32 字节 hex 一起落,复现时才有抓手。

不语
不语 正式会员正式会员认证极客认证极客 #2117 5楼 2026-11-05 19:56
陈先生:【结论】分界线我完全同意,但后台可配 pattern 的准入,我建议用「保存时动态试跑」而不是静态扫描——静态规则漏得太厉害。 先补一个容易搞反的点:PCRE…

【结论】动态试跑我赞成,比静态扫描靠谱得多;但试跑只证明“这几条诱饵都没引发灾难性回溯”,并不能取代入口处的长度上限,两者是不同层次的保护,少任何一个都不完整。

关于 /u 的补充:你说得对,不带 /u 修饰符时,PCRE 按字节计数,一个中文算 3 步。但后台允许配置的 pattern 在实际处理中文时几乎必然带 /u;因为不带 /u 时,非法 UTF-8 字节序列会被 . 直接切开,匹配结果本身就是错的。所以按“pattern 是否带 /u”分流计算步数是对的,但不要把它当作常见情况;后台输入应当强制要求带 /u。另外,判断输入是否为合法 UTF-8,常用写法就是 preg_match('//u', $s) === 1;这项校验应该放在试跑之前,因为非法的字节序列会让试跑结果失真。

试跑的一个盲区:诱饵能暴露灾难性回溯,但暴露不了“步数线性增长、只是跑得慢”的正则。同一个 pattern 配 200 字样本跑 2ms,线上喂进 500KB 正文就是好几秒;试跑通过,照样会拖垮服务。所以试跑必须配入口长度上限:试跑负责发现灾难性回溯,长度上限负责控制线性增长带来的耗时。另外,试跑自身也要设置总预算,例如所有诱饵合计超过 50ms 就中止,否则一堆慢 pattern 会把保存请求本身拖到超时。

辅助函数的细节:三态用 int(1/0/-1)或者抛异常都行,不要混入 null,让调用方被迫判断三种情况。计耗时用 hrtime(true),而不是 microtime,因为单调时钟不受 NTP 校时跳变影响。日志记录前 32 字节的 hex 值时注意:带 /u 时 substr 会把中文切成半个字符,这个 hex 值只能用来辨认特征,不要再拼回正则去复现。

最后一点:试跑用的诱饵池不应该写死;应该把 helper 记下的线上慢日志定期整理,把真实输入补进诱饵池。保存时只是第一道检查,线上慢日志才是发现新攻击面的地方。