Medium chrome Memory Corruption 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMemory corruption in V8
DescriptionMemory corruption in V8
ComponentV8
Sub-componentWASM
Bug ClassMemory Corruption
Tracker564326756
Fix commit42a63bb14672 (v8/v8) +137/-1
CISA KEVNot listed
Creditedgundyman
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
for
test/mjsunit/wasm/regress-564326756.js
modified

Files Changed

  • test/mjsunit/wasm/regress-564326756.js
  • src/wasm/wasm-wrapper-cache-inl.h
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();
+  }
+})();
Loading diff…

Regression Test / PoC

shipped with the fix
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();
+  }
+})();
Loading diff…

Original Bug Report

reported by [email protected]

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 a DedicatedWorker thread.
  • 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-prof the address maps to JS: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 legal mov trapped 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 failed banner: this is not a V8 CHECK, 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 \

View on issue tracker