[1] JSC inline caches: CustomAccessor slot base not reported to the collector
Two of four sibling access types never told the collector what they held.
High. 네 개의 형제 access type이 하나의 클래스와 하나의 barrier 필드를 공유하는데, 그중 두 개만 이 필드를 collector에 신고했습니다. 결과적으로 네 번째 case가 활성 JIT stub에 회수된 메모리를 가리키는 pointer를 그대로 넘기게 됩니다. Stale read를 넘어서는 확장 여부는 해제된 슬롯이 attacker가 형태를 조작한 객체로 재사용되는지에 달려 있습니다.
Garbage-collected 언어 런타임은 하나의 약속 위에 서 있습니다. 살아있는 데이터 구조가 수집된 객체를 가리키는 사용 가능한 pointer를 유지해서는 안 된다는 약속입니다. JavaScriptCore는 JIT로 컴파일된 property-access stub에 대해 이 약속을 enumeration hook으로 지킵니다. 각 캐시된 case는 요청이 오면 자신이 의존하는 모든 heap cell을 신고하는데, 이 stub들은 refcount로 소유되기 때문에 GC heap과 독립적으로 collection을 넘어 생존하기 때문입니다. GetterSetterAccessCase는 property가 native custom getter나 setter로 제공될 때 쓰이는 case 형태로, property가 실제로 발견된 객체를 barrier 필드에 기록합니다. 이때 기록된 객체는 enumeration이 이를 신고하는 동안에만 안전합니다.
관전 포인트: custom-accessor property를 읽는 스크립트가 마지막 참조를 끊고 collection을 강제로 유발하면, 살아있는 inline cache가 dangling cell pointer를 그대로 쥐고 있는 상태가 됩니다. 이는 native accessor에 type-confused slot base가 전달되는 상황의 출발점이 됩니다.
이번 commit은 CustomAccessorGetter/CustomAccessorSetter에 대해서도 customSlotBase를 추적하도록 만들어, 기존에 이미 처리되던 CustomValueGetter/CustomValueSetter와 동일한 방식으로 맞춥니다. 회귀 테스트 JSTests/stress/regress-172736082.js도 함께 추가되었습니다.
Source/JavaScriptCore/bytecode/AccessCase.cpp
JSTests/stress/regress-172736082.js
Patch Details
이번 변경은 AccessCase::forEachDependentCell 안의 switch 문에서 이루어진 단일 그룹 재배치입니다. CustomAccessorGetter와 CustomAccessorSetter는 원래 Load, LoadMegamorphic, StoreMegamorphic과 함께 묶인 큰 fall-through 그룹, 즉 dependent cell을 전혀 신고하지 않는 그룹에 속해 있었습니다. 패치는 이 두 라벨을 GetterSetterAccessCase로 downcast하여 accessor.customSlotBase()를 visiting functor에 전달하는 분기로 옮깁니다. 이로써 네 가지 Custom* access type 모두가 자신의 slot base를 enumerate하게 됩니다. 함께 추가된 회귀 테스트는 생성자 인스턴스의 __proto__를 반복 재할당해 poly-proto 전환을 유도하고, $vm.createCustomTestGetterSetter() 객체를 prototype chain에 배치한 뒤, x.customAccessor 읽기를 반복해 CustomAccessorGetter case를 포함한 stub이 컴파일될 때까지 가열합니다. 이후 마지막 JS 참조를 끊고 gc()를 강제로 실행한 다음, 새로운 custom-getter-setter 객체를 allocate하고 새 shape로 site를 재진입시킵니다.
Re-entrancy 경계를 넘은 뒤가 아니라, dependent reference enumeration 자체가 불완전한 패턴입니다. 캐시된 객체가 참조를 쥐고 있는데도 reachability scan이 이를 신고하지 않아, collector가 여전히 참조되고 있는 대상을 해제해 버립니다.
Background
이 코드가 있는 위치.
JSC는 property access의 결과를 site가 그동안 관측한 객체 shape에 특화된 작은 machine-code stub으로 캐싱합니다. 한 site가 여러 shape를 관측하게 되면 stub은 polymorphic해지고, shape 하나당 AccessCase 객체 하나로 구성된 목록으로 표현됩니다.
AccessCase와 access type.
각 캐시된 case는 type tag(Load, Getter, CustomValueGetter, CustomAccessorGetter 등)와 함께 structure, offset, 그리고 필요한 추가 cell들을 갖습니다. AccessCase는 ThreadSafeRefCounted이며 stub routine에서 refcount로 소유됩니다. PolymorphicAccessJITStubRoutine이 FixedVector<Ref<AccessCase>> m_cases를 갖고 있어서, 그 lifetime은 GC heap과 독립적으로 관리됩니다.
Custom accessor.
JSC와 WebCore는 native property를 PropertySlot custom getter/setter로 노출합니다. CustomValueGetter/CustomValueSetter는 slot base를 receiver로 삼아 native 함수를 호출하고, CustomAccessorGetter/CustomAccessorSetter는 실제 receiver를 그대로 넘겨 호출합니다. 이 네 가지 모두 GetterSetterAccessCase로 모델링되며, property가 발견된 객체는 WriteBarrier<JSObject> m_customSlotBase에 기록됩니다.
WriteBarrier<T>.
GC를 인식하는 필드 wrapper입니다. store 시점에 collector에 알림을 보내 generational barrier가 동작하도록 하지만, 그 자체로 대상을 root로 만드는 것은 아닙니다. 어떤 visiting routine이 이를 collector에 신고해야만 collection을 넘어 대상이 생존합니다.
forEachDependentCell.
캐시된 구조체가 자신이 의존하는 모든 heap cell을 신고하는 enumeration hook입니다. 같은 형태가 bytecode layer 전반에서 반복됩니다: ObjectPropertyCondition::forEachDependentCell, ObjectPropertyConditionSet::forEachDependentCell, CallLinkInfo::forEachDependentCell, PolymorphicCallStubRoutine::forEachDependentCell 등입니다.
Poly-proto.
생성자의 인스턴스들이 서로 다른 prototype을 갖게 되면, JSC는 해당 structure를 poly-proto 형태로 전환합니다. 이 형태에서는 prototype이 structure에 인코딩되는 대신 객체 필드에 저장됩니다. 이런 객체에 대한 access case는 일반 structure chain 대신 PolyProtoAccessChain을 기록합니다.
Stub 재생성.
Polymorphic site가 새로운 shape를 만나면, JSC는 기존 AccessCase 목록에 새 case를 더해 stub을 다시 발행하며, 이 과정에서 각 case에 기록된 상태를 컴파일 시점에 다시 읽습니다.
$vm.
VM 내부를 JS에 노출하는 테스트용 객체로, --useDollarVM=1 뒤에 게이트되어 있습니다. $vm.createCustomTestGetterSetter()는 customAccessor property가 native custom accessor로 제공되는 객체를 만들며, 테스트가 CustomAccessorGetter에 항상 동일하게 도달할 수 있게 해주는 수단입니다.
Analysis
이 버그는 하나의 switch 문 안에 존재하는 비대칭입니다. GetterSetterAccessCase는 자신의 slot base를 WriteBarrier<JSObject> m_customSlotBase에 저장하고, customSlotBase()는 이 barrier에서 raw JSObject*를 그대로 꺼내 줍니다. 두 Custom* 쌍 모두 customSlotBase 인자를 받는 동일한 GetterSetterAccessCase::create 오버로드로 생성됩니다. 실제로 해당 생성자 본문의 ASSERT는 CustomAccessorGetter와 CustomAccessorSetter를 명시적으로 허용하고 있습니다. 그런데도 liveness enumeration에는 CustomValue* 쌍만 등록되어 있었습니다.
Cell ownership vs. GC reachability
PolymorphicAccessJITStubRoutine
└─ FixedVector<Ref<AccessCase>> ← refcounted: survives GC independently
└─ GetterSetterAccessCase
└─ WriteBarrier<JSObject> m_customSlotBase ──┐
│ edge NOT declared
forEachDependentCell(functor) │ for CustomAccessor*
case CustomValueGetter/Setter ──► functor(slotBase) │
case CustomAccessorGetter/Setter ──► (nothing) ───────┘
▼
collector sees slot base unreachable
Case가 아무것도 신고하지 않았기 때문에, collector는 case가 여전히 slot base를 쥐고 있는 동안에도 자유롭게 이를 회수할 수 있었습니다. AccessCase 객체 자체는 cell과 함께 사라지지 않습니다. Stub routine에서 refcount로 소유되기 때문입니다. 결국 남는 것은 회수된 cell을 가리키는 pointer를 쥔 채 살아있는 JIT 데이터 구조입니다. 어떤 소비자가 이 enumeration을 실제로 읽는지(stub의 weak-clearing/validity 판단인지, dependent cell의 marking인지, 혹은 둘 다인지)는 제공된 AccessCase.cpp 발췌만으로는 확인되지 않습니다. visitWeak 이전에서 코드가 잘려 있기 때문입니다. "여기서 신고가 빠졌다"에서 "collector가 회수한다"로 이어지는 인과 관계는 이 지점에 의존합니다. Diff에서 직접 확인되는 사실은, 이 두 type에 대한 dependent-cell 선언이 이전에는 없었고 지금은 존재한다는 것뿐입니다. 이후 customSlotBase()를 읽는 어떤 소비자든(GetterSetterAccessCase::tryGetAlternateBaseImpl()은 정확히 이 pointer를 반환하며, stub 컴파일 시점에서 사용 가능합니다) 회수된 cell을 통해 읽게 될 것입니다.
회귀 테스트는 바로 이 window를 겨냥한 결정적 재현 코드입니다.
createPoly()는new f()를 allocate하고object.__proto__ = {}로 재할당하여f의 인스턴스들이 더 이상 하나의 prototype을 공유하지 않도록 만든 뒤, 새로운new f()를 반환합니다. 50회 반복을 통해f에 대한 JSC의 poly-proto 전환이 유도되며, 이후 이 객체들에 대한 access case는 structure에 인코딩된 prototype chain 대신PolyProtoAccessChain을 갖게 됩니다.obj.__proto__ = $vm.createCustomTestGetterSetter()로 custom-accessor 객체를 prototype chain에 배치합니다.--jitPolicyScale=0.001과--useConcurrentJIT=false하에서 1000회opt(obj)를 반복해 site를 가열하면CustomAccessorGettercase를 포함한 stub이 컴파일됩니다. 이 case는 prototype을m_customSlotBase에 기록합니다.obj = null; gc()로 receiver에 대한 유일한 JS-visible 참조, 즉 해당 prototype에 대한 참조가 끊어집니다. 패치 이전에는 아무도 이를 신고하지 않았으므로 collector가 이를 회수할 수 있었습니다.- 새로운
$vm.createCustomTestGetterSetter()로 같은 shape의 cell을 allocate하고,opt({})로 새 shape를 site에 투입해 살아남은AccessCase목록으로부터 case 목록의 재생성을 강제합니다.
Reachability는 $vm에 게이트되어 있지 않습니다. 취약한 상태는 custom accessor로 제공되는 property를 가진 임의 객체에 대한 평범한 property 읽기만으로도 도달 가능하므로, 일반 페이지 스크립트만으로도 이 cache를 만들어낼 수 있습니다. 테스트가 $vm을 사용하는 이유는 권한이 필요해서가 아니라 custom accessor를 항상 동일하게 얻기 위함입니다.
Stale reference를 넘어서는 확장은 세 가지 조건에 달려 있습니다. (a) 해제된 cell의 슬롯이 attacker가 형태를 조작한 객체로 재사용되는 경우입니다. 테스트의 두 번째 createCustomTestGetterSetter() 호출은 이런 의도와 일치하지만, 실제 slot 재사용 여부는 제공된 자료만으로는 확인되지 않으며 실제 페이지 환경이라면 일치하는 size class에 대한 heap grooming이 필요할 것입니다. (b) stale customSlotBase()가 재생성 과정이나 emit된 코드에서 사용되는 경우입니다. tryGetAlternateBaseImpl()이 정확히 이 pointer를 반환하지만, 제공된 context에는 재생성 경로가 이를 실제로 호출한다는 근거는 나타나 있지 않습니다. (c) 재사용한 객체의 필드가 소비자의 가정과 어긋나는 경우입니다. 이 세 조건이 모두 성립한다면, 기록된 m_customSlotBase와 재사용 객체의 실제 type 사이의 불일치가 native custom accessor 자신의 receiver 가정을 깨는 type-confusion primitive로 이어질 가능성이 있고, 이는 이후 relative read/write로 확장될 가능성도 있습니다. (a)~(c)가 성립하지 않는 경우라면, 관찰되는 영향은 회수된 heap 메모리에 대한 읽기와 예측하기 어려운 crash 정도에 그칠 것입니다.
이 vulnerability는 collector의 reachability invariant를 깨뜨려 WebContent process 내부의 메모리 안전성을 약화시킵니다. 살아있는 JIT inline cache가 참조하는 cell이 dependent로 신고되지 않았기 때문에, collector가 inline-cache 데이터 구조가 여전히 pointer로 쥐고 있는 객체를 회수할 수 있었습니다. GC 기반 언어는 객체의 identity와 lifetime을 스크립트에서 위조할 수 없도록 만들기 위해 바로 이 invariant에 의존합니다. Collection을 유발한 뒤 해제된 슬롯을 무엇이 재사용할지를 조작할 수 있는 attacker라면, stale-object read를 얻고 유리한 heap 조건 하에서는 native custom accessor에 잘못된 type의 slot base가 전달되는 상황까지 만들어낼 수 있습니다. 이는 renderer 내부에서 type confusion과 추가적인 memory-corruption primitive로 이어지는 기반이 됩니다.
Macro로 생성된 access-type 목록에 대한 넓고 평평한 switch 문은 이런 종류의 invariant를 유지하기에 구조적으로 취약한 자리입니다. type을 하나 추가할 때마다 이런 switch 전부를 손봐야 하는데, 컴파일러의 exhaustiveness check는 그 case가 목록에 있는지만 확인할 뿐, 올바른 그룹에 있는지는 확인해 주지 않습니다. 기본적으로 계속 커지는 no-dependent-cells 그룹이야말로 cell edge 하나가 누락되기 딱 좋은 자리입니다. 이 문제를 type 수준에서 제약하는 방법, 즉 손으로 관리하는 type 그룹핑이 아니라 case가 실제로 어떤 subclass를 instantiate하는지로부터 enumeration을 도출하는 방법을 쓴다면, 이번 사례뿐 아니라 이 종류의 버그 전체를 제거할 수 있을 것입니다.
Audit directions
- Reachability enumeration 바깥에 GC 참조를 저장하는 캐시 구조. 여기서 지켜야 할 invariant는 collectable 객체를 향한 모든 저장된 참조가 collector가 참조하는 정확히 하나의 enumeration에 나타나야 한다는 것입니다. 좁게 보면,
AccessCase.cpp와InlineCacheCompiler.cpp에 남아있는 access-type 목록에 대한 나머지 수기 switch 문들, 즉forEachDependentCell,visitWeak/propagateTransitions,doesCalls, 그리고AccessCase::create의 case 그룹핑을 점검할 필요가 있습니다. 각 access type에 대해 downcast 대상 subclass가 선언한 barrier-typed 멤버들과 실제로 신고되는 항목을 교차 검증해야 합니다. 매칭되는 사례는,as<...>AccessCase()로 downcast되는 subclass가WriteBarrier<...>멤버를 갖고 있는데도 해당 type의 분기가 이를 functor에 절대 넘기지 않는 모든 경우입니다. 넓게 보면, refcounted ownership과 GC 참조가 함께 쓰이는 JSC의 모든 구조체에서 같은 형태가 반복됩니다.ProxyableAccessCase,InstanceOfAccessCase::prototype(),ModuleNamespaceAccessCase,IntrinsicGetterAccessCase, 그리고 call 쪽의CallLinkInfo/PolymorphicCallStubRoutineenumeration들입니다.Ref/refcount lifetime과WriteBarrier멤버를 함께 갖는데 두 lifetime이 서로 분리되어 있는 클래스를 찾아보아야 합니다. 가장 넓게 보면, 객체 lifetime이 두 개의 독립적인 시스템(refcounting과 tracing GC)에 의해 관리될 때는 시스템 경계를 넘는 모든 edge에 명시적인 선언이 필요하며, 이런 edge 목록을 손으로 관리하면 반드시 어긋나게 됩니다. 이는 Blink/Oilpan의Visitor::Trace구현, SpiderMonkey의TraceEdgerooting, CPython C extension의tp_traverse에도 동일하게 적용됩니다. 코드 리뷰에서는, traced 또는 barriered 멤버를 선언해 놓고도 trace/visit 메서드가 그 멤버를 이름으로 언급하지 않는 클래스가 있다면 바로 그것이 단서입니다.
Sibling enum variants sharing an implementation class but split across switch branches. 컴파일러의 exhaustiveness check는 해당 variant가 존재한다는 사실만 증명할 뿐, 올바른 그룹에 속해 있다는 것까지 보장하지는 않습니다. AccessCase.cpp에서 case Custom, case Getter, case Setter, case ProxyObject, case InstanceOf를 모두 검색해야 합니다. 그리고 같은 create overload로 생성되는 variant들이 subclass 상태를 읽는 모든 switch에서 동일하게 묶여 있는지 확인해야 합니다. 이 variant 집합은 GetterSetterAccessCase.cpp와 그 형제 파일들의 ASSERT(type == A || type == B || type == C) 라인에 명시적으로 나타나 있습니다. Match tell: factory의 ASSERT가 N개의 type을 허용하는데, 다른 곳의 switch가 바로 그 N개의 type을 state-reading branch와 no-op branch로 갈라놓은 경우입니다. Wider: macro로 생성된 variant 목록을 여러 개의 수작업 dispatcher가 소비하는 곳이면 어디서든 같은 drift가 나타날 수 있습니다. JSC의 FOR_EACH_* bytecode/opcode macro, DFGAbstractInterpreterInlines.h의 DFG node-type switch, B3/Air opcode 처리가 여기에 해당합니다. break로 끝나는 긴 trailing case 묶음이 암묵적 default 역할을 하고 있는지 살펴봐야 합니다. Widest: exhaustive dispatch에서 누락에 의한 default 그룹핑은 조용한 correctness hole에 해당합니다. 안전한 형태는 보수적으로 over-report하며 fail-closed하는 default입니다. 이런 관점은 catch-all body를 공유하는 Rust match arm, IDL/protobuf tag에 대한 C switch dispatch, schema로 생성되는 visitor 전반에도 그대로 적용됩니다. 여기서의 단서는 variant가 추가될 때마다 계속 커지는, "여기서는 할 일 없음"이라는 의미의 큰 fall-through 그룹입니다.
Reachability edges only observable under specific GC timing. liveness 선언의 correctness는 happy path 실행만으로는 확인되지 않습니다. Collection이 공격자에게 유리한 시점에 실행되어야 검증할 수 있습니다. JSC의 기존 GC-stress 및 zombie-object verification mode가 inline-cache regeneration까지 실제로 exercise하는지 점검할 필요가 있습니다. --useGCStress와 eager-sweep 옵션을 켠 상태에서 custom-accessor site를 heat시키고, 실행 도중 receiver를 drop시키는 stress test를 돌려봐야 합니다. JSTests/stress/regress-172736082.js를 template 형태로 삼아, poly-proto 변환을 유도하고 site를 heat시킨 뒤 reference를 drop하고 gc()를 호출합니다. 이후 재할당하고 새로운 shape로 다시 진입시켜 regeneration을 강제로 유발해야 합니다. Match tell: 마지막 cache hit과 다음 regeneration 사이에 collection이 끼어들 때만 나타나는 crash 또는 heap-verifier 경고입니다. Wider: 이는 allocator timing 뒤에 숨어 있는 lifetime bug라는 일반적인 클래스에 해당합니다. 같은 test 형태를 CallLinkInfo/PolymorphicCallStubRoutine call IC와 watchpoint를 보유한 structure에도 동일하게 적용할 수 있습니다. Widest: traced-GC를 사용하는 어떤 runtime에서도 재사용 가능한 기법은 각 cache-mutation boundary마다 collection을 항상 동일하게 주입하는 것입니다. Oilpan의 conservative-GC stress mode와 SpiderMonkey의 zeal mode가 이 역할을 담당합니다. 이 부분의 검증에는 정적 grep이 아니라 runtime instrumentation이 필요합니다.