正则匹配引发的血案

引子:一家商业IT服务公司,提供给客户的服务突然中断了将近一个小时,事后排查原因,竟然是因为一个正则表达式引起的,小小的正则表达式何以引起如此严重的问题?

 

事情的原因是由于正则解析导致cpu资源消耗殆尽,引起连锁反应,后续的服务都无法对外提供。引起故障的正则表达式是这样的,“(?:(?:\"|'|\]|\}|\\|\d|(?:nan|infinity|true|false|null|undefined|symbol|math)|\`|\-|\+)+[)]*;?((?:\s|-|~|!|{}|\|\||\+)*.*(?:.*=.*)))”,其中关键的部分是“.*(?:.*=.*)”,需要了解一点正则引擎工作的原理,比如“?:”是一个非捕获组表达(括号中的表达式会被分组聚合成一个单独的表达式)。

为了简化问题,可以将“.*(?:.*=.*)”,简化为“.*.*=.*”,这样的表达式简直复杂到不知所云。任何一个试图表达“匹配任何一个后面跟着任何字符的字符”这样的表达式都会引起灾难性的回撤,这个往往会导致问题。

 

 

在正则表达式中,“.”表示匹配一个字符,“*”表示匹配零个或多个字符,并且尽量多的匹配。所以,“.*.*=.*”意味着先匹配这零个或多个字符,接着再匹配零个或多个字符,然后匹配一个“=”,然后再匹配零个或多个字符。

假设有字符串“x=x”,就可以匹配“.*.*=.*”。“.*.*”可以匹配第一个“x”,比如“.*”匹配“x”,另一个“.*”匹配零个字符,最后一个“.*”可以匹配最后的“x”。

这个匹配成功的过程总发生了23次匹配。首先,第一个“.*”匹配了“x=x”中的所有的字符,当引擎尝试去匹配下一个“.*”时,已经没有可以匹配的了,所以直接匹配零个字符,然后引擎尝试去匹配“=”,同样因为没有剩余字符了,所以匹配失败。

这种情况下,引擎就会回撤,只用第一个“.*”匹配“x=”,然后第二个“.*”会匹配成功“x”,同样,当引擎尝试去匹配“=”时又发现没有剩余字符而无法匹配,匹配失败,引擎再次回撤。

这次还是会让第一个“.*”来匹配“x=”,但是第二个“.*”不匹配任何字符,引擎就会接着去匹配“=”,自然匹配失败,然后引擎再次回撤。

接着来,现在第一个“.*”只匹配一个“x”,然后第二个“.*”则匹配成功了“=x”。不用说你也明白了,下来匹配“=”的时候,自然不会成功,再次发生回撤。

再次,“.*”匹配了第一个“x”,然后第二个“.*”匹配了“=”,同样显而易见,正则中的“=”再次无法匹配成功,引擎再次回撤。

我们不去推演每一次匹配了,直接说匹配成功的过程。第一个“.*”匹配成功“x”,然后第二个“.*”匹配零个字符,然后表达式中的“=”匹配成功字符串中的“=”,第三个“.*”会匹配成功“x”,最终表达式和字符串匹配成功,这仅仅是对一个只有3个字符的字符串的匹配过程。

下图是完整的匹配23次的过程,采用的引擎是perl的引擎,你会看到执行的步骤和回撤的过程。

 

 

如果字符串从“x=x”变成“x=xx”会发生什么?显然会有更多的回撤发生,事实上,会发生33次匹配的过程,如果字符串变为“x=xxx”则会发生45次匹配,这种匹配次数的增加是非线性的,如果字符串变为“x=xxxxxxxxxxxxxxxxxxxx”(“=”后面有20个x),则匹配过程会变为555次,下图展示了匹配过程。(如果字符串没有了开头的“x=”,引擎会执行4947次,最后发现无法匹配成功)。

 

下面的动图同样展示了字符串“x=xxxxxxxxxxxxxxxxxxxx”匹配的过程:

 

 

 

这是一种糟糕的情况,当输入参数的长度只有少量增加的时候,耗费的时间却是非线性的大量的增加。如果正则表达式适当修改,情况会更糟。看看这个正则匹配表达式“ .*.*=.*;”,相比上面的表达式,其后面多了一个“;”,这样的表达式可能被用来匹配字符串“foo=bar;”。

这个表达式用来匹配“x=x”就不是发生23次匹配,而是90次,如果采用“=”后面有20个“x”的字符串,则会发生5353次匹配。下面是一张对应的图表,会看到Y轴的增长非常迅速。

 

 

同样,下面的动态展示了需要匹配的5353次的过程。

 

 

如果在匹配的过程中使用懒匹配策略而不是贪婪策略,则发生回撤的次数会减少。如果将表达式改为“.*?.*?=.*?”,则匹配字符串“x=x”会发生11次匹配(之前是23次)。因为“.*”之后的“?”告诉引擎在匹配下一个模式之前只需要匹配最小的字符就可以。

但是懒惰策略也并不能完全解决问题,比如表达式从“.*.*=.*;”变成“.*?.*?=.*?;”,匹配字符串“x=x”依然要发生555次匹配,匹配“x=xxxxxxxxxxxxxxxxxxxx”依然要发生5353次匹配。

真正的解决方案,如果不想重新匹配模式的话,是需要摆脱正则匹配规则引擎的这种回撤机制。其实类似的事情从1968年Ken Thompson的论文“Programming Techniques: Regular expression search algorithm”就有解决方案。

 

本文节选翻译自https://blog.cloudflare.com/details-of-the-cloudflare-outage-on-july-2-2019/#appendix-about-regular-expression-backtracking

posted @ 2019-08-29 19:44  boiledwater  阅读(420)  评论(0编辑  收藏  举报