了解一下让大厂服务器故障半小时的正则陷阱

1,697 阅读9分钟

前言

著名 CDN 厂商 Cloudflare 于7月2号出现了近半小时的全球范围故障,用户打开 Cloudflare 域名的页面都会显示 502 错误。这是其近六年来的首次全球范围故障,官方形容这是令人羞愧的:We’re ashamed it happened.

官方解释,故障是由一个有问题的正则表达式防火墙规则引起的,服务进程在执行该正则的匹配时 CPU 过载且长时间无响应,最终导致服务不可用。

可以说这是一起由正则表达式引起的血案了,同样著名的 IT 厂商 Stack Exchange 在16年也出现了一次半小时的故障,同样是无法打开页面,同样是 CPU 爆表,同样的罪魁祸首 - 正则表达式的 Catastrophic Backtracking(回溯地狱)。

技术同学日常编程经常会用到正则表达式,躲不开这个陷阱,故障和黑锅自然也躲不开,因此还是很有必要了解清楚 Catastrophic Backtracking 到底是怎么一回事的。

Backtracking 回溯

Backtracking 简单来说,就是大部分正则表达式引擎,会尽可能尝试所有的路径去找到一个匹配。以一个简单的正则 .*x 为例,用它匹配字符串 "xy" 时:

  1. 由于 * 默认是贪婪的,.* 会将 "xy" 都匹配了,这时 x 匹配失败
  2. 这时引擎 backtrack,让 .* 匹配到的 "xy" 吐一个 "y" 出来,此时 .* 匹配 "x",引擎再拿 x 匹配 "y",匹配失败
  3. 引擎再次 backtrack 让 .* 把 "x" 吐出来,此时 .* 匹配为空(*本来就允许匹配空),引擎再拿 x 匹配 "x",此时引擎成功地找到了一个匹配。

.*x 路径图:

path.png

执行流程(via Regexp::Debugger):

demo-1.gif

*,+,?,{1,2} 这样的数量词,以及 | 这样的选择器,都可能会出现多个合法的匹配,也就是有多条的路径找到最终的匹配,一般的正则表达式引擎为了成功找到一个匹配,会尽可能的 backtrack,也即会尽可能地尝试所有的路径。

正则表达式引擎大致分为两种,一种是 NFA 型(Nondeterministic finite automaton),一种是 DFA 型(Deterministic finite automaton),DFA 型以输入的字符串为主导,不会出现 backtracking,因此也不会出现后述的 Catastrophic Backtracking,但也因此不支持一些依赖 backtracking 实现的功能特性例如 backreference。

大部分的正则引擎为了支持更多的特性,都选择了 NFA 型,例如 JS 和 Java 内置的正则引擎,因此使用这些正则引擎都不得不面对 backtracking 带来的问题。

Catastrophic Backtracking 回溯地狱

当正则表达式里可 backtrack 的位置很多,正则引擎为了成功找到一个匹配不断地 backtrack,导致程序长时间未响应并占用了大量 CPU 资源,这就是所谓的 Catastrophic Backtracking。

(x+)+y 这个正则为例,这个正则看似简单,用它匹配像 "xxy"、"xxx" 这样的字符串也能很快给出结果,看不出异常,然而当它遇到精心构造的字符串 "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"(40个x)时,正则引擎就会掉进回溯地狱,为了匹配不存在的 y 而尝试所有可能的路径,消耗大量时间(用我机器上的 nodejs 超过10分钟都没算出结果)。

分解步骤看一下正则引擎都做了些什么,可能更有助于我们理解:

  1. 引擎将 (x+) 匹配 40个x,y匹配失败
  2. 引擎 backtrack,让 (x+) 吐出1个x,此时(x+)+尝试匹配多出来的一个 x,匹配成功,此时(x+)+匹配到了两轮,分别是 (39个x) 和 (1个x),y匹配失败
  3. 再次 backtrack,(1个x)的这轮被扔掉,y匹配最后一个x,匹配失败
  4. 再次 backtrack,(39个x)吐了1个x出来变成了(38个x),(x+)+匹配剩下的2个x,匹配到了两轮,分别是(38个x)和(2个x),y匹配失败
  5. 再次 backtrack,此时(2个x)的这一轮吐了1个x出来变成了(1个x),y 匹配最后一个x失败 ...

(x+)+匹配 40个x 可以出现 (40)、(39,1)、(38,2)、(38,1,1)、(37,3)...等肉眼可见的指数级复杂度的情况,正则引擎为了成功找到一个匹配为孜孜不倦地将每条路径都尝试一遍,直到最后才敢严谨地给出一个“不匹配”的结果,然而这个过程的耗时足以让程序服务直接不可用了,甭提其对 CPU 的占用会拖垮机器性能。

识别可能导致回溯地狱的正则

Nested Quantifier 嵌套数量词

(x+)+ 这样嵌套数量词(quantifier)的正则表达式是很容易出现 Catastrophic Backtracking 的,原因见上述。除非保证内层的 token 互斥,例如 (x+y+)+z 就是安全的。

当然实际上 (x+)+ 这种奇怪的正则应该没人写得出来,不过原理类似地,用 (.+,)+END 去匹配 csv 字符串 "1,2,3,4,",在遇到长串的 ",,,," 时,也会掉进回溯地狱里的,正确的解法应该是应该是将 . 替换为 [^,]

Tips:大部分时候我们并不需要任意匹配符/./

Alternative 选择器

除了像 *+?{1,10} 这样的 quantifier,| 选择器也会产生可 backtrack 的路径,如果选择器之间出现重叠的部分,像 (.|x)*y 里的 .x 就重叠了,在遇到长串的 x 时也会出现回溯地狱。

具体来说,就是匹配 "xxx" 可以有 (...)、(..x)、(.x.)、(.xx)、(x..)、(x.x)、(xx.)、(xxx) 多种可能的路径,同样是肉眼可见的指数级复杂度,在 "xx...x" 长度稍微长一点时,就足够卡住程序了。

Quantifiers in Sequence 连续的数量词

以 Cloudflare 这次故障的问题正则简化版 .*.*=.* 为例,如果连续的数量词匹配的对象重叠(.. 重叠),或者与分隔符重叠(.= 重叠),也会出现回溯地狱。详细执行过程可以参考官方博客

其它

以 Stack Exchange 故障的问题正则简化版 \s*$ 为例,看起来很正常,目的是为了清理行尾的空格,但是在遇到 20000 个空格的时候,还是让其网站挂了。

原因简单来说就是当 20000 空格的这一行不是以空格结尾时,引擎回溯会尝试从开头第1位、第2位、第3位……开始匹配,这样会产生 20000+19999+...+1=199,990,000次匹配步骤,这比上述的指数级的回溯地狱好一些,但其运行时间也足够让它服务不可用了。

解决方法可以参考下述的 Possessive Quantifier。从这个案例可以学习到,看上去正常不过的正则也可能暗藏杀机。

怎么避免回溯地狱

不用 NFA 型的正则引擎,改用 DFA 型

不用 NFA 就没有 Backtracking,也就没有 Catastrophic Backtracking,一了百了。然而抛弃内置正则引擎,引入新引擎的成本不小,并且用 DFA 型的引擎也意味着要放弃一些特性,例如 backreference。

使用 Possessive Quantifiers 或者 Atomic Grouping

在数量词后面再加个 +,例如 x*+,就把 x* 变成了独占模式,这个额外的 + 就是 Possessive Quantifier。x*+ 在匹配完整个 "xxx" 后,在需要 backtrack 时不会逐位回溯,而是整个放弃。

Possessive Quantifier 的好处是减少了 backtrack 的路径,提升了性能,但是需要注意的是,它可能会抹掉了一些非独占时能完成的匹配,例如 ".*" 可以匹配 "abc"x 但是 ".*+" 不能成功匹配。

Atomic Grouping 与 Possessive Quantifier 的作用类似,写法则是 (?>x*),Atomic Grouping 相对有更多正则引擎支持。

然而 JS 里两者都不支持囧。

设置超时值

一些正则引擎(例如 .NET)支持设置超时值,可以避免正则匹配导致服务长时间无响应,当然这个超时的时长值也是需要权衡的,太长会使服务响应时间的上限变长,太短则引擎不够时间找出确实存在的匹配。

对于不支持设置超时值的正则引擎,可以开一个新的线程来专门跑这个正则,并对这个线程做超时处理。JS 的话可以考虑 WebWorker 或者 childprocess 模块。

Review 代码里的正则表达式

基于前述的常见问题正则的模式,可以人工 review 代码里 hardcode 的正则表达式,发现嫌疑犯就马上对它进行改造。当然也可以借助一些工具进行扫描,例如 JS 程序可以使用 eslint-plugin-security 的 detect-unsafe-regex 规则。

对于动态生成的正则不容易提前 review,此时需要开发者仔细考虑动态生成的正则有没可能掉进回溯地狱里。

Tips: JS 里 hardcode 的正则,比用 new Regexp() 动态创建的正则,少了一个编译的过程,性能上有所提升。

避免运行用户提交的正则表达式

这个显而易见,在服务器上运行用户提交的正则表达式的话,那简直太容易被 ReDoS (Regular expression Denial of Service)了。

如果业务要求没办法的话,至少也按上述方式设置超时值。

多测试 Worst Case

大多数有问题的正则在遇到普通的输入字符串时表现正常,只有遇到极端的输入字符串时才会掉进回溯地狱里。

开发者写一个正则,就应该为它找一个 Worst Case 进行测试,你不找的话,就只能留给黑客帮你找了。

结语

正则表达式是一个复杂又强大的工具,用得好可以轻易地解决一些模式匹配的问题,了解不深的话则可能掉进它的回溯地狱里。只有理解了正则引擎的 Backtracking,才能理解为什么会出现 Catastrophic Backtracking,也才能躲开这个曾经坑害过 Cloudflare 和 Stack Exchange 的陷阱。

参考

Details of the Cloudflare outage on July 2, 2019: blog.cloudflare.com/details-of-…

Stack Exchange Outage Postmortem - July 20, 2016: stackstatus.net/post/147710…

Runaway Regular Expressions: Catastrophic Backtracking: www.regular-expressions.info/catastrophi…

Preventing Regular Expression Denial of Service (ReDoS): www.regular-expressions.info/redos.html

正则绘图工具: www.debuggex.com/

正则可视化调试工具: metacpan.org/pod/Regexp:…