CVE-2026-78899
Overview
Background
- `NativeModule`
- the per-module container that owns the compiled WebAssembly code for one instantiated Wasm module, and is reference-counted and torn down when no longer used.
- `WasmCodeManager`
- the process-wide manager that tracks every
NativeModule’s code region and resolves an arbitrary code address back to its owningWasmCode/NativeModule. - `WasmCodeRefScope`
- a scope object that keeps looked-up
WasmCode(and thus itsNativeModule) alive for the scope’s duration, preventing concurrent teardown from freeing a borrowed pointer. - `call_indirect` feedback
- speculative type-feedback collected for indirect Wasm calls that the optimizing tier uses to decide inlining candidates.
Root Cause Analysis
During call_indirect feedback processing, FeedbackMaker::AddCallIndirectCandidate decoded an untrusted code-pointer handle into an entry address and resolved it via the process-wide wasm::GetWasmCodeManager()->LookupCode(nullptr, entry), searching across every module in the process. Because it passed nullptr for the isolate and held no WasmCodeRefScope, the returned WasmCode and its owning NativeModule were borrowed pointers with no lifetime guarantee, so a concurrent teardown of a foreign NativeModule could free the object while this code path dereferenced it (code->native_module(), code->IsAnonymous()) — a classic use-after-free.
The fix restricts the lookup to the current module via instance_data_->native_module()->Lookup(entry) under a fresh WasmCodeRefScope, which is correct because an inlining target from a foreign module is never a valid candidate anyway and the local module is kept alive by the active instance. Separately, WasmCodeManager::LookupCode(Isolate*, Address) no longer accepts a nullptr isolate (DCHECK_NOT_NULL(isolate)), and the genuinely isolate-less caller (the disassembler) is switched to an explicit non-cached LookupCode(Address) overload that requires its own WasmCodeRefScope.
NativeModule be dereferenced; the fix confines the lookup to the current, still-alive NativeModule and wraps every borrowed lookup in a WasmCodeRefScope.Attack Path
- Instantiate multiple modules
The attacker creates several Wasm instances so that more than one
NativeModuleexists in the process at once. - Trigger feedback collection
Attacker-controlled Wasm executes
call_indirectsites so thatFeedbackMaker::AddCallIndirectCandidateruns and decodes a target code-pointer handle into anentryaddress. - Point the target at a foreign module
By supplying a crafted/untrusted feedback handle, the
entryresolves to code owned by a differentNativeModulevia the process-wideWasmCodeManager::LookupCode. - Free the foreign module concurrently
A concurrent thread tears down that foreign
NativeModule, freeing itsWasmCodewhile the feedback path still holds the borrowed pointer with noWasmCodeRefScope. - Dereference the freed object
The feedback path reads
code->native_module()/code->IsAnonymous()on freed memory, yielding a use-after-free that can be groomed into corruption inside the renderer.
Impact Assessment
WasmCode/NativeModule memory, a strong primitive toward memory corruption and potential renderer-process code execution. Exploitation requires the ability to execute Wasm and to race a foreign-module teardown against call_indirect feedback processing, all within the sandboxed renderer. The severity is heightened because the resolved address originates from untrusted feedback data, giving the attacker influence over which module is targeted.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifsrc/wasm/wasm-code-manager.cc |
modified |
Files Changed
src/wasm/module-compiler.ccsrc/wasm/wasm-code-manager.ccsrc/diagnostics/disassembler.ccsrc/wasm/wasm-code-manager.h
Audit Directions
- Borrowed cross-module lookupsAudit every caller of
WasmCodeManager::LookupCode/LookupNativeModulethat dereferences the result without an enclosingWasmCodeRefScope, since the returned pointer can be freed by concurrent teardown. - Untrusted-address resolutionReview feedback- and sandbox-facing paths that turn untrusted
WasmCodePointerhandles into addresses and then resolve them process-wide, and confine such lookups to the known-alive localNativeModule. - Lifetime-scope preconditionsFlag any function returning a non-reference-counted
WasmCode/NativeModulepointer whose documentedWasmCodeRefScopeor active-call-stack precondition is not actually held at the call site.
Patch
From 00250c2d2cfb2306090a3330c621ee02d2ae6823 Mon Sep 17 00:00:00 2001
From: Daniel Lehmann <dlehmann@chromium.org>
Date: Tue, 04 Aug 2026 14:27:12 +0200
Subject: [PATCH] [wasm][sandbox] Fix call_indirect feedback and module lookup
This change fixes a use-after-free during NativeModule teardown and
simplifies code lookup during feedback processing:
1. Resolve targets for call_indirect inlining only in the current
NativeModule: In FeedbackMaker::AddCallIndirectCandidate, look up the
target in `instance_data_->native_module()` instead of the
process-wide WasmCodeManager. Inlining requires the target to be in
the same module anyway. Looking it up locally avoids racing with
concurrent teardown of foreign modules.
2. Require an Isolate for cached LookupCode: Enforce
`DCHECK_NOT_NULL(isolate)` in `LookupCode(Isolate*, Address)`. Add an
explicit non-cached overload `LookupCode(Address)` and switch
non-isolate callers (e.g., in the disassembler) to use it directly.
Fixed: 541448581
Bug: 540430406
Change-Id: I0b05f142e34d619b2a5c3e0317adec151d328748
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8193537
Reviewed-by: Jakob Kummerow <jkummerow@chromium.org>
Auto-Submit: Daniel Lehmann <dlehmann@chromium.org>
Commit-Queue: Jakob Kummerow <jkummerow@chromium.org>
Cr-Commit-Position: refs/heads/main@{#109042}
---
diff --git a/src/diagnostics/disassembler.cc b/src/diagnostics/disassembler.cc
index d45f150..8b8a4e8 100644
--- a/src/diagnostics/disassembler.cc
+++ b/src/diagnostics/disassembler.cc
@@ -104,8 +104,9 @@
}
#if V8_ENABLE_WEBASSEMBLY
+ wasm::WasmCodeRefScope code_ref_scope;
if (auto* wasm_code = wasm::GetWasmCodeManager()->LookupCode(
- isolate_, reinterpret_cast<Address>(pc))) {
+ reinterpret_cast<Address>(pc))) {
SNPrintF(v8_buffer_, "%p (%s)", static_cast<void*>(pc),
wasm::GetWasmCodeKindAsString(wasm_code->kind()));
return v8_buffer_.begin();
diff --git a/src/wasm/module-compiler.cc b/src/wasm/module-compiler.cc
index 99baed4..e36e2e2 100644
--- a/src/wasm/module-compiler.cc
+++ b/src/wasm/module-compiler.cc
@@ -1213,10 +1213,11 @@
// Feedback data is untrusted. An invalid handle could lead to an OOB read
// from the `WasmCodePointerTable`, which either crashes or returns some
- // garbage. Thus `WasmCodeManager::LookupCode` would either return `nullptr`
- // (safe) or an unrelated code object which will be checked for a compatible
- // signature before being inlined.
- // So the CHECK here is mostly just there to silence a false positive report
+ // garbage. Only look up the target address in the current NativeModule: a
+ // target from a foreign module is not an inline candidate, and looking it
+ // up across all modules via `WasmCodeManager::LookupCode` could race with
+ // concurrent teardown of that foreign module.
+ // The CHECK here is mostly just there to silence a false positive report
// by the sandbox crash filter about an OOB read.
uint32_t untrusted_code_pointer =
static_cast<uint32_t>(target_truncated_smi.value());
@@ -1224,10 +1225,9 @@
WasmCodePointer handle = WasmCodePointer{untrusted_code_pointer};
Address entry = GetProcessWideWasmCodePointerTable()
->GetEntrypointWithoutSignatureCheck(handle);
- wasm::WasmCode* code =
- wasm::GetWasmCodeManager()->LookupCode(nullptr, entry);
- if (!code || code->native_module() != instance_data_->native_module() ||
- code->IsAnonymous()) {
+ WasmCodeRefScope code_ref_scope;
+ wasm::WasmCode* code = instance_data_->native_module()->Lookup(entry);
+ if (!code || code->IsAnonymous()) {
// Was not in the main table (e.g., because it's an imported function).
has_non_inlineable_targets_ = true;
return;
diff --git a/src/wasm/wasm-code-manager.cc b/src/wasm/wasm-code-manager.cc
index 16f8ee8..b29f253 100644
--- a/src/wasm/wasm-code-manager.cc
+++ b/src/wasm/wasm-code-manager.cc
@@ -3061,17 +3061,11 @@
}
WasmCode* WasmCodeManager::LookupCode(Isolate* isolate, Address pc) const {
+ DCHECK_NOT_NULL(isolate);
// Since kNullAddress is used as a sentinel value, we should not try
- // to look it up in the cache
+ // to look it up in the cache.
if (pc == kNullAddress) return nullptr;
- // If 'isolate' is nullptr, do not use a cache. This can happen when
- // called from function V8NameConverter::NameOfAddress
- if (isolate) {
- return isolate->wasm_code_look_up_cache()->GetCacheEntry(pc)->code;
- } else {
- wasm::WasmCodeRefScope code_ref_scope;
- return LookupCode(pc);
- }
+ return isolate->wasm_code_look_up_cache()->GetCacheEntry(pc)->code;
}
std::pair<WasmCode*, SafepointEntry&> WasmCodeManager::LookupCodeAndSafepoint(
diff --git a/src/wasm/wasm-code-manager.h b/src/wasm/wasm-code-manager.h
index 482c66c..d3a367b 100644
--- a/src/wasm/wasm-code-manager.h
+++ b/src/wasm/wasm-code-manager.h
@@ -1178,14 +1178,19 @@
static bool CanRegisterUnwindInfoForNonABICompliantCodeRange();
#endif // V8_OS_WIN64
+ // Returns the NativeModule that contains the given address. Note that the
+ // returned pointer is borrowed and not reference-counted. Callers MUST ensure
+ // that the target NativeModule is kept alive (e.g. by an active call stack,
+ // owning pointer, or WasmCodeRefScope).
NativeModule* LookupNativeModule(Address pc) const;
- // Returns the Wasm code that contains the given address. The result
- // is cached. There is one cache per isolate for performance reasons
- // (to avoid locking and reference counting). Note that the returned
- // value is not reference counted. This should not be an issue since
- // we expect that the code is currently being executed. If 'isolate'
- // is nullptr, no caching occurs.
+ // Returns the Wasm code that contains the given address. The result is cached
+ // in the given isolate's lookup cache. Note that the returned pointer is
+ // borrowed and not reference-counted. The {isolate} must not be nullptr.
WasmCode* LookupCode(Isolate* isolate, Address pc) const;
+ // Non-cached version of LookupCode for contexts where no Isolate is available
+ // (e.g., in the disassembler). The caller must have an active
+ // WasmCodeRefScope. Returns a borrowed pointer.
+ WasmCode* LookupCode(Address pc) const;
// The referenced {SafepointEntry} is owned by the cache. The next call
// to this function must be assumed to invalidate the reference.
std::pair<WasmCode*, SafepointEntry&> LookupCodeAndSafepoint(Isolate* isolate,
@@ -1243,8 +1248,6 @@
void AssignRange(base::AddressRegion, NativeModule*);
- WasmCode* LookupCode(Address pc) const;
-
const size_t max_committed_code_space_;
std::atomic<size_t> total_committed_code_space_{0};
Original Bug Report
WasmCodeManager::LookupCode races NativeModule teardown and reads freed owned_code_ nodes
Report description
WasmCodeManager::LookupCode races NativeModule teardown and reads freed owned_code_ nodes
Bug location
Where do you want to report your vulnerability?
Chrome VRP – Report security issues affecting the Chrome browser. See program rules
Which URL (or repository) have you found the vulnerability in?
https://source.chromium.org/chromium/chromium/src
The problem
Please describe the technical details of the vulnerability
FeedbackMaker::AddCallIndirectCandidate() reads a WasmCodePointer value from call_indirect feedback, obtains its process-wide code entrypoint without a signature check, and calls WasmCodeManager::LookupCode() before checking whether the returned WasmCode belongs to the current instance’s NativeModule.
// v8/src/wasm/module-compiler.cc:1219-1233
WasmCodePointer handle = WasmCodePointer{untrusted_code_pointer};
Address entry = GetProcessWideWasmCodePointerTable()
->GetEntrypointWithoutSignatureCheck(handle);
wasm::WasmCode* code =
wasm::GetWasmCodeManager()->LookupCode(nullptr, entry);
if (!code || code->native_module() != instance_data_->native_module() ||
code->IsAnonymous()) {
has_non_inlineable_targets_ = true;
return;
}
WasmCodeManager::LookupNativeModule() obtains a raw NativeModule* from lookup_map_ while holding native_modules_mutex_, but LookupCode() calls NativeModule::Lookup() only after that lock has been released. No strong owner of the module is acquired.
// v8/src/wasm/wasm-code-manager.cc:3040-3060
NativeModule* WasmCodeManager::LookupNativeModule(Address pc) const {
base::MutexGuard lock(&native_modules_mutex_);
// ...
return region_start <= pc && pc < region_end ? candidate : nullptr;
}
WasmCode* WasmCodeManager::LookupCode(Address pc) const {
NativeModule* candidate = LookupNativeModule(pc);
if (candidate) return candidate->Lookup(pc);
// ...
}
NativeModule::~NativeModule() removes engine bookkeeping, but its code-space entries remain in WasmCodeManager::lookup_map_ until the later WasmCodeAllocator member destructor. NativeModule declares code_allocator_ before allocation_mutex_ and owned_code_, so C++ reverse member destruction deletes the owned_code_ map before WasmCodeAllocator::~WasmCodeAllocator() removes those entries from lookup_map_.
// v8/src/wasm/wasm-code-manager.h:1030-1124
OperationsBarrier::Token engine_scope_;
WasmCodeAllocator code_allocator_;
// ...
mutable base::RecursiveMutex allocation_mutex_;
mutable std::map<Address, std::unique_ptr<WasmCode>> owned_code_;
The attached PoC reaches that dangling-pointer interval as follows:
- During a Dedicated Worker BackForwardCache freeze, a loop
WasmStackChecksuspends optimized WebAssembly code while a nestableWebAssembly.instantiateStreaming()completion task applies an active element segment to an imported ordinary table. WasmStackCheckOp::Effects()permits arbitrary writes, butLateLoadEliminationAnalyzer::ProcessWasmStackCheck()invalidates onlyWasmTrustedInstanceData::kMemory0SizeOffset. A following mutable numericglobal.setinvalidates the cached dispatch target load but not the cached implicit-argument load. The nextcall_indirectexecutes C’s Liftoff code with A’sWasmTrustedInstanceData.- C’s first direct-call counter updates A’s first
call_indirectfeedback slot. Matching function indices and slot layouts turn A’s existing handle into a valid handle for a function in a foreign targetNativeModule. - The feedback consumer reads that foreign handle’s entrypoint. A separate module’s allocator teardown temporarily holds
native_modules_mutex_while the target worker releases its lastNativeModuleowner and frees anowned_code_node; the laterWasmCodeAllocatordestructor cannot eraselookup_map_while that mutex remains held. LookupNativeModule()still returns the dying module, andNativeModule::Lookup()traverses the freedowned_code_node.
The attached AddressSanitizer log reports a read of a freed 48-byte std::map node. Symbolizing its Chrome offsets against Build ID c56df57ff7a2e0eb gives NativeModule::Lookup() through FeedbackMaker::AddCallIndirectCandidate() and TransitiveTypeFeedbackProcessor on one Dedicated Worker, while another Dedicated Worker frees the node through NativeModule::~NativeModule() and Isolate::ReleaseSharedPtrs().
PoC
All WebAssembly modules, Worker sources, navigation, and synchronization are embedded in poc.html. The page uses standard web APIs and an ordinary unshared WebAssembly table.
The attached run used the default BackForwardCache configuration. The final cross-thread lookup is timing-sensitive; rerun with a fresh profile if the page reaches completed-without-crash.
Reproduce
Start the attached local server:
python3 /path/to/attachment/server.py
Run Chrome with a fresh profile:
ASAN_OPTIONS=detect_leaks=0:abort_on_error=1:symbolize=1 \
/path/to/chrome \
--headless=new \
--incognito \
--no-sandbox \
--no-zygote \
--disable-crash-reporter \
--user-data-dir=/path/to/profile \
http://127.0.0.1:8181/poc.html
--incognito and the fresh profile avoid startup state from an earlier run. --no-zygote improves timing reliability, and --no-sandbox simplifies sanitizer log collection.
Impact analysis
A standard webpage can trigger a cross-thread heap use-after-free in trusted native V8 memory outside the V8 sandbox.
The cause
What version of Chrome have you found the security issue in?
153.0.7978.0
Is the security issue related to a crash?
Yes, it is related to a crash.
Choose the type of vulnerability
Memory Corruption (in a sandboxed process)