CVE-2025-13223 is a type confusion in V8's optimizing compilers (Turbofan, Maglev, Turboshaft/Turbolev) affecting property array extension during map transitions. It was Chrome's 7th zero-day of 2025, listed in CISA KEV as actively exploited in the wild.
- Affected: Chrome/Chromium < 142.0.7444.175 (all platforms)
- Severity: Critical (renderer RCE from a single page load)
- Fix commit:
ece65a0(cherry-pick of4cf93118by Leszek Swirski) - Chromium bug: Issue 460017370
- Review: https://chromium-review.googlesource.com/c/v8/v8/+/7146166
In src/compiler/access-builder.cc, the function ForPropertyArraySlot()
generates FieldAccess descriptors for reading/writing properties stored in
an object's out-of-object PropertyArray backing store.
Before the fix, this function used MachineType::AnyTagged() for ALL
property slots, regardless of the property's actual Representation:
// VULNERABLE CODE (simplified)
FieldAccess AccessBuilder::ForPropertyArraySlot(int index) {
// ...
access.machine_type = MachineType::AnyTagged(); // BUG: always tagged
return access;
}However, V8 properties can have three different representations:
| Representation | Storage | MachineType needed |
|---|---|---|
| Smi (small integer) | Tagged pointer | AnyTagged (ok) |
| HeapObject | Tagged pointer | TaggedPointer |
| Double | Raw IEEE-754 double | Float64 |
When a property has Representation::Double, it is stored as a raw 8-byte
IEEE-754 value in the PropertyArray. Reading this with AnyTagged means the
compiler-generated code interprets raw double bits as a tagged V8 pointer --
a classic type confusion.
The fix (4cf93118) modifies ForPropertyArraySlot() to accept a
Representation parameter and compute the correct MachineType:
// FIXED CODE (simplified)
FieldAccess AccessBuilder::ForPropertyArraySlot(
int index, Representation representation) {
// ...
if (representation.IsDouble()) {
access.machine_type = MachineType::Float64();
} else if (representation.IsHeapObject()) {
access.machine_type = MachineType::TaggedPointer();
} else {
access.machine_type = MachineType::AnyTagged();
}
return access;
}The fix also updates all callers across 7 files:
src/compiler/access-builder.cc-- Core fixsrc/compiler/access-builder.h-- Updated declarationsrc/compiler/js-native-context-specialization.cc-- Turbofan: walks descriptor array in lockstep with property array to pass correct Representationsrc/compiler/turboshaft/turbolev-early-lowering-reducer-inl.h-- Turboshaft:ExtendPropertiesBackingStore()now takesMapRefand iterates descriptors to find first out-of-object propertysrc/compiler/turboshaft/turbolev-graph-builder.cc-- Passesold_map()src/maglev/maglev-graph-builder.cc-- Passesold_map()src/maglev/maglev-ir.h--ExtendPropertiesBackingStorenode storesold_map_member for descriptor iteration
The type confusion between Double and Tagged representations means:
-
addrOf: Store a JS object in a HeapObject property slot, but the compiler reads it as a Double. The raw tagged pointer bits leak as a floating-point value.
-
fakeObj: Write a crafted double value (encoding a fake pointer) into what the compiler thinks is a Tagged slot. V8 then follows this "pointer" to attacker-controlled memory.
These two primitives are sufficient for full renderer RCE when combined with sandbox escape techniques.
Create objects with mixed property representations where some out-of-object
properties are stored as doubles. Force property array extension via map
transitions (adding new named properties). The spread operator {...x} with
a __defineGetter__ callback creates the ideal conditions: the getter
triggers massive map transitions that cause property array reallocation,
and the compiler-generated copy code uses AnyTagged for double slots.
Place a target JS object into an Object array (oobObjArr). Read the
overlapping position through the corrupted double array (corrupted_arr).
The raw tagged pointer bits are returned as a float64 value. Extract the
compressed heap pointer using ftoi32().
Overwrite the backing store pointer of oobDblArr through the corrupted
array to point at any V8 heap address. Then oobDblArr[0] reads/writes
at that address.
Confuse DOMRect with DOMArrayBuffer by swapping their Blink wrapper
instance pointers. DOMRect.x then controls the raw (non-sandboxed) base
address of an AudioBuffer channel. AudioBuffer.copyFromChannel() and
copyToChannel() provide full process-wide arbitrary read/write outside
the V8 sandbox.
Locate the WASM import dispatch table in the V8 trusted cage. Overwrite the code entry point for an imported function to redirect execution into shellcode embedded as IEEE-754 doubles in a WASM array's data section. Call the exported WASM function to trigger the shellcode.
The WASM module contains x86_64 Linux shellcode that calls:
execve("/bin/xcalc", ["DISPLAY=:0.0"], NULL)
| File | Description |
|---|---|
trigger.html |
Type confusion trigger only (no exploitation) |
exploit.html |
Full exploit chain -- trigger through RCE |
setup-chrome-13223.ps1 |
Windows setup (downloads Chrome 141, creates run script) |
README.md |
This file |
.\setup-chrome-13223.ps1
.\run-exploit.batDownloads Chrome 141.0.7438.0 for Testing (win64). The type confusion and all memory corruption primitives work. The shellcode is Linux x86_64 so the final stage will crash on Windows rather than executing xcalc, but this confirms the full chain up to code execution.
# Download a vulnerable Chrome for Testing build
# Chrome 141 is the last major version before the fix in 142.0.7444.175
python3 -m http.server 8098 &
google-chrome --no-sandbox --disable-gpu \
--user-data-dir=/tmp/chrome-13223 \
http://localhost:8098/exploit.htmlpython3 -m http.server 8098 &
chrome --no-sandbox --js-flags="--turbofan --no-lazy" \
http://localhost:8098/trigger.htmlThe --turbofan --no-lazy flags force eager Turbofan compilation which
makes the type confusion more reliable.
| Aspect | CVE-2024-5830 | CVE-2025-13223 |
|---|---|---|
| Bug class | Object transition type confusion | Property array extension type confusion |
| Root cause | Map transition during spread | ForPropertyArraySlot() ignores Representation |
| Trigger | __defineGetter__ + spread |
Property array extension during optimization |
| Affected compiler | V8 (runtime) | Turbofan, Maglev, Turboshaft (all optimizers) |
| Sandbox escape | DOMRect/AudioBuffer | DOMRect/AudioBuffer (same technique) |
| Code exec | WASM dispatch table | WASM dispatch table (same technique) |
| Chrome fix | 125.0.6422.113 | 142.0.7444.175 |
- Linux x86_64: Full RCE -- launches xcalc via
execve. - Windows x86_64: All primitives work (type confusion, addrOf, R/W, sandbox escape). Shellcode crashes because it is Linux-specific. Replace the WASM module with Windows shellcode for full Windows RCE.
- aarch64: Requires aarch64 shellcode replacement. The V8 bug and all JavaScript-level primitives are architecture-independent.
The exploit contains V8 heap addresses specific to Chrome 141 Linux x86_64:
| Constant | Value | Purpose |
|---|---|---|
dblArrMap |
0x25510d |
Map for PACKED_DOUBLE_ELEMENTS arrays |
objArrMap |
0x25518d |
Map for PACKED_ELEMENTS arrays |
fakeDblArrayAddr |
0x4881d |
Address of fake double array |
nameAddr |
0xdc5 |
NameDictionary shape address |
trustedOffset |
0x12e9d0 |
TrustedCage base - DOMRect type info |
startAddr |
0x40b00 |
WASM dispatch table search start |
These may need adjustment for different Chrome builds or platforms. Use
--js-flags="--allow-natives-syntax" and %DebugPrint() to find correct
values for a specific build.