[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. Four sibling access types share one class and one barrier field, but only two of them declared that field to the collector — so the fourth-quarter case hands a live JIT stub a pointer into reclaimed memory. Escalation past the stale read is gated on the freed slot being reclaimed with an attacker-shaped object.
Garbage-collected language runtimes rest on one promise: no live data structure retains a usable pointer to a collected object. JavaScriptCore keeps that promise for JIT-compiled property-access stubs through an enumeration hook — each cached case declares, on request, every heap cell it depends on — because those stubs are owned by refcount and therefore survive collections independently of the GC heap. A GetterSetterAccessCase, the case shape used when a property is served by a native custom getter or setter, records the object the property was actually found on in a barrier field, and that recorded object is only safe as long as the enumeration reports it.
The angle: script that reads a custom-accessor property, drops its last reference, and forces a collection leaves a live inline cache holding a dangling cell pointer, which is the starting point for a type-confused slot base handed to a native accessor.
The commit tracks customSlotBase for CustomAccessorGetter/CustomAccessorSetter, matching the treatment CustomValueGetter/CustomValueSetter already had. It adds the regression test JSTests/stress/regress-172736082.js.
Source/JavaScriptCore/bytecode/AccessCase.cpp
JSTests/stress/regress-172736082.js
Patch Details
The change is a single regrouping inside the switch in AccessCase::forEachDependentCell. CustomAccessorGetter and CustomAccessorSetter were listed in the large fall-through group alongside Load, LoadMegamorphic, and StoreMegamorphic — the group that reports no dependent cells at all. The patch moves those two labels into the branch that downcasts to GetterSetterAccessCase and passes accessor.customSlotBase() to the visiting functor. All four Custom* access types now enumerate their slot base. The commit also adds the regression test, which repeatedly reassigns __proto__ on instances of a constructor to drive poly-proto conversion, puts a $vm.createCustomTestGetterSetter() object on the prototype chain, heats a x.customAccessor read until a stub containing a CustomAccessorGetter case is compiled, then drops the last JS reference, forces gc(), allocates a fresh custom-getter-setter object, and re-enters the site with a new shape.
Incomplete dependent-reference enumeration: a cached object holds a reference the reachability scan does not report, so the collector frees a still-referenced target.
Background
Where this lives.
JSC caches the result of a property access as a small machine-code stub specialised to the object shapes the site has seen. When a site observes several shapes the stub becomes polymorphic and is described by a list of AccessCase objects, one per shape.
AccessCase and access types.
Each cached case carries a type tag (Load, Getter, CustomValueGetter, CustomAccessorGetter, and so on) plus the structure, offset, and any extra cells the case needs. AccessCase is ThreadSafeRefCounted and is owned by refcount from the stub routine — PolymorphicAccessJITStubRoutine holds FixedVector<Ref<AccessCase>> m_cases — so its lifetime is governed independently of the GC heap.
Custom accessors.
JSC and WebCore expose native properties as PropertySlot custom getters and setters. CustomValueGetter/CustomValueSetter invoke the native function with the slot base as receiver; CustomAccessorGetter/CustomAccessorSetter invoke it with the actual receiver. All four are modelled by GetterSetterAccessCase, which records the object the property was found on in WriteBarrier<JSObject> m_customSlotBase.
WriteBarrier<T>.
A GC-aware field wrapper. It notifies the collector on stores so generational barriers work, but it does not by itself root the target — the target survives a collection only if some visiting routine reports it to the collector.
forEachDependentCell.
The enumeration hook by which a cached construct declares every heap cell it depends on. The same shape recurs throughout the bytecode layer: ObjectPropertyCondition::forEachDependentCell, ObjectPropertyConditionSet::forEachDependentCell, CallLinkInfo::forEachDependentCell, PolymorphicCallStubRoutine::forEachDependentCell.
Poly-proto.
When a constructor's instances end up with differing prototypes, JSC converts its structures to a poly-proto form where the prototype is stored in an object field rather than encoded in the structure. Access cases over such objects record a PolyProtoAccessChain instead of a plain structure chain.
Stub regeneration.
When a polymorphic site encounters a new shape, JSC re-emits the stub from the existing list of AccessCase objects plus the new one, re-reading each case's recorded state during compilation.
$vm.
A test object exposing VM internals to JS, gated behind --useDollarVM=1. $vm.createCustomTestGetterSetter() builds an object whose customAccessor property is served by a native custom accessor, which is how the test reaches CustomAccessorGetter deterministically.
Analysis
The bug is an asymmetry inside a single switch. GetterSetterAccessCase stores its slot base in WriteBarrier<JSObject> m_customSlotBase, and customSlotBase() hands out the raw JSObject* from that barrier. Both Custom* pairs are constructed through the very same GetterSetterAccessCase::create overloads that take a customSlotBase argument — the ASSERTs in those bodies explicitly admit CustomAccessorGetter and CustomAccessorSetter. Yet only the CustomValue* pair was listed in the liveness enumeration.
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
Because the case declared nothing, the collector was free to reclaim the slot base while the case retained it. The AccessCase object itself does not go away with the cell — it is owned by refcount from the stub routine — so what remains is a live JIT data structure holding a pointer into a reclaimed cell. Which consumers read this enumeration (the weak-clearing/validity decision for the stub, the marking of dependent cells, or both) is not established by the supplied AccessCase.cpp excerpt, which is truncated before visitWeak; the causal chain from "omitted here" to "collector reclaims" rests on that. What the diff establishes directly is that the dependent-cell declaration for these two types was absent and is now present. Any subsequent consumer of customSlotBase() — GetterSetterAccessCase::tryGetAlternateBaseImpl() returns exactly that pointer, and it is available to stub compilation — would then read through a reclaimed cell.
The regression test is a deterministic reproducer for exactly that window:
createPoly()allocatesnew f(), reassignsobject.__proto__ = {}sof's instances no longer share one prototype, and returns a freshnew f(). Fifty iterations drive JSC's poly-proto conversion forf, so later access cases over these objects carry aPolyProtoAccessChainrather than a structure-encoded prototype chain.obj.__proto__ = $vm.createCustomTestGetterSetter()puts a custom-accessor object on the prototype chain.- 1000 iterations of
opt(obj)under--jitPolicyScale=0.001and--useConcurrentJIT=falseheat the site until a stub containing aCustomAccessorGettercase is compiled; that case records the prototype inm_customSlotBase. obj = null; gc()drops the only JS-visible reference to the receiver and hence to that prototype. Pre-fix, nothing declared it, so the collector could reclaim it.- A fresh
$vm.createCustomTestGetterSetter()allocates a same-shaped cell, andopt({})feeds a new shape to the site, forcing regeneration of the case list from the survivingAccessCaseobjects.
Reachability is not gated on $vm: the vulnerable state is reached from ordinary property reads on any object whose property is served by a custom accessor, so plain page script can build the cache. The test uses $vm only to obtain a custom accessor deterministically, not because privilege is required.
Escalation past the stale reference is conditional on three things: (a) the freed cell's slot being reclaimed by an object the attacker shapes — the test's second createCustomTestGetterSetter() call is consistent with that intent, but actual slot reuse is not observable from the supplied material and in a real page would require heap grooming of the matching size class; (b) the stale customSlotBase() being consumed during regeneration or by emitted code — tryGetAlternateBaseImpl() returns exactly that pointer, though no supplied context shows the regeneration path invoking it; and (c) the reclaiming object's fields disagreeing with what the consumer assumes. If all three hold, the mismatch between the recorded m_customSlotBase and the reclaiming object's real type could give a type-confusion primitive against the native custom accessor's own receiver assumptions, which might then be widened into relative read/write. Absent (a)–(c), the observed effect would be a read of reclaimed heap memory with unpredictable crash behaviour.
This vulnerability weakens memory safety inside the WebContent process by breaking the collector's reachability invariant: a cell referenced by a live JIT inline cache was not reported as a dependent, so the collector could reclaim an object the inline-cache data structure still holds a pointer to. GC-managed languages rely on that invariant to make object identity and lifetimes unforgeable from script. An attacker who arranges the collection and then influences what allocation reuses the freed slot could obtain a stale-object read and, under favourable heap conditions, a mistyped slot base handed to a native custom accessor — a foundation for type confusion and further memory-corruption primitives in the renderer.
Wide flat switches over a macro-generated access-type list are a structurally hostile place to keep this kind of invariant: adding a type requires touching every such switch, and the compiler's exhaustiveness check confirms only that the case is listed, never that it is listed in the right group. A no-dependent-cells group that grows by default is exactly where a forgotten cell edge hides. Constraining this at the type level — deriving the enumeration from which subclass a case instantiates, rather than from a hand-maintained type grouping — would remove the whole class rather than this instance of it.
Audit directions
-
Cached structures that store GC references outside the reachability enumeration. The invariant is every stored reference to a collectable object must appear in exactly one enumeration the collector consults. Narrow: audit the remaining hand-written switches over the access-type list in
AccessCase.cppandInlineCacheCompiler.cpp—forEachDependentCell,visitWeak/propagateTransitions,doesCalls, and the case-grouping inAccessCase::create— and for each access type cross-check the subclass it downcasts to against the barrier-typed members that subclass declares. A match is any type whoseas<...>AccessCase()subclass has aWriteBarrier<...>member that the type's branch never passes to the functor. Wider: the same shape recurs in every JSC construct pairing refcounted ownership with GC references —ProxyableAccessCase,InstanceOfAccessCase::prototype(),ModuleNamespaceAccessCase,IntrinsicGetterAccessCase, and the call-sideCallLinkInfo/PolymorphicCallStubRoutineenumerations; look for any class holding both aRef/refcount lifetime and aWriteBarriermember where the two lifetimes are decoupled. Widest: when object lifetime is governed by two independent systems (refcounting and tracing GC), every cross-system edge needs an explicit declaration, and any hand-maintained list of those edges will drift — this applies to Blink/Oilpan'sVisitor::Traceimplementations, SpiderMonkey'sTraceEdgerooting, and CPython C extensions'tp_traverse. In code review, a class that declares a traced or barriered member but whose trace/visit method never mentions that member by name is the tell. -
Sibling enum variants sharing an implementation class but split across switch branches. The compiler's exhaustiveness check proves the variant is present, never that it is in the correct group. Grep
AccessCase.cppfor everycase Custom,case Getter,case Setter,case ProxyObject, andcase InstanceOfand confirm that variants constructed by the samecreateoverload — theASSERT(type == A || type == B || type == C)lines inGetterSetterAccessCase.cppand its siblings name these sets explicitly — are grouped identically in every switch that reads subclass state. Match tell: anASSERTin a factory that admits N types where some switch elsewhere splits those same N types across a state-reading branch and a no-op branch. Wider: the same drift appears wherever a macro-generated variant list is consumed by multiple hand-written dispatchers — JSC'sFOR_EACH_*bytecode/opcode macros, DFG node-type switches inDFGAbstractInterpreterInlines.h, B3/Air opcode handling; look for a large trailingcaserun ending inbreakthat acts as the implicit default. Widest: default-by-omission groupings in exhaustive dispatch are silent correctness holes — the safe shape is a default that fails closed by conservatively over-reporting. Carry this into Rustmatcharms sharing a catch-all body, C switch dispatch over IDL/protobuf tags, and any schema-generated visitor; the tell there is a large fall-through group whose semantic is "nothing to do here" that grows every time a variant is added. -
Reachability edges only observable under specific GC timing. Correctness of a liveness declaration cannot be established by executing the happy path — it needs collection to run at an adversarial moment. Investigate whether JSC's existing GC-stress and zombie-object verification modes exercise inline-cache regeneration: run stress tests that heat a custom-accessor site under
--useGCStressand eager-sweep options with the receiver dropped mid-run, usingJSTests/stress/regress-172736082.jsas the template shape — drive poly-proto conversion, heat the site, drop the reference,gc(), reallocate, re-enter with a new shape to force regeneration. Match tell: any crash or heap-verifier complaint appearing only when a collection is interposed between the last cache hit and the next regeneration. Wider: this is the general lifetime-bug-hidden-behind-allocator-timing class — the same test shape applies toCallLinkInfo/PolymorphicCallStubRoutinecall ICs and to watchpoint-holding structures. Widest: in any traced-GC runtime the reusable technique is deterministic collection injection at each cache-mutation boundary, the role Oilpan's conservative-GC stress mode and SpiderMonkey's zeal modes serve; verification here needs runtime instrumentation rather than static grep.