马泰奥·科利纳 (Matteo Collina), 乔伊·张 (Joyee Cheung)
太长不看 (TL;DR)
Node.js/V8 尽最大努力尝试通过可捕获的错误从栈空间耗尽中恢复,框架已依赖此机制来实现服务可用性。一个仅在……时重现的边缘情况。async_hooks会破坏此恢复路径:当用户代码中的递归耗尽栈空间时,Node.js 会立即退出(退出码7),而不是抛出可恢复的错误。这在无数应用程序中都能复现,因为:
- React Server Components使用
AsyncLocalStorage - Next.js使用
AsyncLocalStorage进行请求上下文跟踪 - 其他框架也可能使用。
AsyncLocalStorage对于请求上下文跟踪 - 大多数 APM 工具(Datadog、New Relic、Dynatrace、Elastic APM、OpenTelemetry)使用
AsyncLocalStorage或async_hooks.createHook来追踪请求
该弱点根本上在于生态系统依赖语言中未指定的行为——从栈空间耗尽中恢复——来实现服务可用性(CWE-758)。鉴于广泛使用async_hooks 根据流行的框架和APM工具,上述边缘情况会更频繁地暴露这一弱点,并可能为许多应用程序带来拒绝服务攻击向量。Node.js在2026年1月的安全更新中推出了一项缓解措施,使这一未指定行为更加一致,从而减少了复现的可能性。然而,只要应用程序和框架仍然依赖未指定行为来保证可用性,这一弱点就依然存在于生态系统中。
针对这些框架/工具的使用者和服务器托管提供商:请尽快更新。
对于库和框架:应用更强大的防御措施来防止栈空间耗尽,以确保服务可用性(例如,限制递归深度,或者如果深度可被攻击者控制,则避免递归)。一个可恢复的RangeError: Maximum call stack size exceeded 只是一个尽力维护的未指定行为,不能依赖它来保证安全。
重现
当启用 async_hooks 时,用户代码中发生栈溢出,Node.js 立即退出并返回代码 7,而不是允许 try-catch 块捕获该错误。这是 Node.js 中的一种特殊条件,它跳过了process.on('uncaughtException') 处理程序,导致异常无法捕获。
import { createHook } from 'node:async_hooks';
// This simulates what APM tools do
createHook({ init() {} }).enable();
function recursive() {
new Promise(() => {}); // Creates async context
return recursive();
}
try {
recursive();
} catch (err) {
console.log('This never runs', err);
}- 预期:
try-catch捕获了RangeError - 实际:立即崩溃,退出代码为 7
为什么这会影响 React 和 Next.js
React Server Components
React 18+ 使用 AsyncLocalStorage (它基于 async_hooks 构建)来追踪 Server Components 的渲染上下文:
// Inside React's internals
import { AsyncLocalStorage } from 'node:async_hooks';
const asyncLocalStorage = new AsyncLocalStorage();
// Every server component render creates async context
async function renderServerComponent(Component, props) {
return asyncLocalStorage.run({ request: currentRequest }, async () => {
return <Component {...props} />;
});
}Next.js 请求上下文
Next.js 使用 AsyncLocalStorage 来追踪请求上下文、Cookie、标头等信息:
// Simplified from Next.js internals
import { AsyncLocalStorage } from 'node:async_hooks';
export const requestAsyncStorage = new AsyncLocalStorage();
// Every request creates async context
export function handleRequest(req, res) {
return requestAsyncStorage.run({ req, res }, async () => {
// Your page/API handler runs here
});
}真实场景
假设有一个处理用户提交的 JSON 的 Next.js API 路由:
// pages/api/process.js
export default async function handler(req, res) {
try {
const data = req.body;
const result = processNestedData(data); // Deeply nested = stack overflow
res.json({ success: true, result });
} catch (err) {
// THIS CATCH BLOCK NEVER RUNS
console.error('Processing failed:', err);
res.status(500).json({ error: 'Processing failed' });
}
}
function processNestedData(data) {
if (Array.isArray(data)) {
return data.map(item => processNestedData(item));
}
return transform(data);
}用户发送深度嵌套的 JSON 可能导致整个服务器崩溃:
[
[
[
[
[
[
[
[
[
[
[
[
[
[
[
[
[
[
[
[
[
[
[
[
/* 50,000 levels deep */
]
]
]
]
]
]
]
]
]
]
]
]
]
]
]
]
]
]
]
]
]
]
]
]- 在没有
async_hooks的情况下:try-catch会捕获RangeError,返回 500,服务器继续运行 - 使用
async_hooks(React/Next.js)时:服务器立即崩溃,退出代码7
为什么使用APM工具(APM Tools)能更轻松地复现
应用性能监控(APM)工具是生产应用程序的基础设施。它们跟踪请求延迟、识别瓶颈、追溯错误根源,并在出现问题时向团队发出警报。公司使用 Datadog、New Relic、Dynatrace、Elastic APM 和 OpenTelemetry 等 APM 工具来保持对其分布式系统的可见性。
为了提供此功能,APM工具需要跟踪请求在应用程序中的流动,甚至跨越异步边界。当一个HTTP请求进入,经过中间件处理,查询数据库,调用外部API,最后返回响应时,APM需要将所有这些操作关联到单个跟踪中。这需要异步上下文跟踪。
大多数现代APM工具使用AsyncLocalStorage(它构建在async_hooks` 在异步操作之间传播跟踪上下文。当你 `require('dd-trace')`、`require('newrelic')` 或初始化 OpenTelemetry(OpenTelemetry) 时,你的应用程序已启用 `async_hooks`。`
` 讽刺意味明显:你用来监控和调试崩溃的工具,反而可能导致某类崩溃的行为发生变化。这并非 APM(APM) 工具的过错——它们只是在完全按照预期使用 Node.js(Node.js) API。
为什么这只是一个缓解措施,而漏洞存在于别处
尽管此问题具有重大的实际影响,但我们想明确说明,为什么Node.js(Node.js)将此次修复视为对整体安全漏洞风险的纯粹缓解:
栈空间耗尽行为未定义
“超过最大调用栈大小”错误并非ECMAScript(ECMAScript)规范的一部分。规范没有施加任何限制,并假设无限的栈空间。施加限制并抛出一个可恢复的错误只是JavaScript引擎尽力实现的行为。应用程序和框架在基于这些未指定的行为建立安全模型时已经面临风险,因为这些行为不能保证一致地再现,参见:
值得注意的是,即使ECMAScript规定正确的尾调用 应该重用栈帧,如今大多数JavaScript引擎(包括V8)尚未实现这一特性。而在少数确实实现了它的JavaScript引擎中,尾调用优化(如同在上述复制品) 可能因无限递归而阻塞应用程序,而不是在某个时刻达到栈大小限制后停止并报错,这是另一种拒绝服务攻击向量。这进一步说明,不能依赖栈溢出行为来防御拒绝服务攻击。
这种行为不是V8安全保证的一部分。
在 Node.js 中,JavaScript 函数调用的堆栈空间使用主要由 V8 实现。为浏览器开发的 JavaScript 引擎具有不同的安全模型,它们不会将此类崩溃视为安全漏洞进行分类。这意味着在上游报告中类似的行为不一致(如这个) 不保证会经过漏洞披露程序,因此仅凭Node.js自身进行的任何安全分类都是无效的。
uncaughtException 限制
uncaughtException 处理程序并非设计用于在触发后恢复进程。Node.js 文档明确警告不要使用这种模式。具体来说,文档指出 "事件处理程序内抛出的异常不会被捕获。相反,进程将以非零退出码退出,并打印堆栈跟踪。这是为了避免无限递归。"
在调用栈大小超出后尝试调用处理程序本身会抛出错误。它能够在没有 Promise 钩子的情况下工作,很大程度上是巧合而非保证的行为。
为何将其纳入安全更新
尽管这是针对未定义行为的补丁,我们仍选择将其包含在安全更新中,因为它对整个生态系统的影响广泛。async_hooks 被 React Server Components、Next.js 和 APM 工具使用,使其成为许多应用程序中实用的拒绝服务攻击向量。
使未定义行为在 Node.js 中更加一致,可以改善开发者体验,并使错误处理更具可预测性。
然而,重要的是要注意,我们很幸运能够修复这个特定案例。无法保证涉及堆栈溢出和 async_hooks 的类似边缘情况总能得到解决。对于必须防御无限递归或由攻击者可控制递归深度导致的栈溢出的关键路径,始终对输入进行清理,或通过其他方式对递归深度施加限制。
值得注意的是,大型数组分配也可能面临类似问题,例如最近的qs漏洞CVE-2025-15284。指出,开发者必须验证并限制可能受攻击者控制的资源使用。运行时在资源耗尽后并不总能可靠地恢复。
技术深入探讨
async_hooks如何工作
当你创建一个Promise时,async_hooks会触发回调函数来追踪异步上下文:
new Promise()
→ V8 promise hook triggered
→ async_hooks init callback runs
→ Your hook code executes (e.g., APM span creation)
致命的TryCatchScope(TryCatchScope)
Node.js封装了async_hooks 回调在一个名为 TryCatchScope 的特殊错误处理器中,使用 CatchMode::kFatal:
// From Node.js internals
void EmitAsyncInit(/* ... */) {
TryCatchScope try_catch(env, TryCatchScope::CatchMode::kFatal);
// Run async_hooks callback
}kFatal 意味着:"如果在此处发生任何错误,则无法恢复。立即退出。"
此行为有文档记录且是故意的。来自 async_hooks 文档:
如果任何
AsyncHook回调抛出异常时,应用程序会打印堆栈跟踪并退出。退出路径确实遵循未捕获异常的路径,但所有'uncaughtException'监听器都会被移除,从而强制进程退出。这种错误处理行为的原因在于,这些回调函数在对象生命周期的潜在不稳定点(例如类的构造和析构期间)运行。因此,有必要快速终止进程以防止将来意外的中止。
这种设计是有道理的:如果你的APM(应用性能管理)工具的init回调抛出错误时,应用程序处于未定义状态。钩子可能已部分执行,资源可能泄漏,继续运行可能导致数据损坏。最好快速且大声地崩溃。
但是Stack Overflow是不同的。该错误并非源自钩子,而是源自用户代码。只是当钩子在调用栈上时,调用栈恰好溢出了。钩子本身没有问题;无需担心状态损坏。
Bug详解
要理解这个bug,我们需要了解promise hooks的工作原理。
当你启用async_hooks用一个init回调,Node.js 注册一个Promise钩子(promise hook)使用 V8。每当创建 Promise 时,这个钩子由 V8 自身调用,而不是由 Node.js JavaScript 代码调用。关键细节是 V8 调用这个钩子。同步地在 Promise 构造函数期间,之前new Promise()返回到你的代码。
调用序列如下所示:
Your code: new Promise()
→ V8 Promise constructor
→ V8 calls promise hook (synchronous, before constructor returns)
→ Node.js promiseInitHook() [JavaScript]
→ emitInitNative() [JavaScript]
→ Your async_hooks init callback
→ V8 Promise constructor returns
Your code continues...
这意味着async_hooks回调不会孤立地运行。它们在相同的调用堆栈作为用户代码。每一个new Promise()该调用会在栈上已有的内容之上,为钩子机制添加几个栈帧。
这是深度递归期间堆栈的样子:
[bottom of stack]
recursive() frame #1
new Promise()
V8 promise hook
async_hooks init callback ← TryCatchScope::kFatal active here
recursive() frame #2
new Promise()
V8 promise hook
async_hooks init callback ← TryCatchScope::kFatal active here
recursive() frame #3
new Promise()
V8 promise hook
async_hooks init callback ← STACK OVERFLOW HAPPENS HERE
[top of stack - limit reached]
每次递归调用都会为用户代码和 async_hooks 机制添加栈帧。当栈最终溢出时,当前正在执行的代码是 async_hooks 回调函数,因此TryCatchScope::kFatal抓住它。
当堆栈溢出时发生的完整序列:
- 用户代码调用
new Promise()递归地 - 每个
new Promise()同步触发V8的Promise钩子 - V8调用Node.js的
promiseInitHook(),然后emitInitNative() - 栈中填满了交错的用户代码和钩子帧
- 栈溢出在钩子回调内部抛出一个
RangeError TryCatchScope::kFatal捕获错误TryCatchScope::~TryCatchScope()调用env_->Exit(ExitCode::kExceptionInFatalExceptionHandler)- Node.js以代码7退出
错误起源于用户代码(递归模式),但由于它是在钩子回调处于活动帧时显现的,因此被当作致命的钩子错误处理。
修复
修复方案检测到栈溢出错误,并将它们重新抛出给用户代码,而不是将其视为致命错误:
TryCatchScope::~TryCatchScope() {
// ... simplified
if (HasCaught() && mode_ == CatchMode::kFatal) {
Local<Value> exception = Exception();
// Stack overflow? Re-throw to user code instead of exiting
if (IsStackOverflowError(env_->isolate(), exception)) {
ReThrow();
Reset();
return;
}
// Other fatal errors: exit as before
FatalException(/* ... */);
}
}经过此修复后:
try-catch代码块按预期捕获了RangeError异常- 应用程序可以优雅地处理该错误
- 启用和不启用
async_hooks时,行为更加一致
简要历史:从 async_hooks 到 AsyncContextFrame(AsyncContext 帧)
理解这个错误需要了解 Node.js 如何演变其异步上下文跟踪。
async_hooks 时代
async_hooks 是在 Node.js 8(2017) 中引入的,作为一个跟踪异步资源的底层 API。它提供了回调(init, before, after, destroy) 在异步资源生命周期的关键点触发。APM(Application Performance Management)工具立即采用它来跨异步边界追踪请求。
然而,async_hooks 具有显著的性能开销。每次创建 Promise、每次定时器以及每次 I/O 操作都会触发这些回调。当这些钩子启用时,这种开销是不可避免的。
AsyncLocalStorage
Node.js 12.17.0 (2020) 引入了 AsyncLocalStorage,这是一个建立在 async_hooks 之上的更高级API。它为最常见的用例提供了更清晰的接口:存储通过异步操作流动的上下文(例如请求ID、用户会话或追踪跨度)。
React Server Components 和 Next.js 采用了 AsyncLocalStorage 用于请求上下文追踪,不知不觉地继承了所有async_hooks行为,包括这个错误。
帮我把这个翻译一下:异步上下文框架革命(AsyncContextFrame Revolution)(Node.js 24+)
在Node.js 24,AsyncLocalStorage使用一个名为 V8 的新特性重新实现AsyncContextFrame这种方法将上下文跟踪直接集成到V8的Promise实现中,消除了在每个异步操作上使用JavaScript回调的需要。
结果显著提升了性能。对于这个漏洞来说更重要的是,AsyncLocalStorage不再使用async_hooks.createHook()内部地。这就是为什么React和Next.js在Node.js 24+上不受此bug影响的原因。
注意:AsyncLocalStorage仍然从该处出口async_hooks用于向后兼容性的模块,尽管它不再使用async_hooks在 Node.js 24+ 上内部运行的机制。它也可从node:async_hooks 与更新的 node:async_context 模块。
有关此演进及其性能影响的更多详细信息,请参阅 《上下文隐藏的成本(The Hidden Cost of Context)》。
受影响的版本
适用于以下版本的已修补发行版:
- Node.js 20.20.0 (LTS)
- Node.js 22.22.0 (LTS)
- Node.js 24.13.0 (LTS)
- Node.js 25.3.0 (Current)
同样受影响(无补丁,生命周期结束):
- 所有从 8.x 到 18.x 的 Node.js 版本(8.x 是第一个包含
async_hooks的版本)
使用低于 20.x 的 Node.js 版本且无法升级的用户,应联系 获取生命周期结束版本的商业支持。
重要提示:React 和 Next.js 按版本影响
对React服务端组件(React Server Components)和Next.js的影响因Node.js版本而异:
| Node.js版本 | React/Next.js受影响? | APM工具(APM Tools)受影响? |
|---|---|---|
| 25.x | 否* | 视情况而定** |
| 24.x | 否* | 视情况而定** |
| 22.x | 是 | 是 |
| 20.x | 是 | 是 |
| < 20.x | 是 | 是 |
*Node.js 24+ 重新实现了 AsyncLocalStorage 而不使用 async_hooks.createHook(),因此在这些版本上 React 和 Next.js 不受影响。
**仅使用 AsyncLocalStorage 的 APM 工具在 Node.js 24+ 上不受影响。APM 工具直接使用async_hooks.createHook() 在所有版本中仍然受到影响。
缓解措施
建议 :升级至 2026 年 1 月 13 日发布的修复版本。
如果无法立即升级,请考虑修改应用程序以避免深度递归,特别是在递归函数中分配承诺(promises)时。
时间线
- 2025 年 12 月 7 日React/Next.js团队联系了马泰奥·科利纳(Matteo Collina)报告此问题。
- 2025年12月8日Vercel 安全团队开放了HackerOne报告 #3456295
- 2025年12月9日马泰奥·科利纳(Matteo Collina)开始编写第一个补丁,将堆栈溢出错误延迟到下一个宏节拍(macrotick)。
- 2025年12月10日: React/Next.js团队验证该补丁未能解决问题。
- 2025年12月10日 : 马泰奥·科利纳(Matteo Collina)准备了一个不同的补丁,立即重新抛出错误,释放堆栈。
- 2025年12月11日 : React/Next.js团队验证该补丁解决了问题。
- 2025年12月12日:Anna Henningsen(安娜·亨宁森)指出了该策略的一个阻碍因素。Node.js团队开始集思广益,寻找替代方案。
- 2025年12月16日:Joyee Cheung(张秋怡)传达,Node.js不能将此视为漏洞,原因在本博客文章中列出。
- 2025年12月17日:Anna Henningsen(安娜·亨宁森)修复了补丁的阻塞问题。
- 2026年1月13日:已发布修补版本并公开披露
结论
此漏洞凸显了async_hooks在 Node.js 生态系统中嵌入得有多深。原本只是一个底层调试 API,现在却成为 React Server Components、Next.js、所有主要 APM 工具以及任何使用AsyncLocalStorage的代码的关键依赖。
该修复改进了深度递归导致的栈大小限制错误的一致性。尽管我们能够解决这一特定情况,但开发者应注意,栈溢出行为并非由ECMAScript规范定义,不应依赖它来保证服务可用性。如果递归深度可能被攻击者控制,务必对输入进行清理,或通过其他方式施加深度限制,而非指望JS运行时自动限制或通过可捕获的错误从中恢复。
使用 React RSC、Next.js 或任何其他框架的用户AsyncLocalStorage,以及生产环境中的任何 APM 工具,都应升级到 2026 年 1 月 13 日发布的修补版本。
致谢
这个修复是由马泰奥·科利纳(Matteo Collina)和安娜·亨宁森 (Anna Henningsen). 感谢马尔科·伊波利托(Marco Ippolito)为了准备发布以及为了拉斐尔·冈萨加 (Rafael Gonzaga),张乔伊(Joyee Cheung),和詹姆斯·斯内尔(James Snell)帮助分诊。
多亏了安德鲁·麦克弗森(Andrew MacPherson)用于报告Next.js/React中的错误,并给React和Next.js团队在Meta和Vercel 报告此问题并提供额外证据,帮助改进了修复。特别感谢吉米·杰(Jimmy Jai)、塞巴斯蒂安·马克巴格(Sebastian Markbage)和塞巴斯蒂安·西尔伯曼(Sebastian Silbermann)。






















