← 返回文章列表

正则的灾难性回溯:一个能拖垮服务的写法

正则避坑

问题长这样

(a+)+$ 这类嵌套量词遇到 aaaa...! 时,引擎会反复回溯,耗时随长度指数增长,把 CPU 打满。

危险信号

  • 量词套量词:(a+)+、(a*)*;
  • 多个分支都能匹配空:(a|ab)* 配长串;
  • 在不可信输入上跑复杂正则。

怎么规避

  1. 改用确定型写法:用具体字符类、避免嵌套量词;
  2. 给正则加超时:很多引擎支持匹配超时,防止单条请求卡死;
  3. 先用长度 / 格式粗筛:再上正则,缩小攻击面。

实战案例:三次“一条正则跑满 CPU”

  1. 邮箱校验拖垮接口:形如 ^([a-z]+)*@…$ 的嵌套量词,遇到一长串没有 @ 的输入会指数回溯。修复:改成不嵌套的字符类写法。
  2. 日志脱敏变慢:对每条日志执行 (.*)+ 之类的贪婪分组,日志量一大即成瓶颈。修复:去掉冗余分组,先按分隔符切分再匹配。
  3. 用户可控的正则:把用户输入当正则执行(例如搜索里的“正则模式”),等于把 CPU 交给攻击者。修复:限制长度与语法,或改用专用全文检索。

常见问题(FAQ)

所有引擎都会回溯吗?主流回溯型引擎(PCRE 及多数语言内置实现)会;RE2、Rust 的 regex 用自动机实现,最坏情况是线性的。正则超时怎么加?部分语言支持匹配超时或可中断线程;不支持时可用子进程隔离并设硬超时。怎么快速判断一条正则危险?用长串极限输入(如 'a'.repeat(30) + '!')测耗时,呈现指数增长就是危险信号。危险正则能自动检测吗?有静态分析工具(如 safe-regex)可提醒嵌套量词,但它只是提示,仍需人工判断输入面。

把正则加固到可上线

  1. 加超时或隔离:能用带超时的库就用;否则把匹配放进子进程并设硬超时,防止单条请求拖垮进程;
  2. 改用线性引擎:Go 的 RE2、Rust 的 regex 用自动机实现,最坏情况线性,处理不可信输入时应优先选择;
  3. 用原子组或占有量词:支持 (?>…) / *+ 的引擎可禁止回退,把指数变成线性;
  4. 先做廉价校验:长度上限、字符集白名单、必须包含的关键字符,先用普通字符串操作挡掉大部分输入;
  5. 预编译并复用:正则编译本身有开销,请求路径上应预编译;同时避免在循环里重复构造。

写正则的几条纪律

  • 避免量词嵌套:(a+)+、(w*)* 一律改写为不嵌套的等价形式;
  • 用具体字符类替代通配:[a-z0-9] 比 . 更可预测,也更快;
  • 锚定边界:能加 ^ / $ 就加,减少无谓的搜索尝试;
  • 宁可多个简单正则:把复杂表达式拆成几个顺序判断,既好读也避免回溯叠加。

上线前的验证方法

用三类输入做基准测试:最短合法串、最长合法串、以及“几乎匹配”的对抗串(如 a 重复 30 次后跟一个不合法的字符)。若耗时随长度呈指数增长,就必须改写或加超时,而不是靠“线上没出事”侥幸上线。

哪些语言与引擎默认安全

  • 线性引擎:Go 的 regexp(RE2 语义)、Rust 的 regex、以及多数 grep 实现不会回溯,最坏情况线性;
  • 回溯引擎:PCRE、Java、Python re、JavaScript 正则都是回溯型,危险模式会指数爆炸;
  • 部分语言提供安全模式:如 Java 可通过独立线程加超时中断,Node 可把匹配放进 worker 并设时限;
  • 注意库差异:同一语言的不同库(如某些第三方实现)行为可能不同,引入前确认其引擎类型。

监控与止损

上线后应对匹配耗时加埋点:当 P99 超过阈值或出现单个请求耗时异常放大时立即告警。若已发生拒绝服务,最快的止损是先在网关按输入长度拦截,再回头改写正则,而不是临时加机器。