这篇是今年 Oakland 的文章,介绍了当前许多编程语言的正则表达式库的一个遗留漏洞,并提出了一个新的解决方案。

文章目录


  1. 背景
    1. 正则表达式基础
    2. 代码举例
  2. 算法优化
    1. 结果缓存
      1. 选择性存储
      2. 表格编码
  3. 实验结果
  4. 其它
  5. 引用

简介现在许多web应用中都使用了正则表达式 (Regular Expression, 下文简称 regex),而一些正则表达式引擎本身存在着缺陷,攻击者可以利用这些缺陷对服务平台进行拒绝服务攻击(DoS, Denial of Service),我们称这种利用正则表达式进行的攻击为*ReDoS (Regular Expression Denial of Service)*。ReDoS利用正则表达式引擎对于某些特定的正则表达式执行缓慢的漏洞来攻击。本文提出一种新的方法来优化正则表达式引擎的时间复杂度上界,将这些引擎的最坏执行时间改善至线性时间。当然,天下没有免费的午餐,文中提出的优化是通过空间消耗换取的。为了降低空间消耗,作者提出了两种降低内存消耗的方案,可以让用户在时间、空间之间进行权衡。

背景

在当前很多使用主流编程语言实现的应用中,正则表达式已成为DoS攻击的一种重要途径。Regex作为一种重要的字符串匹配工具,在很多系统应用中都扮演着重要角色,这就导致许多web应用都存在被ReDoS攻击的风险。一个比较著名的攻击事件发生在2019年,当年Cloudflare被ReDoS攻击了,导致了平台上数千个服务被影响。

ReDoS的根本问题是正则表达式引擎对于某些表达式的执行耗时极长。当正则表达式被用于处理用户输入时,恶意用户可以通过构造这些耗时的regex来极大消耗系统资源,已到达攻击目的。

正则表达式基础

大多数现代编程语言中都集成了正则表达式处理的库函数,用于方便开发人员进行字符串匹配工作。Regex引擎(库)的工作方式可以使用非确定有限自动机 (non-deterministic finite automaton, NFA)来建模。

NFA用于描述字符串的可达性:是否存在一条路径,可以让字符串从NFA示意图的出发点走到结束点,在此过程中,字符串中的字符都与示意图的边上的字符匹配?下面,我们看一个简单的例子:

NFA 示意图

上图中,上半部分为NFA的示意图,下方部分为对应的正则表达式(其中符号 $\epsilon$ 仅表示路径可达,不匹配任何字符):

  • 第一个图匹配单个字母a
  • 第二个图匹配两个连续的字母ab
  • 第三个图匹配任意多个字母a,其中星号表示任意多个(可以为0个)
  • 第四个图匹配字母a或者字母b,竖线符号就是我们在很多编程语言中使用的“或”符号

Regex引擎的作用就是将这些正则表达式通过隐式或者显式的方法,将输入的regex转化为NFA,然后再进行字符串的匹配:

  • 大多数编程语言中的regex引擎使用的算法都继承自 Spencer[2] 这个带回溯 (backtracking) 的算法。这类算法使用深度优先搜索,它们从NFA示意图的左边开始,沿着NFA中与字符串索引对应位置的字符相同的路径向右遍历。当遇到分叉时,它们选择其中一条路径继续遍历,而将其它的分叉先保存起来以便后续使用。此类算法在最差情况下有指数级的时间复杂度
  • 有极少数语言(Rust, Go)中使用了 Thompson 的 "lockstep" 算法 [3] ,此算法是一个广度优先搜索算法。当遇到一个分叉时,算法会尝试所有分叉,跟踪记录所有可能发生的的NFA状态(匹配路径及对应字符串下标)。此类算法在最差情况下有现行的时间复杂度

本论文要解决的问题是 Spencer 算法的问题,此类算法在最差情况下有指数级的算法复杂度。

为什么那么多的编程语言不使用 Thompson 算法?关于此,大家可以自行搜索,总的来说这应该是历史遗留问题,现有的编程语言开发人员对于改变算法也没什么动力。

代码举例

我们使用python代码举一个简单的例子,我们使用正则表达式pattern=(a|a?)+b,使用字符串连续 na作为匹配对象。我们可以看到正则表达式 pattern 中包含了分叉,而我们使用 n 个 a 作为匹配对象,显然无法匹配以字母 b 结尾的 pattern,所以python的正则表达式引擎会去搜索所有的路径,这样和我们之前的分析一样,时间复杂度为 $O(2^n)$。

执行如下代码,查看结果:

#!/usr/bin/env python

from datetime import datetime
from re import match

ptn = r'(a|a?)+b'
for i in range(15, 25):
    start = datetime.now()
    match(ptn, 'a' * i)
    print(f'a * {i} cost: '
          f'{(datetime.now() - start).total_seconds():>6.3f} seconds')

在我的18年mac笔记本上,使用python3.8的执行结果如下:

a * 15 cost:  0.012 seconds
a * 16 cost:  0.026 seconds
a * 17 cost:  0.048 seconds
a * 18 cost:  0.095 seconds
a * 19 cost:  0.203 seconds
a * 20 cost:  0.374 seconds
a * 21 cost:  0.745 seconds
a * 22 cost:  1.717 seconds
a * 23 cost:  2.971 seconds
a * 24 cost:  5.900 seconds

我们可以看到字符串为aaaaaaaaaaaaaaaaaaaaaaaa(24个)的执行时间竟然需要近6秒,显然这样的结果在正常使用中显然是无法接受的。

实际上,python 中有re2包,它对谷歌的RE2实现进行了封装,使用此包可以很容易解决上述问题。我们可以通过命令pip install re2安装 re2 包,而后将上面代码中的from re import match改为from re2 import match后,代码执行速度是飞快的,大家可以自行一试。

算法优化

本文优化算法的提出是在以下两个猜想的前提下进行的:

  • 重新实现算法表达式引擎是不可行的,这样做的工程代价太高,而且风险也太大
  • 解决ReDoS攻击带来的问题是很有吸引力的,经验告诉我们超线性的正则表达式在实际使用中非常常见,而且在一些性能要求很高的上下文中也经常被用到

因此,算法的优化是针对Spencer这类regex引擎的,直接针对算法引擎进行调整要比重新实现的工程代价要低很多,而且对外部(regex使用上下文)的影响也较小。

本文的优化思想是:使用空间换时间。将搜索过的数据保存下来,这样下次就无需再次进行搜索了(这个和我们计算斐波那契数列时保存中间结果类似)。

结果缓存

从出问题的regex来看,这些导致最差搜索情况出现的原因是搜索算法中出现了大量的重复搜索。我们可以使用 Michie 的记忆缓存的方法来缓解这个冗余计算问题。该方法的基本思想就是:如果你需要计算一个函数很多次,那么你可以将已经计算过的输入与其对应的输出记录下来,下次在看到同样的输入时,直接返回已存的结果即可,而无需重复计算。

在正则表达式引擎中,匹配结果由以下三项内容决定:

  1. 当前正在匹配的NFA节点的位置
  2. 字符串中当前正在匹配的字符的索引下标
  3. 反向引用中已经匹配的字符串片段

反向引用:反向引用用于匹配一个之前已经匹配过的字符串,属于传统 perl regex 的扩展语法。通常反向引用的语法为一个反斜杠加一个数字比如\1数字表示匹配的是之前的那一个组 。举例来说表达式(abc|def)+\1会匹配abcabc或者defdef,但是不会匹配abcdef,因为\1要求此处应该匹配与第一组(用小括号括起来的那个分组)一样的内容。

从匹配记录的角度来看,假设我们不考虑记录反向引用,令 NFA 的长度为$m$,需匹配的字符串长度为 $n$ 。那么,我们最多需要使用一个$m*n$的表格来记录匹配结果,一旦表格中所有的位置都返回了不匹配,我们就可得出结论,字符串与regex不匹配。

对于$m*n$表格中的每一元素来说,由于 NFA 中最多只有$m$项,因此这每一个元素的时间复杂度为$O(m)$,因此我们最多需要时间$O(m^2n)$来填满这个表。一般情况下,服务器端的regex长度$m$都是固定的,因此最终时间复杂度和字符串的长度$n$呈线性关系。

相比于原本算法的指数时间复杂度来说,线性时间复杂度已经非常好了。但是考虑到实际我们在使用时,正则表达式的长度$m$与字符串的长度$n$都可能比较大,这样就导致我们用于记录的表格很大,以致占用过多的内存空间。

为了降低存储空间的消耗,文中提出了两个进一步的优化方法:选择性存储与表格编码。

选择性存储

当前我们存储表格的大小为$节点个数(NFA) \times 字符串长度$,为了节省空间,我们可以进行如下优化:

  1. 仅记录入度大于 1 的NFA节点(显然入度为1的节点不会导致冗余搜索,因为到达此节点仅有一条路径)。可能导致入度大于1的表达式举例:选择表达式a|a,重复表达式a{1,3}a*
  2. 仅记录环祖先 (cycle ancestors)节点。这里环祖先节点指的是有向图中后向边所指向的节点。这种操作并不会完全杜绝冗余搜索,但是它对冗余搜索这一操作进行了限制,在此限制下,冗余搜索量为一个仅依赖于正则表达式NFA的一个常量,而不依赖于用户输入的字符串。在此限制下,对于NFA中的一个环,我们最多会执行一次搜索。

下图分别展示了入度优化(左)与环祖先(右)优化对应的节点,图中深色节点表示我们需要在表格中记录的节点。

入度优化与环祖先优化

表格编码

我们可以使用一个二维数组存储数据,其中每一行存储了一个数组,表示某个NFA节点对应的字符串索引的访问情况。此方法的缺点是我们需要在regex匹配开始就分配好存储空间,而实际运行时我们极少情况下会将整个二维数组的数据填满。

一种改进的方法是我们可以使用可以使用一个可以随机存储、读取、更新的数据结构来存储数据,比如哈希表。一个简单的例子是我们可以使用python中的dict来存储数据,字典中的键为NFA节点的位置索引,值为字符串的位置列表/集合。这样只有在某个位置被访问到,才会消耗存储空间。

为了进一步降低存储空间,我们可以对表格数据的每一行数据(对应某个NFA节点)都进行压缩。这些数据可以被压缩,基于以下两个设想:

  • 通常只要数据不是完全随机的,我们就可以有效的对数据进行压缩
  • 正则表达式是用来匹配字符串的,regex中的某个元素仅仅会匹配特定的字符(除了通配符以外),所以在很多情况下,一个NFA对应的一行数据中很多位置是用不到的

论文中提出使用游程编码的方式对数据进行编码,以降低存储空间。对于游程码,我们举一个简单的例子:我们有一串数据AABBBCCCCDDDDDEEE,它由2个A,3个B,4个C,5个D和3个E组成,经过编码我们可以得到压缩后的数据为:2A3B4C5D3E。这种编码方式在数据中有大量连续重复数据的时候可以很大程度上压缩数据,而regex匹配恰好符合这种情况。

对此编码方式,在最差的情况下的空间复杂度和使用普通二维数组存储的复杂度是一样的。但是在大多数情况下可以很大程度压缩数据。对于数据查询,我们可以将访问向量表示成二叉树的形式。虽然这样访问时间会有些许下降(从常量时间变为$O(log\text{k})$时间,k为二叉树中节点数量),而存储空间可以降低非常多。

实验结果

对于执行时间,实验结果与论文中分析的一致,本文提出的优化方法的时间消耗与被匹配字符串的长度呈线性关系。

对于存储空间的消耗,使用哈希表来存储数据消耗的空间在实际实验时比实验直接使用二维数组存储消耗更多。而使用游程编码可以降低90%左右的空间消耗。

其它

ReDoS问题从上世纪60年代开始出现,直到2002年才被正式详细地定义出来。虽然对于regex引擎来说,存在一些可以确保时间复杂度的解决方案,但这些方案由于一些实际原因,很多正在运行的系统并没有适配。本文提出的方案容易集成到现有存在问题的regex引擎中,可以作为现有一些正则表达式引擎的一个补丁以预防超线性时间复杂度的regex造成危害。

论文中还讨论了本文提出的方案如何支持正则表达式的扩展语法,比如反向引用、断言等,感兴趣可以详细阅读论文查看。

引用

[1] Davis, James C., Francisco Servant, and Dongyoon Lee. "Using selective memoization to defeat regular expression denial of service (ReDoS)."2021 IEEE Symposium on Security and Privacy (SP), Los Alamitos, CA, USA. 2021.

[2] Spencer, 1994.A regular-expression matcher.

[3] Thompson, 1968.Regular expression search algorithm.


[本]通信工程@河海大学 & [硕]CS@清华大学
这个人很懒,他什么也没有写!

0
3031
0

更多推荐


2022年11月30日