High watch CVSS 7.5 chrome Race 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
7.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace condition in V8
DescriptionRace condition in V8
ComponentV8
Sub-componentCompiler
Bug ClassRace
Action tierwatch — ub-class only — no demonstrated corruption path
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker541837151
Fix commit81b2acf3bbfe (v8/v8) +19/-9
CWECWE-367 (TOCTOU race)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedSalvatore Gulizia (nickname: Serotav)
Reported2026-08-03 (per vendor advisory)
Fix landed2026-08-07 (public git)
Disclosed2026-08-20
Patch gap13 days — fix public in source before the advisory

Background

`RecordScopeInfos`
a compiler helper that walks a Tagged<HeapObject> and records its ScopeInfo (and, for a SharedFunctionInfo, its outer scope info) during compilation merging.
Background merge
V8’s concurrent step that mutates a SharedFunctionInfo’s scope_info and OuterScopeInfo on 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 ScopeInfo sentinel returned when no real ScopeInfo is present, letting callers detect the racy/absent case via IsEmpty.

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.

Key insight
The core mistake was reading 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

  1. Trigger background compilation Cause a JavaScript function to be compiled on a background thread so a background merge is in flight against its SharedFunctionInfo.
  2. Race the field mutation Have the background merge mutate scope_info and OuterScopeInfo at the same moment the main thread enters RecordScopeInfos.
  3. Observe torn reads The unsynchronized scope_info() / GetOuterScopeInfo() accesses read inconsistent intermediate state, since the two loads are not a single atomic snapshot.
  4. Record corrupt scope info RecordScopeInfos records an inconsistent or transient ScopeInfo, propagating bad compiler state derived from the data race.

Impact Assessment

An attacker who can drive concurrent JIT compilation and background merges in the renderer’s V8 engine can win the data race to make 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.cc
  • src/objects/shared-function-info-inl.h
  • src/objects/shared-function-info.h

Audit Directions

  • Multi-field reads under concurrency
    Look for helpers that read two or more mutable object fields (like scope_info and OuterScopeInfo) with plain loads where a background thread can mutate them concurrently.
  • Missing acquire ordering
    Flag accessors used during compilation/merge that lack kAcquireLoad on fields written with release semantics by background threads.
  • Merge-time SFI access
    Audit other SharedFunctionInfo field reads on the background-merge path that assume a stable object and do not tolerate a racily-compiling, empty state.
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.
Loading diff…

Original Bug Report

reported by [email protected]

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.

  1. Run python3 poc.py 8000.
  2. 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
  1. 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:

Race timeline

  1. The worker reads the old empty scope_info in HasOuterScopeInfo().
  2. The main thread runs CopyFrom(), publishing a real function ScopeInfo without an outer slot, then FeedbackMetadata; code is installed last.
  3. 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.
  4. GetOuterScopeInfo() reloads the new function scope. Its OuterScopeInfo() access aliases unused_parameter_bits and returns the attacker-selected fake pointer.
  5. 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 position 1670115
  • Chromium commit: 75ccbb0e8a6fda6f1153ebfd357d1718da93679b
  • V8: 15.3.8, commit 6a98814ab815ca70a2f78fe413b9012b81042957

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)
View on issue tracker
Links in the report