
Уязвимость 1-day в Chrome Renderer: RCE через путаницу типов в асинхронном стеке вызовов (участие v8ctf)
Эта уязвимость позволяла удаленному злоумышленнику выполнить произвольный код в процессе рендеринга Chrome.
В коде обработки асинхронных стековых трасс была недостаточная проверка типа. Это приводило к путанице типов (type confusion) между FunctionContext и NativeContext, что вызывало незаконный доступ к значению JSGlobalProxy->hash. С помощью heap spraying злоумышленник мог внедрить поддельный асинхронный кадр стека и построить примитив fakeobj. Используя примитив fakeobj, злоумышленник мог добиться произвольного выполнения кода в процессе рендеринга Chrome.
Вы можете ознакомиться с нашими слайдами с TyphoonCon 2024.
Асинхронность — одна из важнейших функций JavaScript. В прошлом было трудно отлаживать асинхронный код с помощью стеков ошибок, потому что асинхронные функции не захватывались в стеке ошибок. Приостановленные асинхронные функции хранятся в очереди обратных вызовов цикла событий, а не в стеке вызовов, поэтому стек ошибок не содержит асинхронной функции. Чтобы решить эту проблему, V8 предоставляет функцию "async stack trace" (по умолчанию начиная с V8 v7.3) для захвата асинхронной функции в стеке ошибок. (v8 blog, v8 docs)
"Promise.all Resolve Element Closure" — это вспомогательная функция для разрешения входных промисов в функции Promise.all. Функция Promise.all принимает массив промисов и возвращает промис, который разрешается, когда все входные промисы будут разрешены. "Promise.all Resolve Element Closure" является обработчиком разрешения каждого входного промиса в функции Promise.all. Роль этой функции — разрешить входной промис и сохранить значение выполнения в результирующем массиве.
Есть два важных момента об этой функции:
FunctionContext, а после вызова — NativeContext. (код V8)Класс ошибки: Путаница типов (type confusion) между 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).
Ниже приведён фрагмент функции CaptureAsyncStackTrace, который обрабатывает случай Promise.all:
} 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);
// Теперь заглядываем в контекст resolve element функции Promise.all(),
// чтобы найти 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 (
При просмотре цепочки промисов, если reaction->fulfill_handler является встроенной функцией "Promise.all Resolve Element Closure", она добавляет кадр асинхронного комбинатора промисов в стек ошибок. Затем она переходит к следующему промису, обращаясь к function->context->capability->promise.
Проблема в том, что функция предполагает, что "Promise.all Resolve Element Closure" ещё не была выполнена. Если эта функция уже выполнена, контекст меняется с FunctionContext на NativeContext. Это приводит к путанице типов между FunctionContext и NativeContext в функции CaptureAsyncStackTrace.
Создание PoC:
Стратегия для вызова уязвимости следующая:
FunctionContext на NativeContext.Для получения функции "Promise.all Resolve Element Closure" на уровне JS-скрипта я использовал синхронный паттерн разрешения промисов для Promise.all. Я позаимствовал этот паттерн из тестовых случаев test262.
После явного вызова функции, чтобы вызвать уязвимость, я использовал пример кода из документации по zero-cost async stack trace для подготовки новой цепочки промисов и установил встроенную функцию как обработчик выполнения одного из промисов.
Наконец, когда возникает ошибка, асинхронный стек захватывается с уже выполненной функцией "Promise.all Resolve Element Closure" в качестве обработчика выполнения, что приводит к путанице типов между FunctionContext и NativeContext.
Вот код PoC: poc.js
(Термины «примитив эксплойта», «стратегия эксплойта», «техника эксплойта» и «поток эксплойта» определены здесь.)
Примитив эксплойта: примитив fakeobj
Стратегия эксплойта: Для построения примитива fakeobj из ошибки путаницы типов я использовал следующую стратегию:
Error.prepareStackTrace с методом getThis для получения поддельного объекта.Ошибка приводит к путанице типов между FunctionContext и NativeContext в функции CaptureAsyncStackTrace. Она обращается к 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. Страницы с разных веб-сайтов выполняются в разных процессах из-за изоляции сайтов в Chrome. Поэтому я создал iframe с другим доменом и запустил эксплойт в iframe, чтобы избежать краха основного процесса.
После перехода к следующему промису в цепочке программа проверяет корректность промиса и пытается добавить асинхронный кадр стека в соответствии с типом асинхронного вызова.
while (!builder->Full()) {
// Проверяем, что {promise} не выполнен.
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} имеет одно из известных асинхронных
// продолжений функции или асинхронного генератора в качестве обработчика выполнения.
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.
Хэин Ли (Haein Lee) из KAIST Hacking Lab