← All reports

[2] Defer GC while using the direct eval CacheLookupKey

HighJSC interpreterUAF

The eval cache kept a raw pointer that nothing was keeping alive.

32f1bfb

High. Direct-eval 경로에서 allocation이 가장 많이 발생하는 구간 동안, refcounting이 의도적으로 제거된 raw pointer가 유지됩니다. 그런데 그 target을 살려두어야 할 객체는 rope가 flatten되는 순간 더 이상 마킹되지 않습니다. 일반 script에서 도달 가능한 경로이며, free 이후 단계로의 확장은 timing을 이기고 body를 reclaim하는 조건에 달려 있습니다.

JSC는 문자열 연결을 lazy하게 표현합니다. rope JSString은 resolved character buffer 대신 다른 string cell에 대한 pointer를 갖고 있으며, 실제로 문자가 필요한 시점에 제자리에서 스스로를 resolve합니다. 특정 call site의 direct eval()은 캐싱되어 반복 평가 시 reparsing을 건너뛰는데, 이 cache는 source text와 bytecode index로 키가 잡히고, probe key는 hot lookup에서의 refcount 트래픽을 피하기 위해 의도적으로 bare StringImpl*(WTF의 refcounted string body)를 보유합니다. 이때 전제되는 기대는, 그 body를 해제할 수 있는 무언가는 bare pointer가 stack에 살아있는 동안 항상 도달 가능하도록 보장된다는 것입니다.

관전 포인트: 어떤 script든 rope를 만들고 eval() 안에서 강제로 flatten시킨 뒤, 같은 구간에서 발생하는 garbage collection이 해제해버리는 string storage에 대한 raw pointer를 엔진이 계속 사용하게 만들 수 있습니다. 그리고 그 pointer를 통해 읽고, 비교하고, refcount를 증가시키는 것도 가능합니다.

commit message에는 그 메커니즘이 직접 명시되어 있습니다. DirectEvalCodeCache::CacheLookupKey는 성능을 위해 raw StringImpl*를 보유하는데, 이 pointer는 stack 위에 있는 rope JSString의 fiber 하나가 갖고 있는 내용일 수 있습니다. rope 자체는 stack 위에서 살아있지만, flattening을 통해 제자리에서 mutate될 수 있고, 이로 인해 그 fiber JSString은 unrooted 상태가 되어 GC의 sweep 대상이 됩니다. 결과적으로 key 안의 raw pointer가 가리키는 String이 dereference되는 상황이 벌어집니다. 이번 fix는 lookup key가 살아있는 동안 GC를 지연시킵니다.

Source/JavaScriptCore/interpreter/Interpreter.cpp

auto cacheKey = DirectEvalCodeCache::CacheLookupKey(programStr.data.impl(), bytecodeIndex);
DirectEvalExecutable* eval = callerBaselineCodeBlock->directEvalCodeCache().get(cacheKey);
if (!eval) {
+ // GC needs to be deferred as it's possible cacheKey holds one of programString's fibers'
+ // contents as a raw StringImpl*. Even though programString is on the stack, rope
+ // flattening could cause that fiber JSString to be unreachable by the GC, causing its
+ // content StringImpl to be deref'd if when the JSString is swept.
+ DeferGC deferGC(vm);
+
auto programSource = programStr.data;
if (SourceProfiler::g_profilerHook) [[unlikely]] {
SourceTaintedOrigin sourceTaintedOrigin = computeNewSourceTaintedOriginFromStack(vm, callFrame);

JSTests/stress/direct-eval-cache-rope.js

+// @requireOptions("--slowPathAllocsBetweenGCs=10")
+
+function getRope(index) {
+ const a = "[" + index + ',[]'.repeat(0x100) + "]";
+ const b = "()";
+
+ [][a];
+
+ return a + b;
+}
+
+function main() {
+ for (let i = 0; i < 1000; i++) {
+ const s = getRope(i);
+ getRope(0);
+
+ gc();
+
+ try {
+ eval(s);
+
+ } catch {
+
+ }
+ }
+
+}
+
+main();

실제 프로덕션 변경은 JSC::eval()의 한 줄입니다. cacheKeyprogramStr.data.impl()로부터 구성되고 directEvalCodeCache().get(cacheKey) probe가 miss한 직후, cache-miss 분기의 첫 문장으로 DeferGC deferGC(vm);이 생성됩니다. 이 deferral은 이후 이어지는 allocation이 많은 나머지 구간을 모두 커버합니다. auto programSource = programStr.data; 복사, SourceProfiler::g_profilerHook 경로, makeSource, parsing, DirectEvalExecutable 생성까지 블록 scope 전체에 걸쳐 적용됩니다. DeferGCif (!eval) { ... } 블록 전체에 scope되어 있기 때문에, CacheLookupKeyRefPtr를 보유하는 CacheKey로 변환하는 set(...) 호출 역시 이 deferral의 보호를 받습니다.

그 외 변경은 regression test 하나뿐입니다. --slowPathAllocsBetweenGCs=10 옵션으로 실행되며, 이전에 atomize된 문자열과 "()"로 구성한 rope를 반복적으로 eval합니다. DirectEvalCodeCache.h는 손대지 않았습니다. CacheLookupKey::m_source는 여전히 SUPPRESS_UNCOUNTED_MEMBER StringImpl*이고, 저장되는 CacheKey::m_source는 여전히 RefPtr<StringImpl>입니다.

유일한 소유자가 이미 in-place 구조적 mutation을 통해 수거 가능한 상태가 된 storage에, raw이고 refcount가 없는 pointer를 캐싱하면서도, 그 pointer가 allocation 지점을 넘어 계속 살아있는 패턴입니다.

Rope JSString. JSC는 문자열 연결을 lazy하게 표현합니다. JSRopeString은 resolved StringImpl 대신 다른 JSString cell에 대한 최대 3개의 fiber pointer를 저장합니다. 첫 번째 fiber word의 하위 비트(isRopeInPointer)가 해당 cell을 rope로 표시합니다. JSString::visitChildrenImpl은 이 비트가 설정되어 있을 때만 fiber를 마킹합니다.

Rope flattening. rope의 문자가 실제로 필요해지면, JSC는 그것을 resolve하면서 같은 cell을 제자리에서 mutate합니다. convertToNonRope(...)가 fiber word들을 resolved String으로 교체하면서, 해당 cell은 더 이상 rope가 아니게 됩니다.

StringImpl. WTF의 refcounted string body로, length, 8/16-bit flag, character buffer를 보유합니다. String이나 RefPtr<StringImpl>은 reference를 소유하지만, raw StringImpl*는 소유하지 않습니다. JSString::destroy는 cell에 내장된 String을 파괴하면서 reference 하나를 release합니다.

GCOwnedDataScope. JSString::value() / view()가 반환하는 wrapper입니다. string data와 owner JSCell을 짝지어, scope의 lifetime 동안 그 owner를 살아있게 유지합니다. JSRopeString::resolveRopeToAtomString에서 볼 수 있는 { this, ... } 구성이 이에 해당합니다.

DirectEvalCodeCache. CodeBlock별로 존재하는 HashMap이며, (source StringImpl, BytecodeIndex)로 키가 잡힙니다. 두 가지 key 타입이 있습니다. 저장되는 CacheKeyRefPtr<StringImpl>을 보유하고, probe 전용인 CacheLookupKeySUPPRESS_UNCOUNTED_MEMBER StringImpl* m_source를 보유하며 CacheLookupKeyHashTranslator를 통해 사용되어 lookup 시 refcount 처리 비용을 피합니다. operator CacheKey()가 lookup key를 저장용 key로 변환합니다. 이렇게 분리된 이유는, 흔히 발생하는 cache hit의 경우 atomic refcount 연산이 전혀 필요 없도록 하기 위해서입니다.

DeferGC. heap의 deferral depth를 올리는 RAII 객체로, scope 안에 있는 동안 collection이 실행되지 않도록 막습니다.

Conservative stack scanning. JSC는 살아있는 machine-stack 및 register word 중 cell pointer처럼 보이는 값을 root로 취급합니다. 이 덕분에 programString 같은 on-stack JSString*이 명시적 handle 없이도 살아있게 됩니다.

Atomization by property access. 객체를 string key로 인덱싱하면 해당 key가 Identifier로 변환되며, 이 과정에서 rope가 resolve되고 그 JSString cell에 AtomStringImpl이 연결됩니다.

이 버그는 use-after-free입니다. 대상 StringImpl의 유일한 소유자가, 그것을 가리키는 bare pointer가 살아있는 구간 도중에 더 이상 마킹되지 않게 되면서 발생합니다.

  rope programString              cacheKey (stack)          GC
  ──────────────────              ────────────────          ──────────────
  fiber0 ─► JSString A
  flatten in place ──┐
    fibers overwritten│            m_source = A's impl
    A now unmarked    └──────────► (uncounted)
                                                            mark: A unreachable
                                                            sweep: ~JSString
                                                              └─► ~String → free impl
                                   hash() / equal() ─────────► freed memory
                                   operator CacheKey() ──────► refcount++ into freed

이 구간에서 counted reference를 보유하는 무언가가 존재하는지 여부는 GCOwnedDataScope::data의 선언 타입에 달려 있습니다. 다만 이는 제공된 context에는 포함되어 있지 않습니다. 바로 다음 문장은 auto programSource = programStr.data;입니다. 만약 dataString(또는 auto가 복사를 유도하는 const String&)이라면, 이 복사가 reference를 하나 잡게 되어 새로 추가된 DeferGC는 사실상 no-op이 될 것입니다. 그러나 fix가 이 복사 이전에 배치되어 있고, in-code comment에서 JSString이 swept될 때 StringImpl이 deref될 수 있다고 명시하고 있는 만큼, 유력한 해석은 programStr.dataJSString cell이 소유한 storage를 가리키는 borrowed, non-owning handle이라는 것입니다. 그렇다면 이 구간에서 유일하게 대상을 살려두는 것은 GCOwnedDataScope의 owner cell뿐입니다.

여기서 누락된 invariant는, StringImpl을 해제시킬 수 있는 객체가 raw pointer가 살아있는 동안 항상 도달 가능함이 증명되어야 한다는 것입니다. commit message와 in-code comment에 따르면 이 조건은 성립하지 않습니다. key가 캡처하는 storage는 programString 자체가 아니라 그 fiberJSString이 소유할 수 있기 때문입니다. flattening은 programString을 제자리에서 mutate하는데, cell이 non-rope가 되고 나면 visitor에게 resolved impl만 보고하게 됩니다. 결과적으로 outer rope는 stack 위에서 conservative하게 rooted되어 있음에도, 그 fiber는 도달 불가능한 상태가 됩니다.

free 이후의 사용은 우연이 아닙니다. CacheLookupKey::hash()m_source->hash()를 dereference하고, operator==WTF::equal(m_source, ...)를 호출해 impl의 length와 character data를 읽습니다. 그리고 operator CacheKey()RefPtr<StringImpl>을 생성합니다. 즉 컴파일된 executable이 per-CodeBlock cache에 다시 저장될 때, 해제된 메모리에 refcount increment가 기록되는 셈입니다. dangling key가 m_cacheMap에 도달하면 eval() frame이 끝난 뒤에도 완전히 살아남으며, 이후 그 cache에 대한 모든 probe가 이 값을 건드리게 됩니다.

regression test를 따라가 보면 다음과 같습니다.

  1. getRope(index)a = "[" + index + ',[]'.repeat(0x100) + "]"를 만듭니다. 이는 771~773자의 rope이며, 유효한 array-literal 문법이면서 on-stack resolve 임계값보다 충분히 낮은 크기입니다.
  2. [][a]a를 key로 하는 property access를 수행합니다. 이 과정에서 key가 Identifier로 변환되므로 rope가 제자리에서 resolve 및 atomize됩니다. 즉 a의 cell은 atomize된 impl을 소유하는 non-rope가 됩니다. 이 resolution이 resolveRopeToAtomString의 on-stack 경로를 타는지, 아니면 resolveRopeToExistingAtomString이나 key-atom cache를 거치는지는 제공된 context로는 확인되지 않지만, 어느 경로든 결국 in-place convertToNonRope로 귀결됩니다.
  3. return a + b는 fiber0가 a의 cell이고 fiber1이 "()"인 새로운 JSRopeString을 만듭니다. 이 연결된 텍스트는 parse는 되지만 실행 시 throw하므로, try/catch가 이를 삼키고 매 iteration마다 서로 다른 source text가 제시됩니다. 이는 cache miss를 보장하며, 곧 patch된 분기로 진입한다는 의미입니다.
  4. getRope(0)gc()는 중간 garbage를 정리합니다. 이때 s와 그것을 통한 fiber0는 여전히 도달 가능한 상태입니다.
  5. eval(s)programString->value(globalObject)를 호출하고, 이는 rope를 제자리에서 flatten합니다. commit message에 따르면 그 결과 data는 fiber cell이 소유한 storage일 수 있으며, 이때 rope 자신의 fiber word는 방금 덮어써졌으므로 그 fiber는 더 이상 마킹되지 않습니다.
  6. cacheKeyimpl()을 bare pointer로 캡처하고, miss 경로는 약 780자를 parsing하면서 반복적으로 allocation을 수행합니다. slowPathAllocsBetweenGCs=10 설정 하에서는 이 구간 안에서 collection이 실행되며, fiber는 unmarked 상태이므로 sweep 단계에서 JSString::destroy가 호출되고 impl이 해제됩니다.

ASAN에서 관찰되는 heap-use-after-free를 넘어선 확장 가능성은 reclaim 조건에 달려 있습니다. 그리고 그 reclaim 시나리오는 다시 이 트리에서 StringImpl body를 뒷받침하는 allocator가 무엇인지(일반 fastMalloc인지, 아니면 TZone/isoheap 파티션인지)에 달려 있는데, 이는 제공된 context로는 확인되지 않습니다. 파티션된 allocator라면 해제된 slot에 들어갈 수 있는 객체의 범위가 좁아질 것입니다. controlled object가 해당 자리에 배치되는 것을 조건으로 삼으면 다음과 같은 가능성들이 존재합니다. key가 소비되기 전에 해제된 slot이 공격자가 조작한 바이트로 reclaim된다면, operator CacheKey() 안의 RefPtr<StringImpl> 생성이 공격자가 선택한 주소 offset에 refcount increment를 수행하게 되어 제한된 increment primitive로 이어질 가능성이 있습니다. 이후 probe 시점 전에 reclaim이 일어난다면, operator==WTF::equal(m_source, other.m_source.get()) 호출이 위조된 객체로부터 length와 character-buffer 필드를 읽게 되어, 직접적인 disclosure가 아니라 비교 오라클 형태의 relative 또는 absolute read로 이어질 가능성이 있습니다. 위조된 equality match가 eval()에 전달된 문자열과 다른 source text로 컴파일된 DirectEvalExecutable을 반환한다면, 같은 realm 안에서의 script-execution 혼동에 해당할 수 있습니다. 그리고 변환된 key가 CodeBlock lifetime의 map에 삽입되는 만큼, 이후의 DirectEvalCodeCache::clear()가 같은 위조 pointer에 대해 deref()를 수행하게 되어, increment/decrement 쌍의 나머지 절반을 제공할 가능성도 있습니다. 이런 시나리오들을 실현하려면 해제된 body의 size class로 heap을 grooming하고, GC-timing race에서 승리해야 하는데, 둘 다 script에서 현실적으로 시도 가능한 조건입니다.

이 vulnerability는 web content로부터 완전히 도달 가능한 경로에서, JavaScript 엔진 heap 내부의 메모리 안전성을 약화시킵니다. 여기서 걸려 있는 security-model 가정은, collection point를 넘어 유지되는 pointer는 자신의 target을 살려두거나 이후 다시 도출되어야 한다는 것입니다. fix 이전에는 direct-eval cache key가 이 가정을 위반했습니다. 유일한 소유자가 rope fiber였고, 그 fiber는 flattening에 의해 unrooted되었기 때문입니다. 변환된 key가 CodeBlock lifetime의 cache에 삽입되는 만큼, dangling pointer는 eval() 호출을 넘어 지속될 수 있습니다. 이는 그 자체로 boundary bypass는 아니지만, renderer 측 메모리 손상의 발판에 해당합니다.

위험한 요소는 raw pointer 자체가 아니라, 코드베이스 곳곳에 떨어져 명시된 두 사실의 조합입니다. CacheLookupKey는 속도를 위해 의도적으로 refcounting을 배제하고, GCOwnedDataScope어떤 owner cell을 지정하지만 그것이 반드시 data를 release하는 destructor를 가진 cell은 아닙니다. rope flattening이 이 암묵적 연결을 깨뜨리는 지점입니다. 이는 이전에 마킹되어 있던 자식이 도달 불가능해지도록 살아있는 cell을 mutate하는 JSC 내 몇 안 되는 연산 중 하나이며, 그 결과 rope로부터 얻어낸 borrowed interior pointer의 lifetime은 감싸는 scope가 끝나는 시점이 아니라 다음 GC에서 끝나게 됩니다. 이번에 채택된 fix는 다소 거친 방식이지만 견고합니다. key를 다시 도출하거나 refcount를 붙이는 대신, 해당 구간에서 GC point 자체를 제거했습니다. 다만 uncounted-key 설계 자체는 그대로 남아있다는 점은 짚어둘 만합니다. 즉 invariant는 이제 타입이 아니라 한 caller 안의 scope에 의해 지켜지고 있습니다.