ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Node.js V8引擎HashDoS漏洞剖析:从哈希碰撞到服务瘫痪

Node.js V8引擎HashDoS漏洞剖析:从哈希碰撞到服务瘫痪

前阵子线上出了个状况,一台配置不算低的实例突然长时间 CPU 满载,接口延迟从几十毫秒涨到十几秒。后来一路查下去,问题落在一个我一开始完全没放在心上的环节——把请求参数里的字段名塞进了一个普通的 JavaScript 对象里。你可能觉得奇怪,这跟 Node.js 有什么关系?答案是,这类场景如果踩中哈希碰撞的痛点,就是一次典型的 HashDoS 攻击,也就是这里要聊的 Node.js V8 引擎 HashDoS 漏洞。这篇不是拷贝一份安全通告,而是把整条链路从现象、原理、复现到加固完整拆开讲清楚,适合正在做 Node 服务端开发、做安全排查、或者还在维护老服务的人参考。

1. 从一次线上事故说起:HashDoS 到底是什么

1.1 那天的故障现场

当时接到告警的是某个内部系统,高峰时段 CPU 突然打满,服务响应从几十毫秒一路掉到十几秒,看起来就跟“雪崩”一样。我们第一时间检查数据库,慢查询为零;检查第三方接口,也没有异常;重启服务后恢复,但过一段时间又复现。后来抓了线上请求样本,才发现某一个接口的 URL 里带了上百个字段名极其相似的 query 参数,而且来的次数非常集中。

我们把这段请求原样在本地 Node.js 进程里跑了一遍,做了个简单压测。结果非常直观:CPU 占用快速爬升,事件循环延迟从个位数毫秒变成几百毫秒,进程没有崩溃,但它已经没有任何余力去处理正常请求了。当时的直觉是这段请求触发了某种算法上的极端路径,而不是单纯的“量大”。

后来看到火焰图里大量时间落在对象属性写入相关的内部函数上,才确定了方向:这不是普通的高并发问题,而是 HashDoS,全称 Hash Denial of Service,也常被称为哈希碰撞拒绝服务攻击。它最恶毒的地方在于,不需要很大的流量,也不需要多大的请求体,只要构造得当,一次请求就能把 CPU 耗到接近瘫痪。

1.2 用一句话解释 HashDoS

一句话版本:攻击者精心构造一批“哈希值趋同”的键,让哈希表在插入时从平均值 O(1) 退化成最坏情况 O(n),当键的数量足够多时,整体操作成本就会变成 O(n²)。对运行时的单线程模型来说,这等于用一个极小的包体制造了一场 CPU 风暴。

拿生活场景类比可能更好懂。哈希表就像是驿站里的快递货架,正常情况下每一件快递都能根据编号快速分到对应的格子里,取件时间复杂度是 O(1)。但如果有人故意让所有快递都填同一个编号,驿站只能把这一格的快递堆成一摞,每次取件都要从上到下翻一遍。取 n 件快递的时间就从 n 变成了 n²。这个平方级的增长,才是 HashDoS 的杀伤力核心。

在我遇到的那个服务里,解析出来的字段名大概有几千个。单看一个字段,查一次并不慢,但它们被映射到同一个桶之后,每插入一个新字段,都要跟同桶内已有的字段逐一做比较。几千个字段叠下来,就是百万级甚至千万级的字符串比较,Node 的事件循环自然被拖死。如果攻击者再同时放多个请求,那效果就是指数级放大。

2. 为什么 Node.js 和 V8 会“中招”:哈希表实现与碰撞原理

2.1 哈希表的“理想世界”与“现实世界”

哈希表是一种根据键直接访问存储位置的数据结构,它的效率高度依赖哈希函数的分布质量。理想情况下,任意两个不同的键都不应该映射到同一个桶;一旦哈希函数对某些输入产生大量相同值,算法就会从常数级退化。

这里的关键点在于:哈希函数并不是密码学上的安全函数,它的设计目标通常是“快”,而不太在乎“防恶意输入”。早期很多语言运行时采用的字符串哈希算法都很简单,比如乘一个固定质数再加总字符,这种算法运算速度快,但完全可预测。攻击者只要知道算法细节,就能离线构造出一大批哈希值一致的键。

Node.js 底层是 V8 引擎,V8 内部处理 JavaScript 对象属性时又依赖哈希表。这意味着,一个用户可控的字符串键,最终可能会进入 V8 的哈希桶。如果那个哈希桶本身没有足够的随机化防护,攻击者就能精准命中。

后来 V8 做了很多改进,其中之一是引入随机种子(seed)。不同进程、甚至同一次进程不同启动时的哈希种子都不一样,攻击者没法离线准备一批“通用碰撞键”。种子机制的引入让攻击成本高了不少,但这不等于所有应用层的写法都能自动免疫,这也是后面会展开的内容。

2.2 V8 对象属性存储的两种模式

V8 在执行 JavaScript 代码时,对普通对象的属性存储有两条路径:快属性(fast properties)和字典模式(dictionary mode)。

快属性路径适合“形状稳定”的对象。什么叫形状稳定?就是对象的属性名和结构在运行过程中基本不变化。这种情况下,V8 会为对象建立隐藏类(HiddenClass),把属性名映射成固定的一组索引,属性值连续存放在一段内存里,访问速度接近数组下标。性能非常好。

但业务里的对象往往没有这么规矩。大量对象的属性名来自运行时的用户输入,数量不固定,顺序也不固定。V8 一旦判断对象形状无法预测,就会把它从快属性切换到字典模式。字典模式其实就是一种哈希表实现,属性和值以键值对的形式分散在桶里,灵活性高,但访问速度比快属性慢。

重点在于:字典模式一样会受哈希碰撞影响。而业务代码里常见的 JSON.parse 结果、query string 解析结果、以及“key 不固定”的动态对象,都容易被运行时判定为不适合快属性,从而自动进入字典模式。开发者通常感知不到这个切换,因为 JavaScript 代码层面都是写 obj[key] = value,底层走什么路径完全由 V8 自主决定。

这里有个容易被忽略的细节:ES6 的 Map 和普通对象的字典模式,在 V8 里走的是不同实现路径。Map 从设计之初就考虑到需要应对更多场景,后来的实现里普遍带随机种子或更稳健的哈希策略。而普通对象的字典模式在很长一段时间内走的是保守路线,没有同等强度的保护。攻击者当然更愿意挑普通对象下手,因为脆弱面更明确、更普遍。

2.3 碰撞如何被放大为 DoS

把链条闭合起来看,HashDoS 的放大效应离不开两个前提:一是存在一个可被用户控制的输入键入口,二是这个入口背后的容器是普通对象或脆弱的哈希表。

当这两个前提成立,攻击者往对象里写入 n 个发生碰撞的键时,每次新增都要遍历同桶内已有的键。最坏情况下,总比较次数是 n²/2。假如 n 是 3000,那大约是 450 万次比较;假如键是那种带前缀长字符串,每次比较还得逐字符走,成本还会成倍增加。我在压测环境里验证过,同样的键数量,随机分布时毫秒级完成,碰撞分布时能到几十秒甚至更高。

更麻烦的是,这个过程中会不断创建临时对象。大量字符串比较产生的中间量,叠加对象本身的动态扩容,会导致堆内存快速上涨。于是你会看到另一个现象:GC 频率飙升,内存曲线像锯齿一样跳动。很多团队排查时第一反应是“内存泄漏”,其实这是 HashDoS 后半段的连锁反应。

由于 Node.js 是单线程事件循环,一次 HashDoS 请求把主线程占住,所有其他请求、定时器、健康检查、日志写入都会排队。即使服务器有多个 CPU 核心,同一个进程内也只有一条主线程在执行业务回调,其他核心对同步计算热点无能为力。这就是为什么一台配置不错的机器也能被几十 KB 的恶意请求击穿。

3. 攻击面梳理:哪类 Node.js 应用最容易受影响

3.1 Web 框架的请求体解析链路

先看一个典型的 Node.js Web 服务:请求进入,经过 HTTP 解析器,再交给框架层的 body 解析模块。无论是 Express 生态里的 body-parser、qs,还是原生 http 模块里的 url/querystring 解析,最终都会把用户提供的参数名转成对象键。

代码层面往往长这样:

const query = querystring.parse(url); for (const key of Object.keys(query)) { result[key] = transform(query[key]); }

或者是更直接的:

const data = JSON.parse(payload);

看起来人畜无害,但这里的 key 完全是用户可控字符串。只要解析链路把键存进普通对象的字典模式,你就有了一条潜在的被放大入口。注意,不只 URL query 参数有这个问题。POST 表单、JSON body、GraphQL 的 variables、WebSocket 消息文本,都可能被解析成嵌套对象。嵌套对象会让问题更隐蔽,因为最深的那个对象可能藏在请求体的第三层、第四层,监控和告警都不容易注意到。

3.2 哪些业务场景会主动触发字典模式

不是所有应用都容易中招。从经验看,下面几类场景出现 HashDoS 风险的概率明显更高:

把外部传入的 key 直接用作对象 key。比如用户自定义配置项、规则引擎里的动态字段名、多租户系统的 meta 信息、或者从文件里读取的 JSON 配置。这类代码的特点是“key 是动态的”,开发时没法列白名单,只能直接塞进对象。

接收超长 query string 或复杂 JSON body。很多接口对 body 大小不做限制,框架默认的默认限制也可能偏大。攻击者不需要把请求体撑到几 MB,只要键数量足够多,就能触发明显的性能劣化。

用普通对象做数据聚合的中间层。服务端渲染、日志结构化、API Gateway 转发参数时,经常会把请求里的字段先摊进一个对象里再做处理。这类服务对性能敏感,一旦某个请求触发碰撞,影响面会被放大到整个网关或渲染进程。

我自己在实际项目里就遇过类似情况。某个后端缓存模块为了通用性,直接从一个存储结构里读出一批动态 key,随后赋值给一个普通对象,再交给前端做筛选。key 是用户自己上传的,只要在字段里带几千个重复前缀的键,后端这个循环就慢到离谱。这种代码看起来完全正常,但它天然就是 HashDoS 的最佳载体。

3.3 旧版本与新运行时的差异

从漏洞时间线来看,很长一段时间里,Node 0.x、4.x 等旧分支都属于 HashDoS 高发区间。当时的 V8 在普通对象属性表上没有足够随机化手段,攻击者只要拿到一组已知的碰撞键,就能直接对服务发起有效攻击。后来 V8 和 Node.js 团队逐步加强防护,引入随机种子、改进了字典桶结构,现代 LTS 版本对这些已知攻击方式已经有了很强的防护能力。

但千万不要理解成“升级就安全了”。原因有两个:一是不少老项目长期锁定在旧版本,安全更新根本没跟上;二是应用层仍然可能出现自己实现的哈希表结构,或者依赖的第三方库里藏着类似实现。运行时的保护再好,也覆盖不到自己写出来的那层问题。

4. 复现与验证:用一段代码“敲响警钟”

4.1 最小复现脚本

作为开发者,光看分析不过瘾,建议在一台本地机器或内网测试机上复现。复现原则只有一条:不要拿公网生产环境做实验。

下面这个脚本跑在 Node 环境下,对比“随机键”和“高相似性键”写入普通对象的耗时差异。注意,这里刻意避开了真实可利用载荷的细节,目的只是让读者看清机制。

const { performance } = require('perf_hooks'); function randomKeys(count) { return Array.from({ length: count }, (_, i) => 'rand_' + i + '_' + Math.random()); } function repetitionKeys(count) { return Array.from({ length: count }, (_, i) => { const suffix = i.toString(36).padStart(6, '0'); return 'user_field_name_'.repeat(4) + suffix; }); } function fillObject(keys) { const obj = {}; const start = performance.now(); for (const key of keys) { obj[key] = 1; } return performance.now() - start; } const count = 30000; const randomMs = fillObject(randomKeys(count)); const repetitionMs = fillObject(repetitionKeys(count)); console.log('random keys:', randomMs.toFixed(2), 'ms'); console.log('repetition-like keys:', repetitionMs.toFixed(2), 'ms');

在你的机器上跑一次,大概率能看到随机键组是毫秒级完成,而高相似性键组耗时要多出几倍甚至一个数量级。这个差异一部分来自字符串比较成本,一部分来自哈希分布不均带来的字典模式退化。真实攻击的构造会比这里复杂得多,但理解这个量级差异,足够帮助团队识别风险。

4.2 观察指标:CPU、事件循环、请求吞吐

复现时别只看脚本单次耗时。要观察真实影响,最好模拟服务端场景:用 Node 起一个 HTTP 服务,接收带大量参数名的请求,然后并发打样本请求,同时用 top、perf 或 node --prof 抓 CPU。

一个明显信号是事件循环延迟从个位数毫秒升到几百毫秒。Node 没有内置的直接命令查看事件循环延迟,但可以通过setTimeout的漂移量间接观察,或者用社区里成熟的事件循环延迟监控库。如果发现延迟稳步上升且伴随 CPU 打满,基本可以锁定同步计算热点。

再看一眼火焰图,名字里会有大量时间落在对象属性写入相关的内部函数上,普通业务代码反而占比很低。这个反直觉的结果,往往就是 HashDoS 的典型画像。业务代码只负责“发起写入”,真正的开销全部发生在 V8 内部的数据结构维护上。

4.3 边界情况与误判排查

复现 HashDoS 有几个容易踩的坑。第一个是单次结果波动大。V8 有 JIT 编译、垃圾回收、隐藏类优化,不同轮次跑出来的时间可能差很多,至少重复 10 次取中位数,不要看单次。第二个是样本量太小,几百个键看不出明显差异,通常要到几万个键才能拉开数量级差距。第三个是把 GC 噪声当结果。如果堆内存设置得特别小,或者正好触发 GC,耗时会翻倍,所以要在同一进程里用同样堆参数做对比。

另外,验证“是否真的命中”最稳妥的方式,是在对比基线后,把同一批请求放到一台不相关的新 Node 进程里再跑一遍。两轮结果趋势一致,才算排除环境偶然性。如果只有一轮快一轮慢,先检查 GC 和系统负载,别急着下结论。

5. 加固方案:从 Node.js 配置到应用设计

5.1 运行时层面的升级与参数

最先要做的一件事,是把运行环境升级到仍在维护的 LTS 版本。这未必是件很爽快的事,尤其是老项目里的依赖兼容问题,但它是后续所有加固工作的起点。对于还在用旧分支的团队,至少要把安全更新列上日程,并设定一个可回滚的切换窗口。

有些团队会想用--max-old-space-size来“防爆”。这个参数控制的是老生代堆内存上限,确实能让进程在内存被打满时更早 OOM,但 OOM 本身也是故障,而且它并不能阻止 CPU 被消耗。把它当成最后兜底可以,当成修复手段就是自欺欺人了。

另一个有用参数是--trace-gc,跑压测时打开,能看到 GC 频率是否异常。如果发现内存平稳但 GC 频繁,再结合 CPU 火焰图,排查方向就会非常清晰。类似地,--prof可以生成 V8 的 CPU 分析日志,用于定位热点函数。

5.2 应用层策略

应用层能做的事,往往比运行层更直接。我按实践优先级列几条:

限制请求体大小。给 JSON 和表单解析都设一个合理上限,比如常见做法是 100KB 或 1MB,取决于业务实际需求。这一步能把碰撞键数量的天花板先压住。

限制参数个数。很多框架只限制整体 body 大小,不限制单个对象里的键个数。需要自己在解析后做检查,比如超过 500 个键就拒绝请求。键数量是 HashDoS 成本的主要放大因子,压住键数量等于压住平方项的最大值。

不要用用户可控键直接当对象属性。如果需要以外部 key 为维度做聚合,优先用 ES6 的 Map。Map 的存储实现和对象字典模式不一样,整体设计上更稳健。代码改动通常很小:

const map = new Map(); map.set(externalKey, value);

对已知的固定字段走白名单模式,只把字段名映射到预定义的内部 key 上,未知字段直接丢弃。这样既不丢功能,也减少了攻击面。

如果不想动业务代码,至少可以在网关层限制 query string 的字段长度和数量。

5.3 网络与架构兜底

再往外看,架构层面的兜底能减轻冲击半径。反向代理一般都可以配置请求体大小限制,比如在 Nginx 或其他代理里直接压到业务可接受的值,这比应用层处理更早,也更省资源。

然后是限流。HashDoS 请求的特征是同一来源、同一路径、参数结构高度相似。识别到这种特征后,可以对来源 IP 或同一用户维度做限流。限流不治本,但能防止单个来源把集群打满。

告警同样重要。CPU 跑满本身就该有告警,更精细的做法是增加 GC 频率或事件循环延迟指标。如果平时事件循环延迟都是个位数,突然出现持续 300ms 以上,无论是不是 HashDoS,都值得人工介入。

6. 踩坑记录与排查清单

6.1 我们修复过程中踩过的坑

第一坑,太信任“现代版本没事”。当时有个老分支服务一直正常,但把同一套请求打到新版本 Node 上一跑,几乎感觉不到延迟。对比之后才意识到,线上到底跑在什么版本、性能基线如何,这两件事必须在加固前搞清楚。版本升级有没有真的生效,不能靠猜。

第二坑,把锅甩给数据库。慢接口一出现,团队第一反应是查数据库连接数和慢查询,结果数据库那边一切正常。原因是 Node 侧 CPU 已经饱和,但入口侧的请求数并没有立刻掉下去,看起来就像数据库在排队。其实问题出在入口进程本身。

第三坑,只限制 body 大小不限制数量。限制 body size 之后,确实把最极端的样本挡掉了,但攻击者把每个请求控制在合理大小内,依然能靠足够的并发制造大量比较。后来把所有解析入口都加了键数上限,才真正稳住。

踩完这些坑之后的体会是:HashDoS 不是单一配置能解决的,它需要运行时、应用层、网络层各守一段。每层看住自己那一块,整体才算扎实。

6.2 一套可复用的排查清单

抓样本:从被卡住的请求里找到参数量异常的 URL 和 body。做隔离:在不影响生产的前提下,将可疑输入在本地新进程复现。看指标:CPU、堆内存、事件循环延迟、GC 频率。看火焰图:确认热点是否落在对象属性写入或解析相关路径。分层限制:从网关到应用,依次收紧 body 大小、参数个数、单字段长度、来源限流。复测:用加固前后的基线做同一样本对比,确认耗时和 CPU 回到正常量级。

这套清单不一定要按顺序全做。早期可以先做做隔离和火焰图,排查出明确结论;中后期再补上分层限制和复测,作为长期防御手段。

最后说句实在话:做安全分析,最怕的不是攻击者多厉害,而是我们想当然地觉得“这个服务这么普通,应该没人盯着”。HashDoS 这一类缺陷不需要多复杂的渗透技巧,它就是把你代码里最普通的那个数据结构,在某一阵子输入下变成最坏情况。我建议每个 Node.js 团队都抽十分钟,用上面的方法做一次自查;排查过程中节省下来的,可能是一次线上事故的整夜。我个人在实际操作中还会多做一步:把压测样本留在测试仓库里,每次升级依赖后重新跑一遍。这个小动作,比改十处配置都值。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进