← All reports

[6] TypedArray constructor length read keyed on opaque-taint bit instead of Proxy identity

LowJavaScriptCore runtime TypedArray constructorsTypeConfusion

240fe1f

Low. The patch narrows which opaque-tainted slots take an observable [[Get]], and the supplied context shows no path from that difference to a size mismatch between allocation and fill. A memory-safety consequence would need the allocation length handed to createUninitialized to diverge from what the copy loop assumes.

Engine builtins that size a fresh buffer from an object's length must choose between a cheap side-effect-free lookup and a full observable property read. When new Float64Array(x) receives a non-iterable object, JSC looks up length with a PropertySlot (the stack record of a property lookup), and exotic objects such as Proxies and module namespaces can mark that slot as opaque-tainted, meaning the result cannot be trusted as a plain value. The expectation is that the recovery path chosen for a tainted slot is valid for every object kind that can produce the taint.

The angle: any script can build array-likes backed by a Proxy or a module namespace and steer which route JSC takes to compute a typed array's length; the open question is whether that steering can ever desync allocation from fill.

Don't use a module namespace length binding as a TypedArray length. VMInquiry taints both a Proxy and a module namespace. The earlier Proxy fix then read length for every tainted object, so a namespace export named length became the typed array's length. The patch reads length through a full [[Get]] only when the inquiry hit a Proxy, including one on the prototype chain. A module namespace keeps the inquiry result, which does not read the length binding.

Source/JavaScriptCore/runtime/JSGenericTypedArrayViewConstructorInlines.h

- if (lengthSlot.isTaintedByOpaqueObject()) {
- JSValue lengthValue = object->get(globalObject, vm.propertyNames->length);
- RETURN_IF_EXCEPTION(scope, nullptr);
- length = lengthValue.toLength(globalObject);
- RETURN_IF_EXCEPTION(scope, nullptr);
- } else if (lengthSlot.isUnset())
- length = 0;
- else {
- JSValue value = lengthSlot.getValue(globalObject, vm.propertyNames->length);
...
+ JSObject* lengthBase = lengthSlot.slotBase();
+ JSValue lengthValue = jsUndefined();
+ if (lengthSlot.isTaintedByOpaqueObject() && lengthBase && lengthBase->type() == ProxyObjectType)
+ lengthValue = object->get(globalObject, vm.propertyNames->length);
+ else if (!lengthSlot.isUnset())
+ lengthValue = lengthSlot.getValue(globalObject, vm.propertyNames->length);
+ RETURN_IF_EXCEPTION(scope, nullptr);
+ length = lengthValue.toLength(globalObject);
+ RETURN_IF_EXCEPTION(scope, nullptr);
}
 
ViewClass* result = ViewClass::createUninitialized(globalObject, structure, length);

JSTests/stress/typedarray-constructor-proxy-array-like.js

+let derived = Object.create(protoProxy);
+let fromDerived = new Float64Array(derived);
+if (protoLog.join(",") !== "Symbol(Symbol.iterator),length,0,1")
+ throw new Error("proto gets " + protoLog.join(","));
...
+let revokedDerived = Object.create(revoked.proxy);
+revokedDerived[Symbol.iterator] = undefined;
+revoked.revoke();
+shouldThrow(() => new Float64Array(revokedDerived), "TypeError");

The patch rewrites the array-like length computation in constructGenericTypedArrayViewWithArguments, the path taken when a TypedArray is built from a non-iterable object. Before the patch there were three branches: an opaque-tainted slot did a full object->get(globalObject, vm.propertyNames->length), an unset slot used length 0, and anything else called lengthSlot.getValue(...). Now the code reads lengthSlot.slotBase() first and performs the full [[Get]] only when the slot is opaque-tainted and the base is a non-null ProxyObjectType. Every other populated slot goes through getValue(...), and every unset slot keeps jsUndefined(), which toLength converts to 0. The three tails merge into one toLength call with a single exception check.

The net behavioral change is confined to opaque-tainted slots whose base is not a ProxyObject: a populated one uses its own value instead of a re-read, and an unset one yields length 0 instead of a full [[Get]]. The two new stress cases put a Proxy on the prototype chain. The first requires the trap log Symbol(Symbol.iterator),length,0,1 for Object.create(protoProxy), and the second requires TypeError for a revoked Proxy on the chain. Both guard the Proxy path the narrowed condition must keep, and neither exercises a non-Proxy tainted slot.

An overloaded opaque-result flag shared by heterogeneous object kinds is consumed as if it identified one kind.

Array-like construction. When new Float64Array(x) receives an object that is not an ArrayBuffer, a typed array or an iterable, the spec computes LengthOfArrayLike(x) (ToLength(Get(x, "length"))), allocates a typed array of that size, then reads x[0] through x[length-1] and stores each converted element.

PropertySlot and VMInquiry. PropertySlot is JSC's stack-allocated record of a property lookup. It holds the found value or accessor, the slotBase() object where the property was found (possibly an ancestor on the prototype chain), a cacheability flag and an m_isTaintedByOpaqueObject bit. InternalMethodType::VMInquiry is a lookup mode in which the engine is only poking around: the slot installs DisallowVMEntry, and getOwnPropertySlot implementations must not perform user-observable actions such as calling JS.

Opaque taint. Exotic objects whose lookups cannot be answered purely mark the slot with setIsTaintedByOpaqueObject(), and PropertySlot::getPureResult then reports jsNull() instead of a cached value.

Proxies and module namespaces. ProxyObject (JSType ProxyObjectType) forwards property operations to user-supplied handler traps. A get trap is user JS and runs only under a real [[Get]], and a revoked Proxy throws TypeError on any trap operation. JSModuleNamespaceObject exposes a module's exports as non-extensible own properties whose values live in the module environment's variable slots. Deferred namespaces, created by import defer, evaluate the underlying module synchronously on first non-inquiry property access via ensureDeferredNamespaceEvaluation.

This is a logic error: a mis-dispatch of the length-recovery path on an overloaded taint flag. The pre-fix consumer chose its recovery from the aggregate bit isTaintedByOpaqueObject() alone, discarding any tainted slot and replacing it with a full object->get(length). That recovery is correct for a Proxy, whose get trap is user JS and whose answer cannot be known without the observable [[Get]]; the prototype-chain tests confirm this is the behavior the fix keeps. The bit is not Proxy-specific, though. JSModuleNamespaceObject::getOwnPropertySlotCommon also calls slot.setIsTaintedByOpaqueObject(), so the old consumer applied Proxy-shaped recovery to every producer of the bit. The fix keys the full [[Get]] on the concrete identity of slotBase() instead.

  tainted length slot, by producer

  Before:  tainted ──► full object->get(length)         (Proxy and namespace alike)
           unset   ──► 0
           else    ──► slot.getValue(...)

  After:   tainted && slotBase is ProxyObject ──► full object->get(length)
           populated (incl. tainted non-Proxy) ──► slot.getValue(...)
           unset (incl. tainted non-Proxy)     ──► undefined ──► toLength ──► 0

For a module namespace, the commit message states the motivation: with the old logic an export named length was read as the typed array length, and the fixed code keeps the inquiry result, which does not read the length binding. What this changes observably is not settled by the supplied context. The non-inquiry branch of getOwnPropertySlotCommon fills the slot via setValueModuleNamespace(this, ..., value, ...) with the export binding's value, and a full [[Get]] on a namespace reads that same binding, which is what spec LengthOfArrayLike requires. A synchronous evaluation of a deferred namespace on a real [[Get]] is likewise expected behavior. It also cannot be determined whether lengthSlot is populated under VMInquiry, since its construction and the VMInquiry branch of getOwnPropertySlotCommon fall in the truncated part of the source. So which non-Proxy producer observably changes behavior, and what concrete harm the pre-fix full [[Get]] caused for it, remains open. No non-Proxy test was added.

Exploitability: a memory-safety consequence would require the allocation length passed to ViewClass::createUninitialized to diverge from what the element-copy loop assumes. That loop is outside the supplied context, and both new tests appear to pass under the pre-patch code too, since a Proxy-based tainted slot took the full [[Get]] path before and after. A secondary consequence of the narrowing is that unset-but-tainted slots from non-Proxy producers now resolve to length 0. If any such producer can leave the slot unset while the property exists, the length would be 0 where it was previously read.

The boundary at stake is internal: the contract between a PropertySlot lookup's taint signal and the native builtin that decides how to recover from it. No cross-origin or sandbox boundary is involved, and the likely consequence is a spec-consistency or observability difference in how the length is sourced.

Insight: a coarse "result is incomplete" bit tells the consumer that it must recover, but not how. Recovery should be keyed on the specific source of opacity, which is what the added prototype-chain tests check: that the narrower slotBase() key still catches a Proxy that is an ancestor rather than the receiver, including a revoked one.