摘要
这个漏洞允许远程攻击者在 Chrome 渲染器进程中执行任意代码。
在异步堆栈跟踪处理代码中存在类型检查不足。它导致 FunctionContext
和 NativeContext
之间的类型混淆,导致非法访问 JSGlobalProxy->hash
值。通过堆喷射,攻击者能够注入一个伪造的异步堆栈帧,并构建 fakeobj 原语。使用 fakeobj 原语,攻击者能够在 Chrome 渲染器进程中实现任意代码执行。
你可以查看 我们的 TyphoonCon 2024 幻灯片。
供应商 / 产品 / 版本
-
Google Chrome -
受影响版本:120.0.6099.109 之前 -
已修复版本:120.0.6099.109
时间线
-
2020-05-13: 引入错误 – [Promise.any] 为 Promise.any 实现异步堆栈跟踪 -
2023-11-10: 错误报告 – 安全性:V8 调试检查失败:LAST_TYPE >= value -
2023-11-15: 补丁 – [promises, 异步堆栈跟踪] 修复闭包已运行的情况 -
2023-12-12: 咨询 – https://chromereleases.googleblog.com/2023/12/stable-channel-update-for-desktop_12.html -
2024-01-12: v8CTF 提交 <– 我们研究这个漏洞的时间 -
2024-02-23: 错误报告披露
背景
异步堆栈跟踪
异步是 JavaScript 中最重要的特性之一。过去,由于异步函数没有被捕获在错误堆栈中,因此很难调试异步代码。挂起的异步函数存储在事件循环的回调队列中而不是调用堆栈中,因此错误堆栈不包含异步函数。为了解决这个问题,V8 提供了 “异步堆栈跟踪” 功能(自 V8 v7.3 起默认提供),以在错误堆栈中捕获异步函数。([v8 博客], [v8 文档])
Promise.all 解决元素闭包
“Promise.all 解决元素闭包” 是一个辅助函数,用于解决 Promise.all
函数中的输入承诺。Promise.all
函数接受一个承诺数组,并在所有输入承诺都解决时返回一个解决的承诺。”Promise.all 解决元素闭包” 是 Promise.all
函数中每个输入承诺的解决处理器。该函数的作用是解决输入承诺并将履行值存储在结果数组中。
关于该函数有两点需要注意:
-
它是一个内在的内置函数,不能直接从 JavaScript 代码访问。 -
该函数的上下文被用作标记,以检查函数是否已执行。它在被调用之前有 FunctionContext
,在被调用之后有NativeContext
。(v8 代码)
漏洞
Bug 类别: 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] 查找承诺链并根据异步调用类型(例如,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() 解决元素上下文
// 以找到当所有并发承诺解决时正在解决的承诺能力。
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 (
在查找承诺链时,如果 reaction->fulfill_handler
是 “Promise.all Resolve Element Closure” 内建函数,它会将异步承诺组合器帧附加到错误堆栈。然后,它通过访问 function->context->capability->promise
移动到下一个承诺。
问题是该函数假设 “Promise.all Resolve Element Closure” 函数尚未执行。如果 “Promise.all Resolve Element Closure” 函数已经被执行,上下文将从 FunctionContext
更改为 NativeContext
。这导致在 CaptureAsyncStackTrace
函数中 FunctionContext
和 NativeContext
之间的类型混淆。
制作 PoC:
触发漏洞的策略如下:
-
获取 “Promise.all Resolve Element Closure” 函数,这是一个内建的内联函数。 -
显式调用 “Promise.all Resolve Element Closure” 函数以将上下文从 FunctionContext
更改为NativeContext
。 -
将 “Promise.all Resolve Element Closure” 函数设置为具有新承诺链的承诺的履行处理器。 -
在承诺链中抛出错误并捕获异步堆栈跟踪。
我使用了 Promise.all
的同步承诺解决模式,在 JS 脚本级别获取 “Promise.all Resolve Element Closure” 函数。我从 test262 测试用例中借鉴了这种模式。
明确调用函数后,为了触发漏洞,我使用了 [零成本异步堆栈跟踪文档][v8 docs] 中的示例代码来准备一个新的承诺链,并将内建内联函数设置为其中一个承诺的履行处理器。
最后,当抛出错误时,异步堆栈跟踪被捕获,已经执行的 “Promise.all Resolve Element Closure” 函数作为履行处理器,导致 FunctionContext
和 NativeContext
之间的类型混淆。
这是 PoC 代码: poc.js因此,内存中的值将在(0, 0xfffff << 1)范围内,并且是偶数。
为了将随机哈希号码匹配到有效的JSPromise对象指针,我们有两个约束条件:
-
解释指针地址应该是一个奇数。 -
我们必须在(0, 0xfffff << 1)范围内喷洒堆。
遵循这些约束条件,我喷洒了堆,创建了JSPromise对象,并将它们左移8位,以使地址成为奇数,并使用小的for循环以适应范围(0, 0xfffff << 1)。
在这里,将随机哈希号码匹配到有效的对象指针看起来机会很低。为了提高可靠性,我使用了iframes技术。由于Chrome中的站点隔离,来自不同网站页面在不同进程中运行。因此,我创建了一个带有不同域名的iframe,并在iframe中运行了漏洞利用,以避免主进程崩溃。
在移动到promise链中的下一个promise之后,程序会检查promise的有效性,并尝试根据异步调用类型附加异步堆栈帧。
漏洞利用
(术语漏洞基础、漏洞策略、漏洞技术和漏洞流程在这里定义。)
漏洞基础: 伪造对象(fakeobj)基础
漏洞策略:为了从类型混淆漏洞构建伪造对象基础,我采用了以下策略:
-
对JSPromise对象进行堆喷射,以使随机哈希号码与有效的JSPromise对象指针匹配。 -
使用哈希值作为有效的JSPromise对象指针,并注入伪造的异步堆栈帧。 -
使用 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对象指针,我们有两个约束:
-
解释指针地址应该是一个奇数。 -
我们必须在范围(0, 0xfffff << 1)内喷洒堆。
遵循这些约束,我喷洒了堆,创建了JSPromise对象,并左移了8位以使地址成为奇数,并使用小的for循环以适应范围(0, 0xfffff << 1)。
将随机哈希数匹配到有效的对象指针看起来可能性相当低。为了提高可靠性,我使用了iframes技术。由于Chrome中的站点隔离,来自不同网站页面在不同进程中运行。因此,我创建了一个带有不同域的iframe,并在iframe中运行了漏洞利用,以避免主进程崩溃。
在移动到promise链中的下一个promise之后,程序会检查promise的有效性,并尝试根据异步调用类型追加异步栈帧。
while (!builder->Full()) {
// Check that the {promise} is not settled.
if (promise->status() != Promise::kPending) return;
// Check that we have exactly one PromiseReaction on the {promise}.
if (!IsPromiseReaction(promise->reactions())) return;
Handle<PromiseReaction> reaction(
PromiseReaction::cast(promise->reactions()), isolate);
if (!IsSmi(reaction->next())) return;
// Check if the {reaction} has one of the known async function or
// async generator continuations as its fulfill handler.
if (IsBuiltinFunction(isolate, reaction->fulfill_handler(),
Builtin::kAsyncFunctionAwaitResolveClosure) ||
IsBuiltinFunction(isolate, reaction->fulfill_handler(),
Builtin::kAsyncGeneratorAwaitResolveClosure) ||
IsBuiltinFunction(
isolate, reaction->fulfill_handler(),
Builtin::kAsyncGeneratorYieldWithAwaitResolveClosure)) {
// Now peek into the handlers' AwaitContext to get to
// the JSGeneratorObject for the async function.
Handle<Context> context(
JSFunction::cast(reaction->fulfill_handler())->context(), isolate);
Handle<JSGeneratorObject> generator_object(
JSGeneratorObject::cast(context->extension()), isolate);
CHECK(generator_object->is_suspended());
// Append async frame corresponding to the {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漏洞利用的典型流程。
-
使用fakeobj原语,我种植并检索了伪造的OOB数组。 -
使用伪造的OOB数组,我构建了caged_read/caged_write原语。 -
为了实现RCE,我参考了Google CTF 2023分享的技术。为了逃脱V8沙箱,我破坏了BytecodeArray对象以执行任意字节码。使用Ldar/Star指令和越界访问,我们可以读写栈。为了泄露chrome二进制基地址,我从一个栈返回地址读取以泄露基地址的低32位,并读取一个libc堆指针来获取地址的高16位。然后,我破坏了栈的帧指针进行栈枢转,并执行ROP链以实现RCE。
这是完整的漏洞利用代码:index.html和exploit.html它在Chrome 118.0.5993.70上进行了测试,这是v8CTF M118的目标版本。
原文始发于微信公众号(3072):TyphoonCon 2024 Chrome Renderer 1day RCE 漏洞分析