[2] Defer GC while using the direct eval CacheLookupKey
The eval cache kept a raw pointer that nothing was keeping alive.
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
JSTests/stress/direct-eval-cache-rope.js
Patch Details
실제 프로덕션 변경은 JSC::eval()의 한 줄입니다. cacheKey가 programStr.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 전체에 걸쳐 적용됩니다. DeferGC가 if (!eval) { ... } 블록 전체에 scope되어 있기 때문에, CacheLookupKey를 RefPtr를 보유하는 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 지점을 넘어 계속 살아있는 패턴입니다.
Background
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 타입이 있습니다. 저장되는 CacheKey는 RefPtr<StringImpl>을 보유하고, probe 전용인 CacheLookupKey는 SUPPRESS_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이 연결됩니다.
Analysis
이 버그는 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;입니다. 만약 data가 String(또는 auto가 복사를 유도하는 const String&)이라면, 이 복사가 reference를 하나 잡게 되어 새로 추가된 DeferGC는 사실상 no-op이 될 것입니다. 그러나 fix가 이 복사 이전에 배치되어 있고, in-code comment에서 JSString이 swept될 때 StringImpl이 deref될 수 있다고 명시하고 있는 만큼, 유력한 해석은 programStr.data가 JSString cell이 소유한 storage를 가리키는 borrowed, non-owning handle이라는 것입니다. 그렇다면 이 구간에서 유일하게 대상을 살려두는 것은 GCOwnedDataScope의 owner cell뿐입니다.
여기서 누락된 invariant는, StringImpl을 해제시킬 수 있는 객체가 raw pointer가 살아있는 동안 항상 도달 가능함이 증명되어야 한다는 것입니다. commit message와 in-code comment에 따르면 이 조건은 성립하지 않습니다. key가 캡처하는 storage는 programString 자체가 아니라 그 fiber인 JSString이 소유할 수 있기 때문입니다. 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를 따라가 보면 다음과 같습니다.
getRope(index)는a = "[" + index + ',[]'.repeat(0x100) + "]"를 만듭니다. 이는 771~773자의 rope이며, 유효한 array-literal 문법이면서 on-stack resolve 임계값보다 충분히 낮은 크기입니다.[][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-placeconvertToNonRope로 귀결됩니다.return a + b는 fiber0가a의 cell이고 fiber1이"()"인 새로운JSRopeString을 만듭니다. 이 연결된 텍스트는 parse는 되지만 실행 시 throw하므로,try/catch가 이를 삼키고 매 iteration마다 서로 다른 source text가 제시됩니다. 이는 cache miss를 보장하며, 곧 patch된 분기로 진입한다는 의미입니다.getRope(0)과gc()는 중간 garbage를 정리합니다. 이때s와 그것을 통한 fiber0는 여전히 도달 가능한 상태입니다.eval(s)는programString->value(globalObject)를 호출하고, 이는 rope를 제자리에서 flatten합니다. commit message에 따르면 그 결과 data는 fiber cell이 소유한 storage일 수 있으며, 이때 rope 자신의 fiber word는 방금 덮어써졌으므로 그 fiber는 더 이상 마킹되지 않습니다.cacheKey는impl()을 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에 의해 지켜지고 있습니다.
Audit directions
-
GC로 해제 가능한 storage에 대한 uncounted borrowed pointer가 allocation 지점을 넘어 유지되는 패턴. 여기서 지켜야 할 invariant는 raw pointer는 이 지점부터 마지막 사용까지 사이에서 아무것도 allocate하지 않을 때에만 owner의 도달 가능성 증명을 넘어 살아남을 수 있다는 것입니다. 좁게는, JavaScriptCore에서
StringImpl*/AtomStringImpl*/SymbolImpl*필드에 붙은SUPPRESS_UNCOUNTED_MEMBER와, hash-translator lookup-key 클래스들(DirectEvalCodeCache.h의CacheLookupKey/CacheKey분리 구조)을 검색해볼 필요가 있습니다. 일치 패턴은,->value().data.impl()이나->view()로부터 만들어진 lookup key의 scope가 parse, allocation, 혹은visit을 유발하는 호출을 넘어 확장되는 경우입니다. 조금 넓게 보면, 동일한 클래스 패턴이 다른 borrowed-buffer wrapper에서도 나타날 수 있습니다.GCOwnedDataScope와JSString으로부터 파생된StringViewlocal 변수, 그리고 allocate 가능한 호출 이전에 캡처된 typed-arraydata()pointer를 점검해볼 필요가 있습니다. 가장 넓게 보면, 이는 managed storage에 대한 borrowed-interior-pointer라는 일반적인 클래스에 해당합니다. 이 invariant는 V8(DisallowGarbageCollection하에서만 유효한String::FlatContent), SpiderMonkey(AutoCheckCannotGC하의JSLinearString::chars), 그리고 GC 대상 buffer에 대한 pointer를 넘겨주는 어떤 FFI에도 동일하게 적용해볼 수 있습니다. 다른 곳에서의 일치 패턴은, managed 객체로부터 얻어낸 raw pointer가 scope guard도, pinning도 없이 존재하고, 획득 시점과 사용 시점 사이에 allocate 가능한 호출이 끼어드는 경우입니다. -
아직 살아 있는 parent의 child를 고아로 만드는 in-place structural mutation. Invariant는 컨테이너에 도달 가능하다고 해서 그것이 방금 전까지 참조하던 대상에도 도달 가능하다는 뜻은 아니다라는 점입니다. 좁게 보면, rope를 resolve하는 모든 caller (
resolveRope,resolveRopeToAtomString,resolveRopeToExistingAtomString, 그리고 fiber를 순회하는 helper들)를 추적해서, 이후에도 파생 pointer나String&,StringView를 계속 들고 있는지 점검해야 합니다. Match tell은 rope일 가능성이 있는 값에 대해->value()/->view()를 읽은 뒤, 마지막 사용 전에 allocation이 일어나는 코드입니다. 조금 더 넓게 보면, JSC 안의 다른 in-place representation 전환에서도 동일한 패턴이 나타납니다. Butterfly reallocation이 기존 property나 element storage를 가리키던 pointer를 무효화시키는 경우, structure transition이 property table을 폐기하는 경우, substring의 base가 resolve된 impl로 교체되는 경우 등이 해당됩니다. 코드 리뷰 관점의 tell은convertTo*,flatten*,resolve*,materialize*라는 이름을 가진 메서드가 visitor의 marking에 쓰이는 필드를 덮어쓰는지 여부입니다. 가장 넓게 보면, lazy tree를 in-place로 정규화하는 모든 runtime에 이 문제가 존재합니다. V8의 ConsString flattening, SpiderMonkey의 rope flattening, path compaction을 사용하는 persistent-data-structure 라이브러리 등이 그 예입니다. Normalize 단계 이후에는, 정규화 이전 shape로부터 파생된 모든 pointer를 재사용하지 말고 다시 파생시켜야 합니다. -
선언된 owner가 실제로 데이터를 소유한 객체가 아닌 keep-alive scope. Invariant는 liveness guard가 참조 대상 바이트를 해제할 destructor를 가진 객체를 정확히 명시해야 한다는 점입니다. 좁게 보면,
Source/JavaScriptCore/runtime에 있는GCOwnedDataScope생성 지점들을 점검해야 합니다 (JSString.cpp의{ this, ... }형태가 template 역할을 합니다). 각 지점에서 반환되는 data가 기록된 owner와는 다른 cell을 가리킬 수 있는지 확인해야 합니다. Match tell은 owner argument가this인데, data 표현식은substringBase()나fiber(i), 또는 다른 cell을 거쳐 도달하는 return문입니다. 조금 더 넓게 보면, region이 아니라 object를 인자로 받는 다른 keep-alive idiom에서도 같은 비대칭이 나타납니다. Protected read가y에서 일어나는데ensureStillAliveHere(x)를 호출하는 경우, 또는 loop body가 callee-owned payload를 dereference하는데 wrapper에 대해Ref/RefPtrlocal을 잡아두는 경우가 그렇습니다. 가장 넓게 보면, guard가 잘못된 owner를 alias하는 문제는 managed language의 reachability fence에서도 나타납니다 (Java의Reference.reachabilityFence, Objective-C의objc_precise_lifetime). Rustunsafe코드에서 borrow를 잘못된 parent로부터 파생된 lifetime에 묶는 경우도 마찬가지입니다. Keep-alive를 마주칠 때마다, 읽고 있는 바이트를 해제하는 destructor가 어느 것인지 확인하고 guard가 그 객체를 정확히 명시하는지 점검해야 합니다. -
올바른 ownership을 대체하는 용도로 쓰이는 성긴
DeferGC/ no-allocation window. 이후의 refactor가 이 window를 조용히 넓히거나, 원래는 조용해야 할 구간 안으로 allocation을 옮겨놓을 가능성이 있습니다. 좁게 보면, JavaScriptCore 안에서 특정 allocation이 아니라 raw pointer를 보호하는 다른DeferGC/DeferGCForAWhile/DisallowGCscope들을 검토하고, guarded pointer의 모든 사용처가 실제로 scope 내부에 있는지 확인해야 합니다. Match tell은 guard 이전에 선언되고 guard 이후에 사용되는 raw pointer입니다. Deferred block 바깥에서 구성된 lookup key가 대표적인 예입니다. 조금 더 넓게 보면, unowned pointer를 보호하는 임기응변식 critical section 전반에 동일한 취약성이 적용됩니다. Safety 근거가 "여기서는 아무것도 allocate하지 않는다"에 불과한AssertNoGCregion이나 lock-free fast path가 그렇습니다. Match tell은 해당 region이 allocate할 수 없다고 주장하는 주석이 있을 뿐, compile-time 강제는 전혀 없는 경우입니다. 가장 넓게 보면, scope로 강제되는 safety property는 type으로 강제되는 것보다 refactoring에 취약하다는 원칙이 여기에도 적용됩니다. 주석으로만 문서화된 Rustunsafeblock, protected data가 guard보다 더 오래 살아남는 C++ RAII guard가 그 예입니다. 이 부분의 검증은 어떤 callee가 allocate할 수 있는지를 추론해야 하는 만큼 대체로 수작업에 의존하게 되므로, 점검만으로 끝내지 말고--slowPathAllocsBetweenGCs와 ASAN을 함께 사용하는 assertion 활성화 stress run을 병행해야 합니다.