CVE-2025-10890
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
DummyDeserializerDelegatethird_party/blink/renderer/bindings/core/v8/serialization/v8_script_value_deserializer.cc |
modified |
Files Changed
third_party/blink/public/mojom/use_counter/metrics/web_feature.mojomthird_party/blink/renderer/bindings/core/v8/serialization/serialized_script_value.hthird_party/blink/renderer/bindings/core/v8/serialization/v8_script_value_deserializer.cc
Patch
From 5dce1815e20eeda684ff15dac7767c2e67d39cc2 Mon Sep 17 00:00:00 2001
From: Andrey Kosyakov <caseq@chromium.org>
Date: Mon, 04 Aug 2025 10:54:04 -0700
Subject: [PATCH] Mask deserialization time of cross-origin messages from unchecked origins
This tracks whether `MessageEvent::origin` property of cross-origin message events has been accessed prior to the `data` property and if not, uses a newly-created isolate to deserialize the message few more times to obscure any timing differences induced by deserialization, e.g. timings of string table operations.
Bug: 430336833
Change-Id: Idcc4d7322ff7f5c5bcfbcae97ba43004652fdc78
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/6787964
Commit-Queue: Andrey Kosyakov <caseq@chromium.org>
Reviewed-by: Andreas Haas <ahaas@chromium.org>
Reviewed-by: Mike West <mkwst@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1496454}
---
diff --git a/third_party/blink/public/mojom/use_counter/metrics/web_feature.mojom b/third_party/blink/public/mojom/use_counter/metrics/web_feature.mojom
index 707cc33..3457977 100644
--- a/third_party/blink/public/mojom/use_counter/metrics/web_feature.mojom
+++ b/third_party/blink/public/mojom/use_counter/metrics/web_feature.mojom
@@ -4933,6 +4933,7 @@
kWebAppManifestDescriptionLocalized = 5622,
kWebAppManifestIconsLocalized = 5623,
kHTMLControlledFrameElement = 5624,
+ kSlowDeserialization = 5625,
// Add new features immediately above this line. Don't change assigned
// numbers of any item, and don't reuse removed slots. Also don't add extra
diff --git a/third_party/blink/renderer/bindings/core/v8/serialization/serialized_script_value.h b/third_party/blink/renderer/bindings/core/v8/serialization/serialized_script_value.h
index 84e003e..d132e89e 100644
--- a/third_party/blink/renderer/bindings/core/v8/serialization/serialized_script_value.h
+++ b/third_party/blink/renderer/bindings/core/v8/serialization/serialized_script_value.h
@@ -171,6 +171,12 @@
kBlockedInNonSecureContext // Block transfer or serialization.
};
+ // Whether to serialize or skip a ScriptWrappable if the object is a
+ // wrapper.
+ enum ScriptWrappablePolicy {
+ kSerializeWrappedObjects,
+ kOmitWrappedObjects,
+ };
SerializeOptions() = default;
explicit SerializeOptions(StoragePolicy for_storage)
: for_storage(for_storage) {}
@@ -179,6 +185,7 @@
WebBlobInfoArray* blob_info = nullptr;
WasmSerializationPolicy wasm_policy = kTransfer;
StoragePolicy for_storage = kNotForStorage;
+ ScriptWrappablePolicy script_wrappable_policy = kSerializeWrappedObjects;
};
static scoped_refptr<SerializedScriptValue> Serialize(v8::Isolate*,
v8::Local<v8::Value>,
@@ -211,6 +218,9 @@
public:
MessagePortArray* message_ports = nullptr;
const WebBlobInfoArray* blob_info = nullptr;
+ // Slow mode is intended to mitigate possible timing attacks on v8 string
+ // table.
+ bool slow_mode = false;
};
v8::Local<v8::Value> Deserialize(v8::Isolate* isolate) {
return Deserialize(isolate, DeserializeOptions());
diff --git a/third_party/blink/renderer/bindings/core/v8/serialization/v8_script_value_deserializer.cc b/third_party/blink/renderer/bindings/core/v8/serialization/v8_script_value_deserializer.cc
index aabaf47f..9535e3a7 100644
--- a/third_party/blink/renderer/bindings/core/v8/serialization/v8_script_value_deserializer.cc
+++ b/third_party/blink/renderer/bindings/core/v8/serialization/v8_script_value_deserializer.cc
@@ -10,12 +10,15 @@
#include "base/feature_list.h"
#include "base/numerics/checked_math.h"
+#include "base/rand_util.h"
#include "base/time/time.h"
+#include "gin/public/isolate_holder.h"
#include "third_party/blink/public/common/fenced_frame/fenced_frame_utils.h"
#include "third_party/blink/public/platform/web_blob_info.h"
#include "third_party/blink/renderer/bindings/core/v8/serialization/serialization_tag.h"
#include "third_party/blink/renderer/bindings/core/v8/serialization/trailer_reader.h"
#include "third_party/blink/renderer/bindings/core/v8/serialization/unpacked_serialized_script_value.h"
+#include "third_party/blink/renderer/bindings/core/v8/serialization/v8_script_value_serializer.h"
#include "third_party/blink/renderer/bindings/core/v8/to_v8_traits.h"
#include "third_party/blink/renderer/bindings/core/v8/v8_blob.h"
#include "third_party/blink/renderer/bindings/core/v8/v8_dom_exception.h"
@@ -66,6 +69,7 @@
#include "third_party/blink/renderer/core/typed_arrays/dom_array_buffer.h"
#include "third_party/blink/renderer/core/typed_arrays/dom_shared_array_buffer.h"
#include "third_party/blink/renderer/platform/bindings/script_state.h"
+#include "third_party/blink/renderer/platform/bindings/v8_per_isolate_data.h"
#include "third_party/blink/renderer/platform/file_metadata.h"
#include "third_party/blink/renderer/platform/heap/garbage_collected.h"
#include "third_party/blink/renderer/platform/runtime_enabled_features.h"
@@ -179,6 +183,7 @@
: script_state_(script_state),
unpacked_value_(unpacked_value),
serialized_script_value_(value),
+ slow_mode_(options.slow_mode),
deserializer_(script_state_->GetIsolate(),
serialized_script_value_->Data(),
serialized_script_value_->DataLengthInBytes(),
@@ -226,9 +231,120 @@
v8::Local<v8::Value> value;
if (!deserializer_.ReadValue(context).ToLocal(&value))
return v8::Null(isolate);
+ if (slow_mode_ && value->IsObject()) {
+ // TODO(caseq): consider additionally gating this on payload size.
+ MaskDeserializationTimings(value.As<v8::Object>());
+ }
return scope.Escape(value);
}
+namespace {
+
+class DummyDeserializerDelegate final : public v8::ValueDeserializer::Delegate {
+ STACK_ALLOCATED();
+
+ public:
+ explicit DummyDeserializerDelegate(SerializedScriptValue& value)
+ : serialized_script_value_(value) {}
+
+ ~DummyDeserializerDelegate() override = default;
+
+ private:
+ v8::MaybeLocal<v8::Object> ReadHostObject(v8::Isolate* isolate) override {
+ return v8::Object::New(isolate);
+ }
+
+ // This and the one below are simplified version of implementations in the
+ // 'production' delegate that remove dependencies on
+ // ExecutionContext/ScriptState and assume additional invariants following
+ // from the fact that the serialization is performed in the same process.
+ v8::MaybeLocal<v8::WasmModuleObject> GetWasmModuleFromId(
+ v8::Isolate* isolate,
+ uint32_t id) override {
+ if (id < serialized_script_value_.WasmModules().size()) {
+ return v8::WasmModuleObject::FromCompiledModule(
+ isolate, serialized_script_value_.WasmModules()[id]);
+ }
+ CHECK(serialized_script_value_.WasmModules().empty());
+ return v8::MaybeLocal<v8::WasmModuleObject>();
+ }
+
+ v8::MaybeLocal<v8::SharedArrayBuffer> GetSharedArrayBufferFromId(
+ v8::Isolate* isolate,
+ uint32_t id) override {
+ auto& shared_array_buffers_contents =
+ serialized_script_value_.SharedArrayBuffersContents();
+ CHECK_LT(id, shared_array_buffers_contents.size());
+ ArrayBufferContents& contents = shared_array_buffers_contents.at(id);
+ return v8::SharedArrayBuffer::New(isolate, contents.BackingStore());
+ }
+
+ const v8::SharedValueConveyor* GetSharedValueConveyor(
+ v8::Isolate* isolate) override {
+ return serialized_script_value_.MaybeGetSharedValueConveyor();
+ }
+
+ SerializedScriptValue& serialized_script_value_;
+};
+
+} // namespace
+
+void V8ScriptValueDeserializer::MaskDeserializationTimings(
+ v8::Local<v8::Object> value) {
+ UseCounter::Count(ExecutionContext::From(script_state_),
+ WebFeature::kSlowDeserialization);
+ V8ScriptValueSerializer::Options options;
+ // Re-serialize the message while omitting script wrapped objects, so
+ // that we don't have to deal wrapped objects while deserializing,
+ // as our current wire format would require the delegate to explicitly
+ // support many different types of objects, while not allowing us to
+ // reuse production delegate, since it requires an ExecutionContext
+ // which we do not have.
+
+ options.script_wrappable_policy =
+ V8ScriptValueSerializer::Options::kOmitWrappedObjects;
+ options.wasm_policy = V8ScriptValueSerializer::Options::kTransfer;
+ V8ScriptValueSerializer serializer(script_state_, options);
+ ExceptionState exception_state(script_state_->GetIsolate());
+ scoped_refptr<SerializedScriptValue> serialized =
+ serializer.Serialize(value, exception_state);
+ CHECK(!exception_state.HadException());
+
+ auto task_runner = ExecutionContext::From(script_state_)
+ ->GetTaskRunner(TaskType::kPostedMessage);
+
+ std::unique_ptr<v8::Isolate::CreateParams> params =
+ gin::IsolateHolder::getDefaultIsolateParams();
+ auto isolate_holder = std::make_unique<gin::IsolateHolder>(
+ task_runner, gin::IsolateHolder::kSingleThread,
+ gin::IsolateHolder::IsolateType::kUtility, std::move(params));
+ v8::Isolate* isolate = isolate_holder->isolate();
+ DummyDeserializerDelegate delegate(*serialized);
+ v8::Isolate::Scope isolate_scope(isolate);
+ v8::HandleScope handle_scope(isolate);
+ v8::Local<v8::Context> context = v8::Context::New(isolate);
+
+ // Deserialize the message in an empty isolate a random number of times
+ // to mask whether the time of the original deserialization in the
+ // target isolate.
+ int iterations = base::RandInt(4, 8);
Regression Test / PoC
diff --git a/third_party/blink/renderer/core/events/message_event_test.cc b/third_party/blink/renderer/core/events/message_event_test.cc
index eb4087f..9b34910 100644
--- a/third_party/blink/renderer/core/events/message_event_test.cc
+++ b/third_party/blink/renderer/core/events/message_event_test.cc
@@ -87,6 +87,7 @@
GCedMessagePortArray* ports = MakeGarbageCollected<GCedMessagePortArray>(0);
MessageEvent::Create(ports, serialized_script_value, /* origin=*/{},
+ MessageEvent::kMessageIsSameOrigin,
/* last_event_id=*/{}, /* source=*/nullptr);
int64_t size_with_event = V8ExternalMemoryAccounterBase::
diff --git a/third_party/blink/web_tests/http/tests/messaging/message-event-slow-deserialization-expected.txt b/third_party/blink/web_tests/http/tests/messaging/message-event-slow-deserialization-expected.txt
new file mode 100644
index 0000000..06e6ed9ee
--- /dev/null
+++ b/third_party/blink/web_tests/http/tests/messaging/message-event-slow-deserialization-expected.txt
@@ -0,0 +1,3 @@
+Used slow mode for cross-origin message with data accessed after origin: false
+Used slow mode for cross-origin message with data accessed before origin: true
+Used slow mode for same-origin message with data accessed before origin: false
diff --git a/third_party/blink/web_tests/http/tests/messaging/message-event-slow-deserialization.html b/third_party/blink/web_tests/http/tests/messaging/message-event-slow-deserialization.html
new file mode 100644
index 0000000..361799f
--- /dev/null
+++ b/third_party/blink/web_tests/http/tests/messaging/message-event-slow-deserialization.html
@@ -0,0 +1,68 @@
+<!DOCTYPE html>
+<body>
+<script type="module">
+import {WebFeature} from '/gen/third_party/blink/public/mojom/use_counter/metrics/web_feature.mojom.m.js';
+
+if (window.testRunner) {
+ testRunner.dumpAsText();
+ testRunner.waitUntilDone();
+}
+
+const doPost = function () {
+ top.postMessage({payload: "don't panic"}, '*');
+}
+
+function createCrossOriginIframeAndPostMessage() {
+ const iframe = document.createElement("iframe");
+ iframe.src = "data:text/html,<" + `script type="application/x-javascript">(${doPost.toString()})()</` + "script>";
+ document.body.appendChild(iframe);
+}
+
+function createSameOriginIframeAndPostMessage() {
+ const iframe = document.createElement("iframe");
+ document.body.appendChild(iframe);
+ iframe.contentWindow.eval(`(${doPost.toString()})()`);
+}
+
+function output(message) {
+ const div = document.createElement('div');
+ div.textContent = message;
+ document.body.appendChild(div);
+}
+
+(async function() {
+ internals.clearUseCounter(document, WebFeature.kSlowDeserialization);
+ createCrossOriginIframeAndPostMessage();
+ const crossOriginEvent1 = await new Promise(resolve => {
+ window.addEventListener('message', resolve, {once: true});
+ });
+ crossOriginEvent1.origin;
+ crossOriginEvent1.data;
+ output('Used slow mode for cross-origin message with data accessed after origin: ' +
+ internals.isUseCounted(document, WebFeature.kSlowDeserialization));
+
+ internals.clearUseCounter(document, WebFeature.kSlowDeserialization);
+ createCrossOriginIframeAndPostMessage();
+ const crossOriginEvent2 = await new Promise(resolve => {
+ window.addEventListener('message', resolve, {once: true});
+ });
+ crossOriginEvent2.data;
+ crossOriginEvent2.origin;
+ output('Used slow mode for cross-origin message with data accessed before origin: ' +
+ internals.isUseCounted(document, WebFeature.kSlowDeserialization));
+
+ internals.clearUseCounter(document, WebFeature.kSlowDeserialization);
+ createSameOriginIframeAndPostMessage();
+ const sameOriginEvent = await new Promise(resolve => {
+ window.addEventListener('message', resolve, {once: true});
+ });
+ sameOriginEvent.origin;
+ sameOriginEvent.data;
+ output('Used slow mode for same-origin message with data accessed before origin: ' +
+ internals.isUseCounted(document, WebFeature.kSlowDeserialization));
+
+ if (window.testRunner) {
+ testRunner.notifyDone();
+ }
+})();
+</script>
Original Bug Report
Cross-context string leakage via V8 string_table
VULNERABILITY DETAILS
The string_table in V8 is a per-isolate hash table storing internalized strings. As V8 in Chromium uses a heap snapshot for startup this means that the hashseed is known and fixed. This allows an attacker to compute the hash of any string and construct an oracle which can reliably test whether a particular string occurs in the string_table. As cross-origin documents can occur in the same isolate, in certain cases this oracle provides a way to leak information cross-origin. Additionally, as the contexts of extension content scripts occur in the same isolate as the contexts of the main document, it’s possible to leak which extensions a user is using and also, potentially more critically, it’s possible to leak information about the execution / control flow of the content scripts.
THE ORACLE
Suppose we want to know whether a string x occurs in the string_table. Suppose x hashes to h. Now, as we know the hash function we brute force strings which hash to h, h + 1, h + 1 + 2, h + 1 + 2 + 3, … (the quadratic probing pattern used in the string_table). We insert those strings into the string_table and after that we measure how long it takes to insert x into the string_table. If x was already in the string_table before insertion of the “chain” we expect to find x quickly. On the other hand, if x wasn’t in the table already we will have to traverse the entire chain we’ve just inserted, taking measurably longer to complete the insertion. Note that the chain can be made arbitrarily long to ensure the time difference is detectable.
VERSION
Chrome Version: 138.0.7204.92 stable
Operating System: Arch Linux (64-bit) Linux 6.15.5-arch1-1
REPRODUCTION CASE
- Compile
gen.cppwithclang++ -O3 -march=native gen.cpp -o gen. - Run
python3 server.pyand forward some URLurlto it. - Replace
ATTACKER_URLinpoc.htmlwithurland open the file in Chromium. - Change the
testandTARGETvariables to test leaking different strings.
Note: The timing is hardware dependent so tweaking the THRESHOLD variable might be required. Adjusting the threshold could technically be done on the fly by calculating the latency on the particular machine.
CREDIT INFORMATION
Reporter credit: Mate Marjanović (SharpEdged)