正则表达式 ReDoS 完全指南:从一次"一个字符串把 CPU 打满、worker 卡死几十秒"看懂灾难性回溯

2021 年我做一个 Web 表单服务每个提交进来都要校验一堆字段邮箱 URL 手机号写正则这件事我压根没多想第一版我做得很省事写正则不就是描述出我想匹配的字符串长什么样能匹配上就行我对着邮箱的样子写一串 pattern 用 re.match 一套匹配上返回真匹配不上返回假就完事了本地开发时真不错我填几个正常的邮箱正则稳稳地判对几行代码搞定我心里很踏实可等这个服务真正上线面对成千上万个真实用户一串问题冒了出来第一种最先把我打懵某次线上 CPU 突然飙到 100 是一个用户提交的字符串让那个看起来人畜无害的邮箱正则把一个 worker 进程整整卡死了几十秒第二种最难缠有用户提交了一个特制的字符串正则跑了几分钟都没出结果第三种最隐蔽同样这条正则本地测试集跑得飞快可线上偶尔某个请求就莫名其妙卡住第四种最莫名其妙我后来加了输入长度限制可一个才 30 个字符的输入照样能把正则卡死我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为写正则就是描述出我想匹配的字符串长什么样这句话把正则当成了一句声明可它不是一条正则会被编译成一个真正在运行的回溯程序引擎拿着你的输入一个字符一个字符地试探着匹配一旦走进死路它会退回到上一个岔路口换一条路再试有一类写法量词套着量词相邻的量词争抢同一批字符会让引擎在某些输入上要试探的路径数量随输入长度指数级爆炸而且这种爆炸恰恰发生在匹配失败的时候一个精心构造的注定匹配不上的输入能逼着引擎把天文数字条死路全部走一遍而这个输入是由攻击者控制的真正用对正则核心不是描述清楚能匹配上就行而是把正则当作一段会回溯有运行成本且成本可能被输入操纵的程序来对待写的时候消除歧义不给引擎回溯的余地跑之前先用长度和字符集把坏输入挡在门外跑的时候给它套上一个能真正生效的超时本文从头梳理为什么能匹配上就行是错的灾难性回溯到底长什么样怎么把危险的正则识别出来怎么改写它怎么给正则套上一个真正有效的超时以及线性引擎 CI 扫描这些把正则用得安全要避开的坑

2021 年我做一个 Web 表单服务,每个提交进来都要校验一堆字段——邮箱、URL、手机号。写正则这件事,我压根没多想。第一版我做得很省事:写正则,不就是描述出我想匹配的字符串长什么样,能匹配上就行?我对着邮箱的样子写一串 pattern,用 re.match() 一套,匹配上返回真、匹配不上返回假,就完事了。本地开发时——真不错:我填几个正常的邮箱,正则稳稳地判对,合法的放过、不合法的拦下,几行代码搞定。我心里很踏实:"写正则嘛,不就是描述清楚、能匹配上?"可等这个服务真正上线、面对成千上万个真实用户,一串问题冒了出来。第一种最先把我打懵:某次线上 CPU 突然飙到 100%,排查半天发现,是一个用户提交的字符串,让那个看起来人畜无害的邮箱正则,把一个 worker 进程整整卡死了几十秒。第二种最难缠:有用户提交了一个特制的字符串,正则跑了几分钟都没出结果,那个请求就那么一直挂着。第三种最隐蔽:同样这条正则,我本地测试集跑得飞快,可线上偶尔某个请求就莫名其妙卡住,我怎么都复现不出来。第四种最莫名其妙:我后来加了输入长度限制,以为长度短了就安全了,可一个才 30 个字符的输入,照样能把正则卡死。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"写正则,就是描述出我想匹配的字符串长什么样"。这句话把正则当成了一句"声明"——我说清楚我要什么,引擎照着找就行。可它不是我脑子里,正则就是一句对"目标长什么样"的描述,像我跟人说"找一个中间带 @ 的字符串"那样,说清楚了,对方一眼就能判断。可正则根本不是这种东西。一条正则,会被编译成一个真正在运行的"回溯程序":引擎拿着你的输入,一个字符一个字符地试探着匹配,一旦走进死路,它会退回到上一个岔路口,换一条路再试。绝大多数正则,岔路很少,试探几下就出结果。但有一类写法——量词套着量词、相邻的量词争抢同一批字符——会让引擎在某些输入上,要试探的路径数量随输入长度指数级爆炸。更要命的是:这种爆炸,不发生在"匹配成功"的时候,恰恰发生在"匹配失败"的时候——一个精心构造的、注定匹配不上的输入,能逼着引擎把天文数字条死路全部走一遍。而这个输入,是由用户、由攻击者控制的。换句话说,我写下的那条正则,不是一句无害的描述,而是一段运行成本由攻击者说了算的程序——它和一个没限制大小的上传、一条没加超时的网络请求一样,是一个能被人拿来拖垮你服务的攻击面。我第一版所有的麻烦,根上都是同一件事:我把一段"有运行成本、且成本受攻击者操纵的程序",当成了一句"无成本的描述"。真正用对正则,核心不是"描述清楚、能匹配上就行",而是把正则当作"一段会回溯、有运行成本、且成本可能被输入操纵的程序"来对待:写的时候消除歧义、不给引擎回溯的余地,跑之前先用长度和字符集把坏输入挡在门外,跑的时候给它套上一个能真正生效的超时。这篇文章就把正则的 ReDoS 风险梳理一遍:为什么"能匹配上就行"是错的、灾难性回溯到底长什么样、怎么把危险的正则识别出来、怎么改写它、怎么给正则套上一个真正有效的超时,以及线性引擎、CI 扫描这些把正则用得安全要避开的坑。

问题背景

先把那串问题的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。

现象:一套"写条正则、能匹配上就行"的表单校验,在真正面对海量真实用户后冒出一串问题:某次线上 CPU 飙到 100%,是一个用户提交的字符串让邮箱正则把一个 worker 卡死了几十秒;有用户提交特制字符串,正则跑几分钟不出结果;同一条正则本地测试集飞快、线上偶尔某个请求就卡住,复现不出来;后来加了长度限制,可一个才 30 字符的输入照样能卡死正则

我当时的错误认知:"写正则,就是描述出我想匹配的字符串长什么样,能匹配上就行。"

真相:这个认知错在它把正则当成了一句"无运行成本的声明"。在我脑子里,正则就像我对着别人说"找一个中间带 @ 的字符串"——说清楚了,对方扫一眼就有答案,这个判断不花什么力气。可正则完全不是这种东西。一条正则会被编译成一个真正在运行的"回溯程序":引擎拿着输入逐字符试探,走进死路就退回岔路口换一条路。多数正则岔路少、试几下就出结果;可量词套量词、相邻量词抢同一批字符这类写法,会让引擎在某些输入上要走的路径数随输入长度指数级爆炸。而且这种爆炸偏偏发生在"匹配失败"时——一个注定匹配不上的输入,逼着引擎把天文数字条死路全走一遍。开头那四个问题,根上全是"把一段成本受攻击者操纵的程序,当成无害的描述":CPU 飙满,是因为我没意识到正则在某些输入上会指数级回溯;正则跑几分钟,是因为我信了"匹配这件事总是很快的";本地飞快线上卡住,是因为本地的输入都是善意的、触发不了回溯,线上才有人专门来构造;30 字符也能卡死,是因为灾难性回溯的爆炸量取决于输入的结构而非长度,几十个字符就足够撑爆。问题的根子清楚了:这不是"正则写得不够准"的小毛病,而是要换一个根本的认知——正则是一段有运行成本、且成本可能被输入操纵的程序,用对它,就是要从写法、输入、超时三处,把这个成本牢牢摁住。

要把正则用得安全,需要几块认知:

  • 为什么"能匹配上就行"是错的——正则是回溯程序,不是无成本的描述;
  • 灾难性回溯长什么样——嵌套量词与相邻可重叠的量词;
  • 怎么把危险的正则识别出来——扫出那些典型的爆炸结构;
  • 怎么改写——消除歧义,用占有式量词和原子组掐掉回溯;
  • 最后一道防线——预校验把坏输入挡在门外,再给正则套上有效的超时;
  • 线性时间引擎、CI 扫描这些工程坑怎么处理。

一、为什么"描述清楚、能匹配上就行"是错的

先把这件最根本的事钉死:"能匹配上就行"错在它脑子里有一幅错误的图景——它把正则,想象成一句"我对目标的描述":我把"我想要什么样的字符串"说清楚,引擎就照着这个描述去判断一个输入合不合格,这个判断像人扫一眼那样,是瞬间的、无成本的。这幅图景之所以危险,是因为它把正则引擎"怎么干活"这件事整个抹掉了。引擎判断一个字符串匹不匹配,用的不是"扫一眼",而是一种叫"回溯"的笨办法:它从输入的第一个字符开始,顺着正则一步一步往下套,每遇到一个量词——比如一个加号、一个星号——它面前就出现一个岔路口:这个量词到底该吃掉几个字符?它会先做一个贪心的猜测,然后接着往下走;一旦后面走不通了、匹配失败了,它不会就此放弃,而是退回最近的那个岔路口,把量词的猜测改一改,再往下试一遍。对绝大多数正则,岔路口很少,这种试探几下就到头了。可如果你的正则里有"歧义"——同一段输入,引擎有很多种不同的方式去切分它、去分配给各个量词——那么岔路口就会层层嵌套,引擎要穷举的"切分方式"数量,会随着输入变长而指数级膨胀。最阴险的一点是:只要输入能匹配成功,引擎往往碰巧走对一条路就停了,你根本看不出问题;真正引爆它的,是一个"几乎能匹配、但最后一个字符偏偏不匹配"的输入——引擎为了确认"它真的匹配不上",必须把所有指数级数量的切分方式全部试过一遍,才敢下"失败"的结论。而构造这样一个输入,对一个懂行的攻击者来说毫不费力。所以正确的图景是:正则不是一句描述,而是一段会回溯试探的程序;它的运行时间不是常数,在最坏情况下可以是输入长度的指数函数;而触发这个最坏情况的输入,正握在攻击者手里。把"我写了一句描述"换成"我写了一段运行成本可能被攻击者引爆的程序",你才算站到了安全使用正则的起点上。

下面这段代码,就是我那个"本地填几个邮箱没事、上线就被卡死"的第一版:

# 反面教材:把正则当成"描述清楚、能匹配上就行"的东西
import re

# 看起来人畜无害的邮箱校验正则 —— 破绽藏在那两组"量词套量词"里
EMAIL_RE = r"^([a-zA-Z0-9]+[.\-_]?)+@([a-zA-Z0-9]+[.\-]?)+\.[a-zA-Z]{2,}$"

def is_valid_email(email):
    return re.match(EMAIL_RE, email) is not None    # 破绽:这条正则在某些输入上会指数级回溯

这段代码在本地开发时表现不错,因为本地我喂给它的输入,其实是"善意而普通"的——是我自己随手敲的几个正常邮箱:a@b.comtom@gmail.com。我亲手扮演了一个温和的用户,我填的输入要么干干净净地匹配成功、要么明显地一眼不匹配,这两种情况下引擎都用不着深度回溯,几步就出结果。代码恰好一路飞快,你看不出任何破绽。它的问题不在某一行语法上——re.match() 用得对,正则本身的语法也挑不出错——而在它对"匹配这件事要花多少时间",做了一个彻底错误的、想当然的假设:它假设匹配总是很快的。可 EMAIL_RE 里那个 ([a-zA-Z0-9]+[.\-_]?)+——一个 + 量词的内部又套着一个 + 量词——是一个典型的会指数级回溯的结构。本地我自己填,邮箱要么对要么明显错,这个结构恰好没被触发;一上线、有人专门构造一个"一长串字母后面跟一个绝不可能匹配的字符"的输入,它会被瞬间引爆。问题的根子清楚了:用对正则,第一步不是把正则写得更准,而是承认"正则是一段有运行成本、成本可能被输入引爆的程序",然后从写法、输入、超时三处把这个成本摁住。下面五节,就是这件事怎么落地。

二、灾难性回溯长什么样:嵌套量词与可重叠的量词

先把"灾难性回溯"这件事亲眼看一遍。下面这条正则 ^(a+)+$ 看着平平无奇,可拿一串 a 后面加一个 ! 去喂它,运行时间会a 的个数指数级暴涨:

import re, time

EVIL = r"^(a+)+$"                       # 嵌套量词:外层 + 套着内层 a+ —— 灾难性回溯的标本

def time_match(text):
    start = time.time()
    re.match(EVIL, text)                # 在"注定不匹配"的输入上,它会把所有切分方式试个遍
    return time.time() - start

for n in range(20, 30):
    text = "a" * n + "!"                # n 个 a,后面跟一个绝不匹配的 ! —— 逼引擎穷举到底
    print(f"{n} 个 a:{time_match(text):.3f} 秒")

你会看到,a 每多一个,耗时大致翻一倍:25 个 a 可能要一秒,30 个就是几十秒,35 个能让它跑上一整天。这里的认知要点是:要看懂灾难性回溯,得想清楚它为什么会"指数级"。问题的核心词是"歧义"。看 (a+)+ 这个结构:外层的 + 说"我要重复若干个组",内层的 a+ 说"每个组里有若干个 a"。现在给它一串 aaaa,引擎要回答一个问题:这 4 个 a,该怎么分给"若干个组"?答案是——分法多得是。可以是一个组装 4 个 a;可以是两个组,装 1 个和 3 个、或 2 个和 2 个、或 3 个和 1 个;可以是三个组、四个组……这一串 a,有指数级数量种"切分方式",而每一种,在引擎眼里都是一条需要去试的、不同的路径。如果输入是 aaaa 正好能匹配,引擎随便撞上一条对的路就停了,你看不出问题。可一旦输入是 aaaa! ——那个 ! 让整条正则注定匹配失败——引擎就必须把这指数级数量的切分方式,一种不漏地全部试过,每一种都走到 ! 那里碰壁、再退回来换下一种,直到穷尽所有可能,它才敢说"确实不匹配"。这就是耗时随长度指数级暴涨的原因。除了 (a+)+ 这种最经典的"嵌套量词",还有一类同样致命的结构叫"相邻的可重叠量词",比如 a*a* 或者 \d+\d+ ——两个相邻的量词,都能匹配同一种字符,于是同一批字符"算前一个量词的、还是算后一个量词的",又产生了大量的切分歧义。它们的病根是同一个:你的正则里,存在"同一段输入有多种方式被分配给各个量词"的歧义,而引擎为了确认一次失败,必须把这些方式全部穷举。一句话:灾难性回溯的根源是正则结构里的"切分歧义",嵌套量词和相邻可重叠量词是它最典型的两副面孔。看清了它长什么样,下一步就是——在自己的代码里,怎么把这种危险的正则识别出来。

三、怎么把危险的正则识别出来

知道了危险结构长什么样,就能主动去扫。下面这个粗筛,专门扫最典型的"量词套量词"形态——它不可能抓全所有危险正则,但能把最常见的那一类挑出来:

import re

# 危险信号:一个分组被量词重复,而这个分组内部本身又含着量词 —— 嵌套量词
NESTED_QUANTIFIER = re.compile(r"\([^()]*[+*][^()]*\)\s*[+*]")

def looks_dangerous(pattern):
    """粗筛:扫出'量词套量词'这类最典型的灾难性回溯结构。"""
    if NESTED_QUANTIFIER.search(pattern):
        return True, "嵌套量词:形如 (...+...)+ 的结构,典型的灾难性回溯"
    # 相邻的可重叠量词也极可疑,例如 a*a* / \d+\d+
    if re.search(r"[+*]\s*\\?.[+*]", pattern):
        return True, "相邻量词:两个挨着的量词可能争夺同一批字符"
    return False, ""

这里的认知要点是:这一节要建立的观念是——危险的正则,是可以被"主动扫出来"的,你不该等线上 CPU 飙满了才回头去找是哪条正则的锅。灾难性回溯虽然后果吓人,但它的成因高度集中:绝大多数 ReDoS,都源自那么几种结构特征明显的写法——量词套量词、相邻可重叠量词、还有"量词作用的内容里含可选项又彼此重叠"这类。结构特征明显,意味着它们可以被另一条正则、或一个简单的扫描器识别出来。looks_dangerous 做的就是这件事:拿一条正则当文本,扫它里面有没有 (...+...)+ 这种嵌套量词的形态、有没有两个挨着的量词。这里要诚实地说清楚这个粗筛的边界:它是一个"宁可错报、不愿漏报"的工具,它扫出来的不一定真的危险,它也绝不可能扫全所有危险写法——真正严密的检测要去分析正则编译后的状态机结构,那是另一个量级的工程。但对日常工程来说,这个粗筛已经很有用了:它能把最常见、最容易写出的那批危险正则拦在你面前,逼你停下来重新审视一遍。这个观念可以推得更远一点:任何"写起来容易、但用错了后果严重"的东西——正则、SQL、shell 命令——都值得配一个哪怕粗糙的静态检查,在它进入生产之前先过一道筛子。检查不必完美,能挡掉八成的常见错误,就远胜于零。一句话:危险正则的成因高度集中、特征明显,所以它能被静态扫描主动揪出来,别等出事了再回头找。能把危险正则识别出来了,接下来的关键就是——怎么把它改写成安全的。

四、改写:消除歧义,不给引擎回溯的余地

识别出危险正则,真正要做的是改写它。改写的总思路只有一句:消除歧义,让输入里每个字符的归属唯一确定。先看第一种改法——把"可重叠的量词嵌套"拆掉:

import re

# 旧的危险写法:([a-zA-Z0-9]+[.\-_]?)+ —— 内层 + 和外层 + 争夺同一批字符,有歧义
# 改写:用一个明确的字符类一次性吃掉整段,不给引擎"换一种分法"的机会
EMAIL_SAFE = re.compile(
    r"^[a-zA-Z0-9._\-]+@[a-zA-Z0-9\-]+(\.[a-zA-Z0-9\-]+)*\.[a-zA-Z]{2,}$"
)

def is_valid_email_safe(email):
    """改写后:每个字符归属唯一,引擎一条直路走到底,没有回溯的余地。"""
    return EMAIL_SAFE.match(email) is not None

关键在那个 (\.[a-zA-Z0-9\-]+)*:被 * 重复的分组,以一个 \. 开头——每一段都必须由一个点引出,引擎不会把它和前一段搞混。第二种改法更直接,Python 3.11 起支持占有式量词和原子组,从根上掐掉回溯:

import re

# Python 3.11+ :占有式量词和原子组 —— 一旦吃下就绝不回吐,从引擎层面禁止回溯
GREEDY  = re.compile(r"^(a+)+$")        # 危险:贪婪量词允许回溯
POSSESS = re.compile(r"^a++$")          # 占有式量词 a++ :a 能吃多少吃多少,绝不回退
ATOMIC  = re.compile(r"^(?>(a+)+)$")    # 原子组 (?>...) :组内匹配一旦定下,就不再回溯

# a++ 和 (?>...) 在面对 "aaaa...!" 时,会迅速失败,而不是指数级回溯

下面这张图,把一个待校验的字符串,从进来到出结果要过的几道关画出来:

这里的认知要点是:改写正则,要抓住"消除歧义"这一个总原则,而占有式量词和原子组,是落实这个原则最锋利的两把刀。先说总原则。第二节讲过,灾难性回溯的病根,是同一段输入有多种方式被切分、被分配给各个量词。那么改写的目标就极其明确:把这种"多种方式"压成"唯一一种方式"。([a-zA-Z0-9]+[.\-_]?)+ 之所以危险,是因为面对一串字母,内层的 [a-zA-Z0-9]+ 和外层的 + 都想要,引擎就有得回溯;而 [a-zA-Z0-9._\-]+ 这个写法,把所有合法字符塞进一个字符类、用一个量词一次吃光,一串字母只有一种归属,引擎想回溯都无从下手。再看那个 (\.[a-zA-Z0-9\-]+)* :让被重复的分组以一个固定的 \. 打头,是一个非常通用的去歧义技巧——每一次重复都被一个明确的分隔符"锚"住了起点,引擎不会把相邻两段的边界算错。这就是"消除歧义"。而占有式量词和原子组,是从另一个方向解决同一个问题:它们不消除歧义,而是直接禁止回溯。普通的贪婪量词 a+ ,吃多了发现后面不匹配,会"回吐"几个字符再试;占有式量词 a++ 的语义是——我吃下的,一个都不吐,后面不匹配那就直接判失败。原子组 (?>...) 是同理:组里一旦匹配定了,就把这个结果"冻住",绝不因为组外的失败而回头重试组内。它们俩等于给引擎下了一道命令:这里不准回溯。代价是你要想清楚——禁止回溯会不会让一些本该匹配的输入被判失败?对邮箱、URL 这类"贪婪地往前吃"就对的场景,通常不会,所以它们用起来很安全。一句话:改写正则的总原则是消除切分歧义,而占有式量词 a++ 和原子组 (?>...) 是直接禁止回溯、最干脆的两件工具。正则本身改安全了,可还有最后一道防线没建——把坏输入挡在门外,再给正则套上超时。

五、最后一道防线:预校验 + 真正有效的超时

就算正则改对了,你也很难保证项目里每一条正则都安全。所以还要有不依赖"正则写得对"的防线。第一道是预校验——在正则之前,先用长度上限和字符集白名单把明显的坏输入挡掉:

def pre_check(value, max_len=254, allowed=None):
    """正则之前的第一道闸:长度上限 + 字符集白名单。"""
    if len(value) > max_len:                        # 超长输入直接拒,根本不喂给正则
        return False
    if allowed is not None and not set(value).issubset(allowed):
        return False                                # 出现白名单外的字符,直接拒
    return True

第二道是超时。这里有个大坑:在 Python 里,正则跑在 C 代码里、全程占着 GIL 不松手,你没法用一个看门狗线程去打断它——线程根本抢不到执行权。唯一可靠的办法是把正则丢进一个独立进程,超时了就从外部强杀这个进程:

import multiprocessing, re

def _worker(pattern, text, q):
    q.put(re.search(pattern, text) is not None)

def match_with_timeout(pattern, text, timeout=2.0):
    """给正则套超时:必须用独立进程 —— 失控的正则只能从外部强杀。"""
    q = multiprocessing.Queue()
    p = multiprocessing.Process(target=_worker, args=(pattern, text, q))
    p.start()
    p.join(timeout)
    if p.is_alive():                                # 超时还没跑完:正则正在疯狂回溯
        p.terminate()                               # 从外部强杀整个进程
        p.join()
        raise TimeoutError("正则匹配超时,疑似 ReDoS")
    return q.get()

把预校验和超时合成一个安全的校验入口——坏输入进不来,正则失控了也拖不垮服务:

import re

EMAIL_SAFE = re.compile(r"^[a-zA-Z0-9._\-]+@[a-zA-Z0-9\-]+(\.[a-zA-Z0-9\-]+)*\.[a-zA-Z]{2,}$")

def validate_email(email):
    """安全校验:先过预校验闸门,再用安全正则跑在带超时的进程里。"""
    if not pre_check(email, max_len=254):
        return False                                # 没过预校验,根本不进正则
    try:
        return match_with_timeout(EMAIL_SAFE.pattern, email, timeout=1.0)
    except TimeoutError:
        return False                                # 超时即判非法,绝不让一个请求拖垮服务

这里的认知要点是:这一节要扭过来的观念是——把希望全押在"我每条正则都写对了"上,是不牢靠的。改写正则当然是治本,但一个真实项目里,正则可能有几十上百条,有些来自第三方库,有些是别人很久以前写的,你没法保证每一条都安全。所以你需要一层"不管正则写得对不对,都能兜住"的防线,这层防线由两道闸门组成。第一道是预校验,它的精髓是"在昂贵的操作之前,先用便宜的检查筛一遍"。长度上限和字符集白名单,都是 O(n) 的、瞬间就能算完的廉价检查;而灾难性回溯需要一个有特定结构的输入才能触发,你用一个严格的字符集白名单一卡,很多攻击性的输入连正则的门都进不去。注意这里的长度上限不是"安全的保证"——第二节说过 30 个字符就足以引爆——它只是把攻击的难度和破坏上限往下压一截,是纵深防御的一层,不是全部。第二道是超时,而这一道里藏着一个必须讲透的真相:在 CPython 里,一个正则匹配是在底层 C 代码里执行的,这段执行从头到尾攥着 GIL 不释放。这意味着什么?意味着你在 Python 层起一个"看门狗线程",指望它两秒后醒来打断正则——这个线程根本醒不过来,它抢不到 GIL,得一直等那个失控的正则自己跑完才轮得到它。所以线程级的超时,对 ReDoS 是完全无效的。唯一真正有效的办法,是把正则放进一个独立的操作系统进程里跑:独立进程有自己的 GIL、自己的 CPU 时间,而最关键的是,你可以从外部对它调用 terminate,操作系统会真的把它杀掉,不管它内部那个 C 函数跑得多疯。match_with_timeout 就是这么做的:起一个子进程跑正则,join 一个超时,到点还活着就 terminate 强杀。validate_email 把两道闸门串起来,成为一个无论如何都不会把 worker 拖死的安全入口。一句话:别只指望正则写对,要用'预校验挡掉坏输入 + 进程级超时强杀失控正则'兜底,而超时必须是进程级的,因为线程拦不住不放 GIL 的 C 层回溯。主干都齐了,最后是几个把正则用到生产里才会撞见的工程坑。

六、工程坑:线性引擎、预编译、CI 扫描与第三方正则

主干之外,还有几个工程坑,不处理就会让你的正则在边角上出问题坑 1:高风险场景,直接换一个线性时间的正则引擎。标准库的 re 是回溯式引擎,天生有 ReDoS 风险。google-re2 这类引擎从原理上就不回溯,运行时间只跟输入长度成正比,代价是不支持反向引用等少数高级特性:

# 用线性时间的正则引擎:google-re2 不做回溯,运行时间只随输入长度线性增长
import re2                                          # pip install google-re2

def is_valid_email_re2(email):
    """re2 的接口和标准库 re 几乎一样,但它从引擎层面就不可能灾难性回溯。"""
    return re2.match(r"^([a-zA-Z0-9]+[.\-_]?)+@[a-zA-Z0-9.\-]+$", email) is not None

坑 2:把危险正则的检测做进 CI,挡在上线之前。第三节那个粗筛不该只是个工具函数,要把它接进持续集成——每次提交,扫一遍项目里所有的正则,扫到危险结构就让流水线红灯:

import sys

def scan_patterns(patterns):
    """上线前扫描:所有正则过一遍危险结构检测,有危险就让 CI 失败。"""
    bad = []
    for name, pat in patterns.items():
        dangerous, reason = looks_dangerous(pat)
        if dangerous:
            bad.append(f"  {name}: {reason}")
    if bad:
        print("发现危险正则,可能存在 ReDoS:")
        print("\n".join(bad))
        sys.exit(1)                                 # CI 红灯,挡在上线之前
    print("正则安全扫描通过")

坑 3:正则要预编译,别在热路径里反复 compile每次 re.match(pattern_str, ...) 传字符串,引擎都要重新编译一次正则(虽有缓存,但缓存会被挤掉)。把正则在模块加载时 re.compile 成对象,热路径里直接用——这和性能有关,也让"哪些正则存在"一目了然、便于扫描。坑 4:危险正则常常来自第三方库。你自己的正则审得再干净,依赖的库里可能就藏着一条会 ReDoS 的正则。引入处理用户输入的库时,要留意它的正则;社区也有公开的 ReDoS 漏洞库可以核对。坑 5:用户输入绝不能直接拼进正则。如果你把用户输入的内容当作正则的一部分compile,那用户就能直接给你注入一条灾难性回溯的正则。用户输入只能作为被匹配的目标,要作为模式的一部分时,必须先用 re.escape() 转义。坑 6:回溯不只拖慢,还可能直接撑爆栈。极深的回溯在某些引擎里会递归过深导致崩溃,不只是慢——所以超时和预校验两道闸,一道都不能省。坑 7:别忘了 .* 也可能是帮凶。一个 .* 夹在两个具体模式中间,配合后面的回溯结构,也能放大回溯量;能用具体字符类(如 [^/]*)就别用万能的 .*,缩小引擎试探的空间。坑 8:监控正则的执行耗时。给关键的校验路径打点统计耗时,某条正则的 P99 耗时突然抬头,往往就是有人在试探你的 ReDoS 面——这是最早的预警信号。

关键概念速查

概念 / 手段 说明
正则是回溯程序 不是无成本的描述,是会逐路径试探、走死路就回退的程序
能匹配上就行的错 只验证了对的输入能过,没考虑坏输入要让引擎跑多久
灾难性回溯 歧义结构在不匹配的输入上,试探路径数随长度指数级膨胀
嵌套量词 形如 (a+)+ 的量词套量词,最典型的回溯爆炸结构
相邻可重叠量词 a*a* 等相邻量词争夺同一批字符,同样致命
ReDoS 攻击者用一个短输入触发灾难性回溯,拖垮 CPU 的拒绝服务漏洞
消除歧义 让每个字符归属唯一,引擎就没有回溯的余地
占有式量词与原子组 a++ 和 (?>...) 吃下不回吐,从引擎层面禁止回溯
预校验闸门 长度上限 + 字符集白名单,坏输入根本不喂给正则
进程级超时 失控正则在 C 里不放 GIL,只能用独立进程从外部强杀

避坑清单

  1. 把正则当作有运行成本的回溯程序,不是"能匹配上就行"的描述。
  2. 警惕嵌套量词 (...+...)+ 和相邻可重叠量词,它们是灾难性回溯的源头。
  3. 危险不在"匹配成功"时暴露,而在"匹配失败"的输入上才指数级回溯。
  4. 改写正则:让每个字符归属唯一,消除引擎"换一种分法"的余地。
  5. 用占有式量词 a++ 或原子组掐掉回溯(Python 3.11+ 支持)。
  6. 正则前先做预校验:长度上限加字符集白名单,坏输入根本不喂进去。
  7. 给正则套超时必须用独立进程,线程拦不住不放 GIL 的 C 层回溯。
  8. 超时即判非法,绝不让一个请求把 worker 进程拖死几十秒。
  9. 高风险场景直接换线性时间引擎,从引擎层面杜绝回溯。
  10. 把危险正则检测做进 CI,扫到嵌套量词就让上线红灯。

总结

回头看那串"一个字符串把 worker 卡死几十秒、特制输入让正则跑几分钟、本地飞快线上卡住、30 字符也能卡死"的问题,以及我后来在正则上接连踩的坑,最该记住的不是某一个改写技巧,而是我动手前那个想当然的判断——"写正则,就是描述出我想匹配的字符串长什么样,能匹配上就行"。这句话错在它把正则当成了一句"无运行成本的声明"。我以为把"我要什么样的字符串"描述清楚、让引擎照着判断,这件事就办成了。可我忽略了一件最要紧的事:一条正则,会被编译成一个真正在运行的"回溯程序"——引擎逐字符地试探着匹配,走进死路就退回岔路口换一条路再试。绝大多数正则岔路很少,可量词套量词、相邻量词抢同一批字符这类有"歧义"的写法,会让引擎在某些输入上要穷举的路径数,随输入长度指数级爆炸。而这个爆炸,偏偏发生在"匹配失败"的时候——一个注定匹配不上的输入,逼着引擎把天文数字条死路全走一遍才肯认输。这个输入,握在攻击者手里。我第一版的错,就是把这样一段"运行成本由攻击者说了算的程序",当成了一句"我说清楚、引擎瞬间就懂"的无害描述。这个错配,本地开发时根本看不出来——因为本地喂给正则的"用户"就是我自己,我随手敲的都是要么干净匹配、要么明显不匹配的普通邮箱,引擎几步就出结果,代码恰好一路飞快;它只会在真正上线、面对海量真实用户里那个专门来构造"几乎匹配但偏偏差一个字符"的攻击者时,以 CPU 飙满、worker 卡死的方式爆出来。

所以做对正则校验,真正的功夫不在"把目标描述清楚"那几行上。描述一个邮箱长什么样并不难。真正的功夫,在于你要从一开始就承认"正则是一段有运行成本、且成本可能被输入引爆的程序",然后从写法、输入、超时三处把这个成本牢牢摁住:你不能写出有歧义的结构,就消除歧义、让每个字符的归属唯一确定,必要时用占有式量词和原子组直接禁止回溯;你不能指望每条正则都写对,就在正则之前加一道预校验,用长度和字符集把坏输入挡在门外;你不能让一条失控的正则拖垮整个服务,就把它丢进独立进程、套上一个真正能生效的超时;而到了线性引擎、CI 扫描、第三方正则这些边角上,你还要处处守住,别让一条危险的正则又从某个角落溜进生产。这篇文章的几节,其实就是顺着这套规矩展开的:先想清楚"能匹配上就行"为什么错,再看灾难性回溯长什么样、怎么识别、怎么改写、怎么用预校验和超时兜底,最后是线性引擎、CI 扫描这几个把正则守扎实的工程细节。

你会发现,正则这件事,和现实里"一个人走迷宫找出口"完全相通。写正则的你,是迷宫的设计者;正则引擎,是被放进迷宫里找出口的那个人。一个不靠谱的迷宫设计者会设计出什么?一座岔路口层层嵌套、每条岔路看上去都同样可能通向出口的迷宫。走迷宫的人没有别的办法,只能一条岔路一条岔路地试:走进一条,撞到死胡同,原路退回到岔路口,再试下一条。岔路一多,他要走的路线组合数量就指数级膨胀——尤其当这座迷宫根本没有出口时,他必须把每一条死路都走到底、退回来,直到穷尽所有可能,才敢断定"走不出去",这一趟下来,几十个岔路口就足以让他走上几天几夜。而一个靠谱的迷宫设计者会怎么设计?他会把迷宫设计得每一个岔路口,该往哪走都是唯一确定的——要么这条路明摆着是死的、一眼就排除,要么正确的方向只有一个、没有第二种可能(这就是消除歧义);他还会在关键的岔路口立一块"此路一旦选定就不准回头"的牌子(这就是占有式量词和原子组);就算迷宫还是复杂,一个负责任的管理者也会在入口拦一道——明显不对劲的人根本不放进去(这就是预校验),并且给每个进迷宫的人发一个计时器,超时没出来就派人进去把他带出来(这就是进程级的超时强杀)。同样是让人走一座迷宫,不靠谱的设计者只想着"我把迷宫画出来就行",靠谱的设计者始终记得"会有人真的要在这座迷宫里一步一步地走,我画的每一个歧义的岔路,都是他要付出的指数级代价"——差别不在"把迷宫画出来这件事本身难不难",只在设计者心里有没有"这座迷宫是要被一步步走的、岔路的歧义会指数级地放大走它的成本"这根弦

最后想说,正则用得安不安全,差距永远不会在"本地开发、自己填几个邮箱测一测"时暴露——本地那个填表单的"用户"就是你自己,你随手敲的都是要么干净匹配、要么明显不匹配的普通输入,正则引擎几步就出了结果,你那段"写条正则、套个 match"的代码恰好每次都飞快地返回,合法的放过、不合法的拦下,你自然觉得"写正则嘛,描述清楚、能匹配上"一点问题都没有。它只在真实的、面对海量用户、其中总有人专门来构造恶意输入的环境里才显形。那时候它会用最难堪的方式给你结账:做不好,你会因为一个用户提交的几十字符的字符串,眼睁睁看着 CPU 飙到 100%、一个 worker 被卡死几十秒,会因为一条藏在校验里的危险正则,让整个服务被一个请求拖垮;而做了,你的每一条正则都消除了歧义、不给引擎回溯的余地,坏输入在预校验那道闸就被挡下,万一哪条正则还是失控了,它也只是在一个独立进程里被超时强杀、绝拖不垮主服务,无论用户构造了多刁钻的输入,每一次匹配都在可控的时间内规规矩矩地出结果。所以别等"CPU 又被一个请求打满"那一刻找上门,在你写下那条校验正则的第一行时就该想清楚:这条正则有没有嵌套量词、有没有歧义、改写过了吗、预校验加了吗、超时套了吗、它失控了会不会拖垮服务,这一道道关口,我是不是都替这段会回溯的程序守住了?这些问题有了答案,你交付的才不只是一套"本地填几个邮箱看着对"的代码,而是一个无论用户构造了多恶意的输入,每一次匹配都跑得飞快、谁也拖不垮的、让人放心的系统。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

大模型输出审核完全指南:从一次"模型把一段不该说的话直接甩给了用户"看懂内容安全与流式审核

2026-5-22 16:30:50

技术教程

大模型输出截断完全指南:从一次"模型返回的 JSON 莫名其妙少了半截"看懂 max_tokens 与 finish_reason

2026-5-22 16:47:43

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索