问题长这样
(a+)+$ 这类嵌套量词遇到 aaaa...! 时,引擎会反复回溯,耗时随长度指数增长,把 CPU 打满。
危险信号
- 量词套量词:
(a+)+、(a*)*; - 多个分支都能匹配空:
(a|ab)*配长串; - 在不可信输入上跑复杂正则。
怎么规避
- 改用确定型写法:用具体字符类、避免嵌套量词;
- 给正则加超时:很多引擎支持匹配超时,防止单条请求卡死;
- 先用长度 / 格式粗筛:再上正则,缩小攻击面。
实战案例:三次“一条正则跑满 CPU”
- 邮箱校验拖垮接口:形如
^([a-z]+)*@…$的嵌套量词,遇到一长串没有@的输入会指数回溯。修复:改成不嵌套的字符类写法。 - 日志脱敏变慢:对每条日志执行
(.*)+之类的贪婪分组,日志量一大即成瓶颈。修复:去掉冗余分组,先按分隔符切分再匹配。 - 用户可控的正则:把用户输入当正则执行(例如搜索里的“正则模式”),等于把 CPU 交给攻击者。修复:限制长度与语法,或改用专用全文检索。
常见问题(FAQ)
所有引擎都会回溯吗?主流回溯型引擎(PCRE 及多数语言内置实现)会;RE2、Rust 的 regex 用自动机实现,最坏情况是线性的。正则超时怎么加?部分语言支持匹配超时或可中断线程;不支持时可用子进程隔离并设硬超时。怎么快速判断一条正则危险?用长串极限输入(如 'a'.repeat(30) + '!')测耗时,呈现指数增长就是危险信号。危险正则能自动检测吗?有静态分析工具(如 safe-regex)可提醒嵌套量词,但它只是提示,仍需人工判断输入面。
把正则加固到可上线
- 加超时或隔离:能用带超时的库就用;否则把匹配放进子进程并设硬超时,防止单条请求拖垮进程;
- 改用线性引擎:Go 的 RE2、Rust 的
regex用自动机实现,最坏情况线性,处理不可信输入时应优先选择; - 用原子组或占有量词:支持
(?>…)/*+的引擎可禁止回退,把指数变成线性; - 先做廉价校验:长度上限、字符集白名单、必须包含的关键字符,先用普通字符串操作挡掉大部分输入;
- 预编译并复用:正则编译本身有开销,请求路径上应预编译;同时避免在循环里重复构造。
写正则的几条纪律
- 避免量词嵌套:
(a+)+、(w*)*一律改写为不嵌套的等价形式; - 用具体字符类替代通配:
[a-z0-9]比.更可预测,也更快; - 锚定边界:能加
^/$就加,减少无谓的搜索尝试; - 宁可多个简单正则:把复杂表达式拆成几个顺序判断,既好读也避免回溯叠加。
上线前的验证方法
用三类输入做基准测试:最短合法串、最长合法串、以及“几乎匹配”的对抗串(如 a 重复 30 次后跟一个不合法的字符)。若耗时随长度呈指数增长,就必须改写或加超时,而不是靠“线上没出事”侥幸上线。
哪些语言与引擎默认安全
- 线性引擎:Go 的
regexp(RE2 语义)、Rust 的regex、以及多数 grep 实现不会回溯,最坏情况线性; - 回溯引擎:PCRE、Java、Python
re、JavaScript 正则都是回溯型,危险模式会指数爆炸; - 部分语言提供安全模式:如 Java 可通过独立线程加超时中断,Node 可把匹配放进 worker 并设时限;
- 注意库差异:同一语言的不同库(如某些第三方实现)行为可能不同,引入前确认其引擎类型。
监控与止损
上线后应对匹配耗时加埋点:当 P99 超过阈值或出现单个请求耗时异常放大时立即告警。若已发生拒绝服务,最快的止损是先在网关按输入长度拦截,再回头改写正则,而不是临时加机器。