High watch CVSS 9.1 chrome Info Disclosure Exploitability: Moderate (assessed) 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
9.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactSide-channel information leakage in V8
DescriptionSide-channel information leakage in V8
ComponentV8
Sub-componentBlink>Bindings
Bug ClassInfo Disclosure
ExploitabilityModerate (assessed by PatchGap · confirmed)
Diff only adds a timing-mask: cross-origin messages are re-deserialized in a throwaway isolate a random number of times to obscure V8 string-table timing. This is a read-only timing side-channel mitigation, not memory corruption; the CVSS 9.1/Critical InfoDisclosure label is inflated.
PatchGap's own analysis from the fixing commit, not a vendor rating. Absence of this line means not yet assessed, not "not exploitable".
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker430336833
Fix commit5dce1815e20e (chromium/src) +279/-20
CWECWE-1300, CWE-203
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N
CISA KEVNot listed
CreditedMate Marjanović (SharpEdged)
Reported2025-07-09 (per vendor advisory)
Fix landed2025-08-04 (public git)
Disclosed2025-09-23
Patch gap50 days — fix public in source before the advisory

Background

`MessageEvent`
the DOM event delivered by postMessage, exposing a data property (the deserialized payload) and an origin property (the sender’s origin) that a receiver can inspect to decide whether to trust the message.
`SerializedScriptValue` deserialization
Blink turns the wire bytes of a cross-origin message into a live V8 object via V8ScriptValueDeserializer, and this work touches V8’s string table (interning), whose behavior is data-dependent.
Timing side channel
because deserialization cost (e.g. whether a string is already interned in the string table) depends on the payload, the wall-clock time to read data can leak information about the message content even before the receiver validates origin.
`slow_mode` masking
a new DeserializeOptions::slow_mode flag that, after the real deserialization, re-runs deserialization a randomized number of times in a fresh throwaway isolate to obscure timing differences.

Root Cause Analysis

When a cross-origin postMessage is delivered, the receiver could read the data property (triggering deserialization) before ever reading origin, and the measurable time of that deserialization varied with the payload’s contents because of data-dependent V8 string-table operations. An attacker page could thus infer information about a cross-origin message it should not be able to read, purely from timing, since no origin check gated the sensitive work. The regression test encodes the intended policy: kSlowDeserialization is counted only for a cross-origin message whose data is accessed before origin (true), and not when origin is read first, nor for same-origin messages (both false).

The fix adds a slow_mode_ path that, in Deserialize, calls MaskDeserializationTimings on the resulting object; that routine re-serializes the value (omitting wrapped objects via the new kOmitWrappedObjects policy), spins up a fresh gin::IsolateHolder utility isolate, and deserializes base::RandInt(4, 8) extra times to blur the timing signal. It works because the observable duration is no longer a clean function of the original payload’s string-table interactions.

Key insight
The core mistake was performing data-dependent, timing-observable deserialization of an untrusted cross-origin payload before any origin check, letting an attacker measure content-correlated timing; the fix decouples the observable time from the payload by adding randomized redundant deserializations in a throwaway isolate (slow_mode) whenever data is read before origin on a cross-origin message.

Attack Path

  1. Deliver a cross-origin message An attacker frame arranges for a victim/cross-origin context to postMessage a payload whose deserialization cost depends on secret-influenced string content.
  2. Read `data` before `origin` The receiving script accesses crossOriginEvent.data prior to crossOriginEvent.origin, so deserialization runs without the origin having been consulted (the exact MessageEvent/origin gating logic that selects slow_mode is only partially shown in the diff) (inferred).
  3. Measure deserialization timing By timing when data becomes available, the attacker observes duration differences driven by V8 string-table operations, inferring bits about the cross-origin payload (inferred).
  4. Repeat to amplify Iterating the measurement lets the attacker statistically recover cross-origin information that same-origin policy is meant to protect (inferred).

Impact Assessment

An attacker gains a side-channel that leaks information about cross-origin postMessage contents, running in the renderer/JavaScript context of the receiving page, with the precondition that the receiver reads data before origin on a cross-origin message. The advisory classifies this as side-channel information leakage in V8 with no exploited-in-the-wild status stated; any concrete secret recovered beyond timing-correlated inference is (inferred).

Changed Functions

FunctionChangeNotes
DummyDeserializerDelegate
third_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.mojom
  • third_party/blink/renderer/bindings/core/v8/serialization/serialized_script_value.h
  • third_party/blink/renderer/bindings/core/v8/serialization/v8_script_value_deserializer.cc

Audit Directions

  • Pre-authorization work on untrusted input
    Look for places that perform data-dependent, observable processing (deserialization, parsing, hashing, interning) of cross-origin/untrusted data before the corresponding trust/origin check runs.
  • String-table and cache timing
    Audit V8/Blink paths whose cost depends on interning or cache hit/miss state, since these leak content through timing to a co-resident attacker.
  • Lazy property side effects
    Review DOM event properties (like data) that trigger expensive lazy computation on first access, where access order relative to security-relevant properties (like origin) changes what an attacker can observe.
From 5dce1815e20eeda684ff15dac7767c2e67d39cc2 Mon Sep 17 00:00:00 2001
From: Andrey Kosyakov <[email protected]>
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 <[email protected]>
Reviewed-by: Andreas Haas <[email protected]>
Reviewed-by: Mike West <[email protected]>
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);
Loading diff…

Regression Test / PoC

shipped with the fix
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>
Loading diff…

Original Bug Report

reported by [email protected]

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

  1. Compile gen.cpp with clang++ -O3 -march=native gen.cpp -o gen.
  2. Run python3 server.py and forward some URL url to it.
  3. Replace ATTACKER_URL in poc.html with url and open the file in Chromium.
  4. Change the test and TARGET variables 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)

View on issue tracker