正则表达式测试器

Catastrophic Backtracking — 一个正则表达式如何击垮 Cloudflare

2019年7月2日 13:42 UTC,Cloudflare 全球边缘节点的每台服务器 CPU 都达到了 100%。HTTP 流量停止流动。根据其公开的事故复盘报告,原因是几分钟前部署的一条新 WAF 规则: (?:(?:\"|'|\]|\}|\\|\d|(?:nan|infinity|true|false|null|undefined|symbol|math)|\`|\-|\+)+[)]*;?((?:\s|-|~|!|{}|\|\||\+)*.*(?:.*=.*))) 末尾带有无界 + 的那个字符类,在应用于某些精心构造的字符串时,触发了 PCRE 的指数时间回溯。正则引擎进入失控状态,尝试数十亿种排列组合来"完成"匹配。Cloudflare 每台服务器上的每个 CPU 同时被这项工作占满,持续了 27 分钟。复盘报告为这种故障模式命名:catastrophic backtracking(灾难性回溯),也称 ReDoS(Regex Denial of Service,正则拒绝服务)。 其机制是结构性的。大多数生产环境的正则引擎(PCRE、Java 的 java.util.regex、Python 的 re、Ruby 的 Onigmo、JavaScript 的 V8 Irregexp)都用回溯实现匹配。当匹配尝试在模式后段失败时,引擎会回退并尝试另一条路径。对于嵌套量词 — 如 (a+)+、(.*)*、(\w*)*$ 这样的模式 — 每个字符划分到内层分组和外层分组的方式呈指数级增长。一个 30 个字符的输入,在引擎放弃之前可能产生 2^30 ≈ 10 亿次划分尝试。CPU 会在单次正则匹配上耗费数秒、数分钟,某些情况下甚至数年。 需要警惕的模式:(1) 任何把量词套在量词外面的形式 — (a+)+ 是教科书案例。(2) 含有相同前缀的分支 — (a|aa)+。(3) 贪婪量词作用于以可能不匹配的字面量结尾的无界片段 — 用 ^(a+)+\$ 去测试 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab" 会让大多数引擎慢如蜗牛。 存在三种防御手段。(1) 使用结构上免疫的引擎。RE2(Google)、Hyperscan、Rust 的 regex crate、Go 的 regexp 以及 Lua patterns 都使用线性时间的 NFA 模拟,它们不回溯。代价是不支持某些形式的反向引用和后顾(lookbehind),但对 95% 的真实模式来说没有问题。RE2 正是因为 Google 曾发生 ReDoS 事故才被设计出来的。(2) 为每次正则求值设置真实时钟超时。Java 的 Pattern.matcher 没有内置超时(必须借助线程中断);.NET 正是为此明确添加了 Regex(pattern, options, timeout)。(3) 对模式做静态检查。vuln-regex-detector、safe-regex 和 ESLint 的 no-unsafe-regex 之类的工具能在上线前标记出最危险的结构形态。 一条务实的规则:如果你接受最终用户提供的正则,绝不要在没有超时的情况下使用语言的默认引擎。如果模式是你自己写的,请先用上述某个检查工具跑一遍,尤其是在部署到请求路径上的 WAF 或输入校验器之前。2019 年的 Cloudflare 这两条都没有做。
// Catastrophic patterns
/^(a+)+$/.test('a'.repeat(30) + 'b');
// V8: ~30 seconds. Engine in tight backtracking loop.

/(.*)*=/.test('=' + 'a'.repeat(50));
// Even worse with inner unbounded greedy.

// Production safety in Node
const RE2 = require('re2');         // linear time
const safe = new RE2('(a+)+');
safe.test('a'.repeat(30) + 'b');    // returns instantly, false

// Native regex with watchdog (workers)
function timedRegex(pattern, input, ms = 50) {
  return new Promise((resolve, reject) => {
    const w = new Worker(`onmessage = e => {
      const re = new RegExp(e.data.p);
      postMessage(re.test(e.data.s));
    }`, { eval: true });
    const timer = setTimeout(() => { w.terminate(); reject(new Error('regex timeout')); }, ms);
    w.onmessage = e => { clearTimeout(timer); resolve(e.data); w.terminate(); };
    w.postMessage({ p: pattern, s: input });
  });
}

// .NET — built-in timeout
var re = new Regex(pattern, RegexOptions.None, TimeSpan.FromMilliseconds(50));

正则表达式引擎对比 — PCRE vs RE2 vs JavaScript V8

现代正则实现由三个引擎主导,它们之间的差异很重要。 PCRE(Perl Compatible Regular Expressions)。驱动 PHP、nginx、Apache、以及历史上 Cloudflare 的 WAF 等众多软件的 C 库。基于回溯,支持最完整的功能集:反向引用、后顾(PCRE2 支持变长)、递归、原子分组、占有型量词、条件模式、支持所有语法形式的命名捕获、callout。代价是最坏情况的行为 — 面对对抗性输入时的指数时间。PCRE2(现代分支)加入了 JIT 编译器,使平均情况的原始匹配速度可与 RE2 竞争,但没有改变最坏情况。 RE2(Google)。由 Russ Cox 编写的 C++ 实现,也绑定到 Go 的标准库。基于 Thompson 的 NFA 模拟算法构建:引擎维护一组可能的解析器状态,并让它们随输入同步前进。无论模式多复杂,时间都与输入长度成线性关系。代价是有些功能无法支持:任意反向引用(正则不能要求记住任意先前匹配过的文本),某些绑定中后顾被限制为有界长度。对真实代码库中 95% 的正则模式而言,RE2 是可以直接替换的方案,以放弃少数冷门特性为代价消除了 ReDoS。Google 在一系列正则故障之后把所有内部服务迁移到了 RE2。 JavaScript V8(Irregexp)。浏览器的正则引擎。基于回溯,但经过深度优化:它在 JIT 层面把正则模式编译成本地代码。支持后顾(自 2018 年,ES2018)、命名捕获 (\(?<name>...\))、Unicode 属性转义 (\p{Letter})、s 标志(dotAll)和 d 标志(匹配索引)。它没有超时原语 — 失控的模式会让标签页崩溃。较新的 V8 版本(2022 年起)有一个实验性的线性时间模式,可以通过 v8 引擎标志中的 /l 标志启用,但默认关闭,也没有暴露给标准 JS。 实用指南:服务端,凡是接触用户输入的模式都优先使用 RE2(Go 原生、Node 用 re2 npm、Rust 用 regex-rs、Python 用 google-re2)。如果需要更丰富的功能集且信任这些模式,就用 PCRE。浏览器端,接受 V8 没有内置保护这一事实 — 保持模式简单,避免嵌套量词,优先使用不回溯的结构(JS 没有原子分组 /(?>x)/,但可以用 /(?=(x))\1/ 这个前瞻技巧模拟)。只要有可能,就用显式字符串操作替换复杂正则 — string.includes、string.split、indexOf 都是线性时间,而且快得没有上限。 功能不匹配也很要紧。从 Stack Overflow 的 PCRE 答案复制的代码经常在 JavaScript 中不工作:PCRE 的变宽后顾、占有型量词 (a++)、递归子例程 ((?R)) 以及部分 Unicode 类都是 PCRE 独有。JavaScript 独有的功能包括 d(hasIndices)标志和 v(Unicode-Sets)标志(ES2024)。在引擎之间移植时,一定要用你的测试用例跑一遍模式 — 悄无声息的语义差异非常常见。
// PCRE-only that fails in JS:
preg_match('/^(?P>group)$(?<group>foo|bar)/', $s);  // recursion
preg_match('/(?<=\w+)b/', $s);                     // var-width lookbehind

// JavaScript-only:
'abcabc'.matchAll(/(?<x>\w)/dg);  // d flag → result[i].indices
[...'abc'.matchAll(/[\p{Letter}--[a]]/v)]; // v flag set difference

// Cross-engine safe baseline (works everywhere):
const safePattern = /^[a-zA-Z][a-zA-Z0-9_]{0,31}$/;

// Server: use RE2 in Node
const RE2 = require('re2');
const safe = new RE2('^([a-z]+)\\s+(\d+)$');
const m = safe.match('alice 42');
m;  // ['alice 42', 'alice', '42']

// Replace regex with string ops when you can — guaranteed O(n)
const isEmail = (s) => s.includes('@') && s.indexOf('@') === s.lastIndexOf('@');

前瞻与后顾 — 什么时候才真正需要它们

环视(lookaround)是零宽断言:它检查当前位置之前或之后的内容,而不消费任何字符。共有四种:肯定前瞻 (?=...)、否定前瞻 (?!...)、肯定后顾 (?<=...)、否定后顾 (?<!...)。它们功能强大,却经常被滥用。正则教程里看到的环视,大约 80% 都可以改写成使用捕获组的普通正则,而且会更快、更易读。 正当的用途可以归为几类。 (1) 在不消费分隔符的情况下做分词。用 /(?<=\D)(?=\d)|(?<=\d)(?=\D)/ 这样的正则把 "abc123def" 拆成字母和数字,会在字母与数字的边界处插入切分点而不消费任何字符,因此 .split 返回 ["abc","123","def"]。用非零宽的正则实现同样的效果,需要显式地重新拼装。 (2) 断言匹配区域之外的约束。"找出后面跟着单位的数字,但我不想让单位出现在匹配结果里":/\d+(?=px|em|rem)/。没有前瞻的话,你要么把单位包含进匹配(再后处理去掉),要么用捕获组并引用第 1 组。前瞻更干净。 (3) 在一个模式里校验多个相互独立的约束。经典的"密码必须包含数字、字母、特殊字符,且不少于 12 个字符"模式:/^(?=.*\d)(?=.*[a-zA-Z])(?=.*[!@#$%])\S{12,}$/。每个 (?=...) 都在不前进位置的情况下针对整个字符串检查一条规则。这可行且简洁,但它同时也是"在应用代码里做多个独立校验会更清晰"的典型例子。 (4) Unicode 中的单词边界。JavaScript 的 \b 默认只针对 ASCII。要在 Unicode 文本上断言"字母边界",需要带 Unicode 标志的 /(?<![\p{Letter}\p{Mark}])foo(?![\p{Letter}\p{Mark}])/u。 什么时候不该用环视:凡是捕获组就能解决的场合。用 /(\d+)px/ 匹配 "12px",不用后顾就能得到 match[1] = "12"。把 /(?<=\$)\d+/.exec("$42")[0] 换成 /\$(\d+)/.exec("$42")[1] — 数据相同,正则更简单,而且在包括后顾受限引擎在内的所有引擎中都能运行。 一个隐蔽的性能陷阱:在回溯引擎中,位于量词分组内部的环视可能成倍放大最坏情况的工作量,因为每一步回溯都会重新求值断言。RE2 能处理前瞻,但不支持任意后顾。Java 在 9 中加入了有界后顾。Python 只支持定长后顾。ECMAScript 自 2018 年起同时支持前瞻和无界后顾,但 Safari 直到 2023 年才提供后顾支持,所以如果需要兼容旧版 Safari,后顾就不能用。
// Tokenize on letter/digit boundary (zero-width split)
'abc123def'.split(/(?<=\D)(?=\d)|(?<=\d)(?=\D)/);
// → ['abc', '123', 'def']

// Match number followed by CSS unit, exclude the unit
const px = '20px 1rem 3em'.match(/\d+(?=px|em|rem)/g);
// → ['20', '1', '3']

// Password rule with lookaheads
/^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[!@#$%^&*])\S{12,}$/.test(pw);

// Lookbehind for dollar amount
'price $42 and €50'.match(/(?<=\$)\d+/);  // ['42']

// SAME RESULT without lookbehind (more portable)
'price $42 and €50'.match(/\$(\d+)/)[1];   // '42'

// AVOID: nested-quantifier-with-lookahead — slow on adversarial input
/^(.+(?=.+))+$/.test('a'.repeat(20) + 'X');  // backtracks badly
最后更新:

关于此工具

正则表达式测试器把 JavaScript 正则应用到样本字符串,显示所有匹配、捕获组和位置。正则是几乎所有编辑器、搜索工具、编程语言内建的通用模式语言,实时测试器把试错过程变成快速反馈循环。

使用方法

  1. 在 Pattern 字段中输入正则表达式模式 — 不需要两侧的斜杠。
  2. 按需添加标志,如 g(全局)、i(忽略大小写)、m(多行)、s(dotall)、u(Unicode)。
  3. 把测试字符串粘贴到下方的文本区域。
  4. 在结果面板中查看每个匹配、其索引以及所有捕获组。
  5. 使用常用模式快捷按钮,从邮箱、URL、日期等可用示例开始。

常见用例

  • 校验表单中的邮箱或电话号码输入。
  • 从一段纯文本中提取所有 URL。
  • 为大规模代码重构编写查找替换模式。
  • 用捕获组把日志行解析成结构化字段。
  • 在上线前测试路由或中间件的正则。
  • 去除或规范化空白、标点或重音字符。

常见问题

Q. 为什么我的模式什么都匹配不到?

A. 检查锚点(^ 和 $)和多行标志,转义特殊字符(. 要写成 \.),并确认你到底需要贪婪量词还是惰性量词。

Q. 这些模式能移植到其他语言吗?

A. 大部分可以。JavaScript 正则接近 PCRE;大多数模式在 Python、Java、Go 中都能用,只有少量语法差异(命名分组、后顾支持)。

Q. g 标志到底做什么?

A. g(全局)让引擎找出所有匹配,而不是只找第一个。没有它,.match 返回的是第一个匹配及其分组,而不是匹配数组。

Q. 正则能解析 HTML 吗?

A. 出了名的不可靠。正则无法匹配任意嵌套的结构。请使用真正的 HTML 解析器;只在简单的、以行为界的提取任务中才用正则。