正则表达式性能ReDoS回溯CPU安全

那个让服务器 CPU 飙到 100% 的正则表达式,是我写的

2026-04-1610 分钟阅读

那是一个周四下午,监控告警突然爆炸——一台处理用户输入的服务器 CPU 钉在了 100%。排查了一个小时,凶手只是两行正则。这是一篇关于「灾难性回溯」的事故复盘,以及如何写出不会炸掉生产环境的正则表达式。

事故当天

那是一个普通的周四下午两点。我正在处理一个与这次事故毫无关系的代码 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 的静态检查,防止类似的炸弹再次进入生产。

经验是最贵的学费,但不必每个人都亲自交。下次写正则之前,先在测试工具里多喂几个极端用例——你可能会发现你的正则有多脆弱。