CVE-2026-113052
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
fortest/mjsunit/wasm/regress-564326756.js |
modified |
Files Changed
test/mjsunit/wasm/regress-564326756.jssrc/wasm/wasm-wrapper-cache-inl.h
Patch
From 42a63bb146721f2175fa7e557594ce698b3d8cbe Mon Sep 17 00:00:00 2001 From: Daniel Lehmann <[email protected]> Date: Tue, 22 Sep 2026 01:53:21 +0200 Subject: [PATCH] [wasm] Flush icache before publishing wrapper code pointer In WasmWrapperCache::ModificationScope::AddWrapper, jit_allocation.UpdateWasmCodePointer() updated the process-wide WasmCodePointerTable entry before FlushInstructionCache() was called. On architectures with non-coherent instruction and data caches such as arm64, another thread concurrently calling an imported function of the same signature could load the new wrapper address from the table and execute stale instruction cache lines (SIGILL) before the instruction cache was flushed. Move FlushInstructionCache() inside the WritableJitAllocation scope immediately prior to UpdateWasmCodePointer() so that instruction cache synchronization completes before the entry point becomes visible to other threads. TAG=agy CONV=9a26f19b-f2cc-4dc2-ac4d-5aa62cb0da45 Fixed: 564326756 Change-Id: I1851fb4a2f33a5ee6499a517a20efae2e96ff6a0 Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8430571 Reviewed-by: Jakob Kummerow <[email protected]> Commit-Queue: Daniel Lehmann <[email protected]> Cr-Commit-Position: refs/heads/main@{#110005} --- diff --git a/src/wasm/wasm-wrapper-cache-inl.h b/src/wasm/wasm-wrapper-cache-inl.h index 22f0a1c..11bbf70a 100644 --- a/src/wasm/wasm-wrapper-cache-inl.h +++ b/src/wasm/wasm-wrapper-cache-inl.h @@ -72,10 +72,10 @@ DCHECK(!RelocInfo::IsWasmStubCall(it.rinfo()->rmode())); it.rinfo()->apply(delta); } + FlushInstructionCache(code_space.begin(), code_space.size()); jit_allocation.UpdateWasmCodePointer(wrapper_handle->code_pointer(), signature_hash); } - FlushInstructionCache(code_space.begin(), code_space.size()); const int frame_slot_count = result.frame_slot_count; const int ool_spill_count = result.ool_spill_count; constexpr bool frame_has_feedback_slot = false; diff --git a/test/mjsunit/wasm/regress-564326756.js b/test/mjsunit/wasm/regress-564326756.js new file mode 100644 index 0000000..e3cc56c --- /dev/null +++ b/test/mjsunit/wasm/regress-564326756.js @@ -0,0 +1,136 @@ +// 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: --wasm-wrapper-tiering-budget=250 + +// WasmWrapperCache::ModificationScope::AddWrapper updated the shared +// WasmCodePointerTable entry before flushing the instruction cache. On ARM64 +// CPUs with non-coherent instruction/data caches (e.g. Apple Silicon), a worker +// thread concurrently calling an import of the same signature could jump to the +// newly published wrapper address and fetch stale L1I cache lines (SIGILL). + +d8.file.execute('test/mjsunit/wasm/wasm-module-builder.js'); + +(function TestConcurrentWasmToJSWrapperTierUp() { + const kNumSigs = 64; + const kImportsPerSig = 16; + const kNumReaders = 3; + // Lowering `--wasm-wrapper-tiering-budget` from its default (1000) to 250 + // makes the compiler thread trigger tier-up 4x faster, while 16 imports * + // 249 calls = 3984 reader calls (~60us on Apple Silicon) spans the + // CompileWrapper window without any single reader import's budget reaching 0. + const kBudget = 250; + + // 1. Build a Wasm module with 64 distinct 7-parameter signatures (`s = 0..63`) + // and 16 imports (`f_<s>_0..15`) per signature: + // - `compile_<s>(count)`: calls `f_<s>_0` `count` times to trigger tier-up. + // - `read_<s>(max_iters)`: calls `f_<s>_0..15` while `--max_iters > 0` and + // `Atomics.load(mem, 4) <= s + 1` (so readers keep calling through `s` + // after `AddWrapper(s)` finishes on native hardware, but bail out early on + // slow simulator builds once the compiler thread moves past `s + 1`). + const builder = new WasmModuleBuilder(); + builder.addImportedMemory('m', 'mem', 1, 1, true); + const makeParams = (s) => [0, 1, 2, 3, 4, 5, 6].map( + b => (s & (1 << b)) ? kWasmExternRef : kWasmI32); + + for (let s = 0; s < kNumSigs; s++) { + const sig = builder.addType(makeSig(makeParams(s), [kWasmI32])); + for (let j = 0; j < kImportsPerSig; j++) { + builder.addImport('m', `f_${s}_${j}`, sig); + } + } + + for (let s = 0; s < kNumSigs; s++) { + const args = makeParams(s).flatMap( + t => t === kWasmExternRef ? [kExprRefNull, kExternRefCode] + : [kExprI32Const, 0]); + const callOne = (j) => [ + ...args, + kExprCallFunction, ...wasmUnsignedLeb(s * kImportsPerSig + j), + kExprDrop, + ]; + const callAll = []; + for (let j = 0; j < kImportsPerSig; j++) callAll.push(...callOne(j)); + + builder.addFunction(`compile_${s}`, kSig_v_i) + .addBody([ + kExprLoop, kWasmVoid, + ...callOne(0), + kExprLocalGet, 0, kExprI32Const, 1, kExprI32Sub, kExprLocalTee, 0, + kExprBrIf, 0, + kExprEnd, + ]) + .exportFunc(); + + builder.addFunction(`read_${s}`, kSig_v_i) + .addBody([ + kExprLoop, kWasmVoid, + ...callAll, + kExprLocalGet, 0, kExprI32Const, 1, kExprI32Sub, kExprLocalTee, 0, + kExprI32Const, 0, kExprI32GtS, + kExprI32Const, 4, kAtomicPrefix, kExprI32AtomicLoad, 2, 0, + kExprI32Const, ...wasmSignedLeb(s + 1), kExprI32LeS, + kExprI32And, + kExprBrIf, 0, + kExprEnd, + ]) + .exportFunc(); + } + + // 2. Shared synchronization state inside the imported WebAssembly.Memory: + // - `sync[0]` (byte offset 0): count of workers that finished instantiation. + // - `sync[1]` (byte offset 4): current signature `s` being compiled. + const module = builder.toModule(); + const memory = new WebAssembly.Memory({initial: 1, maximum: 1, shared: true}); + const sync = new Int32Array(memory.buffer); + Atomics.store(sync, 1, -1); + + // 3. Reader workers: instantiate the module, signal readiness (`sync[0]`), + // and spin-wait for each `s` before calling `read_<s>(kBudget - 1)`. + function workerCode() { + onmessage = function({data: {module, memory, kNumSigs, kImportsPerSig, kBudget}}) { + const sync = new Int32Array(memory.buffer); + const m = {mem: memory}; + for (let s = 0; s < kNumSigs; s++) { + for (let j = 0; j < kImportsPerSig; j++) m[`f_${s}_${j}`] = () => 1; + } + const {exports} = new WebAssembly.Instance(module, {m}); + + Atomics.add(sync, 0, 1); + for (let s = 0; s < kNumSigs; s++) { + while (Atomics.load(sync, 1) < s) {} + exports[`read_${s}`](kBudget - 1); + } + postMessage('ok'); + }; + } + + const workers = []; + for (let w = 0; w < kNumReaders; w++) { + const worker = new Worker(workerCode, {type: 'function'}); + worker.postMessage({module, memory, kNumSigs, kImportsPerSig, kBudget}); + workers.push(worker); + } + + // 4. Main thread (compiler): wait for all workers to instantiate, then step + // through `s = 0..47`, releasing readers on `s` and calling `compile_<s>`. + const m = {mem: memory}; + for (let s = 0; s < kNumSigs; s++) { + for (let j = 0; j < kImportsPerSig; j++) m[`f_${s}_${j}`] = () => 1; + } + const {exports} = new WebAssembly.Instance(module, {m}); + + while (Atomics.load(sync, 0) < workers.length) {} + + for (let s = 0; s < kNumSigs; s++) { + Atomics.store(sync, 1, s); + exports[`compile_${s}`](kBudget); + } + Atomics.store(sync, 1, kNumSigs + 1); + + for (const w of workers) { + assertEquals('ok', w.getMessage()); + w.terminate(); + } +})();
Regression Test / PoC
diff --git a/test/mjsunit/wasm/regress-564326756.js b/test/mjsunit/wasm/regress-564326756.js
new file mode 100644
index 0000000..e3cc56c
--- /dev/null
+++ b/test/mjsunit/wasm/regress-564326756.js
@@ -0,0 +1,136 @@
+// 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: --wasm-wrapper-tiering-budget=250
+
+// WasmWrapperCache::ModificationScope::AddWrapper updated the shared
+// WasmCodePointerTable entry before flushing the instruction cache. On ARM64
+// CPUs with non-coherent instruction/data caches (e.g. Apple Silicon), a worker
+// thread concurrently calling an import of the same signature could jump to the
+// newly published wrapper address and fetch stale L1I cache lines (SIGILL).
+
+d8.file.execute('test/mjsunit/wasm/wasm-module-builder.js');
+
+(function TestConcurrentWasmToJSWrapperTierUp() {
+ const kNumSigs = 64;
+ const kImportsPerSig = 16;
+ const kNumReaders = 3;
+ // Lowering `--wasm-wrapper-tiering-budget` from its default (1000) to 250
+ // makes the compiler thread trigger tier-up 4x faster, while 16 imports *
+ // 249 calls = 3984 reader calls (~60us on Apple Silicon) spans the
+ // CompileWrapper window without any single reader import's budget reaching 0.
+ const kBudget = 250;
+
+ // 1. Build a Wasm module with 64 distinct 7-parameter signatures (`s = 0..63`)
+ // and 16 imports (`f_<s>_0..15`) per signature:
+ // - `compile_<s>(count)`: calls `f_<s>_0` `count` times to trigger tier-up.
+ // - `read_<s>(max_iters)`: calls `f_<s>_0..15` while `--max_iters > 0` and
+ // `Atomics.load(mem, 4) <= s + 1` (so readers keep calling through `s`
+ // after `AddWrapper(s)` finishes on native hardware, but bail out early on
+ // slow simulator builds once the compiler thread moves past `s + 1`).
+ const builder = new WasmModuleBuilder();
+ builder.addImportedMemory('m', 'mem', 1, 1, true);
+ const makeParams = (s) => [0, 1, 2, 3, 4, 5, 6].map(
+ b => (s & (1 << b)) ? kWasmExternRef : kWasmI32);
+
+ for (let s = 0; s < kNumSigs; s++) {
+ const sig = builder.addType(makeSig(makeParams(s), [kWasmI32]));
+ for (let j = 0; j < kImportsPerSig; j++) {
+ builder.addImport('m', `f_${s}_${j}`, sig);
+ }
+ }
+
+ for (let s = 0; s < kNumSigs; s++) {
+ const args = makeParams(s).flatMap(
+ t => t === kWasmExternRef ? [kExprRefNull, kExternRefCode]
+ : [kExprI32Const, 0]);
+ const callOne = (j) => [
+ ...args,
+ kExprCallFunction, ...wasmUnsignedLeb(s * kImportsPerSig + j),
+ kExprDrop,
+ ];
+ const callAll = [];
+ for (let j = 0; j < kImportsPerSig; j++) callAll.push(...callOne(j));
+
+ builder.addFunction(`compile_${s}`, kSig_v_i)
+ .addBody([
+ kExprLoop, kWasmVoid,
+ ...callOne(0),
+ kExprLocalGet, 0, kExprI32Const, 1, kExprI32Sub, kExprLocalTee, 0,
+ kExprBrIf, 0,
+ kExprEnd,
+ ])
+ .exportFunc();
+
+ builder.addFunction(`read_${s}`, kSig_v_i)
+ .addBody([
+ kExprLoop, kWasmVoid,
+ ...callAll,
+ kExprLocalGet, 0, kExprI32Const, 1, kExprI32Sub, kExprLocalTee, 0,
+ kExprI32Const, 0, kExprI32GtS,
+ kExprI32Const, 4, kAtomicPrefix, kExprI32AtomicLoad, 2, 0,
+ kExprI32Const, ...wasmSignedLeb(s + 1), kExprI32LeS,
+ kExprI32And,
+ kExprBrIf, 0,
+ kExprEnd,
+ ])
+ .exportFunc();
+ }
+
+ // 2. Shared synchronization state inside the imported WebAssembly.Memory:
+ // - `sync[0]` (byte offset 0): count of workers that finished instantiation.
+ // - `sync[1]` (byte offset 4): current signature `s` being compiled.
+ const module = builder.toModule();
+ const memory = new WebAssembly.Memory({initial: 1, maximum: 1, shared: true});
+ const sync = new Int32Array(memory.buffer);
+ Atomics.store(sync, 1, -1);
+
+ // 3. Reader workers: instantiate the module, signal readiness (`sync[0]`),
+ // and spin-wait for each `s` before calling `read_<s>(kBudget - 1)`.
+ function workerCode() {
+ onmessage = function({data: {module, memory, kNumSigs, kImportsPerSig, kBudget}}) {
+ const sync = new Int32Array(memory.buffer);
+ const m = {mem: memory};
+ for (let s = 0; s < kNumSigs; s++) {
+ for (let j = 0; j < kImportsPerSig; j++) m[`f_${s}_${j}`] = () => 1;
+ }
+ const {exports} = new WebAssembly.Instance(module, {m});
+
+ Atomics.add(sync, 0, 1);
+ for (let s = 0; s < kNumSigs; s++) {
+ while (Atomics.load(sync, 1) < s) {}
+ exports[`read_${s}`](kBudget - 1);
+ }
+ postMessage('ok');
+ };
+ }
+
+ const workers = [];
+ for (let w = 0; w < kNumReaders; w++) {
+ const worker = new Worker(workerCode, {type: 'function'});
+ worker.postMessage({module, memory, kNumSigs, kImportsPerSig, kBudget});
+ workers.push(worker);
+ }
+
+ // 4. Main thread (compiler): wait for all workers to instantiate, then step
+ // through `s = 0..47`, releasing readers on `s` and calling `compile_<s>`.
+ const m = {mem: memory};
+ for (let s = 0; s < kNumSigs; s++) {
+ for (let j = 0; j < kImportsPerSig; j++) m[`f_${s}_${j}`] = () => 1;
+ }
+ const {exports} = new WebAssembly.Instance(module, {m});
+
+ while (Atomics.load(sync, 0) < workers.length) {}
+
+ for (let s = 0; s < kNumSigs; s++) {
+ Atomics.store(sync, 1, s);
+ exports[`compile_${s}`](kBudget);
+ }
+ Atomics.store(sync, 1, kNumSigs + 1);
+
+ for (const w of workers) {
+ assertEquals('ok', w.getMessage());
+ w.terminate();
+ }
+})();
Original Bug Report
arm64 renderer SIGILL at a wasm→JS import wrapper (missing icache flush before publish) - attempt #2
Steps to reproduce the problem
npm install # installs the pinned Playwright (browser: npx playwright install chromium) node mount-loop.mjs # 600 mounts, two workers
Problem Description
Summary
On arm64, when two Web Workers in one renderer instantiate the same WebAssembly module and call
its imported JS functions concurrently, the renderer intermittently dies with SIGILL at the
entry of a V8-generated wasm→JS import wrapper. The cause is an ordering bug in
WasmWrapperCache::AddWrapper: the wrapper’s address is published into the process-wide code-pointer
table (which any isolate may already be calling through) before the newly written code is flushed
from the data cache to the instruction cache. A second thread can branch into the new wrapper while
its instruction-cache lines are still stale, and execute bytes that were never written — typically
zero-fill, which decodes as an undefined instruction.
This is a memory-safety issue in JIT code publication (a thread executes bytes other than the validated code at that address) and should be triaged under the security template.
Affected
| Chromium | 138 and later (still present in current V8 main). Reproduced on 140 and 149. |
| Architecture | arm64 only. macOS/arm64 and Linux/arm64 both reproduce. x86-64 is clean. |
| Introduced by | V8 commit 8c8d3479bac78ef6c5a8c931b14e90a8a29533de (2025-05-14, “[wasm] update WasmCodePointer via WritableJitAllocation”), first shipped in V8 13.8 / Chromium 138. |
x86-64 is unaffected because its instruction and data caches are coherent with stores, so a missing explicit icache flush is invisible there. arm64 caches are not coherent, so code written at run time must be flushed before any core executes it.
Signature
EXC_BAD_INSTRUCTION/SIGILL(ILL_ILLOPC) on aDedicatedWorkerthread.- Program counter in an unmapped,
RWE(JIT) region — no loaded image contains it. - The PC is at, or a few instructions into, a wasm→JS import wrapper. Under
--js-flags=--perf-basic-profthe address maps toJS:wasm-to-js-<anonymous>-none. - The wrapper’s prologue at the PC is legal code (
mov x16, #…/pacibsp/stp x7, x16, …), which is why the kernel reads back a legal instruction: it reads memory at fault time, while the faulting core decoded stale instruction-cache lines. In one capture a legalmovtrapped exactly at a cache-line boundary, several instructions into the wrapper — consistent with only part of the wrapper’s range having reached the instruction cache. - No
Fatal error/Check failedbanner: this is not a V8CHECK, it is a raw illegal-instruction trap in JIT code.
Root cause
In src/wasm/wasm-wrapper-cache-inl.h, WasmWrapperCache<CacheKey>::ModificationScope::AddWrapper:
{
WritableJitAllocation jit_allocation = ThreadIsolation::RegisterJitAllocation(...);
jit_allocation.CopyCode(0, desc.buffer, desc.instr_size); // write the code
... apply relocations ...
jit_allocation.UpdateWasmCodePointer(wrapper_handle->code_pointer(), // PUBLISH: table entry
signature_hash); // now points at it
}
FlushInstructionCache(code_space.begin(), code_space.size()); // FLUSH: too late
UpdateWasmCodePointer writes the shared WasmCodePointerTable entry with a relaxed store. That
entry is read by every isolate that calls an import of this signature. Import-wrapper tier-up
(Runtime_TierUpWasmToJSWrapper, after kGenericWrapperBudget = 1000 calls) re-points a live entry.
Between the publish and the FlushInstructionCache, another thread already spinning in the import
loop can branch to the wrapper’s address and fetch stale icache lines.
The equivalent non-wrapper path (NativeModule::AddCodeWithCodeSpace) flushes before publishing
into the jump table. The wrapper path had the same order until the commit above moved the
code-pointer update inside the WritableJitAllocation scope, ahead of the flush.
Additional Comments
This is my second attempt at reporting this; the first report was rejected due to me including a tar.gz file rather than the individual unpacked files. I’m not certain this is exploitable, but it feels exploitable-adjacent. Again, feel free to reach out if I can help.
Summary
arm64 renderer SIGILL at a wasm→JS import wrapper (missing icache flush before publish) - attempt #2
Additional Data
Category: Security
Chrome Channel: Stable
Regression: Yes \