TyphoonCon 2024 Chrome Renderer 1day RCE 漏洞分析

摘要

这个漏洞允许远程攻击者在 Chrome 渲染器进程中执行任意代码。

在异步堆栈跟踪处理代码中存在类型检查不足。它导致 FunctionContextNativeContext 之间的类型混淆,导致非法访问 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 函数中每个输入承诺的解决处理器。该函数的作用是解决输入承诺并将履行值存储在结果数组中。

关于该函数有两点需要注意:

  1. 它是一个内在的内置函数,不能直接从 JavaScript 代码访问。
  2. 该函数的上下文被用作标记,以检查函数是否已执行。它在被调用之前有 FunctionContext,在被调用之后有 NativeContext。(v8 代码)

漏洞

Bug 类别: FunctionContextNativeContext 之间的类型混淆

漏洞细节:

该漏洞可以通过捕获已经执行过的 “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] 查找承诺链并根据异步调用类型(例如,awaitPromise.allPromise.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 函数中 FunctionContextNativeContext 之间的类型混淆。

制作 PoC:

触发漏洞的策略如下:

  1. 获取 “Promise.all Resolve Element Closure” 函数,这是一个内建的内联函数。
  2. 显式调用 “Promise.all Resolve Element Closure” 函数以将上下文从 FunctionContext 更改为 NativeContext
  3. 将 “Promise.all Resolve Element Closure” 函数设置为具有新承诺链的承诺的履行处理器。
  4. 在承诺链中抛出错误并捕获异步堆栈跟踪。

我使用了 Promise.all 的同步承诺解决模式,在 JS 脚本级别获取 “Promise.all Resolve Element Closure” 函数。我从 test262 测试用例中借鉴了这种模式。

明确调用函数后,为了触发漏洞,我使用了 [零成本异步堆栈跟踪文档][v8 docs] 中的示例代码来准备一个新的承诺链,并将内建内联函数设置为其中一个承诺的履行处理器。

最后,当抛出错误时,异步堆栈跟踪被捕获,已经执行的 “Promise.all Resolve Element Closure” 函数作为履行处理器,导致 FunctionContextNativeContext 之间的类型混淆。

这是 PoC 代码: poc.js因此,内存中的值将在(0, 0xfffff << 1)范围内,并且是偶数。

为了将随机哈希号码匹配到有效的JSPromise对象指针,我们有两个约束条件:

  1. 解释指针地址应该是一个奇数。
  2. 我们必须在(0, 0xfffff << 1)范围内喷洒堆。

遵循这些约束条件,我喷洒了堆,创建了JSPromise对象,并将它们左移8位,以使地址成为奇数,并使用小的for循环以适应范围(0, 0xfffff << 1)。

在这里,将随机哈希号码匹配到有效的对象指针看起来机会很低。为了提高可靠性,我使用了iframes技术。由于Chrome中的站点隔离,来自不同网站页面在不同进程中运行。因此,我创建了一个带有不同域名的iframe,并在iframe中运行了漏洞利用,以避免主进程崩溃。

在移动到promise链中的下一个promise之后,程序会检查promise的有效性,并尝试根据异步调用类型附加异步堆栈帧。

漏洞利用

(术语漏洞基础、漏洞策略、漏洞技术和漏洞流程在这里定义。)

漏洞基础: 伪造对象(fakeobj)基础

漏洞策略:为了从类型混淆漏洞构建伪造对象基础,我采用了以下策略:

  1. 对JSPromise对象进行堆喷射,以使随机哈希号码与有效的JSPromise对象指针匹配。
  2. 使用哈希值作为有效的JSPromise对象指针,并注入伪造的异步堆栈帧。
  3. 使用 Error.prepareStackTracegetThis 方法来检索伪造的对象。

该漏洞导致 CaptureAsyncStackTrace 函数中 FunctionContextNativeContext 之间的类型混淆。它访问 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对象指针,我们有两个约束:

  1. 解释指针地址应该是一个奇数。
  2. 我们必须在范围(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.prepareStackTracegetThis方法来获取错误对象的receiver(在本例中,它是JSGeneratorObject)。有了receiver,我们可以从堆中检索伪造的对象(fakeobj原语)。

漏洞利用流程:我使用了V8漏洞利用的典型流程。

  1. 使用fakeobj原语,我种植并检索了伪造的OOB数组。
  2. 使用伪造的OOB数组,我构建了caged_read/caged_write原语。
  3. 为了实现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 漏洞分析

版权声明:admin 发表于 2024年6月4日 下午1:17。
转载请注明:TyphoonCon 2024 Chrome Renderer 1day RCE 漏洞分析 | CTF导航

相关文章