此漏洞允许远程攻击者在 Chrome 渲染器进程中执行任意代码。
在异步堆栈跟踪处理代码中存在一个不充分的类型检查。
它导致了 FunctionContext 和 NativeContext 之间的类型混淆,从而非法访问 JSGlobalProxy->hash 值。
通过堆喷射,攻击者能够注入伪造的异步堆栈帧,并构建 fakeobj 原语。
利用 fakeobj 原语,攻击者能够在 Chrome 渲染器进程中实现任意代码执行。
你可以查看 我们在 TyphoonCon 2024 上的幻灯片。
异步是 JavaScript 中最重要的特性之一。 过去,由于异步函数不会被捕获在错误堆栈中,调试异步代码的堆栈信息非常困难。 被挂起的异步函数存储在事件循环的回调队列中,而非调用栈中,因此错误堆栈不包含该异步函数。 为了解决这个问题,V8 提供了“异步堆栈跟踪”功能(自 V8 v7.3 起默认启用),以在错误堆栈中捕获异步函数。(v8 博客, v8 文档)
“Promise.all Resolve Element Closure”是一个辅助函数,用于解析 Promise.all 函数中的输入 promise。
Promise.all 函数接收一个 promise 数组,并返回一个在所有输入 promise 都 resolve 后 resolve 的 promise。
“Promise.all Resolve Element Closure”是 Promise.all 函数中每个输入 promise 的 resolve 处理器。
该函数的作用是解析输入 promise 并将 fulfillment 值存储到结果数组中。
关于此函数有两点需要注意:
FunctionContext,在调用之后,它拥有 NativeContext。(v8 代码)漏洞类型: FunctionContext 与 NativeContext 之间的类型混淆
漏洞细节:
该漏洞可以通过在已执行的“Promise.all Resolve Element Closure”函数或类似的内置函数上捕获异步堆栈跟踪来触发。 在此漏洞利用中,我以“Promise.all Resolve Element Closure”函数为例。
当 JavaScript 代码中抛出错误时,V8 从堆栈中捕获错误堆栈,并将当前微任务中的异步堆栈帧追加到其中 [1]。
CallSiteBuilder builder(isolate, mode, limit, caller);
VisitStack(isolate, &builder);
// 如果启用了 --async-stack-traces 并且“当前微任务”是一个
// PromiseReactionJobTask,我们尝试用异步帧丰富堆栈跟踪。
if (v8_flags.async_stack_traces) {
CaptureAsyncStackTrace(isolate, &builder);
}
CaptureAsyncStackTrace 函数 [2] 会查找 promise 链,并根据异步调用类型(例如 await、Promise.all、Promise.any)追加异步堆栈帧。
下面是处理 Promise.all 情况的 CaptureAsyncStackTrace 函数片段:
} else if (IsBuiltinFunction(isolate, reaction->fulfill_handler(),
Builtin::kPromiseAllResolveElementClosure)) {
Handle<JSFunction> function(JSFunction::cast(reaction->fulfill_handler()),
isolate);
Handle<Context> context(function->context(), isolate);
Handle<JSFunction> combinator(context->native_context()->promise_all(),
isolate);
builder->AppendPromiseCombinatorFrame(function, combinator);
// 现在窥视 Promise.all() 的 resolve 元素上下文,以
// 找到当所有并发 promise 都 resolve 时正在被 resolve 的 promise capability。
int const index =
PromiseBuiltins::kPromiseAllResolveElementCapabilitySlot;
Handle<PromiseCapability> capability(
PromiseCapability::cast(context->get(index)), isolate);
if (!IsJSPromise(capability->promise())) return;
promise = handle(JSPromise::cast(capability->promise()), isolate);
} else if (
在查找 promise 链时,如果 reaction->fulfill_handler 是“Promise.all Resolve Element Closure”内置函数,它会将异步 promise 组合器帧追加到错误堆栈中。
然后,通过访问 function->context->capability->promise 移动到下一个 promise。
问题在于该函数假设“Promise.all Resolve Element Closure”函数尚未被执行。
如果“Promise.all Resolve Element Closure”函数已被执行,其上下文会从 FunctionContext 变为 NativeContext。
这会导致 CaptureAsyncStackTrace 函数中出现 FunctionContext 与 NativeContext 之间的类型混淆。
构造 PoC:
触发漏洞的策略如下:
FunctionContext 变为 NativeContext。我使用了 Promise.all 的同步 promise 解析模式,以便在 JS 脚本层面获取“Promise.all Resolve Element Closure”函数。
我从 test262 测试用例中借鉴了该模式。
显式调用该函数后,为了触发漏洞,我使用了 [零成本异步堆栈跟踪文档][v8 docs] 中的示例代码来准备一个新的 promise 链,并将该内置函数设置为其中一个 promise 的 fulfill 处理器。
最后,当错误被抛出时,异步堆栈跟踪会以已执行的“Promise.all Resolve Element Closure”函数作为 fulfill 处理器被捕获,从而导致 FunctionContext 与 NativeContext 之间的类型混淆。
PoC 代码如下:poc.js
(关于漏洞利用原语、利用策略、利用技术和利用流程,此处有定义。)
漏洞利用原语: fakeobj 原语
漏洞利用策略: 为了从类型混淆漏洞构建 fakeobj 原语,我使用了以下策略:
Error.prepareStackTrace 配合 getThis 方法来检索伪造的对象。该漏洞导致 CaptureAsyncStackTrace 函数中出现 FunctionContext 与 NativeContext 之间的类型混淆。
它访问 Context->PromiseCapability->JSPromise 来构建下一个异步堆栈帧。
当漏洞被触发时,它访问 NativeContext->JSGlobalProxy->hash。
为了利用该漏洞,我将哈希值用作 JSPromise 对象指针。
从以下哈希生成函数可以看出,哈希值的范围是 (0, 0xfffff):
int Isolate::GenerateIdentityHash(uint32_t mask) {
int hash;
int attempts = 0;
do {
hash = random_number_generator()->NextInt() & mask;
} while (hash == 0 && attempts++ < 30);
return hash != 0 ? hash : 1;
}
pwndbg> p/x mask
$1 = 0xfffff
哈希值经过 SMI 标记,因此在内存中,它将存储为 hash << 1。
因此,内存中的值范围是 (0, 0xfffff << 1),且为偶数。
为了使随机哈希数匹配到一个有效的 JSPromise 对象指针,我们需要满足两个约束条件:
根据这些约束,我使用 JSPromise 对象进行堆喷射,并左移 8 位使地址变为奇数,同时使用小的 for 循环以适应 (0, 0xfffff << 1) 的范围。
将随机哈希数匹配到一个有效的对象指针看起来成功率较低。 为了提高可靠性,我使用了 iframe 技术。 由于站点隔离,来自不同网站的页面运行在不同的进程中。 因此,我创建了一个不同域的 iframe,并在该 iframe 中运行漏洞利用,以避免主进程崩溃。
移动到 promise 链中的下一个 promise 后,程序会检查该 promise 的有效性,并尝试根据异步调用类型追加异步堆栈帧。
while (!builder->Full()) {
// 检查 {promise} 是否未 settle。
if (promise->status() != Promise::kPending) return;
// 检查 {promise} 上是否恰好有一个 PromiseReaction。
if (!IsPromiseReaction(promise->reactions())) return;
Handle<PromiseReaction> reaction(
PromiseReaction::cast(promise->reactions()), isolate);
if (!IsSmi(reaction->next())) return;
// 检查 {reaction} 的 fulfill 处理器是否为已知的异步函数或
// 异步生成器延续之一。
if (IsBuiltinFunction(isolate, reaction->fulfill_handler(),
Builtin::kAsyncFunctionAwaitResolveClosure) ||
IsBuiltinFunction(isolate, reaction->fulfill_handler(),
Builtin::kAsyncGeneratorAwaitResolveClosure) ||
IsBuiltinFunction(
isolate, reaction->fulfill_handler(),
Builtin::kAsyncGeneratorYieldWithAwaitResolveClosure)) {
// 现在窥视处理器的 AwaitContext 以获取
// 异步函数的 JSGeneratorObject。
Handle<Context> context(
JSFunction::cast(reaction->fulfill_handler())->context(), isolate);
Handle<JSGeneratorObject> generator_object(
JSGeneratorObject::cast(context->extension()), isolate);
CHECK(generator_object->is_suspended());
// 追加与 {generator_object} 对应的异步帧。
builder->AppendAsyncFrame(generator_object);
我们选择了 kAsyncFunctionAwaitResolveClosure 情况,因为 AppendAsyncFrame 函数的参数 generator_object 是完全可控的。
通过设置适当的伪造对象(如 PromiseReaction、Function、Context、JSGeneratorObject)来满足条件,我们可以通过调用 builder->AppendAsyncFrame(generator_object) 注入我们伪造的异步帧。
我们可以从终端查看注入的伪造异步帧。
Error: Let's have a look...
at bar (../../../../fake_frame.js:168:15)
at async foo (../../../../fake_frame.js:163:9)
at async Promise.all (index 0)
at async Array.sloppy_func (../../../../fake_frame.js:1:1)
这里是 fake_frame.js 代码。
注入伪造的异步帧后,我使用了 Error.prepareStackTrace 配合 getThis 方法来获取错误对象的 receiver(在本例中为 JSGeneratorObject)。
通过 receiver,我们可以从堆中检索伪造的对象(fakeobj 原语)。
漏洞利用流程: 我使用了典型的 V8 漏洞利用流程。
这里是完整的漏洞利用代码:index.html 和 exploit.html 它已在 Chrome 118.0.5993.70 上测试通过,该版本是 v8CTF M118 的目标版本。
KAIST Hacking Lab 的 Haein Lee