浅析正则表达式性能问题

简介: 浅析正则表达式性能问题

5992ca2bcc7fc6f9f27b2a39b0d9f1e3_640_wx_fmt=jpeg&wxfrom=5&wx_lazy=1&wx_co=1.jpg

2019 年七月初,Cloudflare 曾经全球中断服务,原因是为了改进内联 JavaScript 屏蔽,部署了一条正则表达式组成的 WAF 防御规则,耗尽了 CPU 资源。

Cloudflare WAF 的引擎还是 backtraking,所以导致错误的正则写法产生回溯问题,最终 ReDos(正则表达式拒绝服务攻击)。它会导致计算量急剧的放大,使大量网站访问出现了 502。


正则引擎原理


提到正则表达式引擎,首先需要涉及到一个概念:有限状态自动机

有限状态自动机(FSM "finite state machine" 或者FSA "finite state automaton" )是为研究有限内存的计算过程和某些语言类而抽象出的一种计算模型。有限状态自动机拥有有限数量的状态,每个状态可以迁移到零个或多个状态,输入字串决定执行哪个状态的迁移。

传统正则表达式引擎分为两类,分别是 NFA(非确定性有限状态自动机)和 DFA(确定性有限状态自动机)。

最直观的区别就是 NFA 在语法解析的时候,构造出的一个有向图。然后通过深搜的方式,去一条路径一条路径的递归尝试,因此速度较慢,不过实现的功能更丰富,对正则编写功底较高,否则容易因为回溯造成性能问题。DFA 会把正则表达式转换成一个图的邻接表,然后通过跳表的形式判断一个字符串是否匹配该正则,因此匹配速度较快,但是不支持捕获组和断言等功能。


Cloudflare的正则分析


(?:(?:\"|'|\]|\}|\\|\d|(?:nan|infinity|true|false|null|undefined|symbol|math)|\`|\-|\+)+[)]*;?((?:\s|-|~|!|{}|\|\||\+)*.*(?:.*=.*)))

Cloudflare WAF 简化正则之后的出问题的其实是 .*.*=.* 这个模式。这个模式看起来并不是很复杂,要求正则引擎匹配任何值 = 任何值,但是这会导致非常严重的回溯问题。

在正则表达式中 . 表示匹配单个字符,而 .* 表示尽可能多的匹配字符(贪婪匹配,可以是零个字符),在使用贪婪匹配或者惰性匹配或者或匹配进入到匹配路径选择的时候,遇到失败的匹配路径,尝试走另外一个匹配路径的这种行为,称作回溯。因此  .*.*=.* 表示匹配零个或多个字符,然后匹配零个或多个字符,然后匹配一个 = 字符,然后匹配零个或多个字符。

我们使用 a=b 来作为测试文本,正则引擎为 PCRE2(PHP>=7.3)

  1. 第一个 .* 贪婪匹配 0 个字符
  2. 第二个 .* 贪婪匹配剩余全部字符
  3. = 尝试匹配,由于没有更多字符可以匹配了,匹配失败
  4. 引擎向前回溯
  5. 第二个 .* 贪婪匹配到字符 a=
  6. = 尝试匹配字符 b,匹配失败
  7. 引擎向前回溯
  8. 第二个 .* 贪婪匹配到字符 a
  9. = 尝试匹配字符 b, 匹配失败
  10. = 尝试向前匹配 =,匹配成功
  11. 第三个 .* 贪婪匹配 0 个字符
  12. 第三个 .* 贪婪匹配剩余全部字符,正则完成全部字符匹配

62b6f2e20c2f2f47de20a40efcd0b0b4_640_wx_fmt=gif&wxfrom=5&wx_lazy=1.gif

12 次匹配只为了匹配三个字符的字符串(Cloudflare 使用的正则引擎为 backtraking,匹配次数为 23 次),如果测试字符串从 a=b 变为 a=bb,完全匹配需要 17 次,a=bbb 需要 23 次,当 b 的个数为 20 时,次数达到了 278 次。如果 a= 缺少了,那需要匹配次数会增加到 2023 才能得到匹配失败的结果。

b1f92a1487766786fc543bd385b5c895_640_wx_fmt=jpeg&wxfrom=5&wx_lazy=1&wx_co=1.jpg

随着字符数量的增加,匹配需要的时间也相应的增加

PCRE2 为 a(n) = (n^2 - 3\*n + 6)/2,n = b + 5

backtraking 为 a(n) = n^2 + n + 3,n = b + 3

图为 b = 15 时的匹配动画

accc5e9ae661f3a5d42c3c5b2019157d_640_wx_fmt=gif&wxfrom=5&wx_lazy=1.gif

如果稍微修改一下正则表达式,情况就会更糟,比如修改它为 .*.*=.*;(在表达式末尾增加一个分号),PCRE2 引擎的匹配次数会小幅增加到 304 次, 而 backtraking 则会暴涨至 5353 次。更极端的情况下,使用 X(.+)+X 去匹配字符串 ==XX==================== 会引发回溯失控,正则引擎会在第 119989 步终止,并返回匹配失败。


不同语言自带的正则性能比较


避免此类问题的方法就是尽可能使用高效的正则表达式引擎,比如 RE2RustPCRE 等,不同的引擎之间有着较大的性能差异,这里使用 Regex 进行测试,测试仅供横向对比参考,不同的表达式在不同的引擎上各有优劣,实际速度与计算机性能相关。

正则表达式为 .*.*=.*;,测试文本为 a=bb…bb(100 个 b),进行多次测试。

PHP(PCRE),耗时 0.7ms-1ms

JavaScript 速度较快,0.4ms-0.6ms

Python 耗时 0.7ms-0.8ms

Golang 最慢,耗时大于 165ms

Java 耗时大于 5ms

注:JavaScript 在 Chrome Console 中使用表达式 X(.+)+X 的测试耗时超过 20s,而在 Regex 的测试中耗时约为 200ms ,可能是对 JavaScript 的实现不同导致的。从 Chrome 88 开始,Chrome 新增了一项实验性非回溯 RegExp 引擎,它可以保证在字符串长度变大的情况下保持线性的时间变化,可以在添加启动参数 --enable-experimental-regexp_engine-on-excessive-backtracks 在过多的回溯上启用对非回溯引擎的回退(NFA 与 DFA 混用)。


优化建议


某些格式的正则表达式可能涉及大量查找最佳匹配工作,会导致性能的降低,甚至产生预期之外的结果。正则表达式的很多优化技巧都是围绕着减少回溯这样一个原则进行优化的。

例如要匹配 ; 之前并且包括该字符的文本,不要使用模式 .*;,此模式将匹配文本中最后一个 ; 字符之前的文本,其中包括匹配的文本中前面的所有 ;,使用 [^;]*; 则可以避免很多无效的匹配。

同样的,.* 模式会强制匹配到文本的末端并开始查找最佳匹配导致性能较差,除非要匹配到所有剩余数据。

具有冗余嵌套重复的表达式,如 ([a-z0-9]+)* 会导致查找最佳匹配时进行多次搜索,尽可能使用精准匹配条件来使表达式保持简单。

使用取反 ^ 代替 . 进行精准匹配也是不错的选择,如匹配字符串 <div style="color: red">123456</div> ,表达式 <div[^?>]+>[^<]+<\/div> 只用了 14 次匹配,而表达式<div.*?>.*?<\/div> 的匹配次数达到了 39 次。


总结


造成 Cloudflare 这次事件的原因是使用了性能较差的正则引擎以及有问题的正则表达式,造成了灾难性的回溯(然而大部分语言的正则引擎都是使用NFA)。使用 DFA 或许是个好办法,但是不支持断言等功能使会易用性降低。平时写正则的时候尽可能少用模糊匹配可以有效缓解回溯问题。关于 NFA 与 DFA 原理更详细的解释可以参考这篇文章 DFA和NFAhttps://www.iteye.com/blog/hooopo-548087


相关文章
|
算法
如何优化正则表达式性能?
一.背景 正则表达式是计算机科学的一个概念,很多语言都实现了它。正则表达式使用一些特定的元字符来检索、匹配以及替换符合规定的字符串。 构造正则表达式语法的元字符,由普通字符、标准字符、限定字符(量词)、定位符(边界字符)组成,详情如下 二.正则表达式引擎 正则表达式是一个用正则符号写出的公式,程序对这个公式进行语法分析,建立一个语法分析树,再根据这个分析树结合正则表达式的引擎生成执行程序(这个执行程序我们把它称作状态机,也叫状态自动机),用于字符匹配。 而这里的正则表达式引擎就是一套核心算法,用于建立状态机。
643 0
如何优化正则表达式性能?
|
机器学习/深度学习 移动开发 测试技术
Replace方法与正则表达式的性能比较
今天做项目时遇到一个小需求:要将字符串中的回车符号替换成其它符号(比如"")。 考虑到不同的情况下,有些系统中是用\r\n作回车符,有些仅用\n就代表回车符了。以前都是用String类的Replace方法连接替换多次来处理的,今天突然想改为正则表达式一次性搞定,但又怕性能上消耗太大,于是写了下面的测试代码: using System; using System.
1387 0
Python 内置正则表达式库re的使用
正则表达式是记录文本规则的代码,用于查找和处理符合特定规则的字符串。在Python中,常通过原生字符串`r&#39;string&#39;`表示。使用`re.compile()`创建正则对象,便于多次使用。匹配字符串有`match()`(从开头匹配)、`search()`(搜索首个匹配)和`findall()`(找所有匹配)。替换字符串用`sub()`,分割字符串则用`split()`。
|
6月前
|
数据库 Python
Python网络数据抓取(8):正则表达式
Python网络数据抓取(8):正则表达式
64 2
|
6月前
|
自然语言处理 JavaScript 前端开发
Python高级语法与正则表达式(二)
正则表达式描述了一种字符串匹配的模式,可以用来检查一个串是否含有某种子串、将匹配的子串做替换或者从某个串中取出符合某个条件的子串等。
|
6月前
|
安全 算法 Python
Python高级语法与正则表达式(一)
Python提供了 with 语句的写法,既简单又安全。 文件操作的时候使用with语句可以自动调用关闭文件操作,即使出现异常也会自动关闭文件操作。
|
6月前
|
Python
Python使用正则表达式分割字符串
在Python中,你可以使用re模块的split()函数来根据正则表达式分割字符串。这个函数的工作原理类似于Python内置的str.split()方法,但它允许你使用正则表达式作为分隔符。
|
6月前
|
Python
Python中re模块的正则表达式
【6月更文挑战第2天】了解Python的re模块,它是处理正则表达式的核心工具。正则表达式用于在文本中查找特定模式。本文讨论了re模块的用法和技巧,包括导入模块、匹配、分组、替换文本、编译正则表达式以及使用预定义字符类、量词、锚点等高级功能。通过实例展示了如何在Python中执行这些操作,帮助提升文本处理能力。掌握这些技巧将使你更有效地利用正则表达式解决字符串处理问题。
64 2
|
6月前
|
Python
Python正则表达式详解:掌握文本匹配的魔法
Python正则表达式详解:掌握文本匹配的魔法
|
6月前
|
Python
python re 正则表达式库的使用
python re 正则表达式库的使用
49 0