实战指南 6 分钟阅读

贪婪、懒惰与灾难回溯:解释大多数正则 Bug 的三种行为

为什么 .* 会匹配过多?懒惰量词什么时候能救你?以及如何识别能让生产服务器卡死的回溯型正则。

大多数正则 bug 可以归结为三种行为:贪婪、懒惰、灾难性回溯。理解这三件事,你调试正则的时间会大幅缩短。

贪婪量词:能拿多少拿多少

<.*> 作用在 <a><b> 上匹配的不是 <a>,而是整个 <a><b>——因为 * 先尽可能多吃,只有走投无路才吐出来。*+?{m,n} 默认都是贪婪的。这是”为什么匹配得比我预期多”的头号来源,而且它不是 bug,是文档写明的行为。

懒惰量词:第一次机会就停

加个 ? 策略反转:<.*?><a><b> 上匹配 <a>,因为 .*? 只扩展到”剩余部分能匹配”的最小程度。值得背下来的懒惰写法:.*?(任意内容,尽量少)、.+?(至少一个,尽量少)、\d{2,4}?(数字尽量少)。

不过比懒惰更好的往往是否定字符类<[^>]*> 匹配到第一个 > 为止,全程零回溯——意图更清晰、执行也更快。优先 [^…],其次懒惰。

灾难性回溯:能把服务器挂死的 bug

嵌套量词会引发指数级回溯。臭名昭著的形态:

^(a+)+$

aaaaaaaaaaaaaaaaaaaaaaaa! 去匹配,引擎在宣布失败前要尝试指数级数量的拆分方式。输入每多一个字符,耗时翻倍——30 个字符要几分钟,40 个字符要几小时。攻击者深知这一点:向任何”用正则处理用户输入”的端点(搜索、校验、日志过滤)发送构造输入,就能造成拒绝服务,这就是 ReDoS。真实事故里,单个请求校验器中的漏洞正则拖垮过整个平台。

高危模式有共同形态:量词嵌套的组,且内外量词能匹配同一段文本——(a+)+(\d|\w)+(a|aa)+。正则里出现嵌套量词,先当它有罪,再证明无罪。

四步防御

  1. 用长对抗样本测试 —— 重复 token 写上 30–50 个,末尾接一个强制失败的字符。测试卡住,模式就有问题。
  2. 优先否定类和锚点 —— ^[^,]+, 不可能像 ^(.+)+, 那样指数回溯。
  3. 可用时用原子组/占有量词 —— (?>a+)a++ 一旦匹配就不回头(PCRE、Java、.NET 支持,新版 JavaScript 引擎也在跟进)。
  4. 跑正则前先限制输入长度 —— 长度上限把 ReDoS 从”服务器挂了”降级为”返回 400”。

正则测试器做对抗测试只需两分钟:贴模式、贴长恶意样本、看是否秒回。当正则行为依旧诡异时,把”匹配结果”和”期望结果”丢进文本对比,通常会现出藏在背后的不可见字符。