← All issues

[1] JSC OSR exit ScratchBuffer not scanned by GC

JSC's IC and ValueProfile disagreed about a slot's type — and the DFG resolved the conflict by casting a function pointer to a double.

Severity: High | Component: JSC DFG/FTL OSR exit | 87b4375

GC가 인식하지 못하는 scratch buffer에만 live reference가 남아있는 JIT-resumed JSCell에서 attacker가 접근 가능한 UAF를 수정하는 패치입니다. PoC는 이 trigger를 안정적으로 재현하며, 해제되는 type은 web content가 선택할 수 있습니다. 그 결과 type confusion 및 WebContent 내 arbitrary R/W로 이어지는 표준 JSC stepping stone이 확보됩니다.

DFG와 FTL의 OSR exit은 exit 과정에서 스택을 재배치할 때 ScratchBuffer를 사용합니다. 스택이 덮어쓰이면 ScratchBuffer가 기존에 스택에 있던 포인터들의 유일한 보유자가 될 수 있습니다. 이 buffer들은 activeLength 값을 기준으로 GC의 conservative root로 처리되는데, OSR exit에서 이 값을 설정하지 않고 있었습니다. 이번 패치는 DFG::OSRExit::compileExitFTL::compileStub 모두에서 스택 덮어쓰기 영역 전후로 activeLength를 설정하여 이 문제를 수정합니다.

Source/JavaScriptCore/dfg/DFGOSRExit.cpp

- ScratchBuffer* scratchBuffer = vm.scratchBufferForSize(sizeof(EncodedJSValue) * operands.size());
+ const size_t scratchBufferSize = sizeof(EncodedJSValue) * operands.size();
+ ScratchBuffer* scratchBuffer = vm.scratchBufferForSize(scratchBufferSize);
...
+ // The scratch buffer can become the sole retainer of saved on-stack values if the
+ // stack is overwritten by emitSaveCalleeSavesFor below, so set the active length
+ // for the GC.
+ if (scratchBuffer) {
+ jit.move(CCallHelpers::TrustedImmPtr(scratchBuffer->addressOfActiveLength()), GPRInfo::regT0);
+ jit.storePtr(CCallHelpers::TrustedImm32(scratchBufferSize), CCallHelpers::Address(GPRInfo::regT0));
+ }
...
+ if (scratchBuffer) {
+ jit.move(CCallHelpers::TrustedImmPtr(scratchBuffer->addressOfActiveLength()), GPRInfo::regT0);
+ jit.storePtr(CCallHelpers::TrustedImm32(0), CCallHelpers::Address(GPRInfo::regT0));
+ }

JSTests/stress/osr-exit-scratch-buffer-gc.js

+// @requireOptions("--useConcurrentJIT=0", "--useZombieMode=1", "--slowPathAllocsBetweenGCs=16")
+function opt(s) {
+ const o = {};
+ try { return s + s; } catch { return o; }
+}
+function main() {
+ noDFG(main); noFTL(main);
+ for (let i = 0; i < 100; i++) opt("hello");
+ const s = 's'.repeat(0x40000000);
+ const a = [opt(s), opt(s), opt(s), opt(s), opt(s), opt(s), opt(s), opt(s)];
+ setTimeout(() => { a.toString(); }, 100);
+}

각 영향받는 exit compiler에는 세 가지 변경이 이루어졌습니다. 첫째, buffer를 요청하기 전에 scratchBufferSize 계산을 별도의 named local로 분리합니다. 둘째, 스택을 덮어쓰는 호출(DFG의 emitSaveCalleeSavesFor 및 FTL의 동등한 stack reshuffle) 바로 직전에 scratchBufferSizescratchBuffer->addressOfActiveLength()에 저장하는 JIT code가 추가됩니다. 셋째, 스택 복구가 완료된 후 addressOfActiveLength()0을 다시 저장하는 JIT code가 추가됩니다. Regression test는 --slowPathAllocsBetweenGCs=16--useZombieMode=1 옵션을 조합하여 OSR-compiled function 안에서 OOM을 발생시키는 s + s를 실행하고, exit window 내에서 GC를 강제로 유발합니다.

스택 덮어쓰기 구간에서 유일한 retainer가 되는 임시 spill buffer의 GC root 등록 누락.

OSR exit은 speculative type check나 다른 invariant가 실패했을 때 상위 JIT tier(DFG/FTL)에서 baseline으로 복귀하는 런타임 전환입니다. exit compiler는 최적화된 스택 프레임에서 baseline 스택 프레임을 재구성하는 코드를 생성합니다. ScratchBuffervm.scratchBufferForSize()를 통해 제공되는 VM 소유의 고정 크기 scratch 영역입니다. activeLength 필드는 GC에게 앞부분 몇 바이트를 conservative root로 처리할지 알려줍니다. Conservative root scanning은 buffer의 각 word를 순회하며 유효한 cell pointer처럼 보이는 값을 live root로 처리하는 방식입니다.

emitSaveCalleeSavesFor(DFG)와 FTL의 동등한 루틴은 exit 과정에서 JS 스택 프레임을 in-place로 재작성합니다. 테스트 trigger를 활성화하는 debug 옵션은 두 가지입니다. --useZombieMode=1은 해제된 cell을 poisoned 상태로 유지하여 stale pointer 역참조 시 항상 동일하게 fault가 발생하도록 합니다. --slowPathAllocsBetweenGCs=16은 N번의 slow-path allocation마다 GC를 유발합니다.

DFG::OSRExit::compileExitFTL::compileStub 모두 ScratchBuffer를 할당하고 live on-stack EncodedJSValue들을 spill했지만, buffer의 activeLengthscratchBufferSize를 기록하지 않았습니다. 결과적으로 GC는 해당 buffer에서 스캔 가능한 바이트가 0이라고 인식했습니다. 이후 exit은 emitSaveCalleeSavesFor(DFG) 또는 FTL의 동등한 reshuffle을 호출하여 원래 on-stack 슬롯을 덮어씁니다. 스택이 덮어써진 시점부터 값이 복원되기까지의 window 동안, scratch buffer는 해당 object reference의 유일한 live retainer였지만 GC marker에게는 보이지 않는 상태였습니다.

PoC의 동작은 안정적으로 재현됩니다. 먼저 opt는 100번의 small-string 호출로 이루어진 warm-up loop 이후 DFG/FTL로 OSR-compiled됩니다. 이후 's'.repeat(0x40000000)처럼 거대한 문자열을 인자로 전달하면 s + sJSString의 길이 제한을 초과해 예외를 발생시킵니다. catch 절이 실행되며 local 변수 o를 반환합니다. 이 throw-and-catch 경로는 최적화된 opt에서 OSR exit을 강제합니다. 최적화된 프레임의 live JS 값들(o 포함)은 scratch buffer로 spill됩니다. exit 내부에서 스택이 재작성되므로, 이 시점에서 scratch buffer가 해당 cell들에 대한 포인터의 유일한 보유자가 됩니다.

exit의 materialization 작업 중 slow-path allocation이 발생하면 GC가 트리거됩니다. emitRestoreArguments의 argument-object materialization이 그 지점으로 추정되지만, 이는 주변 코드에서 유추한 것이며 diff에서 직접 확인되는 사항은 아닙니다. GC는 activeLength == 0인 상태로 buffer를 스캔하므로 o를 인식하지 못합니다. 결국 o는 수집됩니다. exit이 완료되면 dangling pointer가 baseline 프레임에 다시 push됩니다. 최종적으로 a는 8개의 dangling reference를 보유하게 되고, a.toString() 호출 시 이들을 순회합니다. ZombieMode에서는 역참조가 fault를 일으킵니다. 실제 공격에서는 해제된 슬롯을 attacker가 제어하는 JSCell 레이아웃으로 heap spray를 통해 재사용하는 시나리오가 가능합니다.

공격자가 재개된 baseline 프레임이 해제된 cell을 사용하기 전에, 선택한 Structure/cell type으로 해당 슬롯을 재사용할 수 있습니다. 이 경우 JSCell에 대한 type confusion primitive를 확보하게 됩니다. 이는 WebContent 내 arbitrary read 및 arbitrary write로 이어지는 전형적인 JSC stepping stone입니다. 모든 영향은 WebContent process sandbox 내에 국한됩니다. 이 primitive를 호스트에서의 RCE로 전환하려면 별도의 sandbox escape이 필요합니다.

이 vulnerability는 OSR exit의 ScratchBuffer에만 live pointer가 남아있는 cell이 exit 전체 기간 동안 GC에 보여야 한다는 invariant를 위반하여 WebContent 내 메모리 안전성을 약화시켰습니다. ActiveScratchBufferScope는 JSC에서 scratch 사용 구간을 감싸는 표준 관용구로, 주변의 C++ wrapper(operationCompileOSRExit, IC slow path)들은 이미 이를 사용하고 있었습니다. DFG/FTL OSR exit emitter만이 예외였습니다. ScratchBuffer에 spill 코드를 직접 작성하면서 주변 C++ wrapper들이 통상적으로 제공하는 activeLength bookkeeping을 누락했습니다.