← All reports

[JSC] TypedArray [[Set]] should check receiver before writing to typed array

CVE: CVE-2026-84635 · Safari 27 · Released September 14, 2026 Impact: Processing maliciously crafted web content may lead to an unexpected process termination Apple's description: A logic issue was addressed with improved state management. Credit: Souta Sugiyama

Severity: Low | Component: JSC runtime object model | 9654ed7 | Bugzilla 310457

Low — this is a conformance fix, and the divergence stays inside JavaScript-visible semantics. What keeps it interesting: the write landed on the wrong object and user callbacks ran at points the specification declares unreachable, which is precisely the shape that breaks in-page confinement layers built on receiver substitution.

Every property store in JavaScript carries two objects, not one: the object whose write hook runs, and the object that is supposed to end up holding the property. The specification calls the second one the Receiver, and Reflect.set(target, key, value, receiver) hands it to you as a fourth argument. Typed arrays override [[Set]] so numeric keys address bytes in an ArrayBuffer rather than ordinary property slots — and an override that forgets the Receiver writes into its own storage while the caller believes the write was redirected somewhere else.

The angle: A script can steer an indexed write that was supposed to land on a substitute object into a typed array's backing buffer instead, and make valueOf and prototype setters run at points where the language guarantees they will not.

Source/JavaScriptCore/runtime/JSGenericTypedArrayViewInlines.h

bool JSGenericTypedArrayView<Adaptor>::put(...)
{
JSGenericTypedArrayView* thisObject = jsCast<JSGenericTypedArrayView*>(cell);
 
- if (std::optional<uint32_t> index = parseIndex(propertyName))
+ // https://tc39.es/ecma262/#sec-typedarray-set
+ if (std::optional<uint32_t> index = parseIndex(propertyName)) {
+ if (isThisValueAltered(slot, thisObject)) [[unlikely]] {
+ if (thisObject->isDetached() || !thisObject->inBounds(index.value()))
+ return true;
+ return ordinarySetSlow(globalObject, thisObject, propertyName, value, slot.thisValue(), slot.isStrictMode());
+ }
return putByIndex(thisObject, globalObject, index.value(), value, slot.isStrictMode());
+ }
 
if (isCanonicalNumericIndexString(propertyName.uid())) {
+ if (isThisValueAltered(slot, thisObject)) [[unlikely]]
+ return true;
// Cases like '-0', '1.1', etc. are still obliged to give the RHS a chance to throw.
toNativeFromValue<Adaptor>(globalObject, value);
return true;

Source/JavaScriptCore/runtime/JSObject.cpp

bool ordinarySetWithOwnDescriptor(JSGlobalObject* globalObject, JSObject* object, ...)
RELEASE_AND_RETURN(scope, proxy->ProxyObject::put(proxy, globalObject, propertyName, value, slot));
}
 
+ if (current != object && isTypedArrayType(current->type())) {
+ PutPropertySlot slot(receiver, shouldThrow);
+ RELEASE_AND_RETURN(scope, current->methodTable()->put(current, globalObject, propertyName, value, slot));
+ }
+
// 9.1.9.1-2 Let ownDesc be ? O.[[GetOwnProperty]](P).
...
bool JSObject::attemptToInterceptPutByIndexOnHoleForPrototype(...)
putResult = proxy->putByIndexCommon(globalObject, thisValue, i, value, shouldThrow);
return true;
}
+
+ if (isTypedArrayType(current->type())) {
+ auto* typedArray = jsCast<JSArrayBufferView*>(current);
+ if (typedArray->isOutOfBounds() || i >= typedArray->length()) {
+ putResult = true;
+ return true;
+ }
+ return false;
+ }
+
JSValue prototypeValue = current->getPrototype(globalObject);

JSTests/stress/reflect-set.js

var object = new TypedArray(64);
var receiver = {};
- // The receiver is ignored when the property name is an indexed one.
- // shouldBe(Reflect.set(object, 0, 42, receiver), true);
+ // https://github.com/tc39/ecma262/pull/1556
shouldBe(Reflect.set(object, 0, 42, receiver), true);
- shouldBe(Reflect.get(object, 0), 42);
- shouldBe(receiver.hasOwnProperty(0), false);
+ shouldBe(Reflect.get(object, 0), 0);
+ shouldBe(receiver.hasOwnProperty(0), true);
+ shouldBe(receiver[0], 42);

Three production changes implement the ES2021 receiver rule for the TypedArray [[Set]] internal method, landed as tc39/ecma262 PR #1556.

In JSGenericTypedArrayViewInlines.h, JSGenericTypedArrayView<Adaptor>::put now consults isThisValueAltered(slot, thisObject) on both numeric paths. For a key that parseIndex accepts, an altered receiver splits two ways: if thisObject->isDetached() || !thisObject->inBounds(index.value()), the function returns true immediately — the write is reported as successful and nothing happens anywhere; otherwise it hands off to ordinarySetSlow(globalObject, thisObject, propertyName, value, slot.thisValue(), slot.isStrictMode()) instead of calling putByIndex(thisObject, ...), so the value lands on the receiver rather than in the buffer. For a key that is canonical-numeric but not a parseable index ('-0', '1.1', 'NaN'), an altered receiver returns true before the toNativeFromValue<Adaptor>(globalObject, value) coercion rather than after it. The ordering matters as much as the result: toNativeFromValue calls user-supplied valueOf for an object operand.

In JSObject.cpp, two generic walks learn that a typed array sitting in a prototype chain is an exotic object with its own [[Set]]. ordinarySetWithOwnDescriptor gains a branch immediately after the existing ProxyObject special case: when current != object && isTypedArrayType(current->type()), it constructs a fresh PutPropertySlot slot(receiver, shouldThrow) and delegates to current->methodTable()->put(...), preserving the original receiver across the hand-off. JSObject::attemptToInterceptPutByIndexOnHoleForPrototype gains the matching branch: on reaching a typed array it jsCasts to JSArrayBufferView* and, if typedArray->isOutOfBounds() || i >= typedArray->length(), sets putResult = true and returns true — swallowing the store — otherwise returns false so the caller writes into the receiver's own indexed storage. Either way the walk terminates at the typed array instead of continuing to whatever prototype comes next.

The test collateral tracks the behavioural delta precisely. JSTests/stress/reflect-set.js inverts its assertions — Reflect.get(object, 0) now yields 0 and receiver.hasOwnProperty(0) now yields true — and thirteen test/built-ins/TypedArrayConstructors/internals/Set/* entries plus test/staging/sm/TypedArray/set-with-receiver.js drop out of JSTests/test262/expectations.yaml.

The [[Set]] protocol and its receiver. In the ECMAScript object model, the write operation is O.[[Set]](P, V, Receiver): O is the object whose write hook executes, and Receiver is, separately, the object that should end up holding the property. Reflect.set(target, key, value, receiver) exposes that fourth argument directly to script. An ordinary assignment obj[k] = v starts with Receiver === obj and carries that same receiver unchanged as the algorithm walks up obj's prototype chain.

OrdinarySet and the delegation rule. OrdinarySetWithOwnDescriptor is the specification algorithm behind the default [[Set]]. When the property is not an own property of O, it recurses into the prototype; when it eventually finds a writable data property, it completes the write by creating or updating an own property on Receiver. Never on the prototype where the property was found. That asymmetry — search the chain, write to the receiver — is the whole contract.

Integer-indexed exotic objects. Typed arrays override [[Set]], [[Get]], [[DefineOwnProperty]] and their siblings so that numeric keys address elements of the backing ArrayBuffer rather than ordinary property slots. Two spec predicates govern which numeric keys count. CanonicalNumericIndexString is a property key that survives a ToNumber/ToString round trip — '0', '-0', '1.1', 'NaN' all qualify, though '-0' and '1.1' can never name a real element. IsValidIntegerIndex is the narrower predicate: an in-range integer index on a buffer that is neither detached nor, for a resizable buffer, out of bounds. JSC's counterparts in this code are isDetached(), inBounds(index), isOutOfBounds() and length().

JSC's carriers for the protocol. A PutPropertySlot is the object threaded through a property store; it holds the receiver as slot.thisValue(), the strictness flag as slot.isStrictMode(), and inline-cache bookkeeping. isThisValueAltered(slot, baseObject) returns true when the slot's receiver is not the object whose put is currently running — the engine's standard test for "this [[Set]] was forwarded from somewhere else". ordinarySetSlow is JSC's non-fast-path implementation of the spec's OrdinarySet, and it drives ordinarySetWithOwnDescriptor. toNativeFromValue<Adaptor> converts a JSValue into a typed array's element type, and for an object operand that means calling user-defined valueOf/toString.

The hole-interception walk. attemptToInterceptPutByIndexOnHoleForPrototype is the prototype-chain walk JSC runs when an indexed store lands on a hole in the receiver's own indexed storage. Its job is to discover a prototype that wants to intercept the store — a setter, a ProxyObject, a custom index handler. Returning false means "nobody intercepted", and the caller then writes the value into the receiver's own indexed storage.

Prior art in the same file. Bug 217916 applied this same receiver rule to non-index property names on typed arrays. The commit message frames this patch explicitly as the indexed-key follow-up to that work.

Root cause: a specialized write hook treated the property key as the only input that mattered and never looked at who the write was actually for.

  Reflect.set(ta, 0, 42, receiver)      ta = new Uint8Array(64)

  Before:                              After:
  ta.[[Set]](0, 42, receiver)          ta.[[Set]](0, 42, receiver)
    parseIndex("0") -> 0                 parseIndex("0") -> 0
    └─► putByIndex(thisObject, 0, 42)    isThisValueAltered? yes
          │                              ├─ !inBounds/detached ─► return true
          ▼                              └─► ordinarySetSlow(..., receiver)
    ta.buffer[0] = 42                          │
    receiver: untouched                        ▼
                                         receiver[0] = 42
                                         ta.buffer[0]: untouched

Before the fix, JSGenericTypedArrayView<Adaptor>::put read any numeric property name as an unconditional instruction to act on itself. For a parsed index it went straight to putByIndex(thisObject, ...); for a canonical-numeric-but-invalid name it ran toNativeFromValue<Adaptor> unconditionally. The slot's receiver — the fourth argument of Reflect.set, or the object that originally received an assignment when the typed array sits on its prototype chain — was never consulted at all. The invariant that goes missing is the one the ordinary [[Set]] path honours everywhere else, the one Background names as the whole contract: a data-property write discovered on a prototype is completed on the receiver, not on the holder.

Complementing that, the two generic walks in JSObject.cpp had no notion that a typed array in the middle of a prototype chain is an exotic object with its own [[Set]]. ordinarySetWithOwnDescriptor special-cased ProxyObject and nothing else; attemptToInterceptPutByIndexOnHoleForPrototype likewise walked straight past a typed array to whatever prototype came next. Three concrete divergences follow, and each one is pinned by a test expectation the patch deletes.

The first is the direct-forward case, and the old reflect-set.js records it verbatim. Reflect.set(ta, 0, 42, {}) wrote 42 into ta's backing store and created nothing on the receiver — the removed assertions Reflect.get(object, 0) === 42 and receiver.hasOwnProperty(0) === false are exactly that behaviour asserted as correct, and the patch inverts both. The left column of the diagram above is that old code path: putByIndex(thisObject, ...) with the receiver dropped on the floor.

The second is the prototype-chain case, and it is the one that runs script. With a typed array on an object's prototype chain, an indexed assignment on the receiver reached attemptToInterceptPutByIndexOnHoleForPrototype, which walked past the typed array and could land on a setter defined further up the chain. The removed expectation strings say so in plain terms:

key-is-valid-index-prototype-chain-set.js:
  'Test262Error: 0 setter should be unreachable! (Testing with Float64Array ...)'
key-is-canonical-invalid-index-prototype-chain-set.js:
  'Test262Error: 1 setter should be unreachable! (Testing with Float64Array ...)'

A test262 case asserting a setter is unreachable, failing because the setter ran, is a direct statement that pre-fix JSC executed user JavaScript at a point the specification forbids. The new branch stops the walk at the typed array: out-of-bounds or detached swallows the store with putResult = true, in-bounds returns false so the caller completes the write on the receiver. In neither case does the walk continue upward, so the setter above is never reached.

The third divergence is an ordering bug rather than a destination bug. For an invalid index with a foreign receiver, the toNativeFromValue coercion ran before any receiver check, invoking valueOf where the spec requires an immediate return true. Three deleted expectations — key-is-in-bounds-receiver-is-not-typed-array.js, key-is-out-of-bounds-receiver-is-not-object.js, key-is-out-of-bounds-receiver-is-not-typed-array.js — all carry the same message, 'valueOf is not called Expected SameValue(«1», «0») to be true'. The fix moves the isThisValueAltered check above the coercion, which is why that branch in the diff returns true before toNativeFromValue rather than after.

What this does and does not buy an attacker is worth being precise about. The boundary weakened is the JavaScript language-semantics boundary inside the engine, not a process or same-origin boundary. No memory-safety violation is visible: putByIndex funnels into the typed-array setIndex idiom that re-checks isDetached() and bounds after coercion, so the pre-fix path did not produce an out-of-bounds store. What a script gained was the ability to redirect an indexed write into a typed array's element storage when the receiver was supposed to absorb it — including a view over a SharedArrayBuffer observable to other agents — to suppress creation of the expected own property on the receiver, and to force valueOf and prototype setters to execute inside a window the specification declares unreachable. For in-page confinement layers built on receiver substitution — membranes, realm shims, virtualization proxies that forward writes via Reflect.set with a shadow target — an attacker who could steer a forwarded indexed write would have reached the real typed array rather than the shadow, which at the model level is a confined-write escape at the language layer.

The fix itself introduces one new re-entry point worth tracking. The ordinarySetWithOwnDescriptor branch calls back out through current->methodTable()->put(...) from the middle of the prototype-chain loop, with a freshly constructed PutPropertySlot slot(receiver, shouldThrow) shadowing the caller's slot. The callee set is bounded today by the isTypedArrayType(current->type()) guard, so the only reachable target is JSGenericTypedArrayView::put, which for an altered receiver re-enters ordinarySetSlow on the typed array. Two invariants are assumed rather than enforced at this site: that prototype chains are acyclic, which setPrototypeOf enforces elsewhere, and that they are short enough that one nested ordinarySetWithOwnDescriptor → put → ordinarySetSlow frame group per typed array stays inside the native stack budget. Script controls chain length through repeated Object.setPrototypeOf, so if the second assumption does not hold the class of issue that surfaces is native stack exhaustion rather than memory corruption — and only if the cycle lacks a vm.isSafeToRecurseSoft() guard, which is the last audit direction below. The shadowed slot also intentionally discards the outer slot's inline-cache context, which is the conservative choice for a cross-object delegation.

A typed array's write hook acted on itself for every numeric key, so a forwarded indexed write mutated the buffer instead of the receiver and ran user callbacks the spec had already ruled out.

JSC's generic prototype-chain walks maintain an explicit allow-list of exotic objects that must terminate the walk, and before this patch that list was exactly one entry long: ProxyObjectType. Both ordinarySetWithOwnDescriptor and attemptToInterceptPutByIndexOnHoleForPrototype handled proxies and then fell through to the ordinary path for everything else. Any object with a non-ordinary [[Set]] has to be added to both loops by hand, which makes this a list-maintenance hazard rather than a one-off bug: the correctness of the walk depends on a human remembering to extend two switch-like chains whenever a new exotic type appears. The second pattern worth noting is the shape of the spec-alignment work itself — tc39/ecma262 PR #1556 changed one algorithm, but engines have landed it piecemeal by key category, with WebKit's Bug 217916 covering non-index names years before this commit covered indices. When a conformance fix is split by key type, the un-landed half is a reliable place to look for divergence in other engines too.