仓库源文站点原文


title: "正则灾难性回溯与防御方法" categories: Tech updated: comments: true

mathjax: false

灾难性回溯

考虑用正则表达式 (x+x+)+y 匹配字符串 xxxxxxxxxxy (10 个 x). 显然正则可以改写为 xx+y 或者 x{2,}y, 但先不管这点, 该例旨在展示匹配机制.

删除字符串结尾的 y 使得正则匹配失败会导致 灾难性回溯 (catastrophic backtracking).

<!-- more -->

regex101 上尝试不同长度的 x, 可见复杂度为 O(2^n), 其中 n 为字符串长度.

示例: 匹配 CSV

^(.*?,){11}P 匹配 CSV 文件中第 12 列开头为 P 的行. 用它匹配 "1,2,3,4,5,6,7,8,9,10,11,12,13" 会发生灾难性回溯.

示例: 匹配 HTML

考虑 <html>.*?<head>.*?<title>.*?</title>.*?</head>.*?<body[^>]*>.*?</body>.*?</html> 匹配 HTML 文件 (开 single line mode 应对换行).

如果 HTML 文件少了一些 tags, 会导致灾难性回溯. 比如去掉文件末尾的 </html>, 末尾匹配失败后, 放弃 </body>.*? 匹配的部分, </body> 前面的 .*? 向后查找第二个 </body>, 失败... 一共 7 个 .*?, 因此复杂度是 O(n^7).

优化正则: 独占模式与原子组

一开始的正则表达式 (x+x+)+y 可以改写为 (x+x+)++y, 其中额外的 + 是 possessive quantifier; 或者改写为 (?>(x+x+)+)y, 其中 ?> 表示 atomic group. Python 3.11 才终于把这两个特性加入标准库 re 中.

独占模式. 例如用 b{1,3}+bc 匹配 "bbc". 类似贪婪模式, 首先 b{1,3}+ 尽可能多地匹配, 匹配到 "bb", 之后用 b 匹配 "c" 失败尝试回溯. 此时独占模式 b{1,3}+ 视为整体不在里面回溯, 导致整体匹配直接失败.

匹配 CSV. 希望逗号作为分隔符, 正则 ^(.*?,){11}P 可以改写为 ^([^,\r\n]*,){11}P, 这样只需回溯 11 次. 也可以改写为 ^(?>(.*?,){11})P. 原子组 匹配到的东西视为一个整体 (所以叫原子). 正则引擎会

进一步优化正则可写为 ^(?>((?>[^,\r\n]*),){11})P, 因为独占+贪婪模式比懒惰模式更快; 或者用独占模式写为更简洁的 ^(?>([^,\r\n]*+,){11})P.

匹配 HTML. 原始正则 <html>.*?<head>.*?<title>.*?</title>.*?</head>.*?<body[^>]*>.*?</body>.*?</html> 可用原子组写为 <html>(?>.*?<head>)(?>.*?<title>)(?>.*?</title>)(?>.*?</head>)(?>.*?<body[^>]*>) (?>.*?</body>).*?</html> 因为明确知道这些 tags 只会出现一次, 以此避免回溯.

服务端如何防御正则攻击

如果服务端自己写死了正则, 那么自己排查即可. (可以参考 这篇文章 提到的检测工具, 我没用过.) 遇到过正则攻击 (regular expression denial of service) 的案例包括

如果接受用户端自定义的正则, 有这些方法.

使用文本导向的正则引擎

正则引擎有两种: 文本导向 (text-directed, 或者 DFA) 和正则导向 (regex-directed, 或者 NFA). 前面谈论的灾难性回溯问题都假定用了正则导向的引擎, 这也是大多数正则引擎的实现方式 (包括 Python). 文本导向的引擎保证了线性时间复杂度, 但因为不支持回溯, 也不支持 backreference 和 lookaround (比如 (?=...), (?<=...) 等), 表达性不如正则导向的引擎.

绝大多数情况下两种引擎匹配结果相同. 可以考虑换用文本导向引擎, 如 Google 的 re2 (对 Python 来说, 遇到引擎不支持的特性时, 会 fall back 到原生的 re 库). 用 re2 时性能问题见 issues/420.

限制时间

很多语言支持设置正则最大匹配时间/回溯次数等. 但是 Python 没有现成的支持 (可以参考 这个, 但总体不推荐).

进一步优化正则

参考

扩展阅读 (我没读过)