CVE-2026-95286
Overview
Background
- `V8ObjectToPropertyDescriptor`
- Blink binding routine that reads a JavaScript object’s
get,set,value,writable,enumerable, andconfigurableattributes into abindings::V8PropertyDescriptorBagfor use as av8::PropertyDescriptor. - ToPropertyDescriptor
- the ECMA-262 abstract operation whose steps 12.b and 14.b require a present
getorsetattribute to be eitherundefinedor callable, throwing aTypeErrorotherwise. - ObservableArray
- the Web IDL backing for interfaces like
document.adoptedStyleSheets, which routesdefinePropertyoperations through Blink’s binding layer and can trigger reentrant descriptor conversion. - `AccessorPair`
- the V8 internal object that stores a property’s getter/setter pair and is normally guaranteed to hold callable functions.
Root Cause Analysis
V8ObjectToPropertyDescriptor in v8_binding.cc populated desc.get and desc.set from arbitrary JavaScript values but never enforced ECMA-262 ToPropertyDescriptor steps 12.b and 14.b, which mandate that any present accessor be undefined or callable. Because the conversion reads attributes one at a time, a Proxy has trap fired during the set lookup can redefine Object.prototype.get/set to non-callable numeric values that are then picked up during the reentrant ObservableArray defineProperty trap. The resulting V8PropertyDescriptorBag carried non-function values that were handed to v8::PropertyDescriptor and installed into an AccessorPair, violating V8’s invariant that an AccessorPair holds only callable getters/setters and producing type confusion when V8 later invoked them as functions.
The fix adds explicit checks that reject a present get or set unless it IsUndefined() or IsFunction(), throwing a TypeError before any non-callable value can reach v8::PropertyDescriptor. This restores the ECMA-262 invariant at the boundary and closes the reentrancy window regardless of when the accessor attribute was mutated.
get/set in V8ObjectToPropertyDescriptor, so a value that became non-callable mid-conversion could be installed as an accessor; the fix enforces IsUndefined()-or-IsFunction() on each present accessor and throws a TypeError otherwise.Attack Path
- Obtain an ObservableArray target
Script grabs
document.adoptedStyleSheets, anObservableArraywhosedefinePropertyroutes through Blink’sV8ObjectToPropertyDescriptor. - Craft a reentrant descriptor
Attacker passes a
Proxydescriptor whosehastrap, when queried forset, redefinesObject.prototype.getandObject.prototype.setto a non-callable numeric descriptor. - Trigger conversion
Calling
Reflect.definePropertyon the observable invokesV8ObjectToPropertyDescriptor, and the trap fires after the initialgetlookup so the accessors resolve to non-callable numbers. - Install a poisoned AccessorPair
The unchecked
desc.get/desc.setvalues flow intov8::PropertyDescriptorand V8 stores them in anAccessorPair, breaking the callable-getter/setter invariant. - Confuse the type Later access invokes the stored “accessor” as a function, dereferencing a non-function value and producing exploitable type confusion in V8.
Impact Assessment
ObservableArray-backed interface such as document.adoptedStyleSheets and the ability to mutate Object.prototype accessors during reentrant descriptor conversion. No special permissions or user interaction are needed beyond loading attacker-controlled script.Changed Functions
| Function | Change | Notes |
|---|---|---|
TESTthird_party/blink/renderer/bindings/core/v8/observable_array_test.cc |
modified | |
hasthird_party/blink/renderer/bindings/core/v8/observable_array_test.cc |
modified | |
ifthird_party/blink/renderer/bindings/core/v8/observable_array_test.cc |
modified | |
testthird_party/blink/web_tests/external/wpt/css/cssom/adoptedstylesheets-observablearray.html |
modified | |
hasthird_party/blink/web_tests/external/wpt/css/cssom/adoptedstylesheets-observablearray.html |
modified | |
ifthird_party/blink/web_tests/external/wpt/css/cssom/adoptedstylesheets-observablearray.html |
modified |
Files Changed
third_party/blink/renderer/platform/bindings/v8_binding.ccthird_party/blink/renderer/bindings/core/v8/observable_array_test.ccthird_party/blink/renderer/bindings/bindings.gnithird_party/blink/web_tests/external/wpt/css/cssom/adoptedstylesheets-observablearray.html
Audit Directions
- Spec-step omissions in descriptor conversionAudit binding conversions that populate
v8::PropertyDescriptorfor skipped ECMA-262ToPropertyDescriptorvalidation steps, since missing callability or type checks let malformed descriptors through. - Reentrancy during multi-step lookupsReview code that reads several object attributes sequentially and can invoke JavaScript (via
Proxytraps, getters, or prototype lookups) mid-read, where an invariant validated early can be broken before the value is consumed. - ObservableArray and other IDL trap pathsInspect
ObservableArrayand similar Web IDL mechanisms that routedefineProperty/get/setback through Blink bindings, where reentrant conversion can smuggle attacker-controlled values into V8 internals likeAccessorPair.
Patch
From f8bdf46ae17841a948b16f8c5feb0ee2719a5187 Mon Sep 17 00:00:00 2001 From: Will Harris <[email protected]> Date: Tue, 15 Sep 2026 23:12:32 -0700 Subject: [PATCH] Validate accessor callability in V8ObjectToPropertyDescriptor V8ObjectToPropertyDescriptor previously omitted checking whether present get and set attributes are callable or undefined per ECMA-262 ToPropertyDescriptor (steps 12.b and 14.b). During reentrant descriptor conversion in the ObservableArray defineProperty trap, non-callable values could be passed to v8::PropertyDescriptor and installed as an AccessorPair, causing type confusion in V8. Enforce that present getter and setter properties are either undefined or callable functions, throwing a TypeError otherwise. Bug: 557523002 Change-Id: Ia524d3c71cb5bb6085ef1d2bc0af650b55a87e9c Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8405731 Reviewed-by: Nate Chapin <[email protected]> Commit-Queue: Will Harris <[email protected]> Cr-Commit-Position: refs/heads/main@{#1698665} --- diff --git a/third_party/blink/renderer/platform/bindings/v8_binding.cc b/third_party/blink/renderer/platform/bindings/v8_binding.cc index a74889fc..d61f8e2 100644 --- a/third_party/blink/renderer/platform/bindings/v8_binding.cc +++ b/third_party/blink/renderer/platform/bindings/v8_binding.cc @@ -114,9 +114,29 @@ if (!get_value("get", desc.has_get, desc.get)) return; + // https://tc39.es/ecma262/#sec-topropertydescriptor + // Step 12.b: If IsCallable(getter) is false and getter is not undefined, + // throw a TypeError exception. + // Validate that any present getter attribute is either undefined or a + // callable function to prevent type confusion in V8. + if (desc.has_get && !desc.get->IsUndefined() && !desc.get->IsFunction()) { + V8ThrowException::ThrowTypeError(isolate, "Getter must be a function."); + return; + } + if (!get_value("set", desc.has_set, desc.set)) return; + // https://tc39.es/ecma262/#sec-topropertydescriptor + // Step 14.b: If IsCallable(setter) is false and setter is not undefined, + // throw a TypeError exception. + // Validate that any present setter attribute is either undefined or a + // callable function to prevent type confusion in V8. + if (desc.has_set && !desc.set->IsUndefined() && !desc.set->IsFunction()) { + V8ThrowException::ThrowTypeError(isolate, "Setter must be a function."); + return; + } + if ((desc.has_get || desc.has_set) && (desc.has_value || desc.has_writable)) { V8ThrowException::ThrowTypeError( isolate, diff --git a/third_party/blink/renderer/bindings/core/v8/observable_array_test.cc b/third_party/blink/renderer/bindings/core/v8/observable_array_test.cc new file mode 100644 index 0000000..a0133f8 --- /dev/null +++ b/third_party/blink/renderer/bindings/core/v8/observable_array_test.cc @@ -0,0 +1,149 @@ +// Copyright 2026 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "testing/gtest/include/gtest/gtest.h" +#include "third_party/blink/renderer/bindings/core/v8/script_evaluation_result.h" +#include "third_party/blink/renderer/bindings/core/v8/v8_binding_for_testing.h" +#include "third_party/blink/renderer/core/script/classic_script.h" +#include "third_party/blink/renderer/platform/bindings/v8_binding.h" +#include "third_party/blink/renderer/platform/testing/task_environment.h" +#include "v8/include/v8.h" + +namespace blink { + +namespace { + +// Helper to execute a JavaScript string expression in the test scope and return +// the string result. +String RunScript(V8TestingScope& scope, const char* source) { + v8::Local<v8::Value> result = + ClassicScript::CreateUnspecifiedScript(source) + ->RunScriptAndReturnValue(&scope.GetWindow()) + .GetSuccessValueOrEmpty(); + if (result.IsEmpty() || !result->IsString()) { + return String(); + } + return ToCoreString(scope.GetIsolate(), result.As<v8::String>()); +} + +// Helper to evaluate a descriptor expression and run +// V8ObjectToPropertyDescriptor. +void ConvertDescriptor(V8TestingScope& scope, + const char* descriptor_expression, + bool& has_caught, + bindings::V8PropertyDescriptorBag& desc_bag) { + v8::Local<v8::Value> desc_value = + ClassicScript::CreateUnspecifiedScript(descriptor_expression) + ->RunScriptAndReturnValue(&scope.GetWindow()) + .GetSuccessValueOrEmpty(); + ASSERT_FALSE(desc_value.IsEmpty()); + v8::TryCatch try_catch(scope.GetIsolate()); + bindings::V8ObjectToPropertyDescriptor(scope.GetIsolate(), desc_value, + desc_bag); + has_caught = try_catch.HasCaught(); +} + +} // namespace + +// Tests that V8ObjectToPropertyDescriptor throws a TypeError when the 'get' +// property is not callable and not undefined. +TEST(V8ObjectToPropertyDescriptorTest, NonCallableGetter) { + test::TaskEnvironment task_environment; + V8TestingScope scope; + bool has_caught = false; + bindings::V8PropertyDescriptorBag desc_bag; + ConvertDescriptor(scope, "({get: 1})", has_caught, desc_bag); + EXPECT_TRUE(has_caught); +} + +// Tests that V8ObjectToPropertyDescriptor throws a TypeError when the 'set' +// property is not callable and not undefined. +TEST(V8ObjectToPropertyDescriptorTest, NonCallableSetter) { + test::TaskEnvironment task_environment; + V8TestingScope scope; + bool has_caught = false; + bindings::V8PropertyDescriptorBag desc_bag; + ConvertDescriptor(scope, "({set: 'text'})", has_caught, desc_bag); + EXPECT_TRUE(has_caught); +} + +// Tests that V8ObjectToPropertyDescriptor throws a TypeError when an inherited +// 'get' property is not callable. +TEST(V8ObjectToPropertyDescriptorTest, InheritedNonCallableGetter) { + test::TaskEnvironment task_environment; + V8TestingScope scope; + bool has_caught = false; + bindings::V8PropertyDescriptorBag desc_bag; + ConvertDescriptor(scope, "Object.create({get: 1})", has_caught, desc_bag); + EXPECT_TRUE(has_caught); +} + +// Tests that V8ObjectToPropertyDescriptor accepts undefined accessors. +TEST(V8ObjectToPropertyDescriptorTest, UndefinedAccessors) { + test::TaskEnvironment task_environment; + V8TestingScope scope; + bool has_caught = false; + bindings::V8PropertyDescriptorBag desc_bag; + ConvertDescriptor(scope, "({get: undefined, set: undefined})", has_caught, + desc_bag); + EXPECT_FALSE(has_caught); + EXPECT_TRUE(desc_bag.has_get); + EXPECT_TRUE(desc_bag.has_set); +} + +// Tests that V8ObjectToPropertyDescriptor accepts valid callable accessors. +TEST(V8ObjectToPropertyDescriptorTest, CallableAccessors) { + test::TaskEnvironment task_environment; + V8TestingScope scope; + bool has_caught = false; + bindings::V8PropertyDescriptorBag desc_bag; + ConvertDescriptor(scope, "({get: () => 1, set: () => {}})", has_caught, + desc_bag); + EXPECT_FALSE(has_caught); + EXPECT_TRUE(desc_bag.has_get); + EXPECT_TRUE(desc_bag.has_set); + EXPECT_TRUE(desc_bag.get->IsFunction()); + EXPECT_TRUE(desc_bag.set->IsFunction()); +} + +// The property descriptor of a defineProperty trap must be rejected when its +// accessors are made non-callable during reentrant descriptor conversion. +TEST(ObservableArrayTest, DefinePropertyRejectsNonCallableAccessor) { + test::TaskEnvironment task_environment; + V8TestingScope scope; + const char* source = R"JS((() => { + const observable = document.adoptedStyleSheets; + const numeric = Object.create(null); + numeric.value = 1; + numeric.writable = true; + numeric.configurable = true; + const descriptor = new Proxy(Object.create(null), { + has(_target, key) { + if (key === 'set') { + Reflect.defineProperty(Object.prototype, 'get', numeric); + Reflect.defineProperty(Object.prototype, 'set', numeric); + } + return false; + }, + }); + let outcome; + try { + outcome = 'defined:' + + Reflect.defineProperty(observable, 'testProperty', descriptor); + } catch (e) { + outcome = e instanceof TypeError ? 'TypeError' : 'unexpected:' + e; + } finally { + Reflect.deleteProperty(Object.prototype, 'get'); + Reflect.deleteProperty(Object.prototype, 'set'); + } + if (outcome !== 'TypeError') + return outcome; + if (Reflect.getOwnPropertyDescriptor(observable, 'testProperty')) + return 'property-installed'; + return 'TypeError'; + })())JS"; + EXPECT_EQ("TypeError", RunScript(scope, source)); +} + +} // namespace blink diff --git a/third_party/blink/renderer/bindings/bindings.gni b/third_party/blink/renderer/bindings/bindings.gni index 5013b80..e2342ca3 100644 --- a/third_party/blink/renderer/bindings/bindings.gni +++ b/third_party/blink/renderer/bindings/bindings.gni @@ -236,6 +236,7 @@ "core/v8/module_record_test.cc", "core/v8/native_value_traits_impl_test.cc", "core/v8/native_value_traits_test.cc", + "core/v8/observable_array_test.cc", "core/v8/profiler_trace_builder_test.cc", "core/v8/promise_all_test.cc", "core/v8/referrer_script_info_test.cc", diff --git a/third_party/blink/web_tests/external/wpt/css/cssom/adoptedstylesheets-observablearray.html b/third_party/blink/web_tests/external/wpt/css/cssom/adoptedstylesheets-observablearray.html index 75d8ffc..d2acde0 100644 --- a/third_party/blink/web_tests/external/wpt/css/cssom/adoptedstylesheets-observablearray.html +++ b/third_party/blink/web_tests/external/wpt/css/cssom/adoptedstylesheets-observablearray.html @@ -100,4 +100,37 @@ delete Array.prototype["1"]; assert_equals(backing, null); }, "adoptedStyleSheets should not expose its backing object via prototype"); + +test(function() { + // A prototype-less descriptor keeps Object.defineProperty() below working + // once Object.prototype.get is a number. + const numeric = Object.create(null); + numeric.value = 1; + numeric.writable = true; + numeric.configurable = true; + const descriptor = new Proxy(Object.create(null), { + has(target, key) { + if (key === "set") { + // Runs while the descriptor is being converted, after the initial + // conversion has already looked up "get". + Object.defineProperty(Object.prototype, "get", numeric); + Object.defineProperty(Object.prototype, "set", numeric); + } + return false; + }, + }); + try { + assert_throws_js(TypeError, () => { + Reflect.defineProperty(document.adoptedStyleSheets, "reentrantAccessor", + descriptor); + }); + } finally { + delete Object.prototype.get; + delete Object.prototype.set; + } + assert_equals( + Object.getOwnPropertyDescriptor(document.adoptedStyleSheets, + "reentrantAccessor"), + undefined); +}, "adoptedStyleSheets defineProperty should reject accessors that become non-callable during descriptor conversion"); </script>
Regression Test / PoC
diff --git a/third_party/blink/renderer/bindings/core/v8/observable_array_test.cc b/third_party/blink/renderer/bindings/core/v8/observable_array_test.cc
new file mode 100644
index 0000000..a0133f8
--- /dev/null
+++ b/third_party/blink/renderer/bindings/core/v8/observable_array_test.cc
@@ -0,0 +1,149 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "testing/gtest/include/gtest/gtest.h"
+#include "third_party/blink/renderer/bindings/core/v8/script_evaluation_result.h"
+#include "third_party/blink/renderer/bindings/core/v8/v8_binding_for_testing.h"
+#include "third_party/blink/renderer/core/script/classic_script.h"
+#include "third_party/blink/renderer/platform/bindings/v8_binding.h"
+#include "third_party/blink/renderer/platform/testing/task_environment.h"
+#include "v8/include/v8.h"
+
+namespace blink {
+
+namespace {
+
+// Helper to execute a JavaScript string expression in the test scope and return
+// the string result.
+String RunScript(V8TestingScope& scope, const char* source) {
+ v8::Local<v8::Value> result =
+ ClassicScript::CreateUnspecifiedScript(source)
+ ->RunScriptAndReturnValue(&scope.GetWindow())
+ .GetSuccessValueOrEmpty();
+ if (result.IsEmpty() || !result->IsString()) {
+ return String();
+ }
+ return ToCoreString(scope.GetIsolate(), result.As<v8::String>());
+}
+
+// Helper to evaluate a descriptor expression and run
+// V8ObjectToPropertyDescriptor.
+void ConvertDescriptor(V8TestingScope& scope,
+ const char* descriptor_expression,
+ bool& has_caught,
+ bindings::V8PropertyDescriptorBag& desc_bag) {
+ v8::Local<v8::Value> desc_value =
+ ClassicScript::CreateUnspecifiedScript(descriptor_expression)
+ ->RunScriptAndReturnValue(&scope.GetWindow())
+ .GetSuccessValueOrEmpty();
+ ASSERT_FALSE(desc_value.IsEmpty());
+ v8::TryCatch try_catch(scope.GetIsolate());
+ bindings::V8ObjectToPropertyDescriptor(scope.GetIsolate(), desc_value,
+ desc_bag);
+ has_caught = try_catch.HasCaught();
+}
+
+} // namespace
+
+// Tests that V8ObjectToPropertyDescriptor throws a TypeError when the 'get'
+// property is not callable and not undefined.
+TEST(V8ObjectToPropertyDescriptorTest, NonCallableGetter) {
+ test::TaskEnvironment task_environment;
+ V8TestingScope scope;
+ bool has_caught = false;
+ bindings::V8PropertyDescriptorBag desc_bag;
+ ConvertDescriptor(scope, "({get: 1})", has_caught, desc_bag);
+ EXPECT_TRUE(has_caught);
+}
+
+// Tests that V8ObjectToPropertyDescriptor throws a TypeError when the 'set'
+// property is not callable and not undefined.
+TEST(V8ObjectToPropertyDescriptorTest, NonCallableSetter) {
+ test::TaskEnvironment task_environment;
+ V8TestingScope scope;
+ bool has_caught = false;
+ bindings::V8PropertyDescriptorBag desc_bag;
+ ConvertDescriptor(scope, "({set: 'text'})", has_caught, desc_bag);
+ EXPECT_TRUE(has_caught);
+}
+
+// Tests that V8ObjectToPropertyDescriptor throws a TypeError when an inherited
+// 'get' property is not callable.
+TEST(V8ObjectToPropertyDescriptorTest, InheritedNonCallableGetter) {
+ test::TaskEnvironment task_environment;
+ V8TestingScope scope;
+ bool has_caught = false;
+ bindings::V8PropertyDescriptorBag desc_bag;
+ ConvertDescriptor(scope, "Object.create({get: 1})", has_caught, desc_bag);
+ EXPECT_TRUE(has_caught);
+}
+
+// Tests that V8ObjectToPropertyDescriptor accepts undefined accessors.
+TEST(V8ObjectToPropertyDescriptorTest, UndefinedAccessors) {
+ test::TaskEnvironment task_environment;
+ V8TestingScope scope;
+ bool has_caught = false;
+ bindings::V8PropertyDescriptorBag desc_bag;
+ ConvertDescriptor(scope, "({get: undefined, set: undefined})", has_caught,
+ desc_bag);
+ EXPECT_FALSE(has_caught);
+ EXPECT_TRUE(desc_bag.has_get);
+ EXPECT_TRUE(desc_bag.has_set);
+}
+
+// Tests that V8ObjectToPropertyDescriptor accepts valid callable accessors.
+TEST(V8ObjectToPropertyDescriptorTest, CallableAccessors) {
+ test::TaskEnvironment task_environment;
+ V8TestingScope scope;
+ bool has_caught = false;
+ bindings::V8PropertyDescriptorBag desc_bag;
+ ConvertDescriptor(scope, "({get: () => 1, set: () => {}})", has_caught,
+ desc_bag);
+ EXPECT_FALSE(has_caught);
+ EXPECT_TRUE(desc_bag.has_get);
+ EXPECT_TRUE(desc_bag.has_set);
+ EXPECT_TRUE(desc_bag.get->IsFunction());
+ EXPECT_TRUE(desc_bag.set->IsFunction());
+}
+
+// The property descriptor of a defineProperty trap must be rejected when its
+// accessors are made non-callable during reentrant descriptor conversion.
+TEST(ObservableArrayTest, DefinePropertyRejectsNonCallableAccessor) {
+ test::TaskEnvironment task_environment;
+ V8TestingScope scope;
+ const char* source = R"JS((() => {
+ const observable = document.adoptedStyleSheets;
+ const numeric = Object.create(null);
+ numeric.value = 1;
+ numeric.writable = true;
+ numeric.configurable = true;
+ const descriptor = new Proxy(Object.create(null), {
+ has(_target, key) {
+ if (key === 'set') {
+ Reflect.defineProperty(Object.prototype, 'get', numeric);
+ Reflect.defineProperty(Object.prototype, 'set', numeric);
+ }
+ return false;
+ },
+ });
+ let outcome;
+ try {
+ outcome = 'defined:' +
+ Reflect.defineProperty(observable, 'testProperty', descriptor);
+ } catch (e) {
+ outcome = e instanceof TypeError ? 'TypeError' : 'unexpected:' + e;
+ } finally {
+ Reflect.deleteProperty(Object.prototype, 'get');
+ Reflect.deleteProperty(Object.prototype, 'set');
+ }
+ if (outcome !== 'TypeError')
+ return outcome;
+ if (Reflect.getOwnPropertyDescriptor(observable, 'testProperty'))
+ return 'property-installed';
+ return 'TypeError';
+ })())JS";
+ EXPECT_EQ("TypeError", RunScript(scope, source));
+}
+
+} // namespace blink
diff --git a/third_party/blink/web_tests/external/wpt/css/cssom/adoptedstylesheets-observablearray.html b/third_party/blink/web_tests/external/wpt/css/cssom/adoptedstylesheets-observablearray.html
index 75d8ffc..d2acde0 100644
--- a/third_party/blink/web_tests/external/wpt/css/cssom/adoptedstylesheets-observablearray.html
+++ b/third_party/blink/web_tests/external/wpt/css/cssom/adoptedstylesheets-observablearray.html
@@ -100,4 +100,37 @@
delete Array.prototype["1"];
assert_equals(backing, null);
}, "adoptedStyleSheets should not expose its backing object via prototype");
+
+test(function() {
+ // A prototype-less descriptor keeps Object.defineProperty() below working
+ // once Object.prototype.get is a number.
+ const numeric = Object.create(null);
+ numeric.value = 1;
+ numeric.writable = true;
+ numeric.configurable = true;
+ const descriptor = new Proxy(Object.create(null), {
+ has(target, key) {
+ if (key === "set") {
+ // Runs while the descriptor is being converted, after the initial
+ // conversion has already looked up "get".
+ Object.defineProperty(Object.prototype, "get", numeric);
+ Object.defineProperty(Object.prototype, "set", numeric);
+ }
+ return false;
+ },
+ });
+ try {
+ assert_throws_js(TypeError, () => {
+ Reflect.defineProperty(document.adoptedStyleSheets, "reentrantAccessor",
+ descriptor);
+ });
+ } finally {
+ delete Object.prototype.get;
+ delete Object.prototype.set;
+ }
+ assert_equals(
+ Object.getOwnPropertyDescriptor(document.adoptedStyleSheets,
+ "reentrantAccessor"),
+ undefined);
+}, "adoptedStyleSheets defineProperty should reject accessors that become non-callable during descriptor conversion");
</script>