
2026 年开年到现在CVE 库里已经躺了 40 多个 ReDoSCWE-1333漏洞picomatch 的 extglob 嵌套量词CVE-2026-33671、Open WebUI 的$...重叠量词CVE-2026-59220、Sentry ingestion 管道CVE-2026-52794、Apache Superset 的SQL_REGEXCVE-2026-23985、连 MCP TypeScript SDK 的 UriTemplate 都没逃过CVE-2026-0621。它们有一个共同的可怕之处Node 是单线程事件循环一个正则的灾难性回溯不是“慢一条请求”而是把整个进程冻住所有并发用户一起挂。我一度也信那句“正则再慢也有限小输入更无所谓”。于是把一个带嵌套量词的“纯单词校验器”和它的线性改写拉到同一张基准上用对抗输入跑了中位数——结论和这句共识相去甚远。背景为什么正则的“慢”值得当成安全议题正则几乎是每一行代码都在用的“小操作”表单校验、日志解析、路由匹配、模板渲染、协议头解析……单次看着便宜可它跑在请求热路径上又吃着单线程事件循环的“全局锁”。一旦某个 pattern 在特定输入下退化成指数时间攻击者只需发一个几十字符的字符串就能用一次 CPU 峰值把服务打瘫——这正是上面那一串 CVE 的共性。问题出在“共识”两个字。大家默认正则耗时随输入长度线性增长却很少想清楚回溯型引擎V8、PCRE、Pythonre默认都是在遇到“嵌套量词”或“重叠分支”时最坏情况是指数级。而更阴险的是——这种爆炸只在对抗/边界输入下发生正常输入下它跑得飞快于是代码评审和单元测试全都看不见。解剖回溯引擎怎么被一个嵌套量词拖进指数坑回溯引擎匹配失败时不会乖乖放弃而是把已经吃掉的字符合回去、换一种分法再试。(\w)这个结构就是典型的陷阱外层要把“一串单词字符”再分成若干个\w组。当输入是aaa...a!全是a、结尾一个非单词字符时引擎必须穷举所有可能的分组方式才能确认“整体不匹配”——而 n 个a分成若干正长度组的方式一共有2^(n-1)种。图1(\w)的回溯树。输入一旦无法整体匹配引擎就从右往左“吐”字符、换一种分组再试路径数随长度指数级膨胀。这不是学术玩具。上面那些 CVE 全是一个模子picomatch 的((?:...))、Open WebUI 的$...重叠量词、Superset 的重叠析取——本质都是“让引擎在失败时去穷举指数种路径”。下面用最干净的^(\w)$复现它。实证一同一个校验器对抗输入下指数爆炸把“校验字符串只含单词字符”这件小事先用带嵌套量词的^(\w)$evil再用没有嵌套的^\w$safe喂给非匹配输入a重复 N 次加一个!。Node v22、每组取中位数输入长度 nevil 单次耗时safe 单次耗时evil 比 safe 慢80.0010 ms0.00003 ms33×120.0109 ms0.00003 ms388×160.1727 ms0.00003 ms5,080×202.8469 ms0.00003 ms88,967×2445.2665 ms0.00003 ms1,414,578×28767.5272 ms0.00008 ms9,594,090×303034.8717 ms0.00008 ms37,935,896×规律肉眼可见每多一个字符耗时约翻一倍每多两个字符约翻四倍。n24时一次test()已经 45ms——单这一个调用就超过了 60fps 的 16.6ms 帧预算n30时一个 30 字符的字符串让一次正则卡了 3 秒比线性写法慢了3800 万倍。在 Node 里这 3 秒事件循环全程被这一个调用独占。图2同一语义两种写法在对抗输入下的耗时天差地别。evil 那条线每加一个字符就翻一倍safe 始终贴地。实证二合法输入秒回、删掉嵌套就线性收场为什么这种炸弹能混过评审因为它在合法输入下跑得飞快。把上面同一个 evil 喂给能匹配的a重复 N 次没有结尾的!输入长度 nevil 匹配耗时safe 匹配耗时5120.00025 ms0.00022 ms32,7680.00666 ms0.00629 ms524,288半百万0.12967 ms0.10187 ms半百万字符的合法输入evil 也只花 0.13ms和 safe 几乎一样——单元测试全用合法数据永远不会触发爆炸。这正是 ReDoS 最危险的地方它只在对抗/边界输入下现身。而“修复”其实简单到反直觉把嵌套量词拆掉^(\w)$直接写成^\w$语义完全不变都是“整串只含单词字符”却在对抗输入下从 3 秒变成 0.00008ms。差异不在算法而在有没有给引擎制造指数种失败路径。图3随着长度增加evil 与 safe 的差距从千倍一路拉到千万倍safe 那根柱子在图里几乎看不见。那“给输入限个长度”总行了吧实测给 evil 限长上限 20 → 最坏约 3ms勉强可接受上限 30 → 最坏约 3秒。因为耗时每字符翻倍任何没实测过上限的“合理长度限制”比如 1KB都等于没限制——n40时会是约 52 分钟的一次调用。长度上限是止血不是解药。局限长度上限只是止血、JS 没有占有量词诚实边界避免被当成“银弹”长度上限必要但不足它只能把 DoS 压到“你实测过的那个上限对应的耗时”。上限取 20 还有 3ms 的单调用取 30 就已经 3 秒——你必须针对具体 pattern 实测上限不能拍脑袋写if (len 1024)。JS 没有占有量词 / 原子组在 PCRE、Java 里你可以写(?...)或a*来“锁死”回溯但JavaScript 的RegExp至今不支持这两者a*直接语法报错。所以“加个占有量词”这条在其他语言管用的招在 Node/浏览器里用不了。真正的解药是线性引擎对不可信 / 用户提供的 pattern靠手写改写不够稳应改用线性时间引擎Google RE2、Rustregex、Node 的re2绑定——它们底层是 DFA/游程结构上杜绝了回溯爆炸。上面那些 CVE 的修复要么删掉重叠量词Sentry、Open WebUI要么升级到已打过补丁的版本picomatch 2.3.2/4.0.4方向一致。静态扫描兜底用vuln-regex-detector、semgrep的 ReDoS 规则在 CI 里扫嵌套量词和重叠析取比靠人眼评审靠谱。数字来自单台机器Windows / AMD64 / Node v22的中位数不同 V8 版本、不同 CPU 会有浮动但“指数而非线性”的形状对所有回溯型 NFA 引擎都成立。结论与下一步一句话方法论别再信“正则再慢也有限”。正则耗时在对抗输入下是指数而非线性——一个 30 字符的字符串就能让一次test()卡 3 秒、比线性写法慢 3800 万倍而它只在边界输入下现身单元测试根本发现不了。修法是三件事删掉嵌套量词与重叠析取如(\w)→\w、对不可信 pattern 改用线性引擎、长度上限必须针对具体 pattern 实测而不是拍脑袋。开源地址矩阵门户GitHub - wangzifan396-wzf/WB: nano-tools: 1200 single-file, zero-dependency, local-first web utilities in one portal. Offline and private, nothing leaves your browser. Binary protocol parsers, crypto, dev, audio, visualization, productivity. · GitHub单文件工具聚合器GitHub - wangzifan396-wzf/nano-workbench: Single-file tabbed launcher for the nano-tools matrix - one tab, 382 curated tools (of 1228), instant switching. Zero-dep. Part of nano-tools. · GitHubGitHub 组织主页wangzifan396-wzf (WangZi) · GitHub