CVE-2026-76020
Overview
Background
- `RecordScopeInfos`
- a compiler helper that walks a
Tagged<HeapObject>and records itsScopeInfo(and, for aSharedFunctionInfo, its outer scope info) during compilation merging. - Background merge
- V8’s concurrent step that mutates a
SharedFunctionInfo’sscope_infoandOuterScopeInfoon a background thread while the main thread may be reading the same fields. - `kAcquireLoad`
- a memory-ordering tag for an acquire-ordered atomic read that pairs with a release store, guaranteeing a consistent view of a field mutated by another thread.
- `empty_scope_info`
- a canonical read-only-roots
ScopeInfosentinel returned when no realScopeInfois present, letting callers detect the racy/absent case viaIsEmpty.
Root Cause Analysis
The vulnerable path is RecordScopeInfos, which for a SharedFunctionInfo first read old_sfi->scope_info() and, if empty, old_sfi->GetOuterScopeInfo() as two separate non-atomic accesses. Because a concurrent background merge can mutate both scope_info and OuterScopeInfo, these two reads could observe torn or inconsistent state, violating the invariant that the recorded ScopeInfo reflects a single coherent snapshot of the object.
The fix introduces SharedFunctionInfo::TryGetScopeInfoForMerge(), which performs acquire loads of name_or_scope_info(kAcquireLoad) and raw_outer_scope_info_or_feedback_metadata(kAcquireLoad) in one pass and returns empty_scope_info when neither holds a ScopeInfo. RecordScopeInfos now calls this once and simply returns when the result IsEmpty, safely tolerating a racily compiling function that will be revisited during foreground merging.
scope_info and OuterScopeInfo with plain, unordered loads while a background merge could concurrently write them; the fix collapses the read into a single acquire-ordered pass in TryGetScopeInfoForMerge() that returns a well-defined empty sentinel instead of racing.Attack Path
- Trigger background compilation
Cause a JavaScript function to be compiled on a background thread so a background merge is in flight against its
SharedFunctionInfo. - Race the field mutation
Have the background merge mutate
scope_infoandOuterScopeInfoat the same moment the main thread entersRecordScopeInfos. - Observe torn reads
The unsynchronized
scope_info()/GetOuterScopeInfo()accesses read inconsistent intermediate state, since the two loads are not a single atomic snapshot. - Record corrupt scope info
RecordScopeInfosrecords an inconsistent or transientScopeInfo, propagating bad compiler state derived from the data race.
Impact Assessment
RecordScopeInfos act on inconsistent ScopeInfo/OuterScopeInfo state. The precondition is script execution in the V8 process (a renderer) with timing control sufficient to interleave the background merge against the main-thread read; the gain is compiler-state corruption from the race, undefined behavior that can undermine memory safety.Files Changed
src/codegen/compiler.ccsrc/objects/shared-function-info-inl.hsrc/objects/shared-function-info.h
Audit Directions
- Multi-field reads under concurrencyLook for helpers that read two or more mutable object fields (like
scope_infoandOuterScopeInfo) with plain loads where a background thread can mutate them concurrently. - Missing acquire orderingFlag accessors used during compilation/merge that lack
kAcquireLoadon fields written with release semantics by background threads. - Merge-time SFI accessAudit other
SharedFunctionInfofield reads on the background-merge path that assume a stable object and do not tolerate a racily-compiling, empty state.
Patch
From 81b2acf3bbfecea934d5daed87c27da6347b3182 Mon Sep 17 00:00:00 2001 From: Leszek Swirski <[email protected]> Date: Fri, 07 Aug 2026 13:30:03 +0200 Subject: [PATCH] [compiler] Avoid race condition when reading ScopeInfo during background merge RecordScopeInfos(Tagged<HeapObject> info) has a potential race condition on accesses to both scope_info and OuterScopeInfo, which can both be mutated if the function is concurrently compiled with a background merge. Fix this by introducing SharedFunctionInfo::TryGetScopeInfoForMerge(), which performs a single acquire load pass on name_or_scope_info and raw_outer_scope_info_or_feedback_metadata, returning empty scope info if neither contains a ScopeInfo. TAG=agy CONV=b92d625a-0a50-4a77-88a9-f6a53902a539 Bug: 541837151 Change-Id: Id342ee00ec85c8dc33475ea484e74ea243c5b5ec Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8219869 Commit-Queue: Patrick Thier <[email protected]> Reviewed-by: Patrick Thier <[email protected]> Auto-Submit: Leszek Swirski <[email protected]> Cr-Commit-Position: refs/heads/main@{#109131} --- diff --git a/src/codegen/compiler.cc b/src/codegen/compiler.cc index 4ced00f..0b1e512 100644 --- a/src/codegen/compiler.cc +++ b/src/codegen/compiler.cc @@ -2082,15 +2082,11 @@ void RecordScopeInfos(Tagged<HeapObject> info) { Tagged<ScopeInfo> scope_info; if (Is<SharedFunctionInfo>(info)) { - Tagged<SharedFunctionInfo> old_sfi = Cast<SharedFunctionInfo>(info); - // Also record own scope infos for SFIs. - if (!old_sfi->scope_info()->IsEmpty()) { - scope_info = old_sfi->scope_info(); - } else if (old_sfi->HasOuterScopeInfo()) { - scope_info = old_sfi->GetOuterScopeInfo(); - } else { - return; - } + // We can get an empty scope info here for a function that is racily + // getting compiled, which is ok because we will revisit it during + // foreground merging. + scope_info = Cast<SharedFunctionInfo>(info)->TryGetScopeInfoForMerge(); + if (scope_info->IsEmpty()) return; } else { scope_info = Cast<ScopeInfo>(info); } diff --git a/src/objects/shared-function-info-inl.h b/src/objects/shared-function-info-inl.h index 588843f..617f3b1 100644 --- a/src/objects/shared-function-info-inl.h +++ b/src/objects/shared-function-info-inl.h @@ -728,6 +728,19 @@ return info->OuterScopeInfo(); } +Tagged<ScopeInfo> SharedFunctionInfo::TryGetScopeInfoForMerge() const { + Tagged<Object> maybe_scope_info = name_or_scope_info(kAcquireLoad); + if (IsScopeInfo(maybe_scope_info)) { + return Cast<ScopeInfo>(maybe_scope_info); + } + Tagged<Object> maybe_outer_scope_info_or_feedback = + raw_outer_scope_info_or_feedback_metadata(kAcquireLoad); + if (IsScopeInfo(maybe_outer_scope_info_or_feedback)) { + return Cast<ScopeInfo>(maybe_outer_scope_info_or_feedback); + } + return GetReadOnlyRoots().empty_scope_info(); +} + void SharedFunctionInfo::set_outer_scope_info( Tagged<UnionOf<ScopeInfo, TheHole>> value, WriteBarrierMode mode) { DCHECK(!is_compiled()); diff --git a/src/objects/shared-function-info.h b/src/objects/shared-function-info.h index 1ea073e..841c586 100644 --- a/src/objects/shared-function-info.h +++ b/src/objects/shared-function-info.h @@ -408,6 +408,7 @@ // Get the outer scope info whether this function is compiled or not. inline bool HasOuterScopeInfo() const; inline Tagged<ScopeInfo> GetOuterScopeInfo() const; + inline Tagged<ScopeInfo> TryGetScopeInfoForMerge() const; // [feedback metadata] Metadata template for feedback vectors of instances of // this function.
Original Bug Report
V8 background code-cache merge race treats `unused_parameter_bits` as a `ScopeInfo*`
REPRODUCTION CASE
Tested with official Linux x64 ASAN Chromium 153.0.7979.0 at branch position 1670115.
- Run
python3 poc.py 8000. - Start the ASAN Chrome build with a fresh profile:
./chrome --headless=new \
--user-data-dir=/path/to/fresh-profile \
--no-first-run --disable-breakpad \
--js-flags="--bytecode-old-time=0 --expose-gc" \
http://127.0.0.1:8000/poc.html
- Allow at least 150 seconds before declaring a miss. The first approximately 45-50 seconds are required for ServiceWorker code-cache setup.
For a stock-browser reproduction, omit --js-flags and allow at least six minutes for the first batch. After pre-caching, the PoC waits approximately 210 seconds for V8’s default 180-second bytecode age before racing. Each fallback batch requires the same aging period.
Stock d8 is not a valid reproducer: its --cache modes do not call StartConsumingCodeCache/ConsumeCodeCacheTask and never enter this BackgroundMergeTask configuration. ClusterFuzz should run the attachment in a release ASAN Chrome renderer (is_asan=true, is_debug=false).
VULNERABILITY DETAILS
V8’s default-enabled background code-cache merge reads a SharedFunctionInfo (SFI) while the renderer main thread publishes compiled state to that same cached SFI. HasOuterScopeInfo() can validate the old state, then GetOuterScopeInfo() reloads a new ScopeInfo with no outer-scope slot. In release builds, OuterScopeInfo() then reads the aliased unused_parameter_bits slot as a compressed ScopeInfo*.
JavaScript controls all 31 bits of that slot through formal-parameter usage. With pointer compression, the interpreted pointer is:
fake ScopeInfo = cage_base + (unused_parameter_bits << 1) - 1
Affected code path
Blink CacheStorage full code cache
-> v8::ScriptCompiler::ConsumeCodeCacheTask
-> BackgroundMergeTask::BeginMergeInBackground (worker)
-> ConstantPoolPointerForwarder::RecordScopeInfos
-> SharedFunctionInfo::{Has,Get}OuterScopeInfo
concurrently with
BackgroundMergeTask::CompleteMergeInForeground (main thread)
-> SharedFunctionInfo::CopyFrom
Verified in V8 commit 968f19a8970f8d91702d86f0ec1522f3909781b7:
shared-function-info-inl.h:713-732andshared-function-info.cc:247-274:HasOuterScopeInfo()andGetOuterScopeInfo()load independently whileCopyFrom()publishes the replacement fields separately.compiler.cc:2097-2136,2458-2555, and2593-2688: the background read races the foregroundCopyFrom().scope-info-inl.h:137-169,314-318, andscope-info.cc:487-496: a function scope without an outer scope aliases that slot with JavaScript-controlledunused_parameter_bits.compiler.cc:2165-2169,shared-function-info-inl.h:673-698,scope-info.cc:912-917, andscope-info-inl.h:268-275: the recorded pointer can reach a tagged field store throughSetScopeInfo().
Race timeline
- The worker reads the old empty
scope_infoinHasOuterScopeInfo(). - The main thread runs
CopyFrom(), publishing a real functionScopeInfowithout an outer slot, thenFeedbackMetadata; code is installed last. - The worker still observes the old lazy state but reads the new union and accepts it as an outer
ScopeInfo; release-only checks do not reject the mismatch. GetOuterScopeInfo()reloads the new function scope. ItsOuterScopeInfo()access aliasesunused_parameter_bitsand returns the attacker-selected fake pointer.- UID processing later reaches a read or four-byte tagged write through that pointer.
The words are individually atomic, but the multi-field SFI transition is not. Acquire/release ordering provides no snapshot, lock, version check, or retry across the predicate and getter.
Browser trigger
The attachment pre-caches six source-distinct CacheStorage corpora in a ServiceWorker: 32 scripts per corpus and 3,600 functions per script, or 691,200 function definitions compiled and cached during installation. This takes approximately 50 seconds under the tested ASAN build and is required for reliable reproduction.
For each corpus the PoC serves byte-identical responses without side data to prime lazy compilation-cache entries, waits 210 seconds for default bytecode aging, then releases concurrent raw cached-script loads across same-origin frames. A missed batch therefore does not reuse the previous batch’s compilation-cache state.
For sanitizer triage, --bytecode-old-time=0 --expose-gc replaces only the 210-second aging delay. The PoC performs two explicit GCs before starting the same race.
Security impact
The controlled tagged store provide arbitrary read/write within the V8 heap.
VERSION
- Chrome ASAN Version: Chromium
153.0.7979.0, official Linux x64 ASAN archive at branch position1670115 - Chromium commit:
75ccbb0e8a6fda6f1153ebfd357d1718da93679b - V8:
15.3.8, commit6a98814ab815ca70a2f78fe413b9012b81042957
BISECTION
A git bisect was run between current V8 6a98814ab815ca70a2f78fe413b9012b81042957 (bad) and parent commit 0c26903aa129bb300ae0701eb0826ebd535772de (good). The predicate marked a revision bad when the background merge contained both independent old_sfi->HasOuterScopeInfo() and old_sfi->GetOuterScopeInfo() accesses:
git bisect start 6a98814ab815ca70a2f78fe413b9012b81042957 \
0c26903aa129bb300ae0701eb0826ebd535772de
git bisect run sh -c 'if git grep -q "old_sfi->HasOuterScopeInfo()" -- src/codegen/compiler.cc && git grep -q "old_sfi->GetOuterScopeInfo()" -- src/codegen/compiler.cc; then exit 1; fi; exit 0'
First bad commit:
9cbd134898ea79820e20a5a5a1a583b08e30698b
[compiler] Reuse ScopeInfos from previous compiles
Cr-Commit-Position: refs/heads/main@{#94966}
9cbd134898ea79820e20a5a5a1a583b08e30698b introduced RecordOuterScopeInfos() in ConstantPoolPointerForwarder and the predicate/getter pair used by BeginMergeInBackground. Its direct parent does not contain that worker-side pair. This is a source-level boundary; archived browser binaries were not runtime-bisected.
EXPECTED RESULT
All merges preserve a consistent SFI state, every generation completes without a renderer fault.
ACTUAL RESULT / CRASH STATE
Signal: SEGV_ACCERR
The ASAN build reports this as Received signal 11 SEGV_ACCERR, the corrupted compressed pointer resolves to an inaccessible address inside the V8 cage reservation.
ATTACHMENTS
poc.py: self-contained local Chrome crash reproducer and HTTP server.
CREDIT INFORMATION
Reporter credit:
Salvatore Gulizia (nickname: Serotav)
- http://127.0.0.1:8000/poc.html
- https://chromium.googlesource.com/v8/v8/+/6a98814ab815ca70a2f78fe413b9012b81042957/
- https://chromium.googlesource.com/v8/v8/+/968f19a8970f8d91702d86f0ec1522f3909781b7/
- https://chromium.googlesource.com/v8/v8/+/968f19a8970f8d91702d86f0ec1522f3909781b7/src/codegen/compiler.cc#2097
- https://chromium.googlesource.com/v8/v8/+/968f19a8970f8d91702d86f0ec1522f3909781b7/src/codegen/compiler.cc#2165
- https://chromium.googlesource.com/v8/v8/+/968f19a8970f8d91702d86f0ec1522f3909781b7/src/codegen/compiler.cc#2458
- https://chromium.googlesource.com/v8/v8/+/968f19a8970f8d91702d86f0ec1522f3909781b7/src/codegen/compiler.cc#2593
- https://chromium.googlesource.com/v8/v8/+/968f19a8970f8d91702d86f0ec1522f3909781b7/src/objects/scope-info-inl.h#137
- https://chromium.googlesource.com/v8/v8/+/968f19a8970f8d91702d86f0ec1522f3909781b7/src/objects/scope-info-inl.h#268
- https://chromium.googlesource.com/v8/v8/+/968f19a8970f8d91702d86f0ec1522f3909781b7/src/objects/scope-info-inl.h#314
- https://chromium.googlesource.com/v8/v8/+/968f19a8970f8d91702d86f0ec1522f3909781b7/src/objects/scope-info.cc#487
- https://chromium.googlesource.com/v8/v8/+/968f19a8970f8d91702d86f0ec1522f3909781b7/src/objects/scope-info.cc#912
- https://chromium.googlesource.com/v8/v8/+/968f19a8970f8d91702d86f0ec1522f3909781b7/src/objects/shared-function-info-inl.h#673
- https://chromium.googlesource.com/v8/v8/+/968f19a8970f8d91702d86f0ec1522f3909781b7/src/objects/shared-function-info-inl.h#713
- https://chromium.googlesource.com/v8/v8/+/968f19a8970f8d91702d86f0ec1522f3909781b7/src/objects/shared-function-info.cc#247
- https://chromium.googlesource.com/v8/v8/+/9cbd134898ea79820e20a5a5a1a583b08e30698b/