2019年7月2日,保护着全球约20%互联网流量的Cloudflare遭遇了一次持续27分钟的全球性宕机。原因令人意外:一个正则表达式。
更准确地说,是这个正则表达式:
(?:(?:\"|'|\]|\}|\\|\d|(?:nan|infinity|true|false|null|undefined|symbol|math)|\`|\-|\+)+[)]*;?((?:\s|-|~|!|{}|\|\||\+)*.*(?:.*=.*)))
这个模式被用于WAF(Web应用防火墙)中检测XSS攻击。当它遇到特定的请求时,CPU利用率瞬间飙升到100%,全球各地的服务器相继瘫痪。这不是黑客攻击,没有恶意流量——仅仅是一个正则表达式在处理某个HTTP请求时触发了灾难性回溯。
这不是孤例。2016年,Stack Overflow因类似原因宕机34分钟。2024年9月,Express.js核心依赖path-to-regexp被披露存在ReDoS漏洞,影响数百万Node.js应用。
正则表达式是程序员工具箱中最常用的工具之一,但很少有人意识到它可能成为系统中最危险的攻击面。本文将深入分析正则表达式回溯灾难的技术本质,揭示为什么大多数主流语言的正则引擎都存在这个致命弱点,以及如何从根本上规避这一风险。
回溯:一个优雅但危险的算法
要理解问题,必须先理解正则表达式引擎是如何工作的。
现代编程语言中的正则引擎几乎都采用**回溯NFA(Nondeterministic Finite Automaton)**算法。NFA是一种非确定性有限状态自动机——听起来很学术,但核心思想很简单:当引擎遇到多种可能的匹配路径时,它会选择一条路径尝试,如果失败就"回溯"到上一个决策点,尝试其他路径。
考虑一个简单的例子:正则表达式 ^(a+)+$ 匹配由一个或多个a组成的字符串。看起来无害,对吧?
但当我们用这个表达式去匹配 aaaaaaaaaaaaaaaaaaaaX(20个a后面跟一个X)时,问题出现了。
表达式无法匹配(因为有X),但引擎不会立即给出结果。它需要穷尽所有可能的路径才能确定"无匹配"。让我们看看有多少种路径:
- 外层的
+表示整个组(a+)可以重复1次、2次、3次…… - 内层的
+表示每组中的a可以有1个、2个、3个…… - 对于20个
a,可能的分配方式数量是指数级的
具体计算:对于n个a,内层a+可能的取值组合数大约是2^n级别。当n=20时,需要尝试超过一百万次回溯;当n=30时,超过十亿次;当n=40时,计算时间超过宇宙年龄。
这不是夸张——这是算法复杂度理论的基本结论。
一个具体的回溯过程
让我们用更小的例子来演示。正则表达式 (x+x+)+y 匹配字符串 xxxxxxxxxxy:
- 第一个
x+匹配所有10个x - 第二个
x+失败(没有剩余字符) - 第一个
x+回溯到9个x - 第二个
x+匹配剩余1个x - 外层
+尝试第二次重复,失败 - 匹配
y成功
总共几十步,一切正常。
但如果字符串是 xxxxxxxxxxxx(没有y),引擎就会疯狂回溯:
- 第一个
x+从10→9→8→…→1,每次都要尝试内层x+的各种组合 - 每次外层迭代失败,都要重新排列内层
- 步数呈指数增长
实测数据:
| 字符串长度 | 回溯步数 |
|---|---|
| 10个x | 2,557步 |
| 11个x | 5,117步 |
| 12个x | 10,237步 |
| 19个x | 超过100万步 |
复杂度为O(2^n),每增加一个字符,步数翻倍。
为什么主流语言都用了"错误"的算法?
既然回溯算法有如此严重的问题,为什么Java、Python、JavaScript、Perl、PHP、Ruby等所有主流语言都采用它?
答案在于一个关键的权衡:功能丰富度。
回溯引擎虽然最坏情况下是指数复杂度,但它支持一些非常方便的特性:
反向引用(Backreferences):\1、\2等可以引用之前捕获组的内容。例如 (cat|dog)\1 匹配"catcat"或"dogdog",但不匹配"catdog"。这在处理HTML标签匹配、引号配对等场景非常有用。
前瞻和后瞻(Lookaround):(?=...)、(?!...)等可以检查某个位置后面/前面是否有特定模式,但不消耗字符。
原子组和占有量词:虽然这些特性可以用来防止回溯,但它们本身也需要回溯引擎的支持。
问题是:这些特性使得正则表达式不再是"正则"的——它们超越了正则语言的表达能力。理论上,真正的正则表达式都可以转换为DFA(确定性有限自动机),在线性时间内完成匹配。但反向引用等特性使得DFA转换不再可行。
Russ Cox在2007年的经典文章《Regular Expression Matching Can Be Simple And Fast》中指出:Perl的正则引擎处理一个29字符的字符串需要60秒,而Ken Thompson的NFA算法只需要20微秒——相差一百万倍。
1968年的解法:Thompson NFA
1968年,Ken Thompson在发表在CACM上的论文《Programming Techniques: Regular Expression Search Algorithm》中描述了一种方法:将正则表达式编译成NFA,然后用一种特殊的模拟方式执行,保证线性时间。
关键区别在于如何处理非确定性。
回溯方法:遇到分支时,选择一条路径尝试;失败则回退,尝试另一条。这需要保存大量的回溯状态。
Thompson方法:同时跟踪所有可能的状态。在每一步,引擎维护一个"当前可能处于的所有状态"的集合。读取下一个字符后,计算所有可能的新状态集合。
用一个简单的例子说明。正则表达式 abab|abbb 匹配字符串 abbb:
回溯方式:
- 尝试
abab路径,在第三个字符失败 - 回溯,尝试
abbb路径 - 成功
Thompson方式:
- 初始状态集:{开始状态, a分支起点, b分支起点}
- 读取’a’后:{状态2, 状态5}
- 读取’b’后:{状态3, 状态6}
- 读取’b’后:{状态4, 状态7}
- 读取’b’后:{匹配状态}
每一步只做常量工作,总时间是线性的。
Thompson方法的核心洞察:NFA虽然有多个可能的状态,但状态总数是固定的(等于正则表达式的长度)。因此在任意时刻,可能状态的集合大小是有界的。这消除了指数爆炸的可能。
为什么Thompson NFA不能完全替代回溯引擎?
Thompson NFA完美解决了性能问题,但它有一个根本限制:无法支持反向引用。
反向引用需要"记住"之前匹配的具体内容,这与NFA的状态转移语义不兼容。NFA只关心"我可能处于哪些状态",而不关心"我是怎么到达这些状态的"。
这就是为什么大多数语言选择了回溯引擎:开发者需要反向引用等功能,而Thompson NFA无法提供。
Evil Regex:危险的代码模式
OWASP将可能导致ReDoS的正则表达式称为"Evil Regex"。它们通常具有以下特征:
- 分组嵌套重复:如
(a+)+、([a-z]+)* - 分组内交替重叠:如
(a|aa)+、(a|a?)+ - 连续的贪婪通配符:如
.*.*=.*
典型示例:
(a+)+$ # 最经典的例子
([a-zA-Z]+)*$ # 邮箱验证中常见
(a|aa)+$ # 交替重叠
(a|a?)+$ # 可选字符造成重叠
.*.*=.* # 连续贪婪通配
所有这些模式都有一个共同点:同一字符可以被多种方式"归属"到不同的子模式中。当匹配失败时,引擎必须尝试所有可能的归属方式。
真实世界中的Evil Regex
Cloudflare事件中的正则表达式简化后核心是 .*.*=.*。问题出在这一部分:
.*.*=.*/
三个连续的 .*。当匹配一个长字符串如 x=xxxxxxx...xxx 时:
- 第一个
.*尝试匹配所有内容 - 第二个
.*匹配空 =找不到匹配,回溯- 第一个
.*释放一个字符 - 第二个
.*尝试匹配… - 无限循环
Cloudflare的工程师在事后分析中计算:匹配 x=x 需要23步,匹配 x=xx 需要33步,匹配 x=xxxxxxxxxxxxxxxxxxxx(20个x)需要555步。而如果末尾再加一个分号变成 .*.*=.*;,匹配20个x需要5,353步——仍然是指数增长。
真实攻击案例
Cloudflare 2019年宕机
时间线(UTC):
- 13:42 - 工程师部署WAF规则更新,自动通过CI/CD测试
- 13:45 - 第一个告警触发,显示WAF功能异常
- 随后 - 大量502错误报告,全球CPU利用率飙升至100%
- 14:00 - 确认WAF是问题源头
- 14:07 - 执行全局WAF终止命令
- 14:09 - 流量和CPU恢复正常
根本原因分析揭示了一个令人警醒的事实:
- 正则表达式由工程师手写,可以产生巨大回溯
- 几周前的一次重构意外移除了CPU使用率保护
- 正则引擎(Lua的PCRE)没有复杂度保证
- 测试套件没有检测CPU消耗异常
- SOP允许非紧急规则直接全球部署
- 回滚流程需要完整构建两次WAF,耗时过长
更重要的是,Cloudflare使用自己的产品。当服务宕机时,他们无法访问自己的控制面板——因为控制面板也托管在Cloudflare上。团队不得不使用很少训练过的绕过机制。
Stack Overflow 2016年宕机
2016年7月,Stack Overflow因一个格式错误的帖子触发了正则表达式回溯。帖子本身无害,但它包含了某种特殊字符序列,导致处理帖子的正则表达式进入无限回溯。
这次宕机持续了34分钟,暴露了一个有趣的问题:Python的GIL(全局解释器锁)阻止了一个线程中断另一个正在执行正则匹配的线程。即使有超时机制,也无法停止已经卡住的匹配操作。
path-to-regexp 2024年漏洞
2024年9月,Express.js的核心依赖path-to-regexp被披露存在ReDoS漏洞,分配编号CVE-2024-45296。
path-to-regexp是一个将路径字符串转换为正则表达式的库,广泛用于Express.js、Next.js等框架的路由系统。问题出在路径参数的处理上。
例如,路由 /flights/:from-:to 会生成正则表达式:
/^\/flights\/([^\/]+?)-([^\/]+?)\/?$/i
看起来正常?但如果我们匹配一个恶意构造的路径:/flights/ + 16000个- + /x
匹配时间超过300毫秒。正常的正则匹配应该在1毫秒内完成。
原因分析:当匹配失败时(因为有/x后缀),引擎必须尝试 :from 和 :to 参数之间所有可能的字符分配方式。第一个参数 ([^\/]+?) 可以匹配任意数量的 -,第二个参数也是。当有n个字符需要在两个参数之间分配时,有O(n)种方式;如果有更多参数,组合数会进一步增加。
修复方案很直接:在第二个参数中排除分隔符 -,改为 ([^\/-]+?)。匹配时间从300毫秒降到0.07毫秒。
语言实现对比:谁更安全?
不同编程语言对ReDoS的防护措施差异很大:
高风险:回溯引擎
Python (re模块):标准实现使用回溯NFA。Python 3.11引入了占有量词和原子分组,可以手动防止回溯,但需要开发者主动使用。
# 危险
re.match(r'(a+)+$', 'aaaaaaaaaaaaaaaaaaaaX')
# 安全(Python 3.11+)
re.match(r'(a++)++$', 'aaaaaaaaaaaaaaaaaaaaX') # 占有量词
re.match(r'(?>(a+)+)$', 'aaaaaaaaaaaaaaaaaaaaX') # 原子分组
JavaScript:所有主流引擎(V8、SpiderMonkey、JavaScriptCore)都使用回溯NFA。不支持占有量词或原子分组。唯一的防护是手动限制输入长度或使用替代引擎。
// 危险
/(a+)+$/.test('aaaaaaaaaaaaaaaaaaaaX')
// 只能手动防护
function safeMatch(pattern, text, maxLength = 1000) {
if (text.length > maxLength) return false;
return pattern.test(text);
}
Java:java.util.regex使用回溯NFA。有趣的是,Java的接口设计实际上要求回溯实现,因为它允许在匹配路径中执行任意Java代码。
Perl/PCRE:作为回溯引擎的"鼻祖",Perl 5.10之后的版本会在栈溢出时崩溃(而不是无限循环),但这只是把DoS变成了另一种DoS。
低风险:有限自动机引擎
Go (regexp包):使用RE2语法,基于有限自动机,保证O(n)匹配时间。不支持反向引用。
// 安全 - 线性时间保证
regexp.MustCompile(`^(a+)+$`).MatchString("aaaaaaaaaaaaaaaaaaaaX")
Rust (regex crate):同样使用有限自动机,保证线性时间。由BurntSushi(Andrew Gallant)开发,内部实现了多种策略的动态组合。
RE2 (C++):Google开发的正则引擎,专门为安全而设计。它的设计哲学是:宁可不支持某些功能,也要保证性能可预测。
// RE2保证线性时间
RE2 pattern("(a+)+$");
RE2::FullMatch("aaaaaaaaaaaaaaaaaaaaX", pattern); // O(n) 时间
混合策略
.NET:使用回溯引擎,但提供了RegexOptions.NonBacktracking选项(.NET 7+)可以在需要时切换到非回溯模式。
Node.js生态:可以通过第三方库如re2绑定Google RE2,获得线性时间保证。但需要显式选择使用。
防护策略
1. 使用占有量词和原子分组
如果语言支持,这是最直接的防护方式。
占有量词:a++、a*+、a?+
匹配后不释放已匹配的字符,阻止回溯。
// 危险
(a+)+$
// 安全
(a++)+$
原子分组:(?>pattern)
将整个组视为原子单位,一旦匹配完成,不再回溯进入组内。
// 危险
^(.*?,){11}P
// 安全
^(?>(.*?,){11})P
2. 避免嵌套量词
最安全的做法是完全避免在重复组内使用重复量词。
// 危险:嵌套量词
(x+x+)+y
// 安全:简化表达式
xx+y
3. 使用互斥替代
当必须使用交替时,确保各选项互斥——没有一个字符能被多个选项匹配。
// 危险:'a'可以被两个选项匹配
(a|aa)+
// 安全:选项互斥
(ab|aa)+
4. 限制输入长度
简单但有效的防御措施:
import re
def safe_match(pattern, text, max_length=1000):
if len(text) > max_length:
raise ValueError("Input too long")
return re.match(pattern, text)
5. 使用安全引擎
对于处理不可信输入的场景,考虑使用RE2或Rust regex:
// Node.js + re2
const RE2 = require('re2');
const pattern = new RE2('(a+)+$');
pattern.test('aaaaaaaaaaaaaaaaaaaaX'); // 线性时间
6. 静态检测工具
一些工具可以自动检测Evil Regex:
- safe-regex (JavaScript)
- ReDoSHunter (研究工具)
- SonarQube规则
但这些工具无法100%覆盖,仍需人工审查。
总结:理论、实践与权衡
正则表达式的回溯问题是一个完美的案例,展示了理论计算机科学如何指导工程实践。
Ken Thompson在1968年提出的算法保证了线性时间匹配。但几十年来,这个算法被主流语言抛弃,取而代之的是更容易实现、功能更丰富、但在某些情况下性能灾难性的回溯引擎。
这不是一个错误的决定——反向引用等功能对实际开发非常重要。问题是大多数开发者不知道这个权衡,不知道他们每天都在使用一个可能爆炸的工具。
安全使用正则表达式需要:
- 理解工具的本质:知道你使用的引擎是什么类型,有什么限制
- 审查关键模式:对处理用户输入的正则表达式进行专门审查
- 采用防御性编码:使用占有量词、原子分组、输入限制
- 考虑替代方案:在安全敏感场景,选择RE2、Rust regex等安全引擎
Cloudflare在事故后做了一系列改进:重新引入CPU保护、审查所有3868条WAF规则、将测试套件中加入性能分析、最终将引擎从PCRE迁移到Rust实现的RE2。
他们吸取了教训。你呢?
参考资料
- Cox, R. (2007). Regular Expression Matching Can Be Simple And Fast. https://swtch.com/~rsc/regexp/regexp1.html
- Thompson, K. (1968). Programming Techniques: Regular Expression Search Algorithm. CACM 11(6), 419-422.
- Cloudflare. (2019). Details of the Cloudflare outage on July 2, 2019. https://blog.cloudflare.com/details-of-the-cloudflare-outage-on-july-2-2019/
- OWASP. Regular expression Denial of Service - ReDoS. https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
- Embrey, B. (2024). ReDoS the web. https://blakeembrey.com/posts/2024-09-web-redos/
- Gallant, A. (2023). Regex engine internals as a library. https://blog.burntsushi.net/regex-internals/
- Google. RE2: a fast, safe, thread-friendly alternative to backtracking regular expression engines. https://github.com/google/re2
- Goyvaerts, J. Runaway Regular Expressions: Catastrophic Backtracking. https://www.regular-expressions.info/catastrophic.html
- Li, Y. et al. (2021). ReDoSHunter: A Combined Static and Dynamic Approach for Regular Expression DoS Detection. USENIX Security.
- Davis, J. et al. (2018). The Impact of Regular Expression Denial of Service (ReDoS) in Practice. ESEC/FSE.