← All reports

[1] JSC inline caches: CustomAccessor slot base not reported to the collector

HighJSC inline caches / PolymorphicAccessUAF

Two of four sibling access types never told the collector what they held.

055680a

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

void AccessCase::forEachDependentCell(VM&, const Functor& functor) const
...
case CustomValueGetter:
- case CustomValueSetter: {
+ case CustomValueSetter:
+ case CustomAccessorGetter:
+ case CustomAccessorSetter: {
auto& accessor = this->as<GetterSetterAccessCase>();
if (accessor.customSlotBase())
functor(accessor.customSlotBase());
break;
}
...
- case CustomAccessorGetter:
- case CustomAccessorSetter:
case Load:
case LoadMegamorphic:
case StoreMegamorphic:

JSTests/stress/regress-172736082.js

+//@ runDefault("--useDollarVM=1", "--useConcurrentJIT=false", "--jitPolicyScale=0.001")
+function main() {
+ function createPoly() {
+ function f() {}
+ const object = new f();
+ object.__proto__ = {}; // force f's instances onto differing prototypes
+ return new f();
+ }
+ $vm.createCustomTestGetterSetter();
+ for (let i = 0; i < 50; i++) createPoly(); // drive poly-proto conversion for f
+
+ let obj = createPoly();
+ obj.__proto__ = $vm.createCustomTestGetterSetter();
+
+ function opt(x) {
+ return [x.customAccessor, Math.random(), ...];
+ }
+ for (let i = 0; i < 1000; i++) opt(obj); // compile a CustomAccessorGetter case
+
+ obj = null;
+ gc(); // slot base collected: nothing reported it
+ $vm.createCustomTestGetterSetter(); // fresh allocation of the same shape
+ for (let i = 0; i < 10; i++) opt({}); // feed a new shape, re-enter the site
+}
+main();

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.

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.

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:

  1. createPoly() allocates new f(), reassigns object.__proto__ = {} so f's instances no longer share one prototype, and returns a fresh new f(). Fifty iterations drive JSC's poly-proto conversion for f, so later access cases over these objects carry a PolyProtoAccessChain rather than a structure-encoded prototype chain.
  2. obj.__proto__ = $vm.createCustomTestGetterSetter() puts a custom-accessor object on the prototype chain.
  3. 1000 iterations of opt(obj) under --jitPolicyScale=0.001 and --useConcurrentJIT=false heat the site until a stub containing a CustomAccessorGetter case is compiled; that case records the prototype in m_customSlotBase.
  4. 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.
  5. A fresh $vm.createCustomTestGetterSetter() allocates a same-shaped cell, and opt({}) feeds a new shape to the site, forcing regeneration of the case list from the surviving AccessCase objects.

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.