事故当天
那是一个普通的周四下午两点。我正在处理一个与这次事故毫无关系的代码 PR,突然 Slack 上的监控机器人开始疯狂 at 我——一台部署了用户评论审核服务的服务器,CPU 使用率在 3 分钟内从正常的 15% 爬到了 100%,并且再也没下来。
服务是我三个月前写的。这种时候的第一反应不是恐慌,而是更深层的恐慌:我知道问题在哪,只是不敢确认。
登录服务器,top 命令显示是我的 Node.js 进程在疯狂消耗 CPU。用 --prof 生成火焰图,挂载点一目了然:一个叫 validateUserComment 的函数,占据了超过 95% 的 CPU 时间,而它里面做的事情很简单——运行一个正则表达式。
凶手:两行代码
那段代码大概长这样:
// 目的:验证用户的评论不包含连续重复字符(如"aaaa"、".......")
function validateUserComment(comment) {
const pattern = /^(a+)+$/; // ← 就是这一行
return !pattern.test(comment);
}
这个正则的意图是对的:匹配一个全由字母 'a' 组成的字符串。但它的写法产生了一个被称为 "灾难性回溯"(Catastrophic Backtracking) 的问题,在计算机安全领域有个专用名词:ReDoS(Regular Expression Denial of Service)。
让我解释为什么 ^(a+)+$ 是一颗定时炸弹。
技术核心:灾难性回溯是怎么发生的
正则引擎在匹配失败时会触发"回溯"——退回到上一个决策点,尝试不同的路径。通常这没什么问题。但 (a+)+ 这个结构创造了一个"嵌套量词"陷阱:
对于字符串 "aaaan"(n 个 a 之后跟一个非 a 字符),正则引擎会这样思考:
//「aaaan」的匹配过程(简化)
(a+)+ 尝试匹配:
路径1:外层循环1次,内层 a+ 吃掉所有 "aaaa",然后 $ 要求结束,失败(遇到 n)
路径2:外层循环1次内层吃 "aaa",外层再循环1次内层吃 "a",$ 失败
路径3:外层1次吃 "aa",外层1次吃 "aa",$ 失败
路径4:外层1次吃 "aa",外层1次吃 "a",外层1次吃 "a",$ 失败
... 以此类推,共 2^n 种尝试路径
对于 30 个 a 后跟一个非 a 字符,引擎需要尝试 2^30 ≈ 10 亿次路径。这就是 CPU 飙到 100% 的原因:正则引擎陷入了指数级的穷举。
更致命的是:这个漏洞是用户可控的输入。任何用户只要提交一条 30 个空格结尾的评论,就能让服务器挂死。这已经是一个标准的 服务层安全漏洞。
修复:三种方式
方法一:消除嵌套量词(推荐)
// ❌ 危险写法
const bad = /^(a+)+$/;
// ✅ 安全等价写法
const good = /^a+$/;
方法二:使用原子组(部分引擎支持)
PHP 和 Java 等语言支持原子组 (?>...),让引擎在匹配成功后不再回溯该组内容,从根本上消除灾难性回溯风险。
方法三:超时保护(防御性编程)
// Node.js 使用 timeout 守卫正则执行
const { Worker } = require('worker_threads');
function safeRegexTest(pattern, input, timeoutMs = 100) {
return new Promise((resolve, reject) => {
const worker = new Worker( const { parentPort, workerData } = require('worker_threads'); const regex = new RegExp(workerData.pattern); parentPort.postMessage(regex.test(workerData.input)); , { eval: true, workerData: { pattern: pattern.source, input } });
const timer = setTimeout(() => {
worker.terminate();
reject(new Error('Regex timeout - possible ReDoS'));
}, timeoutMs);
worker.on('message', (result) => {
clearTimeout(timer);
resolve(result);
});
});
}
正则写法的 5 条安全准则
- 🚨 永远不要嵌套量词:
(x+)+、(x|y)+(当 x 和 y 有重叠时)、(x*)*都是高风险模式 - ⏱️ 对用户输入的正则验证加超时:设置 50-200ms 的超时限制,超时即拒绝
- 🔍 用工具检测危险正则:
safe-regex(npm)、rxxr2等工具可以静态分析正则的回溯复杂度 - 📏 限制输入长度:在运行正则之前,先检查输入字符串的最大长度,超出则直接拒绝
- 🧪 在 Regex 测试工具里验证边界用例:用 daima.life 的正则测试器,输入极端值(如 30 个重复字符 + 一个错误字符),观察匹配时间是否异常
事故的尾声
那次事故持续了约 18 分钟,直到运维通过负载均衡把流量切到了健康实例。修复代码只改了一行——把 (a+)+ 换成了 a+——推送上线不到 5 分钟,CPU 立刻回落到正常水位。
但那 18 分钟的故障,换来的不只是一行代码的修复。我们重新审计了整个代码库所有涉及用户输入的正则,发现了 3 处类似的潜在风险点。我们还在 CI/CD 流水线里集成了 safe-regex 的静态检查,防止类似的炸弹再次进入生产。
经验是最贵的学费,但不必每个人都亲自交。下次写正则之前,先在测试工具里多喂几个极端用例——你可能会发现你的正则有多脆弱。