正则回溯灾难:一行表达式拖垮服务

· 约 3 分钟 🔎 正则测试

正则通常是纳秒级操作,但某些模式遇到特定输入会突然卡死几分钟——这就是”灾难性回溯”(catastrophic backtracking)。理解它能避免被 ReDoS 攻击,也能写出更快的正则。

一个经典案例

/^(a+)+$/.test('aaaaaaaaaaaaaaaaaaaaaaaaaaaaX')

看起来人畜无害的正则,在 29 个 a 加一个 X 上能跑 30 秒甚至更久。多加一个 a 耗时翻倍——典型指数爆炸。

Cloudflare 2019 年一次全网宕机 27 分钟,直接原因就是一条含 .*(?:.*=.*) 的 WAF 规则,碰到构造的输入触发了 PCRE 的这种回溯。

为什么会回溯

引擎遇到量词(*+?)时先贪婪吃,吃到不匹配再往回退一位继续试。嵌套量词 (a+)+ 让每个内层的 a+ 有多种”吃法”:

输入 aaaa,(a+)+ 可以拆成:
(aaaa)
(aaa)(a)
(aa)(aa)
(aa)(a)(a)
(a)(aaa)
(a)(aa)(a)
(a)(a)(aa)
(a)(a)(a)(a)

n 个 a 大约有 2ⁿ⁻¹ 种拆法。只要末尾 $ 不匹配(比如多了个 X),引擎会把所有拆法都穷举一遍才放弃。

三个危险模式

1. 嵌套量词

(a+)+        危险
(a*)*        危险
(.*)+        危险
([a-z]+)*    危险

修复:把内层量词拿掉或改写成不等价但等效的单层形式。

a+           OK
[a-z]+       OK

2. 交替分支有公共前缀

(a|a)+       危险(两个分支都能吃 a)
(a|ab)+      危险(第二分支吃的 a 也能分给第一分支)

修复:去重,或把公共前缀提出来。

a+b?         替代上面第二条

3. 贪婪 .* + 末尾锚点失配

^.*.*=.*$               两个 .* 同级叠加
^(\w+\s?)+$             空格可选让分支重叠

修复:用具体字符集缩窄,消除歧义。

^[^=]*=.*$              OK
^\w+(\s\w+)*$           OK(空格必选)

线性引擎 vs 回溯引擎

语言 / 库引擎类型反向引用环视
Rust regexThompson NFA(线性)有限
Go regexpRE2(线性)
Python re2(第三方)RE2(线性)
JavaScript回溯
Python re回溯
Java Pattern回溯

反向引用 (\d)\1 和多数环视是回溯引擎的核心能力,也是线性引擎不支持的原因——这些特性让 DFA 构造无法完成。

独占量词 / 原子组

Java、PCRE、Python 3.11+ 支持独占量词 a++a*+——吃了就不回溯。能从根源上消灭回溯:

(a+)+          回溯指数爆炸
(a++)+         独占,线性时间

JavaScript ES2024 纳入了原子组 (?>...),Node 22+ 支持:

^(?>a+)+$      原子组内不回溯

ES2024 之前 JS 没有等价语法,只能靠改写表达式或换引擎。

超时兜底

改不动正则时,用线性引擎:

// Node.js
import RE2 from 're2';
const re = new RE2('^(a+)+$');
re.test(userInput);    // 线性时间,永远不会挂起

或者丢到 worker 线程跑正则,主线程 setTimeout 100ms 超时就 terminate worker。

手写测试用例

拿到可疑正则,构造”长前缀 + 不匹配后缀”做压测:

const s = 'a'.repeat(30) + 'X';
console.time('r');
re.test(s);
console.timeEnd('r');

30 字符跑超 100ms,基本有回溯问题;跑超 1 秒,立刻整改。

排雷清单

  • 嵌套量词 (a+)+ / (a*)* → 改单层
  • 交替分支能匹配同一段 → 去重或重构
  • .*$ 同行 → 用非点字符集缩窄
  • 用户输入正则或拼 new RegExp(userInput)永远不允许,等于把 DoS 开关交给攻击者
  • 复杂正则上线前跑”长前缀 + 失配后缀”计时
  • 敏感场景换 RE2 / 独占量词

正则表达力很强,但回溯引擎的复杂度曲线是隐藏悬崖。写完复杂正则记得拿压测样例跑一次——没卡就安全,卡了马上改。

❓ 常见问题

为什么 Rust / Go 的正则没有回溯问题?

它们用的是非回溯引擎(基于 Thompson NFA 或 DFA 构造),保证匹配时间线性于输入长度,但代价是不支持反向引用 `\1`、大部分前后断言。JavaScript、Python 3、Java、.NET 默认都是回溯引擎,表达力强但可能指数爆炸。业务对性能敏感时,Rust regex crate / Go regexp / Python re2 是最安全的。

什么叫"灾难性回溯"?

正则在无法匹配时会尝试所有可能的匹配路径,某些模式(嵌套量词、交替重叠)让路径数呈 2ⁿ 指数增长。输入越长,耗时以 2 的幂次膨胀——30 个字符就能卡 10 分钟,40 个字符能跑到第二天还没结束。

怎么快速判断一个正则是否危险?

看三个特征——嵌套量词(`(a+)+`)、交替分支能匹配同一段(`(a|a)+`、`(a|ab)+`)、贪婪 `.*` 配末尾锚点(`^.*.*$`)。命中任一就要警惕。regex101 等在线工具有 debug 面板能看回溯步数。

已经上线的危险正则怎么救?

三条路:改写成非重叠形式(`a+` 代替 `(a+)+`);加独占量词 `a++` 或原子组 `(?>...)` 禁止回溯(Java / PCRE 支持,JS 到 ES2024 才加);换成线性引擎——Node 有 `re2` npm 包(Google RE2 的 binding),保证线性时间。

🔎 打开 正则测试 实时高亮 · 分组捕获 · 标志位

📖 同一工具的其他教程