正则引擎不是一样的

模式 /a+b/ 在所有语言中看起来相同。但执行它的引擎决定了你的程序是在线性时间内处理输入,还是在对抗性输入上永久挂起。

两种根本不同的执行模型:

回溯 NFA(大多数语言)

使用者:JavaScript、Python re、Java java.util.regex、.NET、PCRE、Ruby、Perl

引擎尝试模式的一条路径。失败时回溯到最后的选择点,尝试另一种替代方案。这使得强大特性(反向引用、环视、占有量词)成为可能,但也创造了在特定模式/输入组合上指数时间的可能性。

线性时间 DFA / NFA 模拟(RE2)

使用者:Go regexp、Rust regex、Google RE2、Intel Hyperscan

引擎同时模拟所有可能的 NFA 状态。它从不回溯。这保证了 O(n) 匹配时间,无论模式多复杂——但它无法支持反向引用、环视或占有量词。

可移植性陷阱

在 Python 中工作的模式,在 Go 中可能不可能实现:

python
# Python:可以工作(回溯 NFA 支持后行断言)
import re
re.findall(r'(?<=\$)\d+', 'Price $100')  # ['100']
go
// Go:编译错误(RE2 不支持后行断言)
regexp.MustCompile(`(?<=\$)\d+`)  // panic: error parsing regexp

这不是 bug——是深思熟虑的权衡。Go 选择了安全性(保证线性时间)而非表达力。

ReDoS:当正则变成漏洞

正则表达式拒绝服务(ReDoS)发生在攻击者构造输入触发脆弱模式的指数回溯时。

脆弱模式的形状

灾难性回溯需要:

  1. 一个量化的分组,内含替代或重复
  2. 替代能匹配相同的字符
  3. 一个尾部锚点或字面量强制在部分匹配后失败

经典示例:

javascript
// 脆弱:嵌套量词 + 重叠替代
const pattern = /^(a+)+$/;

// 正常输入:"aaaaab" → 快速失败(b 不匹配)
// 对抗性输入:"aaaaaaaaaaaaaaaaaaaaaaaaaaaa!" 
// → 指数回溯(2^n 条路径要尝试才能确认失败)

30 个 a 后跟 !,回溯引擎要尝试 ~2³⁰ ≈ 10 亿条路径。

真实 CVE 案例

ReDoS 不是理论问题:

  • CVE-2016-4010(Node.js hawk 库):通过构造的 HTTP 头绕过认证
  • CVE-2018-13863(npm clean-css):构造的 CSS 注释导致服务挂起
  • CVE-2020-5243(npm ua-parser-js):User-Agent 解析在对抗性字符串上挂起
  • Cloudflare 全球宕机 (2019-07-02):部署到 WAF 规则的一条正则导致全球 CPU 耗尽

识别脆弱模式

危险信号:

模式形状 示例 风险
(a+)+ 嵌套量词 指数
(a|a)+ 重叠替代 指数
(a+b?)+ 量词后可选元素 指数
(\w+\s*)+$ 贪婪量词 + 尾部锚点 多项式/指数
(.*a){n} 点星 + 重复 多项式

防御措施

  1. 使用 RE2 或等效引擎:Go、Rust regex、Python re2 库保证线性时间
  2. 输入长度限制:匹配前将输入截断到合理最大值
  3. 匹配超时:Java Matcher 和 .NET Regex 支持超时参数
  4. 静态分析rxxr2safe-regexeslint-plugin-regexp 等工具检测脆弱模式
  5. 原子组/占有量词(?>a+)a++ 阻止回溯进入分组(PCRE、Java、.NET——不支持 JavaScript)

正则是错误工具的场景

上下文无关语言

正则匹配的是正则语言(Chomsky 层次的 3 型语言)。许多现实世界的格式是上下文无关语言(2 型)或上下文相关语言(1 型):

格式 语法类 正则可行性
平衡括号 上下文无关 不可能(无扩展)
HTML/XML 标签 上下文无关 不可能正确解析
JSON 上下文无关 不可能
Email (RFC 5322) 上下文无关(嵌套注释) 标准正则不可能
带引号字段的 CSV 上下文无关 极度脆弱
编程语言语法 上下文无关/相关 不可能

邮箱"验证"

广泛流传的"邮箱验证正则"要么:

  • 过度严格:拒绝有效地址如 "user name"@example.comuser+tag@[IPv6:::1]
  • 过度宽松:接受语法有效但不可投递的地址

RFC 5322 允许:

  • 带引号的 local 部分:"hello world"@example.com
  • 注释:user(comment)@example.com
  • IP 字面域:user@[192.168.1.1]

正确方法:接受包含 @ 和类域名部分的任何内容,然后通过发送确认邮件验证可投递性。没有任何正则能告诉你邮箱是否存在。

URL 验证

URL 由 RFC 3986 语法定义,包含层次化和不透明方案、百分号编码八位字节、方括号中的 IPv6 字面量和国际化域名。使用 URL 解析器(JavaScript URL()、Python urllib.parse、Go net/url)。

语法参考

以下表格涵盖所有主要引擎支持的通用正则语法。

元字符

符号 含义
. 除换行外的任意字符(除非 s 标志)
\d 数字 [0-9]
\D 非数字 [^0-9]
\w 单词字符 [a-zA-Z0-9_](ASCII,除非 Unicode 模式)
\W 非单词字符
\s 空白字符 [\t\n\r\f\v ]
\S 非空白字符
\b 单词边界
\B 非单词边界

量词

贪婪 惰性 占有 含义
* *? *+ 0 次或更多
+ +? ++ 1 次或更多
? ?? ?+ 0 或 1 次
{n} {n}? {n}+ 恰好 n 次
{n,} {n,}? {n,}+ 至少 n 次
{n,m} {n,m}? {n,m}+ n 到 m 次

占有量词(++*+?+)从不回溯。在 PCRE、Java、.NET 中可用——JavaScript 和 Python re 不支持。

分组与断言

语法 引擎支持 含义
(...) 所有 捕获组
(?:...) 所有 非捕获组
(?<name>...) JS, Java, .NET, PCRE 命名组
(?P<name>...) Python, Go 命名组(Python/RE2 语法)
(?=...) 所有回溯引擎 正向前瞻
(?!...) 所有回溯引擎 负向前瞻
(?<=...) JS(ES2018+), Python, Java, .NET, PCRE 正向后行
(?<!...) JS(ES2018+), Python, Java, .NET, PCRE 负向后行
(?>...) PCRE, Java, .NET 原子组
\1, \2 所有回溯引擎 反向引用

Go RE2 不支持以上所有:环视、反向引用、原子组、占有量词。

Unicode 正确的正则

\w 的问题

在大多数引擎中,\w 默认只匹配 ASCII 单词字符。这对非拉丁文字完全失效:

javascript
/\w+/.test("café")    // 匹配 "caf"(漏掉了 é)
/\w+/u.test("日本語") // 仍然不匹配(u 标志不修复 \w)

Unicode 属性转义

现代引擎支持 \p{...} 进行 Unicode 感知匹配:

javascript
// JavaScript(需要 u 或 v 标志)
/\p{Letter}+/u.test("café")    // true
/\p{Script=Han}+/u.test("中文") // true
/\p{Emoji}/u.test("🚀")        // true
python
# Python regex 模块(非 re)
import regex
regex.findall(r'\p{Han}+', '中文test中文')  # ['中文', '中文']

字形簇

用户感知的一个"字符"可能是多个 Unicode 码点:

code
é = U+0065 (e) + U+0301 (组合尖音符)
👨‍👩‍👧‍👦 = 7 个码点通过 ZWJ 连接

标准 . 匹配一个码点,不是一个字形簇。匹配用户感知的字符需要 \X(PCRE、Python regex)或 Unicode 分段算法。

跨引擎兼容性矩阵

特性 JavaScript Python re Python regex Go RE2 Java .NET PCRE
前瞻
后行 ES2018+ 定宽 变宽 变宽
反向引用
原子组
占有量词
Unicode 属性 u/v 标志 有限 完整
命名组 (?<n>) (?P<n>) 两种 (?P<n>) (?<n>) (?<n>) 两种
递归
保证线性时间 超时

性能模式

用具体字符类代替点星

code
# 慢:.* 在整个输入上回溯
/^.*@.*\..*$/

# 快:字符类限制回溯
/^[^@]+@[^.]+\..+$/

尽可能锚定

无锚定的模式从输入的每个位置开始扫描。锚定消除了 O(n) 个起始位置:

python
# 最坏情况 O(n*m)——从每个位置尝试
re.search(r'\d{4}-\d{2}-\d{2}', text)

# 如果知道在开头,O(m)
re.match(r'\d{4}-\d{2}-\d{2}', text)

用有序替代快速失败

将最可能匹配的放在替代的前面:

code
# 如果输入通常是 "http",把它放在前面
/^(http|https|ftp):\/\//

在循环中预编译

每种语言都有编译步骤。在循环中只编译一次:

python
import re
pattern = re.compile(r'\d+')
results = [pattern.findall(line) for line in lines]
go
var numberRegex = regexp.MustCompile(`\d+`)
// 在循环中使用 numberRegex.FindAllString()

实用模式(附注意事项)

日期格式(非日期验证)

regex
\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])

这验证的是格式,不是日历正确性(允许 2 月 31 日)。日期验证应使用日期库。

语义化版本

regex
^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([\da-zA-Z-]+(?:\.[\da-zA-Z-]+)*))?(?:\+([\da-zA-Z-]+(?:\.[\da-zA-Z-]+)*))?$

这是 semver.org 的官方正则——是正则完美匹配语法的少有案例之一,因为 SemVer 刻意设计为正则语言。

IPv4(严格)

regex
^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)$

对于 IPv6,请使用解析器——压缩表示法(::、混合 IPv4 映射)使正则不切实际。

总结

正则表达式是一个有精确能力和硬性限制的工具。它匹配正则语言,时间复杂度为输入长度的线性(DFA/RE2)或潜在指数时间(回溯 NFA)。选择正确的引擎、理解回溯行为、识别语法类何时超出正则语言——这些是区分生产使用和脆弱黑客的技能。

核心原则:

  • 将不可信输入 + 回溯正则视为拒绝服务攻击面
  • 当安全性比表达力重要时使用 RE2/Go 风格引擎
  • 永远不要单独用正则验证复杂格式(邮箱、URL、HTML)
  • 用对抗性输入测试,而不仅是正常路径示例
  • 移植模式前检查跨引擎兼容性