← All reports

[2] JSString use-after-free via GCOwnedDataScope and atomization swap

HighJSC HeapUAF

A scope guard that pinned the string cell but not the bytes you were reading

e69c479

High. 순수 웹 콘텐츠만으로 이 경로에 도달할 수 있습니다 — 회귀 테스트 네 개가 모두 순수 JavaScript로 작성되어 있습니다. 결과적으로 엔진 코드가 해제된 heap을 읽어 그 바이트를 관찰 가능한 JS 값으로 돌려주는 상황이 발생합니다. Critical까지 가지 않는 이유는 이 primitive가 read-only이기 때문입니다. Disclosure를 넘어서는 확장을 하려면 별도의 버그가 필요합니다.

JSC는 네이티브 런타임 코드에 문자열의 character buffer를 가리키는 직접 포인터를 넘겨주는데, 이때 네이티브 함수가 실행되는 동안 그 포인터가 유효한 상태로 유지된다는 것을 어떤 식으로든 보장해야 합니다. 이를 담당하는 메커니즘이 GCOwnedDataScope입니다. 이 RAII 헬퍼는 소유 대상인 garbage-collected cell을 스택에 고정시켜, conservative stack scanning이 이를 찾아내고 collector가 회수하지 못하도록 막습니다. 한편 별개로, 문자열이 property key로 사용되고 VM의 interning table에 동일한 "atom" 문자열이 이미 존재하는 경우, 엔진은 해당 문자열 cell의 backing buffer를 공유 atom으로 in place 교체합니다. GCOwnedDataScope가 내세우는 계약은, 넘겨준 데이터가 해당 lexical scope 전체에 걸쳐 유효하게 유지된다는 것입니다.

관전 포인트: 스크립트가 네이티브 문자열 함수로 하여금 해제된 heap buffer를 읽게 만들고, 그 바이트를 JS 문자열로 반환받을 수 있습니다. Renderer 쪽에서 사용 가능한 disclosure primitive로, address-space layout randomization을 무력화하는 데 활용될 수 있습니다.

JSString::swapToAtomStringStringImpl을 atomized된 대응물로 교체할 때, 기존 StringImplHeap::m_possiblyAccessedStringsFromConcurrentThreads를 통해 다음 GC까지만 살아있는 상태로 유지되었습니다. 그런데 GCOwnedDataScope가 스택에 존재하는 경우, ~GCOwnedDataScope가 실행되기 전에 buffer가 먼저 해제될 수 있었고, 그 결과 buffer가 dangling pointer로 남게 되었습니다.

다음과 같은 방식으로 수정되었습니다.

  1. m_possiblyAccessedStringsFromConcurrentThreadsm_possiblyAccessedStringsFromConcurrentThreadsOrGCOwnedDataScope로 이름을 바꾸고, (JSString*, String) 쌍을 저장해 ownership을 추적할 수 있도록 했습니다.
  2. Conservative root scanning 과정에서 스택에서 여전히 참조되고 있는 모든 JSString을 찾아내어 m_discoveredAccessedStringsFromGCOwnedDataScope에 기록하도록 했습니다.
  3. GC finalize 시점에는 리스트 전체를 비우는 대신, 스택에서 발견되지 않은 JSString에 해당하는 항목만 걸러내도록 변경했습니다.
  4. GC와 GC 사이에는, JS가 실행 중이지 않고 JIT compilation도 진행 중이지 않은 시점에 IncrementalSweeper가 보존 리스트를 비우도록 했습니다. 기존에는 GC finalize 시점에만 리스트가 비워졌기 때문에 collection과 collection 사이에 리스트가 무한정 커질 수 있었습니다. 이 변경이 없으면 Speedometer 점수가 퇴보하는 것으로 나타났고, 적용 이후에는 오히려 0.2% 개선되는 것으로 관찰되었습니다.
  5. VectorSegmentedVector로 전환하고 새로운 doubling growth policy를 적용해, 리사이즈 시 항목을 복사하지 않도록 했습니다. 이 리스트는 20만 개 이상까지 커질 수 있어, 복사를 피하는 것이 성능상 의미가 있습니다.

Source/JavaScriptCore/runtime/JSString.h

ALWAYS_INLINE void JSString::swapToAtomString(VM& vm, RefPtr<AtomStringImpl>&& atom) const
{
 
- // We replace currently held string with new AtomString. But the old string can be accessed from concurrent compilers and GC threads at any time.
 
- // So, we keep the old string alive by appending it to Heap::m_possiblyAccessedStringsFromConcurrentThreads. And GC clears that list when GC finishes.
+ // When we swap a JSString's value to an AtomString, the old StringImpl can still be accessed
+ // by concurrent JIT compiler threads, GC threads, or via GCOwnedDataScope references on the stack.
+ // We keep the old string alive by appending it (paired with its owning JSString*) to
+ // Heap::m_possiblyAccessedStringsFromConcurrentThreadsOrGCOwnedDataScope.
String target(WTF::move(atom));
WTF::storeStoreFence();
valueInternal().swap(target);
 
- vm.heap.appendPossiblyAccessedStringFromConcurrentThreads(WTF::move(target));
+ vm.heap.appendPossiblyAccessedStringFromConcurrentThreadsOrGCOwnedDataScope(this, WTF::move(target));
}

Source/JavaScriptCore/heap/Heap.cpp

 
- m_possiblyAccessedStringsFromConcurrentThreads.clear();
+ m_possiblyAccessedStringsFromConcurrentThreadsOrGCOwnedDataScope.removeAllMatching([&](const auto& iter) {
+ return !m_discoveredAccessedStringsFromGCOwnedDataScope.contains(iter.first);
+ });
+ m_discoveredAccessedStringsFromGCOwnedDataScope.clear();

Source/JavaScriptCore/heap/ConservativeRoots.cpp

 
diff
- return isLive && !mayHaveIndexingHeader(cellKind);
+ if (isLive && !mayHaveIndexingHeader(cellKind)) {
+ static_assert(!JSString::numberOfLowerTierPreciseCells && !JSRopeString::numberOfLowerTierPreciseCells, ...);
+ if (auto* string = dynamicDowncast<const JSString>(std::bit_cast<const JSCell*>(pointer)))
+ m_heap.m_discoveredAccessedStringsFromGCOwnedDataScope.add(string);
+ return true;
+ }
+ return false;

JSTests/stress/stringProtoFuncAt-GCOwnedDataScope-atomstring-swap.js

+const target = "A".repeat(128);
+const dummy = {};
+Reflect.set(dummy, target, 1);
+const freshRope = "A".repeat(128);
+let nonAtom = "D".repeat(64) + "D".repeat(64);
+String.prototype.at.call(nonAtom, 0);
+let thatObj = {
+ [Symbol.toPrimitive]() {
+ // Trigger atomization via Reflect.set to avoid Inline Cache holding the string
+ Reflect.set(dummy, freshRope, 1);
+ // Overwrite the VM's lastAtomizedIdentifierStringImpl cache
+ Reflect.set(dummy, nonAtom, 1);
+ gc();
+ return 0;
+ }
+};
+// Verify that GCOwnedDataScope/Heap keeps the StringImpl alive across the callback
+String.prototype.at.call(freshRope, thatObj);

이번 변경은 retain list의 identity 및 lifetime 규칙을 다시 설계하고, conservative stack scanning을 reclamation 판단에 활용할 수 있도록 확장하며, GC 사이 구간에서의 drain 경로와 이를 지원하는 컨테이너 기능을 추가합니다.

먼저 retain list 자체를 보면, Heap::m_possiblyAccessedStringsFromConcurrentThreads (기존 Vector<String>)가 m_possiblyAccessedStringsFromConcurrentThreadsOrGCOwnedDataScope로 이름이 바뀌고, 타입도 SegmentedVector<std::pair<const JSString*, String>, 256, 10, SegmentedVectorGrowthPolicy::Doubling>로 변경됩니다. 이에 따라 보존되는 각 이전 String은 원래 이를 소유했던 JSString*와 쌍을 이루게 됩니다. appendPossiblyAccessedStringFromConcurrentThreads(String&&)appendPossiblyAccessedStringFromConcurrentThreadsOrGCOwnedDataScope(const JSString* owner, String&&)로 바뀌었고, JSString::swapToAtomStringthis를 넘겨줍니다.

Scanning 쪽에서는 ConservativeRoots::genericAddPointertryPointer lambda가 다시 작성되었습니다. 기존에는 isLive && !mayHaveIndexingHeader(cellKind)를 그대로 반환했지만, 이제는 해당 branch 안에서 발견된 cell을 const JSString으로 dynamicDowncast하고, true를 반환하기 전에 새로 추가된 Heap::m_discoveredAccessedStringsFromGCOwnedDataScope hash set에 삽입합니다. 이 과정은 JSString/JSRopeString에 lower-tier precise cell이 없음을 보장하는 static_assert로 보호됩니다. 이에 맞춰 Heap::finalize도 retain list에 대해 더 이상 .clear()를 호출하지 않고, 대신 removeAllMatching을 사용해 stack에서 발견되지 않은 JSString*와 짝지어진 항목만 제거한 뒤 discovered set을 비웁니다.

나머지 변경은 drain 및 container 관련 배관 작업입니다. IncrementalSweeper::doSweep에서 새로 추가된 Heap::clearConcurrentRetainedDataIfPossible()가 호출됩니다. 이 함수는 vm().entryScope가 설정되어 있으면 즉시 중단하고, !m_topGCOwnedDataScope를 assert하며, JITWorklist::totalOngoingCompilations()가 0일 때만 (새로 추가된 public overload) list 전체를 비웁니다. Debug 빌드에서만 동작하는 bookkeeping은 새로 추가된 GCOwnedDataScope.cpp의 out-of-line 함수 setTopGCOwnedDataScopeIfNeeded/clearTopGCOwnedDataScopeIfNeeded를 통해 Heap::m_topGCOwnedDataScope를 추적합니다. 그리고 WTF::SegmentedVector에는 SegmentedVectorGrowthPolicy template parameter (Constant/Doubling)가 추가되고, clz 기반으로 인덱스를 분해하는 segmentLocationFor(), sizeOfSegment(), addressAt 기반의 grow/resize, 그리고 새로운 removeAllMatching()이 추가됩니다. String.prototype.at, startsWith, endsWith, localeCompare를 다루는 4개의 JSTests regression test도 함께 추가되었습니다.

단 하나의 reader 유형만을 대상으로 설계된 지연 회수 방식 — 수집 주기 종료 시점에 해제되는 replaced buffer가, 그 주기보다 오래 살아남는 stack-held view에 의해 여전히 참조되는 상황.

Strings in WTF. StringImpl은 문자열의 길이, flag, character buffer를 담는 refcounted 객체이며, String은 이를 감싸는 RefPtr<StringImpl> wrapper입니다. StringView는 impl의 buffer에 대한 pointer+length 쌍으로 이루어진, 소유권 없는 view입니다.

Atomization. JSC는 identifier와 property key를 pointer 비교만으로 처리할 수 있도록, 스레드별로 고유한("atom") 문자열 테이블을 유지합니다. 문자열을 property key로 변환하는 과정은 이 테이블에서 조회하거나 새로 삽입하는 동작에 해당합니다. JSString은 문자열 값을 감싸는 GC cell로, 내부 fiber는 확정된 StringImpl pointer이거나 rope 형태입니다. JSString이 property key로 사용되고 동일한 atom이 이미 존재하는 경우, JSString::swapToAtomString이 cell의 fiber를 기존 AtomStringImpl로 제자리에서 교체합니다. 이렇게 하면 이후의 key 연산이 pointer 비교만으로 빠르게 처리됩니다. 이때 기존에 갖고 있던 impl은 Heap에 넘겨져 지연 해제 대상이 되는데, concurrent JIT 및 GC thread가 이전에 읽어둔 fiber를 여전히 참조하고 있을 수 있기 때문입니다.

GCOwnedDataScope<T>. {const JSCell* owner, T data}를 담는 RAII 구조체로, 소멸자에서 ensureStillAliveHere(owner)를 호출합니다. 이렇게 하면 컴파일러가 scope의 lifetime 동안 owning cell에 대한 참조를 stack에 유지하도록 강제되어, conservative scanning이 이를 찾아낼 수 있게 됩니다. 이 구조체가 존재하는 이유는 StringPrototype 함수들이 접근할 때마다 refcount를 증가시키지 않고도 raw character에 접근해야 하기 때문입니다.

Conservative stack scanning. Stop-the-world collection이 시작되면 JSC는 machine stack과 register를 순회하며, MarkedBlock이나 PreciseAllocation 내부를 가리키는 것으로 보이는 각 word에 대해 liveness를 확인하고 해당 cell을 root로 취급합니다. ensureStillAliveHere가 충분한 효과를 갖는 이유가 바로 이 동작 덕분입니다.

Heap::finalize. Collection 종료 단계로, stringSplitCache, jsonAtomStringCache, immutableButterflyToStringCache와 같이 주기마다 사용되는 cache들이 이 단계에서 비워집니다. 이번 patch 이전까지는 possibly-accessed-strings list도 여기서 함께 비워졌습니다.

Between-collection machinery. IncrementalSweeper는 collection 사이 구간에서 marked block을 점진적으로 sweep하는 timer 기반 task입니다. VM::entryScope는 VM 내에서 JavaScript 실행이 진행 중일 때 non-null 값을 갖습니다. JITWorklist::totalOngoingCompilations()는 현재 background JIT thread에서 실행 중인 compilation 수를 나타내며, 이 두 신호가 새로 추가된 drain 경로에서 "어떤 reader도 view를 들고 있지 않다"를 판단하는 대리 지표로 사용됩니다.

SegmentedVector. 고정 크기의 heap segment에 원소를 저장하여, 성장 과정에서도 원소의 주소가 안정적으로 유지되는 vector입니다. 이번 commit에서는 각 segment가 이전 segment의 두 배 크기를 갖는 Doubling policy가 추가되었습니다.

Root cause는 서로 맞물리지 않는 두 가지 liveness 모델에 있습니다. GCOwnedDataScope는 owning cell을 고정해 유지하는 반면, retain list는 collection 주기 경계에서 해제됩니다. 어느 쪽도, cell이 더 이상 참조하지 않지만 stack frame이 여전히 바라보고 있는 buffer는 다루지 않습니다.

  Native frame (StringPrototype)      JS callback (Symbol.toPrimitive)
  ──────────────────────────────      ────────────────────────────────
  scope = view over impl_A ──┐
    (pins JSString S, not impl_A)
                             │        Reflect.set(dummy, S, 1)
                             │          S.fiber: impl_A -> atom_A
                             │          retain list <- impl_A
                             │        gc()
                             │          finalize(): list.clear()
                             │          free(impl_A)  <-- last ref gone
  read scope.data[i]  ◄──────┘        ← UAF read of freed buffer

swapToAtomString은 기존 StringImpl에 대한 마지막 strong reference를 retain list로 옮깁니다. 이 list는 원래 concurrent JIT 및 GC thread만을 위해 존재했던 것이므로, 보존 기간이 "다음 GC가 끝날 때까지"로 정의되어 있었고, Heap::finalize는 이를 조건 없이 비워왔습니다. Owning JSString을 살아있게 유지한다고 해서 기존 impl까지 안전해지는 것은 아닙니다. Swap 이후에는 JSString이 더 이상 그 impl을 참조하지 않기 때문입니다. 여기서 빠져 있던 것은 lock이나 check가 아니라, displaced buffer의 회수 여부가 오직 collector thread의 liveness에만 연결되어 있었을 뿐, 어떤 stack frame이 그로부터 파생된 view를 여전히 들고 있는지는 전혀 반영하지 않았다는 점입니다. 이번 fix는 conservative scanning 과정에서 stack에서 발견된 모든 JSString을 기록하고, owning JSString이 발견되지 않은 항목만 제거하도록 하여 이 누락된 입력을 채워 넣습니다.

4개의 regression test는 순수 JavaScript로 작성되어 있으며, gc() 외에 특별한 flag를 필요로 하지 않습니다. gc()는 script 상에서 allocation pressure로 근사할 수 있는 수준입니다. stringProtoFuncAt을 단계별로 살펴보면 다음과 같습니다.

  1. const target = "A".repeat(128); Reflect.set(dummy, target, 1)는 128개의 'A'로 이루어진 AtomStringImpl을 atom table에 삽입합니다. 이로써 동일한 atom이 이미 존재하는 상태가 만들어집니다.
  2. const freshRope = "A".repeat(128)는 동일한 내용을 갖지만, 별도의 non-atom StringImpl을 가진 두 번째 JSString을 생성합니다.
  3. String.prototype.at.call(freshRope, thatObj)는 native 함수로 진입합니다. 이 함수는 먼저 receiver의 character를 materialize하는데, 이때 non-atom impl의 buffer를 바라보는 GCOwnedDataScope를 얻게 됩니다. 그런 다음에야 index 인자를 coerce하면서 thatObj[Symbol.toPrimitive]를 호출합니다. 4개의 regression test 모두 바로 이 순서를 검증 대상으로 삼고 있으며, fix 역시 이 순서를 전제로 설계되었습니다. 즉 버그가 의존하는 것은 개별 호출이 아니라 이 순서 자체입니다.
  4. Callback 내부의 Reflect.set(dummy, freshRope, 1)는 해당 문자열을 property key로 사용합니다. 조회 과정에서 기존 atom이 발견되므로 swapToAtomStringAtomStringImpl을 설치하고, 원래 impl에 남아 있던 유일한 reference를 retain list로 옮깁니다. 테스트 내 주석에는 inline cache가 문자열을 붙잡지 않도록 일부러 Reflect.set을 선택했다는 점이 명시되어 있습니다.
  5. Reflect.set(dummy, nonAtom, 1)은 테스트 주석에서 VM의 lastAtomizedIdentifierStringImpl cache로 언급된 대상을 다른 값으로 덮어씁니다. 이로써 원래 impl을 살아있게 만들어주던 또 다른 reference가 제거됩니다.
  6. gc()가 collection을 수행합니다. Fix 이전에는 finalize.clear()가 마지막 reference를 제거하여, impl과 그 안의 128글자 buffer가 해제됩니다.
  7. Callback이 반환됩니다. Native frame에 있던 GCOwnedDataScope는 여전히 살아있는 상태이지만, 그 view는 이미 해제된 buffer를 여전히 가리키고 있고, 함수는 이후 이를 indexing합니다.

이 stale read를 넘어서는 확장 가능성은 reclamation 여부에 달려 있으며, 각 단계는 모두 조건부입니다. 공격자가 victim 문자열의 크기를 fastMalloc size class에 맞춰 자신이 다시 채울 수 있도록 조절하고, gc() 반환 이후부터 native read 이전 사이 시점에 callback 안에서 대체 객체를 allocate한다고 가정하면, 이 read는 공격자가 제어하거나 그에 인접한 heap 내용을 읽어낼 가능성이 있습니다. String.prototype.at은 indexing된 code unit을 JS 문자열로 반환하며, index는 callback 호출 이전에 캡처된 length로만 제한됩니다. 따라서 이는 회수된 allocation의 바이트 값을 제한된 범위 내에서 노출하는 disclosure로 이어질 가능성이 있습니다. 여기에는 해당 영역에 놓인 임의의 pointer 값도 포함될 수 있으며, 이는 heap 주소나 binary base를 추론하는 데 활용될 수 있습니다. startsWith/endsWith/localeCompare variant는 이보다는 약하지만 여전히 활용 가능합니다. 이들은 같은 메모리를 직접적인 character 형태가 아니라 comparison oracle 형태로 노출시킬 가능성이 있습니다. 이 구조에서 write primitive는 따라오지 않습니다. 보존된 String은 오직 scope의 view를 통해서만 읽히기 때문입니다.

발견 과정은 blind fuzzing보다는 GCOwnedDataScope contract를 겨냥한 pattern audit에 가까워 보입니다. 4개의 테스트는 at, startsWith, endsWith, localeCompare에 거의 동일한 template을 적용한 형태로, cell-owned view를 얻은 뒤 사용자 JS를 통해 인자를 coerce시키는 패턴을 식별하고 이에 해당하는 StringPrototype 함수들을 하나씩 나열한 사람의 흔적이 드러납니다. 테스트에는 엔진 내부의 비자명한 동작도 함께 반영되어 있습니다. "Inline Cache가 문자열을 붙잡는 것을 피하기 위해" Reflect.set을 선택한 점, 그리고 오직 last-atomized-identifier cache를 밀어내기 위한 목적만으로 두 번째 Reflect.set을 넣은 점은 fuzzer가 자연스럽게 생성해낼 수 있는 종류의 코드가 아닙니다.

이 vulnerability는 WebContent process 내부의 memory safety를 약화시킵니다. 여기서 위협받는 security-model 전제는, GCOwnedDataScope 하에서 native code에 넘겨진 데이터가 해당 lexical scope 전체 동안 유효하게 유지된다는 보장입니다. 이는 이 클래스가 존재하는 이유 그 자체이며, JSString::value/view를 사용하는 모든 StringPrototype 소비자가 의존하는 전제이기도 합니다. Fix 이전에는 atomization으로 인해 displaced된 buffer에 대해서는 script가 이 보장을 깨뜨릴 수 있었습니다. 즉 순수 JavaScript 공격자가 엔진 코드로 하여금 이미 해제된 heap allocation을 읽게 만들고, 그 바이트 값을 관찰 가능한 JS 값이나 comparison 결과로 되돌려 받을 수 있었습니다. 실질적으로 이는 ASLR 우회와 이후 corruption bug 준비 단계에 활용 가능한 heap-content disclosure primitive에 해당하며, 이 자체만으로 sandbox 경계를 넘지는 않습니다.

Insight: 이번 fix의 후반부는 proof가 아니라 heuristic에 가깝습니다. Heap::clearConcurrentRetainedDataIfPossiblevm().entryScope와 JIT worklist count를 검사하여 어떤 GCOwnedDataScope도 살아있지 않다고 판단하는데, 실제 invariant를 검증하는 ASSERT(!m_topGCOwnedDataScope)는 release 빌드에서는 컴파일 대상에서 제외됩니다. 게다가 in-tree FIXME 주석에서는 WebCore의 testing/debugger 코드가 JS stack 중간에서 runloop을 돌린다는 점을 이미 인정하고 있으며, entryScope bail이 존재하는 이유도 바로 여기에 있습니다. 이번 commit은 ConservativeRoots의 역할도 함께 넓힙니다. 기존에는 marking root만 산출하던 scan이, 이제는 reclamation 판단을 위한 set까지 함께 산출하게 되었습니다. 따라서 이 list를 pruning하는 어떤 GC 경로든, 그 전에 반드시 전체 stack scan이 완료되었음이 보장되어야 합니다.