28 个字符的正则,让 Node 卡了 2.7 秒
线上有个校验接口,正常请求 2 ms 返回。有人传了一个 28 字符的字符串,单个请求耗掉 2.7 秒,期间整个 Node 进程不响应——因为正则跑在主线程上,阻塞就是全阻塞。
一条能复现的正则
const VULN = /^(\w+\s?)*$/;
const SAFE = /^\w+(?:\s\w+)*$/;
喂一个「合法字符加末尾一个感叹号」的串,逼引擎把所有可能的分组方式试一遍:
| 输入长度 | 易受害模式 | 安全模式 | 倍数 |
|---|---|---|---|
| 16 | 8.12 ms | 0.041 ms | 198x |
| 18 | 3.44 ms | 0.034 ms | 101x |
| 20 | 13.50 ms | 0.003 ms | 4500x |
| 22 | 56.33 ms | 0.003 ms | 18777x |
| 24 | 176.31 ms | 0.008 ms | 22039x |
| 26 | 687.72 ms | 0.003 ms | 229240x |
| 28 | 2771.88 ms | 0.002 ms | 1385940x |
每多两个字符,耗时翻四倍。按这个斜率,32 个字符约 44 秒,40 个字符就是两小时——一个 HTTP 请求,一行 40 字节的字符串。
为什么是指数
(\w+\s?)* 里有两层量词:外层 * 重复整个分组,内层 + 重复 \w。关键在于两层匹配的字符集重叠——aaaa 这一串,可以整体算一个 \w+,也可以拆成 aa+aa、a+aaa,共 2 的 n-1 次方种切法。
引擎用回溯实现:先贪心地一次吃光,失败后吐回一个字符重试。末尾的 ! 让整个匹配注定失败,于是它必须把所有切法都走一遍才能确认「不匹配」。这就是灾难性回溯(catastrophic backtracking)。
安全版的 ^\w+(?:\s\w+)*$ 没有这个歧义:\w+ 只在开头出现一次,后面的重复单元必须以空白开头,而空白不属于 \w,两层之间边界确定,退化为线性扫描。实测在 28 字符上 2 微秒。
怎么一眼认出来
危险形状只有一个:同一个字符集被内外两层量词重复。
(a+)+、(a*)*、(\w+\s?)*—— 分组内是量词,分组外还是量词(\d+|[a-z]+)+—— 分组内是选择分支,分支之间能匹配同样的字符^(\S+\.)+\S+$这类「点号分隔」的老写法,末尾补个非法字符就炸
反过来,(?:\w+\s)+ 因为分隔符不属于 \w,就不危险。判断标准不是「有没有嵌套量词」,而是「内层能匹配的字符,外层还能不能再匹配一次」。
修法有优先级
一,改写成无歧义的形状。 这是唯一治本的做法,成本和风险都最低。把 (\w+\s?)* 换成 \w+(?:\s\w+)* 即可。改完先跑一遍原来的用例:hello world、a b c、a<TAB>b 依旧匹配,两个连续空格的 a b 依旧不匹配,语义没变。
二,先卡长度。 指数爆炸的前提是输入足够长。校验前先判断 s.length > 64 直接返回 false,等于给所有正则加了一层硬顶。代价是零,收益是把「几十秒」变成「不可能发生」。
三,换线性引擎。 Go 的 regexp、Rust 的 regex crate 用的是 NFA 模拟,最坏 O(n·m),永远不回溯。如果你的校验逻辑能挪到这类服务里,等于从根上免疫。反过来,Java 的 java.util.regex、Python 的 re、PCRE、JS 的 RegExp 都是回溯实现,写法该谨慎还是一样谨慎。
四,别指望超时。 JS 没有原子分组,也没有占有量词,正则一旦开始回溯,没有任何 API 能在中途掐断。setTimeout 在主线程阻塞时同样不会触发。
一个容易漏的点
不是只有 test() 会中招。replace() 用 g 标志全局匹配时,每个位置都要试一次;split() 带捕获组的分隔符同理;matchAll() 更明显。前面那张表的耗时是按单次 test() 算的,换成 s.replace(VULN, '') 只会更慢。
所以排查时不要只盯 test,把项目里所有正则统一过一遍量词结构。绝大多数线上 ReDoS 不是有人恶意构造,而是某个字段校验规则从 2019 年写下来就没人再看第二眼。
- 点赞
- 收藏
- 关注作者
评论(0)