RESOLVED FIXED312672
Array ToPrimitive Fast Path Ignores Object.prototype.valueOf Override
https://bugs.webkit.org/show_bug.cgi?id=312672
Summary Array ToPrimitive Fast Path Ignores Object.prototype.valueOf Override
parkjuny
Reported 2026-04-18 02:27:28 PDT
Created attachment 479192 [details] poc.js ## Summary `JSObject::toPrimitive` has a fast path for arrays that calls `fastToString()` directly, bypassing `OrdinaryToPrimitive`. The guard `isToPrimitiveFastAndNonObservable()` checks four watchpoints, none of which detect reassignment of `Object.prototype.valueOf`. The bug is a runtime C++ fast-path issue, uniform across all JIT tiers. The discrepancy is between arrays with original structure (fast path taken, wrong result) and arrays with a modified prototype (fast path skipped, correct result). ## Bug ### Summary When `isToPrimitiveFastAndNonObservable()` returns true, `JSObject::toPrimitive` (JSObject.cpp:2601–2604) calls `fastToString()` and returns early, ignoring `preferredType`. Replacing `Object.prototype.valueOf` with a function that returns a primitive does not cause any JSC structure transition (it is a value-slot update on a pre-existing writable property), so none of the four watchpoints fire. The fast path then incorrectly returns a join string instead of the `valueOf` result. ### Detail **Fast path (JSObject.cpp:2596–2604):** ```cpp JSValue JSObject::toPrimitive(JSGlobalObject* globalObject, PreferredPrimitiveType preferredType) const { VM& vm = globalObject->vm(); auto scope = DECLARE_THROW_SCOPE(vm); if (isJSArray(this)) { auto* array = jsCast<JSArray*>(const_cast<JSObject*>(this)); if (array->isToPrimitiveFastAndNonObservable()) [[likely]] RELEASE_AND_RETURN(scope, array->fastToString(globalObject)); } // falls through to ordinaryToPrimitive } ``` **Guard function (JSArray.cpp:1999–2013):** ```cpp bool JSArray::isToPrimitiveFastAndNonObservable() { JSGlobalObject* globalObject = this->realm(); if (!globalObject->arrayPrototypeChainIsSane()) [[unlikely]] return false; if (!globalObject->arrayToStringWatchpointSet().isStillValid()) [[unlikely]] return false; if (!globalObject->arraySymbolToPrimitiveWatchpointSet().isStillValid()) [[unlikely]] return false; if (!globalObject->arrayJoinWatchpointSet().isStillValid()) [[unlikely]] return false; Structure* structure = this->structure(); return globalObject->isOriginalArrayStructure(structure); } ``` Why `Object.prototype.valueOf = function() { return 42; }` is not detected: 1. **`arrayPrototypeChainIsSane`** — `ObjectAdaptiveStructureWatchpoint` installed via `ObjectPropertyCondition::absenceOfIndexedProperties` (JSGlobalObject.cpp:3296). Fires only on structure transitions (property additions/deletions). Reassigning an existing writable property's value is a value-slot update with no structure transition. 2. **`arrayToStringWatchpointSet`** — Watches `Array.prototype.toString` only (JSGlobalObject.cpp:2245). 3. **`arraySymbolToPrimitiveWatchpointSet`** — Watches for absence of `Symbol.toPrimitive` on `Array.prototype` / `Object.prototype` (JSGlobalObject.cpp:2275–2276). Unrelated to `valueOf`. 4. **`arrayJoinWatchpointSet`** — Watches `Array.prototype.join` only (JSGlobalObject.cpp:2244). JSC has `m_stringValueOfWatchpointSet` for `String.prototype.valueOf` (JSGlobalObject.cpp:2251), but no equivalent for `Object.prototype.valueOf`. ### Trigger Conditions 1. `Object.prototype.valueOf` is overridden to return a primitive. 2. Array has original structure (unmodified prototype chain). 3. An operation invokes ToPrimitive on the array (e.g., `+`, `==`, `<`, `-`). ## Version ### Reproduced Version - `main` branch latest commit (2026/04/18): `a4390137a403` ## Reproduction Case ### Release Build ```bash jsc poc.js ``` Result: ``` 1,2,3 42 ``` `arr` (fast path) produces `"1,2,3"` instead of `"42"`; `slowArr` (slow path) correctly returns `"42"`. ### PoC Code ```js Object.prototype.valueOf = function() { return 42; }; // arr: original structure → isToPrimitiveFastAndNonObservable() = true → fast path var arr = [1, 2, 3]; // slowArr: modified prototype → fast path skipped → ordinaryToPrimitive (correct) var slowArr = [1, 2, 3]; slowArr.__proto__ = Object.create(Array.prototype); print("" + arr); // 1,2,3 (bug: fast path ignores valueOf, should be "42") print("" + slowArr); // 42 (correct: slow path calls valueOf first) ``` ## Suggested Patch Add a watchpoint for `Object.prototype.valueOf`, mirroring `m_stringValueOfWatchpointSet`, and check it in `isToPrimitiveFastAndNonObservable()`. ```diff diff --git a/Source/JavaScriptCore/runtime/JSArray.cpp b/Source/JavaScriptCore/runtime/JSArray.cpp index 3bb3ce58fdf6..90f0a615d787 100644 --- a/Source/JavaScriptCore/runtime/JSArray.cpp +++ b/Source/JavaScriptCore/runtime/JSArray.cpp @@ -2007,6 +2007,8 @@ bool JSArray::isToPrimitiveFastAndNonObservable() return false; if (!globalObject->arrayJoinWatchpointSet().isStillValid()) [[unlikely]] return false; + if (!globalObject->objectPrototypeValueOfWatchpointSet().isStillValid()) [[unlikely]] + return false; Structure* structure = this->structure(); return globalObject->isOriginalArrayStructure(structure); diff --git a/Source/JavaScriptCore/runtime/JSGlobalObject.cpp b/Source/JavaScriptCore/runtime/JSGlobalObject.cpp index 160a40e9f15f..59c57c9d3888 100644 --- a/Source/JavaScriptCore/runtime/JSGlobalObject.cpp +++ b/Source/JavaScriptCore/runtime/JSGlobalObject.cpp @@ -2249,6 +2249,7 @@ capitalName ## Constructor* lowerName ## Constructor = featureFlag ? capitalName installObjectPropertyChangeAdaptiveWatchpoint(setupAdaptiveWatchpoint(this, m_stringPrototype.get(), vm.propertyNames->iteratorSymbol), m_stringIteratorProtocolWatchpointSet); installObjectPropertyChangeAdaptiveWatchpoint(setupAdaptiveWatchpoint(this, m_stringPrototype.get(), vm.propertyNames->toString), m_stringToStringWatchpointSet); installObjectPropertyChangeAdaptiveWatchpoint(setupAdaptiveWatchpoint(this, m_stringPrototype.get(), vm.propertyNames->valueOf), m_stringValueOfWatchpointSet); + installObjectPropertyChangeAdaptiveWatchpoint(setupAdaptiveWatchpoint(this, m_objectPrototype.get(), vm.propertyNames->valueOf), m_objectPrototypeValueOfWatchpointSet); installObjectPropertyChangeAdaptiveWatchpoint(setupAdaptiveWatchpoint(this, m_regExpPrototype.get(), vm.propertyNames->exec), m_regExpPrimordialPropertiesWatchpointSet); installObjectPropertyChangeAdaptiveWatchpoint(setupAdaptiveWatchpoint(this, m_regExpPrototype.get(), vm.propertyNames->flags), m_regExpPrimordialPropertiesWatchpointSet); installObjectPropertyChangeAdaptiveWatchpoint(setupAdaptiveWatchpoint(this, m_regExpPrototype.get(), vm.propertyNames->dotAll), m_regExpPrimordialPropertiesWatchpointSet); diff --git a/Source/JavaScriptCore/runtime/JSGlobalObject.h b/Source/JavaScriptCore/runtime/JSGlobalObject.h index 87ad406bd9a0..77c5dbba3848 100644 --- a/Source/JavaScriptCore/runtime/JSGlobalObject.h +++ b/Source/JavaScriptCore/runtime/JSGlobalObject.h @@ -530,6 +530,7 @@ public: InlineWatchpointSet m_numberToStringWatchpointSet { IsWatched }; InlineWatchpointSet m_stringToStringWatchpointSet { IsWatched }; InlineWatchpointSet m_stringValueOfWatchpointSet { IsWatched }; + InlineWatchpointSet m_objectPrototypeValueOfWatchpointSet { IsWatched }; InlineWatchpointSet m_structureCacheClearedWatchpointSet { IsWatched }; InlineWatchpointSet m_arrayBufferSpeciesWatchpointSet { ClearWatchpoint }; InlineWatchpointSet m_sharedArrayBufferSpeciesWatchpointSet { ClearWatchpoint }; @@ -584,6 +585,7 @@ public: InlineWatchpointSet& arraySymbolToPrimitiveWatchpointSet() LIFETIME_BOUND { return m_arraySymbolToPrimitiveWatchpointSet; } InlineWatchpointSet& stringToStringWatchpointSet() LIFETIME_BOUND { return m_stringToStringWatchpointSet; } InlineWatchpointSet& stringValueOfWatchpointSet() LIFETIME_BOUND { return m_stringValueOfWatchpointSet; } + InlineWatchpointSet& objectPrototypeValueOfWatchpointSet() LIFETIME_BOUND { return m_objectPrototypeValueOfWatchpointSet; } InlineWatchpointSet& regExpPrimordialPropertiesWatchpointSet() LIFETIME_BOUND { return m_regExpPrimordialPropertiesWatchpointSet; } InlineWatchpointSet& mapSetWatchpointSet() LIFETIME_BOUND { return m_mapSetWatchpointSet; } InlineWatchpointSet& setAddWatchpointSet() LIFETIME_BOUND { return m_setAddWatchpointSet; } ``` ### Credit Information Reporter credit: Junyoung Park (@candymate) of KAIST Hacking Lab
Attachments
poc.js (481 bytes, text/javascript)
2026-04-18 02:27 PDT, parkjuny
no flags
Radar WebKit Bug Importer
Comment 1 2026-04-19 13:22:15 PDT
Kai Tamkun
Comment 2 2026-04-24 08:23:25 PDT
EWS
Comment 3 2026-05-11 14:14:01 PDT
Committed 313028@main (0c9e1d804a0f): <https://commits.webkit.org/313028@main> Reviewed commits have been landed. Closing PR #63524 and removing active labels.
Note You need to log in before you can comment on or make changes to this bug.