[1] [JSC] Fix GC safety for sunk contiguous array materialization in FTL
B3 was right that the value was dead — the collector agreed, and freed it.
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
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입니다. 순서대로 살펴보면 다음과 같습니다.
opt(escape)는new Array(2)를 allocate하고 두 개의{}객체 참조를 저장한 뒤,escape가 true일 때만 array를 반환합니다. 대부분의 경로에서 array가 escape하지 않기 때문에, 이것이 allocation sinking이 발동하는 조건이 됩니다.escape가 열 번 중 한 번 true인 상태로 1000번의 warm-up 호출이 이루어지며, 이 과정에서 함수가 FTL로 tier-up되고 컴파일러가 allocation을 sink하는 데 필요한 profile이 수집됩니다.- escape하는 경로에서는 FTL이
MaterializeNewArrayWithButterfly를 emit합니다. 두 cell pointer를store64로 butterfly에 저장한 뒤allocateJSArray를 호출하는 방식입니다. - 테스트의 allocation pressure는 이 header allocation을 slow path로 유도하고,
--slowPathAllocsBetweenGCs=3은 그 지점에서 collection이 실행되도록 보장합니다. 이 플래그는 세 번째 slow-path allocation마다 GC를 강제하는 것일 뿐, slow path 진입 자체를 강제하지는 않습니다. - 두 번째 루프에서는 새로운
{}객체를 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가 해제해버릴 수 있었습니다.
Insight
이 버그 클래스는 최적화가 제 역할을 정확히 수행하는 과정에서 만들어집니다. 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이 여전히 핵심 안전장치로 남아 있습니다.
Audit directions
-
아직 GC가 볼 수 있는 소유자가 없는 메모리에 cell pointer가 저장되는 경우. 여기서 지켜져야 할 invariant는 cell pointer가 tracing되지 않는 메모리에 쓰여지는 순간부터 소유자가 publish될 때까지, 그 pointer는 collector가 볼 수 있는 root에도 함께 남아 있어야 한다는 것입니다. 좁게 보면:
FTLLowerDFGToB3.cpp에서 새로 allocate된butterfly/storageLValue에 대한store64(호출을 검색하고, 그 뒤로mutatorFence()이전에allocate*/vmCall/lazy-slow-path가 이어지는 지점을 찾아보아야 합니다. 우선 살펴볼 대상은 형제 materializer들(compileMaterializeNewObject,compileNewArrayBuffer,compileNewArrayWithSpread,compileNewArrayWithSize)과allocateJSArray의 다른 모든 호출부입니다. 매칭 단서는lowJSValue가 만들어낸LValue가 텍스트 상 마지막으로 등장하는 위치가 store이면서, 그 store와 다음 allocation 사이에ensureStillAliveHere가 없는 경우입니다. 넓게 보면: 이 버그 클래스는 optimizing backend의 liveness만이 root를 살아있게 유지하는 유일한 수단인 모든 지점에서 나타날 수 있습니다. DFG의SpeculativeJITmaterialization 경로, 두 번째 allocation을 거치는 동안 rawButterfly*/void*를 들고 있는DFGOperations의 slow path, 그리고 raw pointer local이 collection 가능한 call을 넘어 살아남는 지점이 단서가 되는 Wasm GC struct/array 초기화 lowering을 감사할 필요가 있습니다. 가장 넓게 보면: optimizing compiler와 conservative 또는 부분적으로 precise한 root scanning을 결합하는 모든 managed runtime이 이 invariant를 안고 있습니다. V8/Turbofan의 handle 및RootVisitordiscipline, SpiderMonkey의Rooted/AutoSuppressGC영역, Go의runtime.KeepAlive, JNI critical section이 그 예입니다. 이식 가능한 질문은 다음과 같습니다. managed pointer에 대한 마지막 IR use와 다음 safepoint 사이에, 그 pointer가 오직 collector가 아직 tracing하지 않는 메모리에서만 도달 가능한 경로가 존재하는가? -
Safepoint를 넘어 관찰되는, 부분적으로만 초기화된 객체. FTL/DFG의 모든 allocation sequence에서 첫 번째 allocation call과 마지막
mutatorFence()사이에 두 번째 allocation이나 임의의 runtime call이 끼어드는 지점을 감사해야 합니다. 그 사이에 collection이 도달하면, 완전히 형성되지도 완전히 root로 잡히지도 않은 객체를 collector가 보게 됩니다. 우선 살펴볼 대상은FTLLowerDFGToB3.cpp에서 결합된 두 객체(cell + butterfly, cell + storage, iterator + internal field)를 allocate하는 지점들이며, 그 순서를 확인해야 합니다. 소유자를 먼저 allocate하고 payload를 나중에 allocate하는 순서라면 tracing되지 않는 구간 자체가 사라집니다. 특정 지점이 왜 반대 순서를 택했는지 물어보면 대개는 타당한 이유가 있거나, 아니면 바로 이 버그가 드러납니다. 매칭 단서는 두 개의 allocation call 사이에 store가 존재하고 그 뒤에 fence가 하나만 따라오는 패턴입니다. 가장 넓게 보면: construct 후 publish하는 2단계 프로토콜을 가진 모든 runtime(Java의 escape-analysis 기반 재구성, .NET tiered-JIT의 object init, C++에서 pool로의 placement-new 이후 또 다른 allocating call이 이어지는 경우)이 이 문제와 연관됩니다. Invariant는 다음과 같습니다. 객체에 대한 첫 write와 그 객체가 reclaimer에게 도달 가능해지는 시점 사이에는, 메모리를 회수할 수 있는 어떤 동작도 실행되어서는 안 된다. -
Keepalive intrinsic이 유일한 correctness 메커니즘인 만큼, 이것이 빠지면 아무 흔적도 남지 않습니다.
FTLLowerDFGToB3.cpp에 존재하는ensureStillAliveHere의 모든 호출 지점을 추적하고, 각각이 어떤 규칙을 encode하고 있는지 재구성해야 합니다. 그런 다음 구조적으로 동일하면서도 이 호출이 빠진 코드를 찾아야 합니다. N개의 유사한 lowering 중 하나에만 keepalive가 존재한다는 사실 자체가, 나머지 N-1개가 한 번도 점검되지 않았다는 근거가 됩니다. 여기서 검증은 순수하게 문법적인 작업이 아닙니다. 후보를 확정하려면 이번 commit에서 사용된 것과 동일한 stress-test 구성(--slowPathAllocsBetweenGCs=<n>,--useConcurrentJIT=false, 낮은--jitPolicyScale)을 실제로 실행해야 하고,ftl-materialize-new-array-with-butterfly.js에서처럼 새로 할당된 객체를 대상으로 한 identity-comparison oracle도 함께 적용해야 합니다. 가장 넓게 보면, 이 점검 방식은 명시적인 keepalive primitive(GC.KeepAlive,runtime.KeepAlive,std::black_box계열 barrier)를 가진 모든 코드베이스에 동일하게 적용됩니다. 핵심 invariant는 다음과 같습니다. correctness가 아무 코드도 생성하지 않는 intrinsic에 의존한다면, 그 intrinsic의 모든 peer 호출 지점을 빠짐없이 열거해야 합니다. 누락은 컴파일러에게도, 코드를 읽는 사람에게도 보이지 않기 때문입니다. -
GC와 관련된 case와 그렇지 않은 case가 하나의 fallthrough를 공유하는, type-directed switch arm입니다. 패치 이전 코드는
ALL_INT32_INDEXING_TYPES와ALL_CONTIGUOUS_INDEXING_TYPES를 하나의 arm에서 함께 처리했습니다. Store instruction이 동일하다는 이유였지만, 정작 GC obligation은 서로 다릅니다. FTL과 DFG backend에 있는 다른 indexing-type switch, value-format switch(m_heaps.forIndexingType,ALL_*_INDEXING_TYPES매크로, boxed/unboxed format dispatch)도 함께 점검할 필요가 있습니다. Cell을 담을 수 있는 representation과 그렇지 않은 representation을 하나의 arm으로 합친 사례가 있는지 살펴봐야 합니다. 식별 기준은 다음과 같습니다. case-label 목록 중 하나 이상이 cell을 담을 수 있고 하나 이상은 담을 수 없는데도, 공유되는 body에서는 값을 순수하게 64비트로만 취급하는 패턴입니다. 가장 넓게 보면, tagged representation과 untagged representation을 machine operation이 우연히 일치한다는 이유만으로 하나로 묶는 모든 dispatch가 이 대상에 해당합니다. 물어야 할 질문은 다음과 같습니다. 이 병합된 case의 모든 arm이 동일한 lifetime/ownership obligation을 공유하는가, 아니면 단지 동일한 instruction encoding만 공유하는가?