[2] JSString use-after-free via GCOwnedDataScope and atomization swap
A scope guard that pinned the string cell but not the bytes you were reading
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::swapToAtomString이StringImpl을 atomized된 대응물로 교체할 때, 기존StringImpl은Heap::m_possiblyAccessedStringsFromConcurrentThreads를 통해 다음 GC까지만 살아있는 상태로 유지되었습니다. 그런데GCOwnedDataScope가 스택에 존재하는 경우,~GCOwnedDataScope가 실행되기 전에 buffer가 먼저 해제될 수 있었고, 그 결과 buffer가 dangling pointer로 남게 되었습니다.다음과 같은 방식으로 수정되었습니다.
m_possiblyAccessedStringsFromConcurrentThreads를m_possiblyAccessedStringsFromConcurrentThreadsOrGCOwnedDataScope로 이름을 바꾸고,(JSString*, String)쌍을 저장해 ownership을 추적할 수 있도록 했습니다.- Conservative root scanning 과정에서 스택에서 여전히 참조되고 있는 모든
JSString을 찾아내어m_discoveredAccessedStringsFromGCOwnedDataScope에 기록하도록 했습니다.- GC finalize 시점에는 리스트 전체를 비우는 대신, 스택에서 발견되지 않은
JSString에 해당하는 항목만 걸러내도록 변경했습니다.- GC와 GC 사이에는, JS가 실행 중이지 않고 JIT compilation도 진행 중이지 않은 시점에
IncrementalSweeper가 보존 리스트를 비우도록 했습니다. 기존에는 GC finalize 시점에만 리스트가 비워졌기 때문에 collection과 collection 사이에 리스트가 무한정 커질 수 있었습니다. 이 변경이 없으면 Speedometer 점수가 퇴보하는 것으로 나타났고, 적용 이후에는 오히려 0.2% 개선되는 것으로 관찰되었습니다.Vector를SegmentedVector로 전환하고 새로운 doubling growth policy를 적용해, 리사이즈 시 항목을 복사하지 않도록 했습니다. 이 리스트는 20만 개 이상까지 커질 수 있어, 복사를 피하는 것이 성능상 의미가 있습니다.
Source/JavaScriptCore/runtime/JSString.h
Source/JavaScriptCore/heap/Heap.cpp
Source/JavaScriptCore/heap/ConservativeRoots.cpp
JSTests/stress/stringProtoFuncAt-GCOwnedDataScope-atomstring-swap.js
Patch Details
이번 변경은 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::swapToAtomString이 this를 넘겨줍니다.
Scanning 쪽에서는 ConservativeRoots::genericAddPointer의 tryPointer 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에 의해 여전히 참조되는 상황.
Background
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가 추가되었습니다.
Analysis
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을 단계별로 살펴보면 다음과 같습니다.
const target = "A".repeat(128); Reflect.set(dummy, target, 1)는 128개의'A'로 이루어진AtomStringImpl을 atom table에 삽입합니다. 이로써 동일한 atom이 이미 존재하는 상태가 만들어집니다.const freshRope = "A".repeat(128)는 동일한 내용을 갖지만, 별도의 non-atomStringImpl을 가진 두 번째JSString을 생성합니다.String.prototype.at.call(freshRope, thatObj)는 native 함수로 진입합니다. 이 함수는 먼저 receiver의 character를 materialize하는데, 이때 non-atom impl의 buffer를 바라보는GCOwnedDataScope를 얻게 됩니다. 그런 다음에야 index 인자를 coerce하면서thatObj[Symbol.toPrimitive]를 호출합니다. 4개의 regression test 모두 바로 이 순서를 검증 대상으로 삼고 있으며, fix 역시 이 순서를 전제로 설계되었습니다. 즉 버그가 의존하는 것은 개별 호출이 아니라 이 순서 자체입니다.- Callback 내부의
Reflect.set(dummy, freshRope, 1)는 해당 문자열을 property key로 사용합니다. 조회 과정에서 기존 atom이 발견되므로swapToAtomString이AtomStringImpl을 설치하고, 원래 impl에 남아 있던 유일한 reference를 retain list로 옮깁니다. 테스트 내 주석에는 inline cache가 문자열을 붙잡지 않도록 일부러Reflect.set을 선택했다는 점이 명시되어 있습니다. Reflect.set(dummy, nonAtom, 1)은 테스트 주석에서 VM의lastAtomizedIdentifierStringImplcache로 언급된 대상을 다른 값으로 덮어씁니다. 이로써 원래 impl을 살아있게 만들어주던 또 다른 reference가 제거됩니다.gc()가 collection을 수행합니다. Fix 이전에는finalize의.clear()가 마지막 reference를 제거하여, impl과 그 안의 128글자 buffer가 해제됩니다.- 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::clearConcurrentRetainedDataIfPossible는 vm().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이 완료되었음이 보장되어야 합니다.
Audit directions
-
마지막 reference가 고정된 주기로 비워지는 container 안에만 존재하는 객체를, 무관한 코드가 이로부터 파생된 raw view를 통해 계속 들고 있는 경우. 여기서 지켜져야 할 invariant는 지연 회수(deferred-reclamation) 방식은 그 설계 대상이 된 reader 범주만이 아니라 모든 reader 범주를 빠짐없이 포괄해야 한다는 것입니다. 좁게 보면,
Heap::finalize를 읽고, 이번에 바뀐 코드 바로 옆에 있는 다른 주기별 clear 동작들 —vm().stringSplitCache.clear(),vm().jsonAtomStringCache.clearJSStrings(),immutableButterflyToStringCache.clear()— 을 각각 점검할 필요가 있습니다. 각 container가 어떤 buffer에 대한 마지막Ref/RefPtr을 쥐고 있는지, 그리고 그 buffer를 stack frame이 여전히 바라보고 있을 가능성은 없는지 확인해야 합니다. 신호가 되는 패턴은, owning reference를 담은 container에 대한clear()가 존재하면서 동시에 그 owned 객체가 non-owning view를 통해서도 도달 가능한 구조입니다. 넓게 보면, "특정 reader까지만 살려두겠다"는 목적으로 만들어진 list가 나중에 다른 reader까지 함께 상속받게 되는 모든 사례에 동일한 클래스의 문제가 나타날 수 있습니다. JSC 내에서possiblyAccessed*,pending*Release,deferred*라는 이름을 가진 다른 멤버들을 검색하여, 이들의 clear 조건이 현재 존재하는 모든 consumer를 실제로 커버하는지 확인해볼 필요가 있습니다. 가장 넓게 보면, reader가 온전히 열거되지 않은 상태로 이루어지는 지연 회수라는 패턴은 Chromium의 Oilpan pre-finalizer, V8의KeepDuringJobset, Linux의 RCU grace period, Rust의 crossbeam-epoch에도 동일하게 적용될 수 있습니다. "내가 아는 한 아무도 읽고 있지 않다"는 조건으로 메모리를 해제하는 구조를 볼 때마다, 등록되지 않은 채로 읽고 있는 주체가 있는지를 물어야 합니다. -
컨테이너를 pin하지만 buffer 버전은 pin하지 않는 guard 아래에서 넘겨진 interior pointer가, re-entrancy 지점을 넘어서까지 유지되는 패턴. Invariant는 객체를 pin한다고 해서 그 객체가 제자리에서 교체할 수 있는 storage까지 pin되는 것은 아니다라는 점입니다. Narrow: 네 개의 새 테스트가 겨냥하는 형태를 확인하려면
StringPrototype.cpp/StringPrototypeInlines.h를 점검해야 합니다. 구체적으로는 receiver에 대해JSString::value/view에서 얻은GCOwnedDataScope가 있고, 그 뒤에 argument coercion (toPrimitive,toString, object에 대한toIntegerOrInfinity) 또는 ICU 호출이 오며, 이후 앞서 얻은 view를 다시 사용하는 형태입니다. 코드 리뷰에서의 tell은auto x = ...->value(globalObject)혹은->view(globalObject)형태의 코드 뒤에,x의 마지막 사용 지점까지 사이에 user JS로 진입할 수 있는 호출이 존재하는 경우입니다. Wider: JSC 내 모든GCOwnedDataScope<T>인스턴스화를 나열하고, 각각에 대해 owner cell이 어떤 in-place mutation을 지원하는지 확인해야 합니다. 여기서 주목할 형태는 destructor가 owner만 건드리는 반면data멤버는 unowned pointer나 view인 scope-guard 타입입니다. Widest: relocatable하거나 replaceable한 storage로의 interior pointer를 노출하는 런타임이라면 handle이 아니라 storage 자체를 버전화해야 합니다. 이 질문은 JNI의GetStringCritical/GetPrimitiveArrayCritical, CPython의Py_buffer,DisallowGarbageCollectionscope를 동반한 V8의String::GetFlatContent에도 동일하게 적용됩니다. tell은 pointer와 함께 guard를 반환하는 API인데, 그 guard의 명시된 contract가 mutation이 아니라 collection에 관한 것인 경우입니다. -
release build에서는 heuristic proxy로 근사되고, debug-only assertion으로만 강제되는 reclamation safety 조건. Invariant는 release-build 상의 memory safety가 어떤 조건에 의존한다면, 그 조건은 release build에서 실제로 검사되거나, 검사되는 다른 무언가에 의해 논리적으로 함의되어야 한다는 것입니다.
Heap::clearConcurrentRetainedDataIfPossible를 조사해야 합니다. 실제 precondition은ASSERT(!m_topGCOwnedDataScope)이며 이는NDEBUG에서 컴파일 시 제거됩니다. 반면 release에서의 안전성은vm().entryScope가 null이라는 것과JITWorklist::totalOngoingCompilations() == 0이라는 조건에만 의존하게 됩니다.IncrementalSweeper::doSweep으로 이어지는 모든 호출 경로를 추적하여, live한GCOwnedDataScope와 null인entryScope가 동시에 성립하는 경로가 있는지 살펴봐야 합니다. in-tree FIXME는 JS stack 내부에서 runloop을 구동하는 WebCore testing/debugger 코드를 이미 불안정한 사례로 지목하고 있습니다. 또한 GC 설정 중 어느 것도m_discoveredAccessedStringsFromGCOwnedDataScope를 conservative stack scan으로 먼저 채우지 않은 채 retain list를 pruning하지 않는지 확인해야 합니다 (eden collection과 full collection,VerifierSlotVisitor경로를 모두 점검). tell은worldIsStopped()하에서의ConservativeRootsscan에 의해 dominate되지 않는,finalize측 pruning으로의 호출입니다. Wider: JSC의 heap과 JIT worklist 안에서 counter나 flag로 게이팅되는 다른 release-time free들도 점검할 필요가 있습니다. "in-flight 작업이 없다"는 조건이 "포인터를 들고 있는 reader가 없다"는 조건을 대신하고 있는 경우가 그 대상입니다. Widest:assert로 가드되는 contract가 release-build free를 뒷받침하는 모든 코드베이스에 적용됩니다. 커널의 refcount debug check, Rust의debug_assert!로 가드된 unsafe invariant 등이 해당됩니다. -
공유 컨테이너 타입에 새로 도입된 index arithmetic으로, segment/offset 분해 과정의 off-by-one이 element access가 도달하는 메모리를 조용히 바꿔버리는 패턴.
Source/WTF/wtf/SegmentedVector.h에 새로 추가된SegmentedVectorGrowthPolicy::Doubling경로를 검토해야 합니다. 대상은segmentLocationFor(clz 기반segmentIndex/offset분해),sizeOfSegment, 재작성된ensureSegmentsFor(이제segmentLocationFor(size - 1).segmentIndex + 1형태이며size > 0을 필요로 함),m_segments.size()로 크기를 결정하는allocateSegment, 그리고 새로 추가된removeAllMatchingcompaction loop입니다. 이들을 inline-capacity variant와 대조해서 점검해야 합니다.addressAt이 이제 분해 이전에InlineCapacity를 먼저 빼기 때문입니다. 검증은 코드 검토만으로는 부족하며, 각 doubling step 주변의 경계 인덱스에 대해 index → segment → address 매핑을 property-testing 방식으로 확인해야 합니다. 버그의 tell은 계산된segmentIndex가, offset이 요구하는 크기보다 작은sizeOfSegment로 할당된 segment를 가리키는 경우입니다. Wider: 컨테이너가 template parameter 뒤에 두 번째 growth 혹은 layout policy를 갖게 될 때마다, 기존 layout을 가정하고 있던 모든 helper (entries(), capacity 계산, iterator,shrinkToFit)가 함께 업데이트되었는지 확인해야 합니다. 같은 헤더 안에 남아 있는SegmentSize기반 modulo 연산을 검색하는 것이 좋습니다. Widest: layout 가정이 여러 helper에 중복 존재하다가 일부에서만 갱신되는 패턴은 slab allocator, arena allocator, chunked buffer, rope/piece-table 구현 등에서 반복적으로 나타납니다. layout policy는 모든 accessor가 호출하는 단 하나의 decomposition 함수만을 가져야 합니다.