正则引擎不是一样的
模式 /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:可以工作(回溯 NFA 支持后行断言)
import re
re.findall(r'(?<=\$)\d+', 'Price $100') # ['100']
// Go:编译错误(RE2 不支持后行断言)
regexp.MustCompile(`(?<=\$)\d+`) // panic: error parsing regexp
这不是 bug——是深思熟虑的权衡。Go 选择了安全性(保证线性时间)而非表达力。
ReDoS:当正则变成漏洞
正则表达式拒绝服务(ReDoS)发生在攻击者构造输入触发脆弱模式的指数回溯时。
脆弱模式的形状
灾难性回溯需要:
- 一个量化的分组,内含替代或重复
- 替代能匹配相同的字符
- 一个尾部锚点或字面量强制在部分匹配后失败
经典示例:
// 脆弱:嵌套量词 + 重叠替代
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} |
点星 + 重复 | 多项式 |
防御措施
- 使用 RE2 或等效引擎:Go、Rust
regex、Pythonre2库保证线性时间 - 输入长度限制:匹配前将输入截断到合理最大值
- 匹配超时:Java
Matcher和 .NETRegex支持超时参数 - 静态分析:
rxxr2、safe-regex、eslint-plugin-regexp等工具检测脆弱模式 - 原子组/占有量词:
(?>a+)或a++阻止回溯进入分组(PCRE、Java、.NET——不支持 JavaScript)
正则是错误工具的场景
上下文无关语言
正则匹配的是正则语言(Chomsky 层次的 3 型语言)。许多现实世界的格式是上下文无关语言(2 型)或上下文相关语言(1 型):
| 格式 | 语法类 | 正则可行性 |
|---|---|---|
| 平衡括号 | 上下文无关 | 不可能(无扩展) |
| HTML/XML 标签 | 上下文无关 | 不可能正确解析 |
| JSON | 上下文无关 | 不可能 |
| Email (RFC 5322) | 上下文无关(嵌套注释) | 标准正则不可能 |
| 带引号字段的 CSV | 上下文无关 | 极度脆弱 |
| 编程语言语法 | 上下文无关/相关 | 不可能 |
邮箱"验证"
广泛流传的"邮箱验证正则"要么:
- 过度严格:拒绝有效地址如
"user name"@example.com、user+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 单词字符。这对非拉丁文字完全失效:
/\w+/.test("café") // 匹配 "caf"(漏掉了 é)
/\w+/u.test("日本語") // 仍然不匹配(u 标志不修复 \w)
Unicode 属性转义
现代引擎支持 \p{...} 进行 Unicode 感知匹配:
// JavaScript(需要 u 或 v 标志)
/\p{Letter}+/u.test("café") // true
/\p{Script=Han}+/u.test("中文") // true
/\p{Emoji}/u.test("🚀") // true
# Python regex 模块(非 re)
import regex
regex.findall(r'\p{Han}+', '中文test中文') # ['中文', '中文']
字形簇
用户感知的一个"字符"可能是多个 Unicode 码点:
é = 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>) |
两种 |
| 递归 | 否 | 否 | 是 | 否 | 否 | 是 | 是 |
| 保证线性时间 | 否 | 否 | 否 | 是 | 否 | 超时 | 否 |
性能模式
用具体字符类代替点星
# 慢:.* 在整个输入上回溯
/^.*@.*\..*$/
# 快:字符类限制回溯
/^[^@]+@[^.]+\..+$/
尽可能锚定
无锚定的模式从输入的每个位置开始扫描。锚定消除了 O(n) 个起始位置:
# 最坏情况 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)
用有序替代快速失败
将最可能匹配的放在替代的前面:
# 如果输入通常是 "http",把它放在前面
/^(http|https|ftp):\/\//
在循环中预编译
每种语言都有编译步骤。在循环中只编译一次:
import re
pattern = re.compile(r'\d+')
results = [pattern.findall(line) for line in lines]
var numberRegex = regexp.MustCompile(`\d+`)
// 在循环中使用 numberRegex.FindAllString()
实用模式(附注意事项)
日期格式(非日期验证)
\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])
这验证的是格式,不是日历正确性(允许 2 月 31 日)。日期验证应使用日期库。
语义化版本
^(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(严格)
^(?:(?: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)
- 用对抗性输入测试,而不仅是正常路径示例
- 移植模式前检查跨引擎兼容性