
비동기 스택 트레이스의 타입 혼동을 통한 Chrome Renderer 1day RCE (v8ctf submission)
이 취약점으로 인해 원격 공격자가 Chrome 렌더러 프로세스 내에서 임의 코드를 실행할 수 있었습니다.
비동기 스택 트레이스 처리 코드에 불충분한 타입 검사가 있었습니다.
이로 인해 FunctionContext와 NativeContext 사이의 타입 혼동(type confusion)이 발생하여 JSGlobalProxy->hash 값에 대한 불법적인 접근이 발생했습니다.
힙 스프레이(heap spraying)를 통해 공격자는 가짜 비동기 스택 프레임을 주입하고 fakeobj 프리미티브를 구성할 수 있었습니다.
fakeobj 프리미티브를 사용하여 공격자는 Chrome 렌더러 프로세스에서 임의 코드 실행을 달성할 수 있었습니다.
저희 TyphoonCon 2024 슬라이드에서 확인하실 수 있습니다.
비동기(asynchronous)는 JavaScript에서 가장 중요한 기능 중 하나입니다. 과거에는 비동기 함수가 오류 스택에 캡처되지 않았기 때문에 오류 스택으로 비동기 코드를 디버깅하기 어려웠습니다. 일시 중단된 비동기 함수는 호출 스택(call stack)이 아닌 이벤트 루프의 콜백 큐에 저장되므로 오류 스택에 비동기 함수가 포함되지 않습니다. 이 문제를 해결하기 위해 V8은 오류 스택에 비동기 함수를 캡처하는 "async stack trace" 기능(V8 v7.3부터 기본 제공)을 제공합니다. (v8 blog, v8 docs)
"Promise.all Resolve Element Closure"는 Promise.all 함수에서 입력된 프로미스들을 resolve하는 헬퍼 함수입니다.
Promise.all 함수는 프로미스 배열을 받아 입력된 모든 프로미스가 resolve되면 resolve되는 프로미스를 반환합니다.
"Promise.all Resolve Element Closure"는 Promise.all 함수에서 각 입력 프로미스의 resolve 핸들러입니다.
이 함수의 역할은 입력 프로미스를 resolve하고 이행(fulfillment) 값을 결과 배열에 저장하는 것입니다.
이 함수에 대해 주목해야 할 2가지 사항이 있습니다:
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);
// If --async-stack-traces are enabled and the "current microtask" is a
// PromiseReactionJobTask, we try to enrich the stack trace with async
// frames.
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);
// Now peak into the Promise.all() resolve element context to
// find the promise capability that's being resolved when all
// the concurrent promises resolve.
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 작성:
취약점을 트리거하는 전략은 다음과 같습니다:
FunctionContext에서 NativeContext로 변경합니다.JS 스크립트 레벨에서 "Promise.all Resolve Element Closure" 함수를 얻기 위해 Promise.all의 동기식 프로미스 resolve 패턴을 사용했습니다.
이 패턴은 test262 테스트 케이스에서 차용했습니다.
함수를 명시적으로 호출한 후, 취약점을 트리거하기 위해 zero-cost async stack trace 문서의 샘플 코드를 사용하여 새 프로미스 체인을 준비하고 내장 빌트인 함수를 그중 하나의 프로미스의 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 객체 포인터와 일치시키기 위해 2가지 제약 조건이 있었습니다:
제약 조건에 따라 주소를 홀수로 만들기 위해 8비트 왼쪽 시프트한 JSPromise 객체로 힙을 스프레이하고, (0, 0xfffff << 1) 범위에 맞추기 위해 작은 for 루프를 사용했습니다.
여기서 무작위 해시 값을 유효한 객체 포인터와 일치시킬 확률은 꽤 낮아 보입니다. 신뢰성을 높이기 위해 iframe 기법을 사용했습니다. Chrome의 사이트 격리(site isolation)로 인해 서로 다른 웹사이트의 페이지는 서로 다른 프로세스에서 실행됩니다. 따라서 다른 도메인의 iframe을 생성하고 iframe 내에서 익스플로잇을 실행하여 메인 프로세스의 크래시를 방지했습니다.
프로미스 체인의 다음 프로미스로 이동한 후, 프로그램은 프로미스의 유효성을 확인하고 비동기 호출 유형에 따라 비동기 스택 프레임을 추가하려고 시도합니다.
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);
AppendAsyncFrame 함수의 매개변수인 generator_object를 완전히 제어할 수 있기 때문에 kAsyncFunctionAwaitResolveClosure 케이스를 선택했습니다.
조건을 통과하도록 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 익스플로잇의 일반적인 익스플로잇 흐름을 사용했습니다.
V8 샌드박스를 탈출하기 위해 BytecodeArray 객체를 변조하여 임의 바이트코드를 실행했습니다. 범위를 벗어난 접근과 함께 Ldar/Star 명령어를 사용하여 스택을 읽고 쓸 수 있습니다. Chrome 바이너리 베이스 주소를 누출하기 위해 스택에서 반환 주소를 읽어 베이스 주소의 하위 32비트를 누출하고, libc 힙 포인터를 읽어 주소의 상위 16비트를 얻었습니다. 그런 다음 스택 피보팅을 위해 프레임 포인터를 변조하고 ROP 체인을 실행하여 RCE를 달성했습니다.
다음은 전체 익스플로잇 코드입니다: index.html 및 exploit.html v8CTF M118의 대상 버전이었던 Chrome 118.0.5993.70에서 테스트되었습니다.
KAIST Hacking Lab의 Haein Lee