Medium watch CVSS 3.1 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
3.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in V8
DescriptionUse after free in V8
ComponentV8
Sub-componentWASM
Bug ClassUAF
Action tierwatch — corruption primitive but gated (local) — not default-reachable
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.
Tracker542146471
Fix commite533bd77f25f (v8/v8) +9/-0
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedJihyeon Jeong (Compsec Lab, Seoul National University / Research Intern)
Reported2026-08-03 · bounty $1,000 (per vendor advisory)
Fix landed2026-08-11 (public git)
Disclosed2026-09-08
Patch gap28 days — fix public in source before the advisory

Background

`StreamingDecoder`
the V8 wasm component that incrementally decodes a WebAssembly module as bytes stream in, holding decode state in stream_state_.
`SetUrl()`
a StreamingDecoder method that writes the module’s source URL into the shared string url_ via url_->assign(...).
`SBXCHECK_EQ`
a sandbox-hardening check macro that safely crashes the process when its equality condition fails, used to contain in-sandbox memory-corruption primitives.
In-sandbox corruption primitive
an attacker capability to arbitrarily modify memory inside the V8 sandbox, which the sandbox threat model assumes but tries to keep from escalating to full RCE.

Root Cause Analysis

The StreamingDecoder::SetUrl method wrote into the shared url_ string via url_->assign(url.begin(), url.size()) without verifying that the decoder was still in a state where url_ was not concurrently being read. The API contract requires SetUrl() to be called early (during kReceivingBytes), but this invariant was never enforced, so an attacker with an in-sandbox corruption primitive could drive a swapping/race attack that mutates url_ while it is being read elsewhere, yielding a use-after-free.

The fix brackets the url_->assign(...) with two SBXCHECK_EQ(stream_state_, kReceivingBytes) guards, one before and one after the assignment, so a state mismatch caused by such a race triggers a safe crash rather than memory corruption. This is a best-effort mitigation: as the patch comment notes, the CHECK is itself possibly racy and a full lock would be needed for complete safety, but a check that fails most of the time is deemed sufficient to blunt the attack.

Key insight
The core mistake was mutating the shared url_ string in SetUrl() without confirming the decoder was still in the kReceivingBytes state, leaving no defense against a corruption-driven race; the fix adds SBXCHECK_EQ guards around the assignment so the process safely crashes if the state was swapped mid-write.

Attack Path

  1. Obtain a corruption primitive The attacker first achieves an in-sandbox memory-corruption capability inside the V8 sandbox, consistent with V8’s sandbox threat model.
  2. Locate the streaming decode The attacker targets a StreamingDecoder whose url_ shared string is being read concurrently while a module streams in.
  3. Race the `SetUrl()` write Using the primitive, the attacker performs a swapping attack that advances or corrupts stream_state_ so that url_->assign(...) runs while url_ is being read or freed.
  4. Trigger the use-after-free The mutation of url_ during a concurrent read produces a use-after-free on the shared string, which the attacker leverages to escalate beyond the sandbox.

Impact Assessment

An attacker who already holds an in-sandbox corruption primitive in the V8 (renderer) process can escalate it into a use-after-free of the url_ shared string via a race around SetUrl(), potentially strengthening a path toward sandbox escape. The precondition is significant: the attacker must already control memory inside the sandbox and win the timing race against a concurrent read of url_. The patched SBXCHECK_EQ guards convert most such races into a safe crash rather than exploitable corruption.

Files Changed

  • src/wasm/streaming-decoder.h

Audit Directions

  • Unchecked shared-state mutation
    Look for methods that write into shared_ptr-held or otherwise shared objects without asserting the owner’s lifecycle state, especially where an API contract assumes call ordering that is never enforced.
  • Sandbox race windows
    Audit code reachable with an in-sandbox corruption primitive where a field like stream_state_ gates access to a resource but is read without SBXCHECK/locking around the critical section.
  • Best-effort mitigations
    Revisit sites already guarded by a single CHECK where the comment admits the check is racy, since these mark spots where a lock may ultimately be required for full safety.
From e533bd77f25f9786d206314b076bd4ed50df9dee Mon Sep 17 00:00:00 2001
From: Jakob Kummerow <jkummerow@chromium.org>
Date: Mon, 10 Aug 2026 20:45:05 +0200
Subject: [PATCH] [wasm][sandbox] CHECK for SetUrl() race

An attacker with in-sandbox corruption can cause a race condition
around WasmStreaming::SetUrl(). Mitigate that with CHECKs.

Fixed: 542146471
Change-Id: I844b8d5da7f38b4c73f0105c56aabb7d54009034
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8233198
Auto-Submit: Jakob Kummerow <jkummerow@chromium.org>
Reviewed-by: Matthias Liedtke <mliedtke@chromium.org>
Commit-Queue: Matthias Liedtke <mliedtke@chromium.org>
Cr-Commit-Position: refs/heads/main@{#109173}
---
diff --git a/src/wasm/streaming-decoder.h b/src/wasm/streaming-decoder.h
index 2f5094a..d6b5b5a 100644
--- a/src/wasm/streaming-decoder.h
+++ b/src/wasm/streaming-decoder.h
@@ -125,7 +125,16 @@
   std::shared_ptr<const std::string> shared_url() const { return url_; }
 
   void SetUrl(base::Vector<const char> url) {
+    // We shouldn't modify {url_} while it is being read. The API contract
+    // hence states that {SetUrl()} must be called early.
+    // Enforcing this with a CHECK is possibly racy when an attacker with an
+    // in-sandbox corruption primitive performs a swapping attack. If that
+    // becomes a problem, we can add a lock. As an attack mitigation, a CHECK
+    // that fails most of the time is probably good enough.
+    SBXCHECK_EQ(stream_state_, kReceivingBytes);
     url_->assign(url.begin(), url.size());
+    // Safely crash if a race did happen:
+    SBXCHECK_EQ(stream_state_, kReceivingBytes);
   }
 
   static std::unique_ptr<StreamingDecoder> Create(
Loading diff…

Original Bug Report

reported by sm...@gmail.com

V8 Sandbox Bypass: same-tag Managed<WasmStreaming> substitution races mutable StreamingDecoder::url_ after callback publication and causes a native heap UAF


Report description

V8 Sandbox Bypass: same-tag Managed<WasmStreaming> substitution races mutable StreamingDecoder::url_ after callback publication and causes a native heap UAF


Bug location

Where do you want to report your vulnerability?

Chrome VRP – Report security issues affecting the Chrome browser. See program rules

Which URL (or repository) have you found the vulnerability in?

https://source.chromium.org/chromium/chromium/src


The problem

Please describe the technical details of the vulnerability

Each WebAssembly.compileStreaming() call creates a distinct WasmStreaming, wraps its std::shared_ptr in Managed<WasmStreaming>, and installs that wrapper as the fulfillment and rejection callback data.

// v8/src/wasm/wasm-js.cc:838-854
std::shared_ptr<WasmStreaming> streaming = std::make_shared<WasmStreaming>(...);
i::DirectHandle<i::Managed<WasmStreaming>> data =
    i::Managed<WasmStreaming>::From(i_isolate, 0, streaming);
v8::Function::New(context, i_isolate->wasm_streaming_callback(),
                  Utils::ToLocal(i::Cast<i::Object>(data)), 1);

WasmStreaming::kManagedTag is exactly kWasmWasmStreamingTag, and WasmStreaming::Unpack() resolves callback data through Managed<WasmStreaming>::ptr(). That exact-tag lookup verifies the native resource type but cannot verify that the live WasmStreaming belongs to the Promise reaction carrying the callback data.

// v8/include/v8-wasm.h:164-168; v8/src/wasm/wasm-js.cc:166-173
static constexpr internal::ExternalPointerTag kManagedTag =
    internal::kWasmWasmStreamingTag;

std::shared_ptr<WasmStreaming> WasmStreaming::Unpack(Isolate* isolate,
                                                     Local<Value> value) {
  auto managed =
      i::Cast<i::Managed<WasmStreaming>>(Utils::OpenDirectHandle(*value));
  return managed->ptr().as_shared_ptr();
}

With arbitrary read/write access inside the V8 sandbox, the PoC copies request B’s genuine live external-pointer handle into the callback-data Foreign of another streaming request. The other request’s fulfillment callback consequently obtains B’s live WasmStreaming. The external-pointer entry, ManagedPtrDestructor, and WasmStreaming are all still valid.

Blink obtains the WasmStreaming from args.Data() but obtains the Response independently from args[0]. StreamFromResponseCallback() writes that response’s URL, optionally installs its code-caching callback, and only then starts body loading; it performs no request-identity check.

// third_party/blink/renderer/bindings/core/v8/v8_wasm_response_extensions.cc:493-498,528,600-625
std::shared_ptr<v8::WasmStreaming> streaming =
    v8::WasmStreaming::Unpack(args.GetIsolate(), args.Data());
Response* response = V8Response::ToWrappable(args.GetIsolate(), args[0]);
// ...
streaming->SetUrl(url_utf8.c_str(), url_utf8.size());
if (cache_handler) {
  streaming->SetMoreFunctionsCanBeSerializedCallback(...);
}
response->BodyBuffer()->StartLoading(...);

StreamingDecoder retains one mutable std::string. shared_url() shares that same object instead of taking an immutable value snapshot, while SetUrl() performs an unchecked std::string::assign() on it.

// v8/src/wasm/streaming-decoder.h:124-129,169-181
std::shared_ptr<const std::string> shared_url() const { return url_; }

void SetUrl(base::Vector<const char> url) {
  url_->assign(url.begin(), url.size());
}

StreamState stream_state_{kReceivingBytes};
const std::shared_ptr<std::string> url_ = std::make_shared<std::string>();

WasmStreamingImpl::SetMoreFunctionsCanBeSerializedCallback() captures this shared mutable string and later copies *url while a V8 compilation callback constructs CompiledWasmModule.

// v8/src/wasm/wasm-js.cc:98-105
streaming_decoder_->SetMoreFunctionsCanBeSerializedCallback(
    [callback = std::move(callback),
     url = streaming_decoder_->shared_url()](
        const std::shared_ptr<i::wasm::NativeModule>& native_module) {
      callback(CompiledWasmModule{native_module, *url});
    });

The API contract says SetUrl() must run before Finish(), but the implementation neither checks stream_state_ nor synchronizes with CompilationStateImpl::callbacks_mutex_. A substituted request can therefore call SetUrl() on an already-finished decoder while TriggerCodeCachingAfterTimeoutTask::Run() reaches CompilationStateImpl::TriggerCallbacks() and copies the same string on a V8 background thread.

The PoC uses already-aborted data-URL responses for these late URL changes. Blink executes SetUrl() first, then BodyStreamBuffer::StartLoading() observes the abort. Because normal completion already moved the decoder’s processors out, the subsequent StreamingDecoder::Abort() returns without delivering bytes to the finished stream or removing the callback already published to CompilationState.

The dangling pointer is produced in libc++’s long-string copy path. For the attached result, the first late assignment uses a 17,036,831-byte URL, producing a 17,036,832-byte allocation with public capacity 17,036,831. The background copy snapshots the old data pointer and length, then allocates its destination. Concurrently, a second late assignment uses a 17,036,832-byte URL, which is one byte beyond the old capacity; std::string::__grow_by_and_replace() allocates replacement storage and frees the old buffer. The pending __asan_memmove() then reads 17,036,832 bytes, including the terminator, through the dangling old pointer.

PoC

The attachments are poc.html, server.py, and asan.log. server.py embeds an unmodified XZ/base64 encoding of the published @ffmpeg/core 0.12.10 dist/umd/ffmpeg-core.wasm, obtained during packaging from https://cdn.jsdelivr.net/npm/@ ffmpeg/core@0.12.10/dist/umd/ffmpeg-core.wasm; the decoded bytes are 32,232,419 bytes with SHA-256 9f57947a5bd530d8f00c5b3f2cb2a3492faa7e5d823315342d6a8656d0a6b7b7. It verifies those bytes and serves only poc.html and /module.wasm from 127.0.0.1. This endpoint is required because FetchManager::Loader::DidReceiveResponse() creates ScriptCachedMetadataHandler for an HTTP-family application/wasm response (third_party/blink/renderer/core/fetch/fetch_manager.cc:723-741), and StreamFromResponseCallback() installs the V8 caching callback only when that handler exists (third_party/blink/renderer/bindings/core/v8/v8_wasm_response_extensions.cc:600-625). The PoC does not contact the packaging URL and uses no separate .wasm file.

The page creates finished victim streams, copies each victim’s genuine live kWasmWasmStreamingTag handle into pending compileStreaming() callback-data Foreign objects, and drives default tiering so late SetUrl() calls race the code-caching callback.

The frozen package reproduced the target UAF in 17 of 30 isolated launches of the official build (56.7%). A missed race normally closes the page; repeat the same Chrome command while leaving server.py running.

Reproduce

Confirmed in asan-v8-sandbox-testing-linux-release-1669311.zip.

In one terminal, start the fixed loopback endpoint:

python3 /path/to/server.py

After it prints the readiness line, run the official sandbox-testing ASan build in a second terminal:

/path/to/chrome \
  --headless=new \
  --incognito \
  '--js-flags=--run-as-sandbox-security-poc' \
  http://127.0.0.1:8181/poc.html

Impact analysis

An attacker with arbitrary read/write access inside the V8 sandbox can redirect an ordinary WebAssembly.compileStreaming() callback to another live exact-tag WasmStreaming and trigger a cross-thread heap UAF in native libc++ storage outside the sandbox.


The cause

What version of Chrome have you found the security issue in?

153.0.7978.0

Yes, it is related to a crash.

Choose the type of vulnerability

Sandbox Escape

View on issue tracker