正则回溯爆炸:一个表达式把 CPU 打满,3 步改写救回服务
一个看起来逻辑正常的正则,遇到恶意或异常长的输入,可能从几毫秒变成几十秒,直接把服务的 CPU 顶到 100%。这篇文章用本机跑出来的数据说明回溯爆炸是怎么发生的,并给出 3 个能直接落地的修复方法。
为什么说这是“隐蔽雷”
正则引擎大多采用回溯(backtracking)来处理可选分支。Pattern 里出现一个“量词包量词”时,引擎会在不同拆法之间反复试,路径数随输入长度指数增长。
最经典的坏味道是这个:
import re
pattern = re.compile(r"^(a+)+$")
它想表达“整串都是 a”。但如果输入是 aaaaaaaa...X,结尾多一个不匹配的 X,(a+) 和外面这个 + 就会把前面几十个 a 拆成无数种组合,每一种都要推到结尾才发现失败。字符串长度每加 1,尝试次数就接近翻一倍。
复现:同一段匹配,耗时怎么涨
我把下面这段在 Python 3.14 本机跑了一遍,改变 n 观察耗时:
import re, time
def match_time(n: int):
s = "a" * n + "X"
t = time.perf_counter()
re.match(r"^(a+)+$", s)
return time.perf_counter() - t
for n in (20, 22, 24, 26):
print(n, round(match_time(n), 4))
| a 的个数 | 本机耗时(秒) |
|---|---|
| 20 | 0.0392 |
| 22 | 0.1565 |
| 24 | 0.6256 |
| 26 | 2.5148 |
每多 2 个字符,耗时约变为 4 倍;套成单字符就是约 2 倍。按这个趋势,n=30 会到几十秒,n=36 就会上千秒。像 (a+)+$ 这类模式,只要输入长度不受控,就是线上定时炸弹。OWASP 等安全资料里把这类问题统称为 ReDoS。
3 步改写救回服务
第一步:去掉嵌套量词,回到单一匹配路径。
同样的“整串都是 a”,直接写成线性模式:
import re
# 原来:会指数回溯
re.fullmatch(r"(a+)+", s)
# 改后:一次扫到尾
re.fullmatch(r"a+", s)
大多数 ReDoS 都能靠这步解决:把 (a+)+、([a-z]+)+、(\d+)+ 这种“一个字符组加一层量词,外面再套一层量词”改成一层。
第二步:能改写就改写,改不了就限制输入长度。
有些复杂 pattern 不能简化,那就先卡住输入:if len(text) > 512: reject。安全资料里的建议也类似——不可信的输入,在做复杂解析前先限长。宁可拒绝“过长”,也别让一个请求把整台机器拖住。
第三步:给匹配加超时,防住漏网之鱼。
Python 标准库的 re 没有内置超时,这也是 ReDoS 在 Python 服务里容易失控的原因。第三方 regex 包提供了 timeout 参数:
import regex
try:
regex.match(r"^(a+)+$", bad_text, timeout=1)
except TimeoutError:
# 1 秒没匹配完就当失败处理,不再死等
pass
如果不想引入新依赖,就把正则放进工作进程执行,主进程设个超时把它干掉。核心思路都一样:匹配可以被拒绝,但服务不能被一个表达式按死。
结果与下一步
这一轮跑完得到三个能直接带走的结论:
^(a+)+$这类“量词包量词”是本机实测的指数坑,n从 20 到 26,耗时从 0.04 秒涨到 2.5 秒。- 优先去嵌套量词改写,其次限制输入长度,最后用 timeout 兜底。
- 看到网上复制来的复杂正则,先扫一眼有没有两层量词套同一个字符组。
Podcast 里常听到“一个正则让服务挂了”,真正落地处理时别只补小时段重启,先查 pattern 本身是不是 ReDoS。把这三步写进 code review 检查项,比自己记几个零散案例更省心。
- 点赞
- 收藏
- 关注作者
评论(0)