The Basics
| Disclosure / Patch Date | January 16, 2024 |
| Product | Google Chrome / V8 JavaScript engine |
| Advisory | Chrome Stable Channel Update · CISA KEV (added 2024-01-17, federal remediation deadline 2024-02-07) · NVD CVE-2024-0519 (CVSS 8.8, CWE-787) |
| Affected Versions | Chrome < 120.0.6099.224 (Chromium-based browsers including Edge, Opera, etc. inherit the V8 vulnerability) |
| First Patched Version | Chrome 120.0.6099.224 (V8 12.0.267.16) |
| Issue / Bug Report | crbug.com/1517354 (redirects to Buganizer 41490332; access-restricted) |
| Patch CL | v8/v8 e0f2a195 — “Merged: [runtime] Drop fast last-property deletion” by Toon Verwaest (cherry-pick of 389ea9be to branch-heads/12.0) |
| Bug-Introducing CL | v8/v8 98acfb36 (2017-04-24, Jakob Kummerow — “[builtins] DeleteProperty: Handle last-added fast properties”) introduced the fast-delete path; subsequent commits across 2019-2023 re-enabled or restructured the optimization. See §6 fault lineage for the full 22-commit history. |
| Reporter | Anonymous |
| In-the-wild? | Yes (per CISA KEV; renderer stage of an Android exploit chain — see §5 ITW Android Chain) |
| Bug class | Out-of-bounds memory access in V8 (CWE-787, per Chrome advisory + NVD) |
| Root cause | TOCTOU race in JIT compiler’s constant-folding pipeline → type confusion (PropertyConstness::kConst invariant violation). Confused-pointer reads explain the OOB memory access classified above; exploitation analysis in §5. |
”[$TBD][1517354] High CVE-2024-0519: Out of bounds memory access in V8. Reported by Anonymous on 2024-01-11.”
— Chrome Stable Channel release note, January 16, 2024
1. The Vulnerability
CVE-2024-0519 is described in Chrome’s advisory as an out-of-bounds memory access in V8 (CWE-787). At root it is a TOCTOU race in V8’s background compiler that produces a type confusion which, in turn, yields the OOB read/write the advisory describes. It was patched in Chrome 120.0.6099.224 on January 16, 2024. It was reported anonymously on January 11, 2024, and was being actively exploited in the wild at the time of disclosure — a CISA KEV entry confirmed federal patching urgency.1 CISA added the CVE to its Known Exploited Vulnerabilities catalog on 2024-01-17, one day after the patch, with a federal-agency remediation deadline of 2024-02-07. CWE-787 (out-of-bounds write). CISA added the CVE to its Known Exploited Vulnerabilities catalog on 2024-01-17, one day after the patch, with a federal-agency remediation deadline of 2024-02-07. CWE-787 (out-of-bounds write). Google Threat Intelligence later confirmed it as the renderer-stage vulnerability in a three-bug Android exploitation chain alongside CVE-2025-27038 (Adreno GPU user-land UAF) and CVE-2023-33106 (KGSL kernel driver).
A note on confidence. Some claims here are anchored in patch artifacts; others are best-fit inferences from layout analysis. I reproduced the race, the filler read, and the type confusion against pre-patch V8 with a C++ harness. What I was unable to do was reproduce a JavaScript trigger or construct a reliable arbitrary R/W primitive — both end where the unreleased ITW sample begins. Every load-bearing claim is marked reproduced or inferred in §7.
The TOCTOU (time-of-check / time-of-use) window opens like this. When the main thread deletes the last named property from a regular JavaScript object, the fast-path implementation (DeleteObjectPropertyFast) writes a filler map pointer (one_pointer_filler_map) into the cleared field slot before rolling the object’s map back via the back pointer. During that window — after the field is cleared but before the map is updated — V8’s background TurboFan compiler can be simultaneously executing GetOwnFastDataPropertyFromHeap on the same object. That function reads the map via an acquire load, passes a map-match check, then reads the field value. The acquire load sees the old map (not yet rolled back), so the check passes. The field read returns the filler map pointer.
The filler passes all four integrity checks in the pipeline (map match, double map check via RawInobjectPropertyAt, uninitialized test, and FitsRepresentation(Tagged)). The compiler returns the filler as the property’s “constant” value, and TryFoldLoadConstantDataField embeds it as a literal HeapConstant node in the TurboFan graph.
When compiled code then accesses a sub-field of this “constant” value — e.g., reading obj.inner.field where obj.inner was const-folded — the kFastDataConstant optimization eliminates the intermediate CheckMaps guard between the two LoadField instructions (see §4). The second LoadField reads from the filler map’s memory at the expected sub-field offset, without any type check. The result is a type confusion: JIT code treats filler map metadata (Map bitfields, DescriptorArray pointers, null_value singletons) as application-level values.
The fix — commit e0f2a195d87c9a06685121e0e783efd92d030df3 by Toon Verwaest — deleted the entire fast-path optimization: 292 lines gone, no replacement. This is a stronger signal than a whack-a-mole patch. V8 concluded the optimization was structurally unsafe to maintain alongside the rest of the optimization machinery and rare enough in practice that removing it was cheaper than fixing it.
This vulnerability is one instance of a chronic 9-year bug class in V8’s PropertyConstness::kConst invariant, with at least 22 commits across the same code neighborhood from 2017 through 2026 and a regression test suite of four structurally identical tests — all of which were written to catch variants of the same fundamental gap: a code path that fails to inform all dependent consumers when a “constant” property is no longer constant.
2. Patch Triage and Fix Discovery
The Naive Hunt
In late 2025 I picked up the Project Zero ITW tracker — in-the-wild zero-days. Filtered for V8, 2024, no public RCA. Seven came up. CVE-2024-0519 was first.
The Chrome release note said: ”[$TBD][1517354] High CVE-2024-0519: Out of bounds memory access in V8. Reported by Anonymous on 2024-01-11.” Bug tracker: private. Typical embargo for ITW vulns.
I cloned the V8 repository and tried to isolate the patch commit by date. Based on the stable release (Tuesday, January 16, 2024) and report date (January 11), I ran:
git log --since="2023-12-01" --until="2024-01-16"
Notable commits that surfaced:
4f7cd6206d412024-01-17 — Enhance condition to compare ‘length’ for array[[DefineOwnProperty]]cebda1e268fd2023-12-05 — [sandbox] SBXCHECK that arguments access is in bounds (Samuel Groß,Bug: chromium:1507223)0deeaf5f593b2023-12-05 — [sandbox] Introduce SBXCHECK
cebda1e268fd mentioned “bounds” and “out-of-bounds access” and landed in the same Chrome 120 release window. I stupidly thought this might be it. Samuel Groß, SBXCHECK, OOB — it felt right. That led me on a wild goose chase into a V8 sandbox audit.2 Separate post, coming later. The bug ID was chromium:1507223, not chromium:1517354. The numerical proximity (1507223 vs 1517354) was enough to anchor an entire week of misdirected work. Separate post, coming later. The bug ID was chromium:1507223, not chromium:1517354. The numerical proximity (1507223 vs 1517354) was enough to anchor an entire week of misdirected work. The bug ID was chromium:1507223, not chromium:1517354. Wrong commit entirely.
mistymntncop’s Gist (Hypothesis 2)
I searched Twitter. Found a researcher going by mistymntncop with multiple threads about a CVE they’d labeled their “cold case.” A gist gave me real direction. They had mapped the deletion path: NotifyObjectLayoutChange, SetProperties, ClearField, HeapObjectLayout::set_map. The key observation: “Is the problem because of a modification it is doing or something it is forgetting to do?”
Their conclusion: the JS-level semantics anomaly is “not useful for exploitation AT ALL.”
They were right — at that layer. But they hadn’t gone one layer lower. More on this in §4.
Bug ID 41490332 — The Breakthrough
Then: a tweet from mistymntncop:
“Just great :) ! Also, CVE-2024-0519 (41490332) is my CVE white whale haha.”
That was the first real lead. Old Monorail crbug IDs map to newer Buganizer IDs. Clicking crbug.com/1517354 redirects to issues.chromium.org/issues/41490332 with “Access is denied.” Same issue, two IDs.
With 41490332 I found two things.
1. The Scribd V8 exploit tracker — an unofficial mirror of Samuel Groß’s V8 vulnerability tracker.3 Groß’s tracker is internally maintained at Google. The Scribd copy is a snapshot circulated in security circles. Not authoritative, but it preserves descriptive prose that the public CVE record doesn’t have. Groß’s tracker is internally maintained at Google. The Scribd copy is a snapshot circulated in security circles. Not authoritative, but it preserves descriptive prose that the public CVE record doesn’t have. The entry for 41490332:
- Description: “Incorrect fast-path for JS property deletion in runtime”
- ITW: Yes
- V8 sandbox bypass: Yes
- Requires optimizing JIT: Probably
- Surface: JavaScript
That word — runtime — was doing heavy lifting. The bug is in the runtime layer (C++ runtime functions), not the JS semantics layer. That word redirected my attention from the JavaScript observable behavior down to src/runtime/runtime-object.cc.
2. The Cromite breadcrumb. Cromite is a privacy-focused Android Chromium fork. Their GitHub release issue #720 for Chromium 120.0.6099.230 explicitly mapped:
CVE-2024-0519 → bug 1517354 → commit e0f2a195d87c9a06685121e0e783efd92d030df3
Non-authoritative — packaging-side metadata, not a Google RCA — but concrete and fetchable. That commit became the primary lead.
Verifying the Fix Commit
git show e0f2a195d87c9a06685121e0e783efd92d030df3 | head -1
git log -1 --format="%B" e0f2a195 | grep -i "Bug:"
commit e0f2a195d87c9a06685121e0e783efd92d030df3
Bug: chromium:1517354
The commit message references chromium:1517354 — the public crbug from the Chrome release note. This is the fix. (Full commit metadata, the diff itself, and the analytical implications of what was deleted: see §6.)
3. Vulnerable Code Path
Layer 1: JavaScript
let obj = { a: 1, b: 2, c: 3 };
delete obj.c; // deletes the last named property
delete on a named own property dispatches through the Runtime_DeleteProperty entry point in src/runtime/runtime-object.cc. Runtime functions are the C++ implementations of JavaScript built-in operations.
Layer 2: Runtime — DeleteObjectPropertyFast
Before the fix, Runtime_DeleteProperty could invoke DeleteObjectPropertyFast if certain preconditions were met:
- The receiver is a regular (non-special) object
- The property being deleted is the last one added (tracked by the map)
- The property is configurable
- The map has a back pointer (i.e., it’s not a root/prototype map)
- The last map transition was from adding a property (not a special transition)
- The property name is a unique string
When all conditions held, the fast path:
- Called
ClearFieldto writeone_pointer_filler_mapinto the field slot - Used the map’s back pointer to roll the object’s map back to the parent map
- Called
GeneralizeAllTransitionsToFieldAsMutableto walk the transition tree and demote anyPropertyConstness::kConstannotations for the deleted field tokMutable
The race-relevant lines, in execution order:
// src/runtime/runtime-object.cc
// In ClearField:
line 84: MapWord filler_map_word = ReadOnlyRoots(isolate).one_pointer_filler_map_word();
line 91: TaggedField<MapWord>::Release_Store(object, offset, filler_map_word);
// In DeleteObjectPropertyFast:
line 196: ClearField(...);
// race window OPEN
line 212: receiver->set_map(*parent_map, kReleaseStore);
The intent was to avoid the heavier JSReceiver::DeleteProperty path which goes through IC infrastructure and full map migrations. The optimization was real — it avoided significant overhead for a common pattern.
Layer 3: The Race Window
The race window opens at line 91 (filler write) and closes at line 212 (map rollback). During that span, the field slot contains one_pointer_filler_map while the object’s map still reflects its pre-deletion shape — any thread that reads the slot in that interval sees filler from a slot whose map check still passes. The cross-thread choreography is visualized in §5 Phase 1.
Layer 4: Background Compiler — GetOwnFastDataPropertyFromHeap
V8’s TurboFan compiler runs on a background thread. During background compilation, when the compiler encounters a property load on an object it has tracked, it can call GetOwnFastDataPropertyFromHeap (src/compiler/heap-refs.cc:284) to directly read the property value from the heap — bypassing IC lookup and embedding the value as a constant in the compiled code.
This function works like this:
line 298: Tagged<Map> map = holder.object()->map(cage_base, kAcquireLoad);
line 299: if (*holder.map(broker).object() != map) return {};
// [NO LOCK BETWEEN CHECK AND READ]
line 306: constant = holder.object()->RawInobjectPropertyAt(cage_base, map, field_index);
There is no lock between the map check and the field read.
If the main thread calls ClearField after the background compiler reads the map (line 298) but before it reads the field (line 306), the background compiler will:
- Read the old map, which hasn’t been rolled back yet — map check PASSES
- Read the field slot — which now contains
one_pointer_filler_map
4. Root Cause
The TOCTOU Race
The root cause is a classic TOCTOU race between two unsynchronized threads:
Reader (background compiler, GetOwnFastDataPropertyFromHeap):
- Reads map via
kAcquireLoad - Checks map matches cached expectation
- [window begins]
- Reads field value
Writer (main thread, DeleteObjectPropertyFast → ClearField):
- Writes
one_pointer_filler_mapto field slot - [window ends]
- Calls
set_mapto roll map back
If the writer lands between steps 2 and 4 of the reader, the reader gets a filler map pointer from a field it believes contains a valid property value.
Why the Filler Passes All Four Pipeline Checks
After reading the field, GetOwnFastDataPropertyFromHeap runs four checks before accepting the value as a “constant” to embed in JIT code. The one_pointer_filler_map passes all four:
| Check | What it tests | Why filler PASSES |
|---|---|---|
| Map match (kAcquireLoad, line 298) | Live map == cached map | Map not yet rolled back at race time |
RawInobjectPropertyAt double map check | Same map, re-verify after read | Still same map (rollback hasn’t happened) |
IsUninitialized | Value == uninitialized_value root | one_pointer_filler_map ≠ uninitialized_value — they are different root objects |
FitsRepresentation(Tagged) | Value fits the field’s declared representation | Filler IS a HeapObject, and Tagged representation accepts any HeapObject or Smi |
That last check is the key architectural fact: Tagged representation accepts any HeapObject. It was designed for polymorphic properties. The filler map is a HeapObject. It fits. No check fires.
Why Tagged Representation Is Exploitable
If the field had been declared as Smi representation, the FitsRepresentation check would have rejected the filler (a HeapObject, not a Smi). If declared as HeapObject with a specific map type, the representation check might catch it. But Tagged is the broadest representation — it accepts anything.
Many in-object properties in real JavaScript objects use Tagged representation because they start with diverse types or were never monomorphized. An attacker can influence representation by controlling the initialization pattern of the object.
What Happens After the Race
The filler map pointer passes GetOwnFastDataPropertyFromHeap and is returned as the property’s “constant” value. The compiler records this via OwnConstantDataPropertyDependency. At JIT code finalization, OwnConstantDataPropertyDependency::IsValid checks: does the current map match? Does the current value equal the recorded value? If the object has not been further modified since the race, both checks may pass — because the filler value in the slot matches what was recorded during compilation.
The JIT code is installed with the filler map pointer embedded as a literal constant via TryFoldLoadConstantDataField (src/compiler/property-access-builder.cc:182, with the ConstantNoHole embed call at line 214).
The kFastDataConstant Guard Elimination
When TurboFan compiles a chained property access like obj.inner.field where obj.inner is a kFastDataConstant field, it emits two LoadField instructions:
- First LoadField: reads the kConst property from the receiver (
receiver + fieldOffset) - Second LoadField: reads a sub-field of the returned value (
result + fieldOffset2)
No CheckMaps guard is emitted between the two LoadFields. The kConst assumption removes the intermediate type check — the compiler trusts the constant value is always of the same type, so it skips the dynamic check before the second access.
This is the mechanism that makes the type confusion exploitable:
- Race fires → first LoadField returns
one_pointer_filler_mapinstead of expected inner object - Second LoadField reads
filler_map + sub_field_offsetwithout any CheckMaps deopt guard - At offset +0x18, filler_map contains
0x000006d9→ cage+0x6d8 →empty_descriptor_array(InstanceType 233) - JIT code receives a DescriptorArray where it expected an application-level value — non-crashing type confusion
The optimization that creates the race (kConst assumption enabling background heap reads) is the same optimization that removes the guard that would catch the race’s output (kFastDataConstant eliminating CheckMaps). Two bugs, one optimization. This architectural coupling explains why the fix was to delete the entire optimization rather than patch any single gap.
The algebraic check: V8’s JSObject layout under pointer compression places the map at +0x00, properties at +0x04, elements at +0x08 (each 4 bytes — kTaggedSize = kInt32Size), then inobject slots starting at +0x0c. Inobject slot index 3 sits at +0x0c + 3 × 4 = +0x18. That same offset within a Map object is the instance_descriptors field. So when JIT reads the 4th inobject slot of a value that is actually a one_pointer_filler_map (which IS a Map), it reads filler_map’s instance_descriptors — pointing to the empty_descriptor_array singleton at cage+0x6d8.
Why JavaScript Probes Failed
This is the counterintuitive finding that consumed 63 probe iterations in d8, then nine more PoC strategies in Chrome’s renderer, before being understood.
V8’s safepoint mechanism ensures that when the JS main thread is executing, background compiler threads are parked at safepoints when V8 needs to coordinate. The JS execution model and the background compiler model share a safepoint protocol: the compiler doesn’t freely access the heap while JS is running through code paths that haven’t yielded. V8 provides intrinsics (%WaitForBackgroundOptimization, %FinalizeOptimization) that make this explicit for testing.
In d8, every JS-level timing attempt produces zero races: 20,000 fuzzed delete iterations, 63 structured probes, 6 deterministic timing window tests — all zero hits across early sessions of this RCA.
In Chrome’s renderer (content_shell, V8 12.0.267.16, headless), the picture initially looks more promising — Chrome runs the BG TurboFan compiler on a genuinely concurrent OS thread. I tested nine distinct PoC strategies against a real Chrome renderer, totalling ~53.5 million race attempts. All produced zero hits:
The version numbers below are the strategies that were actually run end-to-end. v2 was a dead-end design discarded before measurement; v10 is a diagnostic probe (no race attempts), discussed immediately below the table.
| Strategy | Mechanism | Attempts | Hits |
|---|---|---|---|
| v1 | Original (no intrinsics, single Victim, periodic rewarm) | 500,000 | 0 |
| v3 | Forced sync via %WaitForBackgroundOptimization (deterministic) | 50,000 | 0 |
| v4 | Fresh Victim constructor per attempt (defeats kConst poisoning), natural async | 10,000,000 | 0 |
| v5 | Sync-compile gate + multi-cycle re-tier per family | 140,000 | 0 |
| v6 | 16 parallel Web Workers (separate Isolates) | 16,000,000 | 0 |
| v7 | Chained kFastDataConstant access (o.inner.p4 — two LoadFields) | 10,000,000 | 0 |
| v8 | N=50 readers per family, 100% TF-confirmed before burst | 10,000,000 | 0 |
| v9 | Maglev + TurboFan parallel compile queues, 4-chain reader, 114k mid-burst re-deopts | 6,000,000 | 0 |
| v11 | SharedArrayBuffer-clock precision timing, Service Worker microtask yields, served via cross-origin-isolated HTTP origin | 800,000 | 0 |
| Total | ~53,490,000 | 0 |
A diagnostic v10 run (using %GetOptimizationStatus, %DebugPrint, --trace-opt, --trace-deopt) confirmed three things that, taken together, explain the zero result:
1. TurboFan does take the kFastDataConstant const-folding path on the chained reader. Issuing delete victim.inner without calling the reader still flips the reader’s optimization status (the TurboFan bit clears).4 Status went from IsFunction|TurboFan|Sparkplug (bits 1+16+64) to IsFunction|TurbofanOSR (bits 1+128). The TF bit clearing on a no-op delete proves V8 walked a dependency list of TF-compiled code that depended on inner being kConst. Status went from IsFunction|TurboFan|Sparkplug (bits 1+16+64) to IsFunction|TurbofanOSR (bits 1+128). The TF bit clearing on a no-op delete proves V8 walked a dependency list of TF-compiled code that depended on inner being kConst. That status change can only happen if V8 walked a dependency list of TF-compiled code that depended on inner being kConst — proving OwnConstantDataPropertyDependency was registered. The PoC architecture in v7-v9 was correct.
2. %OptimizeFunctionOnNextCall(fn) without a second argument compiles synchronously in V8 12.0. The literal --trace-opt output from v10:
[compiling method 0x258e0008d265 <JSFunction reader> (target MAGLEV), mode: ConcurrencyMode::kConcurrent]
[compiling method 0x258e0008d265 <JSFunction reader> (target TURBOFAN), mode: ConcurrencyMode::kSynchronous]
[compiling method 0x258e0008d265 <JSFunction reader> (target MAGLEV), mode: ConcurrencyMode::kConcurrent]
[compiling method 0x258e0008d265 <JSFunction reader> (target TURBOFAN), mode: ConcurrencyMode::kConcurrent]
Lines 1 and 3 are natural Maglev tier-up (concurrent). Line 2 is the explicit %OptimizeFunctionOnNextCall(reader) from v10 Phase 2 — kSynchronous. Line 4 is %OptimizeFunctionOnNextCall(reader, "concurrent") from Phase 7 — kConcurrent. This explains why the v3 strategy produced zero hits even with what appeared to be a textbook deterministic-timing-intrinsic setup: the sync compile blocks main thread, closing the race window before any delete can interleave. The "concurrent" second argument is mandatory for the race-window strategies to fire at all.
3. BG concurrent compile completes in microseconds. v10 measured 5000 calls + Maglev concurrent + TF concurrent compile all completing inside ~5 milliseconds. The per-compile race window is microseconds wide. To overlap from JS, the race burst’s delete must land within those few microseconds — but main thread doesn’t yield to a safepoint while inside the non-yielding C++ runtime function DeleteObjectPropertyFast. From V8’s safepoint protocol’s perspective, the entire DeleteObjectPropertyFast call is one indivisible step.
The cctest C++ harness wins because its reader thread is not V8-managed. It’s a raw OS thread that reads cage memory directly, with no safepoint coordination. It reads continuously regardless of what main is doing. That’s why a single ~4,000-iteration cctest run produces 6-21 filler reads while ~53 million in-Chrome JS attempts produce zero.
What the in-the-wild exploit actually used to trigger the race from JS — most plausibly some combination of WebGL-driven compile pipelines, cross-Isolate timing tricks via SharedArrayBuffer, or a Chrome-internal optimization path the content_shell build doesn’t enable — is not recoverable from the public record. The fix commit e0f2a195 deleted both the production code and the existing C++ test (DeletePropertyGeneralizesConstness), and V8 added no JS regression test referencing bug 1517354 or Buganizer 41490332. There is no minimal JS trigger preserved in V8’s regression suite for this ITW vulnerability.
The C++ Harness Result
A C++ harness (test/cctest/test-toctou-race.cc) running on pre-patch V8 12.0.267.16 (commit fbc4963bbff, the parent of the fix):
=== TOCTOU Race Test ===
Object map: 0x31f200153f85, 8 descriptors
Target descriptor: 7
Initial value: 42
!!! TOCTOU BUG at iteration 369: read FILLER MAP (one_pointer_filler_map) instead of Smi!
...
Bugs found: 21
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!!! TOCTOU RACE CONFIRMED !!!
!!! CVE-2024-0519 trigger mechanism found !!!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
21 filler reads in ~4,000 iterations. 5/5 runs. 152 total filler reads across 10 runs. The race is reliably triggerable, not a narrow window. Re-executed against the same pre-patch build later, a single 100,000-iteration run produced 6 filler reads, confirming the harness still works on the build environment.
On the post-patch build (also e0f2a195), the harness produces zero hits. DeleteObjectPropertyFast and ClearField are gone. The write side of the race no longer exists. The race is impossible.
The contrast between the cctest harness (6 hits in a single ~4,000-iteration run) and the in-Chrome JS PoCs (0 hits across ~53.5 million attempts in nine strategies) is the central technical observation of this RCA. The race exists; it is reliably triggerable from a thread that V8 does not coordinate via its safepoint protocol; and reaching it from pure JavaScript requires a primitive that the public record has not exposed.
Why mistymntncop Was Right — And Stopped One Layer Too Early
mistymntncop’s gist analyzed the exact same functions: ClearField, NotifyObjectLayoutChange, SetProperties, HeapObjectLayout::set_map. They had the preconditions right. They had the anomaly right. Their conclusion — that the JS-layer anomaly was “not useful for exploitation AT ALL” — was also right, at the JS layer they could observe.
The gap was one layer lower: the background compiler accessing the heap directly while ClearField is executing. That’s not visible from JavaScript. The safepoint machinery hides it. You only find it by looking at what the background compiler does with kConst field values during concurrent heap access — and running a concurrent C++ harness, with its reader thread outside V8’s safepoint coordination, to trigger it.
The Scribd tracker’s word — “runtime” — was what pushed past the JS layer. The fast-delete bug lives in C++ runtime code, not in JavaScript semantics.
What the nine in-Chrome JS PoC variants establish — and what mistymntncop’s analysis didn’t have the artifacts to establish — is that the architecture of the kConst const-fold race is correct (TurboFan does emit the const-folding path, the dependency machinery does fire as expected). What they don’t establish is a JS-level trigger: my PoCs did not interleave the race window from JavaScript across the conditions tested. The ITW exploit’s missing ingredient — Android-specific scheduling, embedder-specific concurrent-compilation behavior, a heap-grooming or object-layout setup I didn’t replicate, or a different trigger pattern entirely — is not recoverable from the public record.
5. Memory Consequence
The Confused Value Is Deterministic
V8 uses 32-bit compressed pointers inside the V8 sandbox cage. one_pointer_filler_map is a read-only root at a fixed cage-relative offset on any given V8 build. Its cage offset is 0xa10; the compressed tagged pointer written into the field slot by ClearField is 0x000a11.
This value is constant across processes. ASLR randomizes the cage base, not intra-cage offsets. Across 5 runs on different cage base addresses, the compressed value was 0x000a11 every time.
The filler_map Memory Layout
The one_pointer_filler_map object at cage+0xa10 was dumped on two independent processes with different cage bases (0x0000039c00000000 and 0x0000183500000000). Both produced identical layout values:
+0x00: 0x000004c5 compressed HeapPtr -> cage+0x4c4 (meta_map of this Map)
+0x04: 0x02000001 compressed HeapPtr -> cage+0x2000000
+0x08: 0x0d0000fa NOT a tagged HeapObject (bit 0 = 0) — raw bitfield value
+0x0c: 0x084003ff bit 0 = 1, treated as compressed HeapPtr or misread as Smi
+0x10: 0x0000007d compressed HeapPtr -> cage+0x7c
+0x14: 0x0000007d compressed HeapPtr -> cage+0x7c
+0x18: 0x000006d9 compressed HeapPtr -> cage+0x6d8
+0x1c: 0x000006b5 compressed HeapPtr -> cage+0x6b4
These values are fixed for a given V8 build — the attacker knows the full layout before triggering the race.
What Type-Confused Code Actually Reads
When JIT code treats one_pointer_filler_map at cage+0xa10 as a JSArray:
| JSArray field | Offset | Value read | Consequence |
|---|---|---|---|
.map | +0x00 | 0x000004c5 → cage+0x4c4 | some other Map object — map check would fail there |
.properties | +0x04 | 0x02000001 → cage+0x2000000 | a PropertyArray/FixedArray at 32MB cage offset |
.elements | +0x08 | 0x0d0000fa | NOT a tagged HeapObject — bit 0 is 0 |
.length | +0x0c | 0x084003ff → Smi 69,206,527 | enormous “length” — bounds check bypass |
Two critical findings from this dump:
Finding 1: The confused JSArray .elements pointer is invalid. 0x0d0000fa has bit 0 clear — it is not a tagged HeapObject pointer in V8’s scheme. A JIT-compiled array element access on the confused property would attempt to dereference this value as an elements array. On any modern 64-bit OS, 0x0d0000fa in the lower 4GB is unmapped → crash. This rules out a naive JSArray OOB read as the direct exploitation path.
Finding 2: The confused JSArray .length is 69,206,527. The Map’s internal bitfields at +0x0c produce 0x084003ff. If JIT interprets this as a Smi, the array appears to have 69 million elements. Any bounds check relying on this “length” would permit index access far outside any real allocation. This is a real bounds check bypass primitive — but only exploitable if the elements pointer issue is resolved by choosing a different object type for the kConst property.
Correction: cage_base Cannot Be Extracted from filler_map
An earlier version of this analysis hypothesized that reading from the filler_map layout could leak the cage base via float64 confusion. This is incorrect. All fields in one_pointer_filler_map are 32-bit compressed cage-relative offsets or raw bitfields — none encodes the full 64-bit cage base. The float64 confusion path (0x02000001000004c5 = 4.78e-299) produces a small near-zero double with no cage base embedded.
The Correct Exploitation Framing
The 0519 primitive gives within-cage type confusion. The cage base is not needed for within-cage OOB exploitation — relative offsets within the cage are sufficient for arbitrary reads and writes against cage-resident objects. The ITW Android chain reflects this framing:
- CVE-2024-0519 (this bug) — within-cage type confusion → within-cage OOB R/W in Chrome’s renderer. Establishes renderer-level control without requiring cage_base.
- CVE-2025-27038 — Adreno GPU user-land UAF, triggered via WebGL from a compromised renderer. This is the sandbox escape step. cage_base is relevant here.
- CVE-2023-33106 — KGSL kernel driver escalation from the GPU process.
Complete Exploit Surface Map
Enumerating all JSObject field offsets against filler_map’s memory classifies each read outcome. This is the complete picture of what JIT code gets when it accesses fields of the type-confused object:
| JSObject offset | filler_map value | Classification | Object identified |
|---|---|---|---|
| +0x00 (map) | 0x000004c5 | BORING | MAP_TYPE meta-map (cage+0x4c4) |
| +0x04 (properties) | 0x02000001 | CHROME-SCALE | cage+0x2000000 (32MB) — MemoryChunk header (reproduced) |
| +0x08 (elements) | 0x0d0000fa | CRASH | NOT tagged (bit0=0) — crash path |
| +0x0c (inobject[0]) | 0x084003ff | CHROME-SCALE | cage+0x84003fe (132MB) — gap padding (reproduced) |
| +0x10 (inobject[1]) | 0x0000007d | BORING | null_value (Oddball, cage+0x7c) |
| +0x14 (inobject[2]) | 0x0000007d | BORING | null_value (Oddball, cage+0x7c) |
| +0x18 (inobject[3]) | 0x000006d9 | EXPLOIT | DESCRIPTOR_ARRAY_TYPE (cage+0x6d8) |
| +0x1c (inobject[4]) | 0x000006b5 | VALID | WEAK_ARRAY_LIST_TYPE (cage+0x6b4) |
All classifications confirmed via InstanceType lookup against the live V8 heap. Identical across multiple ASLR runs.
In cctest (small heap, ~1.3MB committed): Two non-crashing, non-trivial read paths exist:
-
+0x18 → DESCRIPTOR_ARRAY_TYPE (cage+0x6d8): A kConst JSObject property with 5+ inobject properties will, when type-confused via the TOCTOU race, return a DescriptorArray when JIT reads its 4th inobject field. DescriptorArrays store property metadata (field types, representations, descriptor key/value/detail triplets). No crash — the DescriptorArray is a valid HeapObject in read-only space. JIT code can further dereference its contents.
-
+0x1c → WEAK_ARRAY_LIST_TYPE (cage+0x6b4): Similar non-crashing read, but the WeakArrayList at cage+0x6b4 contains 0xfff7ffff sentinel values (cleared weak references). Further dereferences from this object would reach cage+0xfff7fffe ≈ 4GB offset — past the cage boundary → crash. Less useful.
At Chrome-scale heap (140MB+ committed): Both formerly Chrome-only paths are now reproduced at cctest scale.
-
+0x04 → cage+0x2000000 (filler_map’s properties-field target). At cage scale ≥32MB, the page is committed and readable. The
ChromeScaleHeapGroomingcctest (576 FixedArrays of 16K elements ≈ 36MB) reaches this address and dumps it: it lands on a V8MemoryChunkheader at the 128th 256KB chunk boundary. Decoded fields include the chunk-size word (0x00040000) and a 64-bit linked-list pointer to the next chunk’s first object. In Chrome’s renderer the chunk boundary still falls at 128 × 256KB, but the V8 snapshot (several MB) shifts every chunk’s start, so cage+0x2000000 lands inside the usable area of a chunk rather than at its header — i.e., on whatever JSObject the renderer’s grooming places there. An attacker who can place a controlled FixedArray (or any object with controlled body bytes) at that exact offset gets a separate OOB primitive distinct from the +0x18 DescriptorArray path. -
+0x0c → cage+0x84003fe (first inobject property). 132MB into cage — reachable when the cctest is configured with ≥140MB old-space allocation. The same
ChromeScaleHeapGroomingtest extended to 2240 FixedArrays (≈140MB) reaches this address. The 8-word memory dump at cage+0x84003fe shows all-zero bytes — the address lands in the gap-padding region between FixedArray allocations. In a live Chrome renderer with snapshot-shifted chunk boundaries, the same offset would fall elsewhere, opening the same chunk-boundary-grooming attack surface as cage+0x2000000. Additionally, theempty_descriptor_arraysingleton (at cage+0x6d8 in V8 read-only space) contains a hard-coded forward pointer to cage+0x84003fe at its +0x1c field, so the address is a known reference point in V8’s structural layout, not an arbitrary location.
The OOB Chain — Step by Step
The CVE description says “Out of bounds memory access in V8.” The chain has two phases. The first is the race itself — a timing-bounded interleaving between two threads in two specific functions. The second is what TurboFan’s compile pipeline does with the value the race produces. Each phase is shown twice below: as actual V8 source (the proof) and as a flow diagram (the trace).
Phase 1: the race (steps 1–2)
The race lives between two functions in two source files. Below is the actual pre-patch V8 12.0.267.16 source (commit fbc4963bbff), with the race-relevant lines highlighted.
DeleteObjectPropertyFast
81
inline void ClearField(
↳
Isolate* isolate,
↳
Tagged<JSObject> object,
82
FieldIndex index) {
83
if (index.is_inobject()) {
84
MapWord filler_map_word =
85
ROr.one_pointer_filler_map_word();
90
int offset = index.offset();
91
⚠
TaggedField<MapWord>::
↳
⚠
Release_Store(object, offset,
↳
⚠
filler_map_word); // ← write
...
196
ClearField(...); // ← from above
...
// race window OPEN
212
⚠
receiver->set_map(*parent_map,
↳
⚠
kReleaseStore); // ← close
GetOwnFastDataPropertyFromHeap
284
OptionalObjectRef
↳
GetOwnFastDataPropertyFromHeap(
↳
JSHeapBroker* broker,
285
JSObjectRef holder,
286
Representation representation,
287
FieldIndex field_index) {
288
Optional<Object> constant;
290
DisallowGC no_gc;
291
cage_base = broker->cage_base();
298
⚠
Tagged<Map> map = holder.object()
↳
⚠
->map(cage_base, kAcquireLoad); // ← old map
299
if (*holder.map(broker).object() != map)
301
return {}; // check passes
...
// no lock between
304
if (field_index.is_inobject()) {
305
⚠
constant =
306
⚠
holder.object()->RawInobjectPropertyAt(
↳
⚠
cage_base, map, field_index); // ← gets filler
RawInobjectPropertyAt (right, L306) between Main's ClearField filler write (left, L91) and Main's set_map rollback (left, L212), the BG compiler reads one_pointer_filler_map from a slot the main thread has cleared. The map check at right L298 passes because the rollback has not happened yet. The same race traced as a horizontal timing diagram. Time flows left to right; events at the same column happen at the same time. The amber band marks the race window — the span during which Main has cleared the slot but not yet rolled the map back, and BG can read filler from a slot whose map check still passes.
Once the race fires, OwnConstantDataPropertyDependency records the filler as the property’s kConst value. The race is over. Everything downstream is deterministic compile-pipeline behavior on a single thread.
Phase 2: pipeline propagation (steps 3–6)
The post-race chain has four stages: two at compile time (TurboFan graph emission of the chained access) and two at runtime (the installed JIT code executing). The compile-time stages live in src/compiler/property-access-builder.cc. Below is the actual source for each.
TryFoldLoadConstantDataField · src/compiler/property-access-builder.cc
182
Node* PropertyAccessBuilder::
183
TryFoldLoadConstantDataField(
184
NameRef name,
185
PropertyAccessInfo const& info,
186
Node* lookup_start_object) {
187
if (!info.IsFastDataConstant())
188
return nullptr;
...
210
⚠
OptionalObjectRef value =
211
⚠
holder->GetOwnFastDataProperty(
212
⚠
broker(),
213
⚠
info.field_representation(),
214
⚠
info.field_index(),
215
⚠
dependencies()); // ← reads
216
return value.has_value()
217
⚠
? jsgraph()->ConstantNoHole(
218
⚠
*value, broker()) // ← embeds!
219
: nullptr;
220
}
GetOwnFastDataProperty — which dispatches to GetOwnFastDataPropertyFromHeap, the racy reader from Phase 1. If the race fires, value is the filler map. L217 then wraps it in a HeapConstant node and inserts it into the TurboFan graph as a literal compile-time constant. From here, every downstream optimization sees the filler as “the constant value of o.inner.” BuildLoadDataField · src/compiler/property-access-builder.cc
285
Node* PropertyAccessBuilder::
286
BuildLoadDataField(...) {
287
⚠
if (Node* value =
288
⚠
TryFoldLoadConstantDataField(
289
⚠
name, info, lookup_obj)) {
290
⚠
return value; // ← const-fold path
291
}
...
// (otherwise: emit normal LoadField)
318
return BuildLoadDataField(
319
name, storage, field_access, ...);
320
}
... (NO CheckMaps emitted)
o.inner) and the second LoadField that compiled code will emit for the chained .p4 access, there is no CheckMaps guard. The kConst assumption is what justifies skipping the check. So when the const-folded value is filler, the next LoadField reads from filler memory without runtime validation. This is the architectural punchline. Step 4 is the architectural punchline. The same kFastDataConstant optimization that creates the race (by enabling BG heap reads of property values via TryFoldLoadConstantDataField) is the same optimization that removes the guard that would catch the race’s output (by eliding the intermediate CheckMaps between chained LoadFields). The fix deletes the entire optimization for exactly this reason — patching one half without the other doesn’t make the code safe.
The compile pipeline is done. Installed JIT code now contains a LoadField that, when executed at runtime, reads from filler_map + sub_field_offset directly, with no map check. For the case study where o.inner.p4 is at the 4th inobject slot of inner, the offset is +0x18 and the value at cage+0xa10 + 0x18 = cage+0xa28 is 0x000006d9 — a compressed tagged pointer to cage+0x6d8, which is the empty_descriptor_array singleton in V8’s read-only space. JS code receives a DescriptorArray where it expected its application value. The out-of-bounds memory access has occurred.
The full chain visualized as a flow:
flowchart TD
subgraph Compile [Compile time — TurboFan graph]
direction TB
C3["<strong>Step 3</strong><br/><code>TryFoldLoadConstantDataField</code><br/>embeds filler as HeapConstant"]
C4["<strong>Step 4</strong> ⚠<br/>Chained access compiled<br/>two LoadField instructions<br/><strong>no CheckMaps between them</strong><br/>(kFastDataConstant guard elimination)"]
C3 --> C4
end
subgraph Runtime [Runtime — installed JIT code]
direction TB
R5["<strong>Step 5</strong><br/>second LoadField executes<br/><code>LoadField(filler_map, +0x18)</code><br/>reads <code>0x000006d9</code> → cage+0x6d8"]
R6["<strong>Step 6</strong><br/>returns <code>empty_descriptor_array</code><br/>InstanceType 233<br/>(V8 RO singleton)<br/><strong>OOB: not a JS-visible object</strong>"]
R5 --> R6
end
Compile --> Runtime
style C4 fill:#ffe5b4,stroke:#b85a5a,stroke-width:3px,color:#000
style R6 fill:#d4edda,stroke:#3c8d3c,color:#000
Verification
Steps 1-4 are shown directly in the V8 source above. Steps 5-6 are runtime consequences of the compiled code, verified by cctest:
| Step | Verification |
|---|---|
| 1 | C++ harness (test/cctest/test-toctou-race.cc) on fbc4963bbff: 152 filler reads / 10 runs |
| 2 | Pipeline checks reproduced in harness; all 4 pass for filler value (Tagged representation) |
| 5 | TEST(DescriptorArrayConfusedRead) cctest: CHECK_EQ(InstanceType, 233) passes — JIT reads from filler_map+0x18 and gets a DescriptorArray |
| 6 | InstanceType 233 confirmed = DESCRIPTOR_ARRAY_TYPE; cage offset 0x6d8 constant across ASLR runs (5+ runs with different cage bases, identical offset) |
The end-to-end chain (race → filler → embedded constant → guard elimination → OOB read → DescriptorArray returned to JS) is the best-supported reconstructed mechanism for CVE-2024-0519, anchored by the patch diff, the local cctest reproduction, and the layout analysis above.
The Actual Exploitation Path
The ITW exploit chose a kConst property type where the filler_map’s memory layout at type-specific offsets produces a useful confused access. The attacker controls which type JIT assigns to the field by controlling the initialization pattern of the kConst property. The complete surface map (above) shows two viable paths:
In Chrome-scale heap (the ITW context):
- Properties-ptr OOB (+0x04): when the type-confused JSObject is treated as having a backing PropertyArray, the confused
.propertiespointer lands at cage+0x2000000, which is a live heap object in Chrome’s renderer. Combined with the type-confused-as-JSArray reading at +0x0c (where filler_map’s0x084003ffdecodes to a Smi length of 69,206,527), the chain provides OOB read across 69M slots once the properties pointer resolves to a valid FixedArray. - Standard JSArray OOB (+0x08): when the type-confused object is treated as a JSArray,
.elementsreads filler_map[+0x08] =0x0d0000fa. If the Chrome renderer’s committed heap exceeds ~208 MiB (≈218 MB; cage+0x0d0000fa is 0x0d0000fa bytes ≈ 208 MiB into the cage), this otherwise-crashing pointer becomes a valid heap address, unlocking the JSArray OOB path directly.
In cctest (RCA reproduction context):
- DescriptorArray confusion (+0x18): non-crashing type confusion demonstrated with a 5-inobject-property JSObject. JIT reads DescriptorArray instead of expected property value. Observable, no crash, valid HeapObject returned.
Either path provides the structural preconditions for within-cage OOB R/W against cage objects (JSObjects, ArrayBuffers, etc.) — the bridge an exploit chain would need to reach the WebGL interaction that triggers CVE-2025-27038. Constructing a reliable arbitrary-R/W primitive from this confusion was not demonstrated end-to-end here; the exact ITW JavaScript setup remains unknown without the original exploit sample, so the specific object layout and property access pattern used by the attacker cannot be determined.
The ITW Android Chain
Google Threat Intelligence, “Look What You Made Us Patch: 2025 Zero-Days in Review” (2026-03-05), authors including Clement Lecigne:
“CVE-2025-27038 is a UAF vulnerability in the Qualcomm Adreno GPU user-land library that can be triggered through a sequence of WebGL commands followed by a specifically crafted glFenceSync call. The vulnerability allows attackers to achieve code execution within the Chrome GPU process on Android devices. We observed in-the-wild exploitation of this vulnerability in a chain with vulnerabilities in the Chrome renderer (CVE-2024-0519) and the KGSL driver (CVE-2023-33106).”
The three-stage chain:
- Chrome renderer (V8) — CVE-2024-0519 — initial in-cage memory corruption, establishes code execution or info leak within the renderer process
- Chrome GPU process — CVE-2025-27038 — Adreno GPU user-land UAF triggered via WebGL + crafted
glFenceSync, achieves code execution in the GPU process - Kernel — CVE-2023-33106 — KGSL (Kernel Graphics Support Layer) driver, gets from GPU process to ring 0
This framing explains why the 0519 primitive needs to survive long enough to make GPU/WebGL calls: the chain hands off from renderer to GPU process via CVE-2025-27038, which requires WebGL interaction from a compromised renderer.
The choice of CVE-2024-0519 as the renderer stage is consistent with the findings in this analysis: a race that produces a type confusion primitive inside the V8 cage, suitable as the entry point for a longer exploitation chain.
6. Patch Analysis
The Fix
Commit: e0f2a195d87c9a06685121e0e783efd92d030df3
Author: Toon Verwaest
Date: 2024-01-11 (main), cherry-picked to 12.0 branch 2024-01-12
Message: [runtime] Drop fast last-property deletion
“This interacts badly with other optimizations and isn’t particularly common.”
Files changed:
| File | Change |
|---|---|
src/runtime/runtime-object.cc | -176 lines |
test/cctest/test-field-type-tracking.cc | -116 lines |
Functions deleted:
DeleteObjectPropertyFast— the fast path itselfClearField— writesone_pointer_filler_mapto the field slot (the write side of the race)GeneralizeAllTransitionsToFieldAsMutable— walks transition tree to demote kConst to kMutableTEST(DeletePropertyGeneralizesConstness)— the test that was supposed to validate the generalization worked
No replacement code. The diff is 292 pure deletions across two files — the entire optimization, gone.
The deleted test name is worth reading carefully: DeletePropertyGeneralizesConstness. It was a C++ unit test that verified that when a property is deleted via the fast path, the constness annotations across the transition tree are properly generalized to mutable. The fact that this test was also deleted — rather than rewritten — signals that there was no safe implementation of the invariant this test was checking. V8’s response was: this can’t be made to work correctly, the optimization has to go.
The “Drop the Optimization” Signal
Most V8 security fixes are incremental patches: add a generalization call, check a nullptr, guard with IsFastDataConstant. The 0519 fix is unusual: the entire optimization was deleted. Verwaest’s commit message is terse — “interacts badly with other optimizations” — but the action speaks louder. When a V8 optimization is deleted entirely rather than patched, it means the team concluded that patching the specific gap would not make the code safe, because the interactions with surrounding optimizations are too complex to reason about correctly.
This is a recurring pattern in the PropertyConstness bug class. Every fix in this family has been whack-a-mole: patch one path, another path later turns out to have the same gap. The 0519 fix was the first one that escalated to “remove the optimization entirely.” A stronger signal than all the incremental patches combined.
The 9-Year Bug Class
The PropertyConstness bug class has been active across at least 22 commits from 2017 to 2026. Selected history:
| Date | Commit | Author | Trigger |
|---|---|---|---|
| 2017-04-24 | 98acfb36 | Jakob Kummerow | Introduces DeleteFastProperty CSA builtin (“may need revert”) |
| 2019-05-14 | f0e054c2 | Benedikt Meurer | Disable optimization for const fields (v8:9233). Adds regress-v8-9233.js |
| 2019-05-17 | 571cc43c | Benedikt Meurer | Restore with receiver_map-only generalization |
| 2021-04-06 | db2acd7a | Igor Sheludko | Ensure map updated before generalizing constness (chromium:1195331) |
| 2021-05-03 | d570bbe0 | Igor Sheludko | Add GeneralizeAllTransitionsToFieldAsMutable walk (chromium:1201938) |
| 2024-01-11 | e0f2a195 | Toon Verwaest | CVE-2024-0519: delete entire optimization |
| 2024-02-19 | 496f467bb72 | Olivier Flückiger | Don’t preserve constness across prototype transitions (chromium:325020448) |
| 2025-12-05 | 29b2f74fb42 | Toon Verwaest | Missed dependency for constant fields on unstable maps |
| 2026-03-23 | 8e7a5dad8058 | Igor Sheludko | Type confusion in compiler when corrupting object maps (Fixed: 494388119) |
Four structurally identical regression tests exist across this history — all named regress-* for the same code neighborhood. The pattern of incremental fixes, reverts, and re-lands over nine years indicates this is a structural problem in how V8 propagates PropertyConstness information across compiler tiers, not a single fixable bug.
7. Reproduction Notes
This section distinguishes between claims reproduced locally and claims inferred from source analysis or third-party attribution.
Reproduced (R)
| Claim | Method |
|---|---|
Fix commit e0f2a195 has Bug: chromium:1517354 in commit message | Git fetch + local clone verification |
Fix deletes DeleteObjectPropertyFast, ClearField, GeneralizeAllTransitionsToFieldAsMutable, test | Diff of fbc4963bbff vs e0f2a195 on the 2 relevant files |
Bug IDs crbug:1517354 and Buganizer 41490332 refer to the same issue | crbug.com/1517354 redirects to issues.chromium.org/issues/41490332 (“Access is denied”) |
TOCTOU race exists: background compiler reads one_pointer_filler_map from deleted field | C++ harness on pre-patch build (fbc4963bbff, V8 12.0.267.16): 21 filler reads in ~4,000 iterations, 5/5 runs; 152 total filler reads across 10 runs |
Filler passes all 4 checks in GetOwnFastDataPropertyFromHeap | Pipeline analysis: map match, double map check, IsUninitialized, FitsRepresentation(Tagged) — each verified against pre-patch source |
Filler compressed ptr = 0x000a11 (cage offset 0xa10), constant across ASLR | Verbose harness: 5 runs with different cage bases, offset identical across all runs |
Race impossible post-patch (e0f2a195) | DeleteObjectPropertyFast has 0 references post-patch; harness produces 0 hits |
| Pure-JS PoC in Chrome’s renderer (content_shell, V8 12.0.267.16, headless): 9 distinct strategies (v1, v3, v4, v5, v6, v7, v8, v9, v11), ~53.5M total race attempts, 0 hits | content_shell with --js-flags="--allow-natives-syntax --concurrent-recompilation --maglev". Strategies span: forced sync compile via %WaitForBackgroundOptimization, fresh map families per attempt, 16 parallel Workers (separate Isolates), chained kFastDataConstant access, N=50 readers per family, mid-burst re-deopts, Maglev + TurboFan parallel queues, SAB-clock precision timing, Service Worker microtask yields. v8 specifically confirmed 100% TF-coverage (25,000 / 25,000 readers). |
| TurboFan IS taking the kFastDataConstant const-folding path on the chained reader | Diagnostic v10: delete-without-reader-call flips reader’s optimization status from IsFunction|TurboFan|Sparkplug to IsFunction|TurbofanOSR — only possible if V8 walked a dependency list of TF-compiled code that depended on the deleted property being kConst |
%OptimizeFunctionOnNextCall(fn) (no second arg) compiles synchronously in V8 12.0 | Diagnostic v10 trace-opt: [compiling method <reader> (target TURBOFAN), mode: ConcurrencyMode::kSynchronous] |
| BG concurrent compile completes in microseconds | Diagnostic v10 timing: 5000 calls + Maglev concurrent + TF concurrent compile all complete inside ~5 ms |
| ITW chain attribution (0519 → CVE-2025-27038 → CVE-2023-33106) | Google Threat Intelligence “2025 Zero-Days in Review” (2026-03-05), authors include Clement Lecigne |
regress-crbug-325020448_2024-02.js FAILS on pre-patch, PASSES post-patch | Pre-patch vs post-patch d8 regression test differential |
Pre-patch build: V8 12.0.267.16 at fbc4963bbff, macOS ARM64 | Local native build (out/prepatch_dbg/d8) |
cage+0x2000000 (filler_map[+0x04]) is committed and readable at 32MB+ heap; lands on V8 MemoryChunk header at the 128th 256KB chunk boundary | TEST(ChromeScaleHeapGrooming) cctest: 576 FixedArrays × 16K elems ≈ 36MB allocation; chunk-size word 0x40000 and linked-list pointer to next chunk’s first object recovered |
| cage+0x84003fe (filler_map[+0x0c]) is committed and readable at 140MB+ heap; lands in gap-padding between FixedArrays | TEST(ChromeScaleHeapGrooming) extended to kTargetMB = 140 (2240 FixedArrays × 16K elems): heap reaches target at allocation 1553/2240; 8-word memory dump returns all-zero bytes. empty_descriptor_array singleton (RO space, cage+0x6d8) contains a hard-coded forward pointer to cage+0x84003fe at its +0x1c field |
Inferred (I)
| Claim | Confidence | Basis |
|---|---|---|
| Exact JS-only trigger pattern used by the ITW exploit | Unknown | The race requires concurrent reads from the background compiler thread overlapping with DeleteObjectPropertyFast on the main thread. I confirmed reproduction in C++ (152 filler reads / 10 runs); JS-level reproduction in d8 fails because safepoints serialize the compiler. Pure-JS PoCs in Chrome content_shell across 9 strategies and ~53.5M attempts did not hit — the ITW exploit’s actual trigger is not recoverable from the conditions I was able to test (heap shape, scheduling, embedder, object layout — any could account for the gap). |
| Filler map pointer propagates through JIT pipeline to OOB | Medium-High | Pipeline analysis of OwnConstantDataPropertyDependency::IsValid path; the in-cctest end-to-end is partially reproduced via TEST(DescriptorArrayConfusedRead) for the +0x18 path |
| The filler → OOB path produces exploitable addrof/fakeobj primitives | Medium | Standard V8 exploitation literature for type confusion. Specific primitive not locally constructed — deliberate scope decision: exploit construction is a separate project, and the bug is two-years-patched (no VRP value). The verified surface map (§5) shows the necessary preconditions are present; turning the type confusion into a reliable R/W primitive is left to a future post. |
What Cannot Be Reproduced Post-Patch
CVE-2024-0519 depends on DeleteObjectPropertyFast and ClearField existing in V8. Both were deleted by e0f2a195. There is no way to reproduce the root cause on any V8 version ≥ e0f2a195 (Chrome 120.0.6099.224, released 2024-01-16). The race is structurally impossible on HEAD V8.
Any reproduction attempt on a patched build will confirm the absence of the race — not the presence of a new vulnerability.
Build Environment
- Pre-patch d8: V8 12.0.267.16 at
fbc4963bbff(commit immediately beforee0f2a195) - Build: native macOS ARM64 (
out/prepatch_dbg/d8) - SDK note: 2024-era V8 builds against macOS 14.4 SDK, not macOS 26.x SDK (bundled libc++ drift —
max_align_t/__builtin_clzgconflicts). Required:mac_sdk_path = "/Library/Developer/CommandLineTools/SDKs/MacOSX14.4.sdk"inargs.gn - Test harness:
test/cctest/test-toctou-race.cc(pre-patch only)
Appendix A: Key Files
| File | Role |
|---|---|
src/runtime/runtime-object.cc | Deleted code: DeleteObjectPropertyFast, ClearField, GeneralizeAllTransitionsToFieldAsMutable |
src/compiler/heap-refs.cc:284 | GetOwnFastDataPropertyFromHeap — the vulnerable concurrent reader |
src/compiler/compilation-dependencies.cc | OwnConstantDataPropertyDependency::Install / IsValid |
test/cctest/test-field-type-tracking.cc | Deleted test: DeletePropertyGeneralizesConstness |
test/cctest/test-toctou-race.cc | C++ harness confirming the race (local, pre-patch only) |
Appendix B: Prior Public Work
No substantive public RCA for CVE-2024-0519 existed prior to this writeup. Public signals:
- Google Chrome Stable release note (2024-01-16) — official metadata only
- mistymntncop gist — right code area, right preconditions, stopped at the JS-semantics layer
- Cromite issue #720 — packaging breadcrumb mapping
CVE-2024-0519 → e0f2a195 - Scribd V8 exploit tracker (unofficial mirror of Samuel Groß’s V8 vulnerability tracker, circulated in security circles; not authoritative) — description “Incorrect fast-path for JS property deletion in runtime”, ITW=Yes, Sandbox Bypass=Yes
- GTI: “Look What You Made Us Patch — 2025 Zero-Days in Review” (Google Threat Intelligence, 2026-03-05) — first-party ITW chain attribution (renderer → GPU process → kernel)
- NVD: CVE-2024-0519 and CISA KEV catalog entry — federal-side metadata; CISA KEV entry added 2024-01-17 with remediation deadline 2024-02-07
- Fix commit
e0f2a195— V8 gitiles, the cherry-pick to branch-heads/12.0 - Chromium bug 41490332 (formerly
crbug.com/1517354) — Chromium issue tracker, access-restricted
Acknowledgments
mistymntncop (@mistymntncop) published the only serious public technical investigation of CVE-2024-0519 before this RCA. Their gist precisely enumerates the five preconditions for DeleteObjectPropertyFast to engage, correctly decomposes every code path the deletion touches — ClearField, Heap::NotifyObjectLayoutChange, MapUpdater::GeneralizeField, HeapObjectLayout::set_map — and collects a set of related V8 commits and Chromium issues that informed the fault lineage work in this analysis. Their January 2025 tweets asked the question that drove this entire RCA: “Is the problem because of a modification it is doing or something it is forgetting to do?” They correctly identified the site and the shape of the question. This RCA is the answer.
All reproduced claims backed by local builds and C++ harness runs on V8 12.0.267.16 (pre-patch). Inferred claims clearly labeled with confidence levels.