大多数正则 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)+。正则里出现嵌套量词,先当它有罪,再证明无罪。
四步防御
- 用长对抗样本测试 —— 重复 token 写上 30–50 个,末尾接一个强制失败的字符。测试卡住,模式就有问题。
- 优先否定类和锚点 ——
^[^,]+,不可能像^(.+)+,那样指数回溯。 - 可用时用原子组/占有量词 ——
(?>a+)或a++一旦匹配就不回头(PCRE、Java、.NET 支持,新版 JavaScript 引擎也在跟进)。 - 跑正则前先限制输入长度 —— 长度上限把 ReDoS 从”服务器挂了”降级为”返回 400”。
用 正则测试器做对抗测试只需两分钟:贴模式、贴长恶意样本、看是否秒回。当正则行为依旧诡异时,把”匹配结果”和”期望结果”丢进文本对比,通常会现出藏在背后的不可见字符。