← All reports

[1] [JSC] Fix GC safety for sunk contiguous array materialization in FTL

HighJSC FTL JITUninitializedMemory

B3 was right that the value was dead — the collector agreed, and freed it.

e638840

High. Collector가 JavaScript 쪽에서 여전히 live reference를 쥐고 있는 객체를 해제할 수 있습니다. 해제되는 타입과 그 자리를 다시 채우는 타입 모두 script가 선택할 수 있습니다. 이 확장은 하나의 allocation slow path 안에서 collection을 발생시키는 조건에 달려 있습니다. 이는 heap-grooming 문제이지 architectural한 문제는 아닙니다.

Garbage collector는 collection이 실행될 수 있는 모든 시점에서 모든 live pointer를 파악할 수 있어야 합니다. JSC의 collector는 대부분의 pointer를 객체에서 객체로 이어지는 tracing으로 찾아내고, 나머지는 machine stack과 callee-saved register를 conservative하게 스캔하여 cell pointer처럼 보이는 word를 찾는 방식으로 확보합니다. FTL은 JSC의 최상위 optimizing JIT로, 마지막 단계에서 DFG의 IR을 B3로 lowering합니다. B3는 backward dataflow로 value liveness를 계산하는 backend이기 때문에, value가 담긴 register나 stack slot은 IR 상 마지막 사용 직후 곧바로 재사용 가능한 상태가 됩니다. Butterfly는 객체의 indexed element를 담는, 별도로 allocate되는 블록인데, 이를 소유한 cell을 통해서만 tracing됩니다. 따라서 butterfly에 기록된 cell pointer는, 이를 소유하는 array header가 존재해서 그곳을 가리키기 전까지는 collector에게 보이지 않습니다.

관전 포인트: 어떤 script든 JIT가 array를 다시 구성하는 함수를 hot-loop로 반복 실행시켜, array header allocation 시점에 collection을 발생시킬 수 있습니다. 그 결과 element가 freed 객체를 가리키는 live array를 그대로 손에 쥐게 되고, 이후 그 자리를 자신이 원하는 객체로 채워 재사용시킬 수 있습니다.

Commit message는 이 mechanism을 다음과 같이 직접 설명합니다.

compileMaterializeNewArrayWithButterfly는 JSArray header를 allocate하기 전에 raw butterfly에 element value를 기록합니다. contiguous array의 경우 element value는 GC cell pointer이지만, 이 값의 마지막 B3 사용처는 butterfly로의 store64입니다. 따라서 B3의 backward liveness 분석은 이 지점에서 값을 dead로 표시하게 되고, allocateJSArray의 slow path가 collection을 트리거하는 시점에는 stack에서 이미 사라진 상태가 됩니다. 이 시점에서 butterfly는 아직 소유자가 없기 때문에, GC는 그 내용을 tracing하지 않습니다.

Fix는 contiguous element value들을 모아 allocateJSArray 이후 ensureStillAliveHere를 호출하는 방식으로 이루어집니다. 이 호출은 명령어를 추가하지 않는 patchpoint를 삽입해 각 value에 대한 formal B3 use를 만들어내고, 이를 통해 liveness가 allocation slow path까지 역방향으로 연장되어 값들이 stack에 강제로 올라가게 됩니다. 그 결과 GC의 conservative scanner가 이를 찾아낼 수 있게 됩니다.

INT32와 DOUBLE element는 cell pointer가 아니므로 별도 처리가 필요하지 않습니다.

Source/JavaScriptCore/ftl/FTLLowerDFGToB3.cpp

ObjectMaterializationData& data = m_node->objectMaterializationData();
 
+ // contiguous element value는 GC cell pointer일 수 있으므로 allocateJSArray를 거치는 동안 계속 live 상태로 유지합니다
+ // allocateCell 시점에는 butterfly에 소유자가 없어 GC가 내용을 tracing하지 않기 때문입니다
+ Vector<LValue> contiguousElementValues;
for (unsigned i = 0; i < data.m_properties.size(); ++i) {
...
 
- case ALL_INT32_INDEXING_TYPES:
+ case ALL_INT32_INDEXING_TYPES: {
+ LValue value = lowJSValue(edge, ManualOperandSpeculation);
+ m_out.store64(value, butterfly, m_heaps.forIndexingType(indexingType)->at(index));
+ break;
+ }
case ALL_CONTIGUOUS_INDEXING_TYPES: {
LValue value = lowJSValue(edge, ManualOperandSpeculation);
m_out.store64(value, butterfly, m_heaps.forIndexingType(indexingType)->at(index));
+ contiguousElementValues.append(value);
break;
}
...
LValue array = allocateJSArray(indexingType, publicLength, butterfly);
+ // allocateJSArray의 slow path에 있는 GC point를 거치는 동안 contiguous element value를 live 상태로 유지합니다
+ ensureStillAliveHere(contiguousElementValues);
setJSValue(array);
mutatorFence();
 
- void ensureStillAliveHere(LValue value)
+ void ensureStillAliveHereImpl(const Vector<LValue>& values)
{
+ if (values.isEmpty())
+ return;
PatchpointValue* patchpoint = m_out.patchpoint(Void);
...
 
**Patch Details**
 
`LowerDFGToB3::compileMaterializeNewArrayWithButterfly`에는 두 가지 변경과 함께 helper 일반화가 적용됩니다. 기존에는 element-store 루프가 `ALL_INT32_INDEXING_TYPES`와 `ALL_CONTIGUOUS_INDEXING_TYPES`를 하나의 switch arm에서 함께 처리했습니다. 이 패치는 INT32를 별도의 바이트 동일 블록으로 분리하고, contiguous element 값만 새로운 `Vector<LValue> contiguousElementValues`에 누적하도록 변경합니다. 이 값들은 `m_out.store64(value, butterfly, ...)`로 저장되는 시점에 함께 수집됩니다. `LValue array = allocateJSArray(indexingType, publicLength, butterfly);` 직후, 그리고 `setJSValue(array)`와 `mutatorFence()` 이전 위치에서 `ensureStillAliveHere(contiguousElementValues)` 호출이 추가되었습니다.
 
`ensureStillAliveHere(LValue)`는 `ensureStillAliveHereImpl(const Vector<LValue>&)`로 리팩터링되었습니다. vector가 비어 있으면 조기 반환하고, 그렇지 않으면 명령어를 하나도 생성하지 않는 `PatchpointValue`(`Effects::none()`에 `writesLocalState`와 `reads = HeapRange::top()`을 더한 형태)를 하나 생성합니다. 이 patchpoint는 모든 값을 `ValueRep::ColdAny`로 append하고 빈 generator를 설치합니다. 얇은 오버로드 두 개가 vector 형태와 단일 값 형태의 호출을 각각 커버합니다. 새로 추가된 stress test `JSTests/stress/ftl-materialize-new-array-with-butterfly.js`는 `--useConcurrentJIT=false --jitPolicyScale=0.1 --slowPathAllocsBetweenGCs=3` 옵션으로 실행되며, `arr[0]`을 새로 할당된 객체와 비교하는 방식으로 재사용 여부를 검출합니다.
 
<mark>아직 트레이싱되지 않는 메모리에 대한 store를 heap pointer의 마지막 사용으로 간주하는 컴파일러 liveness 분석 패턴으로, 이후 allocation safepoint를 지나면서 collector의 root set에서 해당 pointer가 빠지게 됩니다.</mark>
 
**Background**
 
**이 코드가 있는 위치.** FTL은 JSC의 최상위 optimization tier입니다. `FTLLowerDFGToB3`는 DFG의 IR을 B3로 변환하는데, B3는 FTL backend가 register allocation을 수행하고 기계어를 생성하는 데 사용하는 저수준 SSA IR입니다.
 
**Object allocation sinking과 materialization.** Object allocation sinking은 allocate된 객체가 특정 경로에서 escape하지 않는다는 사실이 증명될 경우 allocation 자체를 제거하는 DFG 최적화입니다. 반면 escape하는 경로에서는 컴파일러가 *materialization* node를 삽입하여, 기록해둔 field 및 element 값들로부터 객체를 다시 만들어냅니다. `MaterializeNewArrayWithButterfly`는 array를 재구성하는 node 형태로, element를 담는 out-of-line `Butterfly`와 이를 가리키는 `JSArray` cell header로 구성됩니다.
 
**Butterfly와 indexing type.** Butterfly는 객체의 indexed element와 out-of-line property를 담기 위해 별도로 할당되는 메모리 블록입니다. GC는 butterfly를 소유하는 cell을 통해서만 이를 tracing합니다. *Indexing type*은 element가 무엇을 담는지를 결정하는데, `Int32`와 `Double` butterfly는 unboxed 또는 boxed 형태의 non-pointer 값을 담는 반면, `Contiguous` butterfly는 GC cell을 가리키는 pointer일 수도 있는 임의의 `JSValue`를 담습니다.
 
**B3 liveness.** B3의 backend는 backward dataflow로 값의 liveness를 계산합니다. 값은 정의 시점부터 마지막 사용 시점까지 live 상태이며, 그 이후에는 register나 stack slot이 재사용을 위해 해제됩니다. IR에는 "이 값은 garbage collector가 찾을 수 있는 상태로 남아 있어야 한다"는 개념 자체가 존재하지 않습니다. Liveness는 오직 IR 상의 사용 여부만을 기준으로 판단됩니다.
 
**Conservative stack scanning.** JSC의 collector는 machine stack과 callee-saved register를 워드 단위로 스캔하며, cell pointer처럼 보이는 값을 모두 root로 취급합니다. 값이 stack slot에 단순히 놓여 있기만 해도 객체가 살아있는 상태로 유지되는 이유가 여기에 있으며, 반대로 register allocator가 값을 드롭한 경우에는 이 보장이 성립하지 않습니다.
 
**`ensureStillAliveHere`.** 빈 generator를 가진 `PatchpointValue`를 생성하는 FTL helper로, 기계어 명령어를 하나도 만들지 않으면서 주어진 값들을 형식적으로 *사용*합니다. 이것이 하나의 use로 취급되기 때문에 B3 liveness는 해당 값들을 그 프로그램 지점까지 살아있게 유지하며, register allocator는 conservative scanner가 관찰할 수 있는 위치에 값을 보관해야 합니다. 이 idiom이 존재하는 이유는 IR 자체가 GC 의존성을 달리 표현할 방법이 없기 때문입니다. B3에는 "root"라는 개념이 없으므로, 가짜 use를 만드는 것이 유일하게 사용 가능한 수단입니다.
 
**Allocation slow path와 `mutatorFence`.** Inline JIT allocation은 thread-local free list에서 메모리를 가져오는데, free list가 비어 있으면 runtime call로 fallback하며, 이 runtime 경로가 바로 collection이 실행될 수 있는 지점입니다. `mutatorFence()`는 객체 초기화 이후에 삽입되는 store-store fence로, concurrent collector가 부분적으로만 초기화된 cell을 관찰하지 않도록 보장합니다.
 
**`--slowPathAllocsBetweenGCs=N`.** N번의 slow-path allocation마다 runtime이 collection을 수행하도록 만드는 JSC debug 옵션입니다. GC stress test에서 slow path가 실제로 도달했을 때 collection 타이밍을 결정적으로 만들기 위해 사용됩니다.
 
**Analysis**
 
이 취약점은 조기 garbage collection으로 인한 use-after-free에 해당합니다. JIT lowering 단계의 GC-safety 허점이며, 해제된 cell이 재사용되는 순간 type confusion으로까지 이어집니다.
 

Pre-fix emitted sequence GC view of element cells ──────────────────────── ──────────────────────── butterfly = allocate(...) (untraced raw memory) store64 value0 -> butterfly[0] value0 LAST B3 USE -> dead store64 value1 -> butterfly[1] value1 LAST B3 USE -> dead array = allocateJSArray(...) └─► slow path -> runtime └─► COLLECTION RUNS ──► butterfly unowned: not traced value0/value1 not on stack: not roots => both objects freed setJSValue(array) array published with dangling elements mutatorFence() ```

위 다이어그램에서 두 store64는 element 값에 대한 B3의 마지막 IR use에 해당합니다. 따라서 store가 emit되는 순간 backward liveness는 해당 값을 dead로 표시하고, register allocator는 해당 slot을 재사용할 수 있게 됩니다. 이후 allocateJSArray의 slow path 안에서 실행되는 collection은 두 객체에 도달할 방법이 없습니다. Marking phase 입장에서는 butterfly를 소유할 JSArray header가 아직 존재하지 않으므로 도달이 불가능하고, conservative scanner 입장에서는 값이 stack slot이나 callee-saved register에 여전히 남아 있다는 보장이 없으므로 마찬가지로 도달할 수 없습니다. 실행은 그대로 계속되어 setJSValue(array)가 dangling pointer를 담은 butterfly를 가진 JSArray를 publish하게 되고, 이후 arr[0]/arr[1]을 읽는 동작은 JavaScript에 해제되었거나 재할당되었을 수도 있는 cell에 대한 참조를 그대로 넘겨주게 됩니다. INT32와 DOUBLE element는 cell pointer가 아니며, 바로 이 이유로 패치가 INT32 케이스를 공유 switch arm에서 분리하고 contiguous element만 별도로 누적하도록 만듭니다.

패치의 커밋 메시지 자체가 복원하려는 invariant를 명시하고 있습니다. "the butterfly is unowned at allocateCell and the GC won't trace its contents."

이 regression test는 crash test가 아니라 손으로 작성된 UAF oracle입니다. 순서대로 살펴보면 다음과 같습니다.

  1. opt(escape)new Array(2)를 allocate하고 두 개의 {} 객체 참조를 저장한 뒤, escape가 true일 때만 array를 반환합니다. 대부분의 경로에서 array가 escape하지 않기 때문에, 이것이 allocation sinking이 발동하는 조건이 됩니다.
  2. escape가 열 번 중 한 번 true인 상태로 1000번의 warm-up 호출이 이루어지며, 이 과정에서 함수가 FTL로 tier-up되고 컴파일러가 allocation을 sink하는 데 필요한 profile이 수집됩니다.
  3. escape하는 경로에서는 FTL이 MaterializeNewArrayWithButterfly를 emit합니다. 두 cell pointer를 store64로 butterfly에 저장한 뒤 allocateJSArray를 호출하는 방식입니다.
  4. 테스트의 allocation pressure는 이 header allocation을 slow path로 유도하고, --slowPathAllocsBetweenGCs=3은 그 지점에서 collection이 실행되도록 보장합니다. 이 플래그는 세 번째 slow-path allocation마다 GC를 강제하는 것일 뿐, slow path 진입 자체를 강제하지는 않습니다.
  5. 두 번째 루프에서는 새로운 {} 객체를 allocate하며 arr[0] === object를 검사합니다. 패치 이전에는 두 element 객체가 이미 회수된 상태였기 때문에, 새로 allocate된 객체가 해제된 cell을 재사용할 수 있었고 identity 비교가 참이 되었습니다. 회수 여부를 직접 확인하는 oracle인 셈입니다.

Debug flag 없이 이 상황을 재현하려면 attacker가 직접 collection timing을 조작해야 합니다. 해당 size class의 array-header allocation이 빈 free list에 도달하도록 heap을 grooming하고, slow path가 collection을 선택할 만큼 충분한 allocation pressure를 유지해야 합니다. 이 timing이 달성된다면, 해제된 element cell이 attacker가 제어하는 같은 size class의 allocation으로 재사용될 가능성이 있습니다. 그리고 살아남은 array는 arr[0]을 통해 attacker가 선택한 객체를 aliasing하게 될 가능성이 있습니다. 재사용된 allocation을 한 type으로 간주하면서 다른 type으로 읽는 동작이 성립하면, 전형적인 addrof/fakeobj 쌍으로 이어질 가능성이 있고, 이는 다시 renderer address space에서의 arbitrary read/write로 이어질 가능성이 있습니다. 이 버그가 대부분의 UAF보다 실질적으로 더 유용한 이유는, element 값이 attacker가 직접 작성하는 평범한 JS 값(arr[0] = <any object>)이라는 점입니다. 즉 해제되는 type과 재사용하는 type 모두가 attacker의 통제 하에 있습니다.

Exploitation은 WebContent process 안에 국한됩니다. 이것은 renderer 안에서 발생하는 JSC heap corruption이며, 시스템 전체를 장악하려면 별도의 sandbox escape가 여전히 필요합니다. FTL이 활성화되어 있어야 하므로, Lockdown Mode처럼 JIT가 비활성화된 구성에서는 이 경로가 영향을 미치지 않습니다.

발견 경로로는 blind fuzzing보다 JSC의 GC-safety convention에 대한 패턴 감사 쪽이 유력해 보입니다. Fix가 전적으로 ensureStillAliveHere idiom을 기준으로 표현되어 있다는 점을 고려하면, cell pointer가 소유되지 않은 메모리에 저장된 뒤 allocation이 뒤따르는 지점들을 나열하고 그중 keepalive가 빠진 곳을 찾는 방식이 자연스러운 접근입니다. 테스트의 형태도 이런 해석을 뒷받침합니다. Fuzzer가 축소한 결과물이 아니라, 손으로 작성된 최소한의 identity oracle이며, collection이 결정적으로 발생하리라고 auditor가 이미 예측한 지점에 맞춰 debug flag가 선택되어 있습니다.

이 취약점은 WebContent process의 JavaScript engine 내부 메모리 안전성을 약화시킵니다. 여기서 흔들리는 보안 모델의 전제는 JSC의 핵심 GC invariant입니다. 즉, 도달 가능한 모든 cell은 모든 safepoint에서 collector의 root set에 보여야 하며, 이는 tracing되는 소유 객체를 통하거나 conservative하게 스캔되는 stack 또는 register slot을 통해 이루어져야 합니다. 패치 이전에는 JIT가 생성한 코드가, 아직 어떤 cell도 소유하지 않은 butterfly 내부에 살아있는 객체에 대한 유일한 참조를 들고 있는 상황이 가능했습니다. 그 결과 JavaScript가 여전히 참조를 들고 있는 객체를 collector가 해제해버릴 수 있었습니다.

이 버그 클래스는 최적화가 제 역할을 정확히 수행하는 과정에서 만들어집니다. B3의 backward liveness는 해당 값에 더 이상 IR use가 없다는 판단이 옳고, register allocator가 그 slot을 재사용하는 것도 옳습니다. 문제는 lowering 쪽에 있는 unsound한 가정입니다. 값이 collector에게 계속 발견 가능한 상태로 남아 있어야 한다는 전제인데, IR은 이 속성을 전혀 모델링하지 않습니다. JSC에서 이 의존성을 표현하는 유일한 수단은 수동으로 배치된 ensureStillAliveHere keepalive뿐입니다. 따라서 소유 cell이 존재하기 전에 cell pointer를 메모리에 쓰는 모든 lowering은 오직 convention에 의해서만 정확성을 보장받으며, 그 취약 구간은 정확히 [소유되지 않은 메모리에 대한 첫 store, 소유자의 publication] 사이입니다. Allocation sinking이 이 버그 클래스를 특히 확장시키는 이유는, materialization이 graph 어디에도 더 이상 보관되어 있지 않은 느슨한 값들로부터 의도적으로 객체를 재구성하기 때문입니다. 참고로 이 패치는 butterfly가 tracing 가능해지는 시점 자체를 바꾸지 않습니다. 다만 keepalive를 allocation 구간까지 연장할 뿐입니다. Header를 먼저 allocate하거나 butterfly를 독립적으로 tracing 가능하게 만드는 구조적 대안은 이번 패치에서 채택되지 않았으며, 그 결과 convention이 여전히 핵심 안전장치로 남아 있습니다.