Writeup
Pwn Writeups — TAMUctf26
Pwn Writeups
This document contains the writeups for Meep, zagjail, Task Manager, Goodbye libc, and Military System from TAMUctf 2026.
Meep — Writeup
This writeup details the solution for the Meep challenge. The service is a 32-bit big-endian MIPS binary with a format string in the greeting path and a stack overflow in the diagnostics path.
Summary
This one is old-school in the best way. The binary gives you a read bug and a control-flow bug in two separate functions, and the only real work is turning the first one into the address you need for the second.
The exploit chain is:
- Leak a saved frame pointer with the format string in
greet() - Derive the future
diagnostics()buffer address from that leak - Overflow
diagnostics()and overwrite savedrawith the stack buffer address - Return into MIPS shellcode on the executable stack and run
cat /home/flag.txt
Key Code Snippets
Vulnerable paths:
void greet(logger) { char name[128]; send(1, "Enter admin name: ", 0x12, 0); recv(0, name, 0x100, 0); logger("Hello:\n"); printf(name);}
void diagnostics(void) { char cmd[128]; send(1, "Enter diagnostic command:\n", 0x1b, 0); recv(0, cmd, 0x100, 0); send(1, "Running command...\n", 0x13, 0);}Exploit core:
io.recvuntil(b"Enter admin name: ")io.send(b"%40$p\x00")io.recvuntil(b"Hello:\n\n")
saved_fp = int(io.recvuntil(b"Enter diagnostic command:", drop=True).strip(), 16)diagnostics_buf = saved_fp - 0x90
payload = shellcode.ljust(140, b"A") + p32(diagnostics_buf)io.send(payload.ljust(0x100, b"B"))io.recvuntil(b"Running command...\n")io.sendline(b"cat /home/flag.txt")Analysis
Meep is the kind of challenge where you should resist overthinking it. You do not need fancy ROP, GOT abuse, or partial overwrites. The binary already gives you a format string and an executable stack. That is enough.
The format string walks through the greet() stack frame, and %40$p leaks the caller’s saved frame pointer. Since greet() and diagnostics() use the same frame size, that leak predicts where the diagnostics() stack buffer will be placed. In other words, the first bug tells you where the second bug will store your payload.
The second bug is a classic overflow: recv(..., 0x100, ...) writes into a 128-byte local buffer. On this target the saved return address sits 0x8c bytes after the start of cmd, so the overflow can directly place shellcode in the buffer and overwrite saved ra to point back into it. The stack is executable, so the clean answer is to return straight into shellcode and keep moving.
On MIPS, especially big-endian MIPS, the only thing that really matters is being precise with offsets and packing. Once those are correct, the exploit is mechanically simple.
Exploitation and Flag Recovery
- Send
%40$pto leak the saved frame pointer from the caller frame - Compute
diagnostics_buf = saved_fp - 0x90 - Send MIPS shellcode padded to the saved-
raoffset - Overwrite saved
rawithdiagnostics_buf - Wait for the command prompt to finish consuming the overflow
- Send
cat /home/flag.txt
The only real remote issue was I/O coalescing. If the follow-up shell command lands in the same recv() as the overflow, you lose. Padding the second-stage send to the full 0x100 bytes and waiting for Running command... fixes that cleanly.
Solver
The solver is intentionally minimal: leak one pointer, compute one buffer address, smash ra, and use the shellcode to get a shell. That is the right level of complexity for a binary like this.
Exploit script: meep.py
Final Result
gigem{m33p_m1p_1_n33d_4_m4p}
zagjail — Writeup
This writeup details the solution for the zagjail challenge. The service is not a true sandbox; it is a Python wrapper around the Zag compiler with a regex-based source checker that misses real pointer expressions.
Summary
The wrapper only performs syntactic checks. That sounds scary until you realize it is not validating semantics at all. If the regex does not recognize your expression, it effectively does not exist to the jail.
That is why *(&a + 2) wins. It bypasses the checker, compiles just fine, and gives a clean stack read/write primitive inside the generated ELF.
The final exploit:
- Reads
main’s saved return address with*(&a + 2) - Computes the libc base from that return address
- Overwrites the saved RIP slots above
awith a libc ROP chain - Returns into
system("/bin/sh") - Sends
cat flag.txt
Key Code Snippets
Checker bypass:
var a:u64 = 0;var libc_ret:u64 = *(&a + 2);Final payload:
fn main() i32 { var a:u64 = 0; var libc_ret:u64 = *(&a + 2); var libc_base:u64 = libc_ret - 171176; *(&a + 2) = libc_base + 164971; *(&a + 3) = libc_base + 172357; *(&a + 4) = libc_base + 1728164; *(&a + 5) = libc_base + 340240; *(&a + 6) = libc_base + 271200; return 0;}Analysis
The core mistake is the mismatch between the jail’s model and the compiler’s semantics. The checker tracks only simple pointer variables and direct forms like *p or p + 1. It does not build or validate expression trees, so *(&a + 2) reaches memory above a local variable without triggering the filter.
That is the whole challenge. Once you see that, you stop thinking about “sandbox escape” and start thinking about “normal pwn on a compiled binary.” The wrapper is just noise around a program that still returns through libc like everything else.
Once compiled, the Zag program behaves like a normal ELF linked against libc. A local variable a sits at rbp-8, so &a + 1 reaches saved rbp and &a + 2 reaches saved rip. That gives a direct libc leak from the startup return path, which is enough to build a standard ret2libc chain.
There was a tempting dead end with &main, but it was unnecessary anyway. A real return address is always a better anchor than a compiler-specific function reference when you are trying to stabilize a remote exploit.
Exploitation and Flag Recovery
- Declare a local
u64 - Read
main’s saved RIP with*(&a + 2) - Compute
libc_base = libc_ret - 0x29ca8 - Write a small aligned ROP chain back to
*(&a + 2)through*(&a + 6) - Return from
main - Interact with the spawned shell and run
cat flag.txt
The chain uses a leading ret for alignment, then pop rdi ; ret, the "/bin/sh" string in libc, system, and exit. Nothing exotic. Just clean stack surgery and a normal libc finish.
Solver
The solver just feeds the malicious Zag source, waits for the compile-and-exec transition, and then treats the service like a shell. That is the correct mindset here. The “jail” is gone the moment your compiled program returns into your ROP chain.
Exploit script: zagjail.py
Final Result
gigem{custom_language_but_still_links_to_libc_:thinking:_jk_thanks_for_the_cool_language}
Task Manager — Writeup
This writeup details the solution for the Task Manager challenge. The target is a hardened PIE binary with a linked-list bug, a forged object opportunity, and an exit path that must be repaired for the exploit to become reachable.
Summary
At first glance this looks like a heap challenge. It is, but not in the way people usually mean it. The important primitive is not “heap arbitrary write.” The important primitive is “I can lie to the program about what the linked list is.”
The add path overflows an 80-byte task field by 8 bytes into the next pointer. Separately, the TaskHead layout can be reinterpreted as a fake Tasks node. By combining those two facts, the exploit constructs a fake linked list that turns the show functionality into an arbitrary read primitive.
The final exploit performs four leaks:
- Heap leak from
task1->next - Stack leak from
taskPointer->head - Libc leak from
main’s saved return address - PIE leak from a
mainpointer left on the stack
Then it writes a libc ROP chain to main’s saved RIP and overwrites the global size variable so the buggy cleanup loop is skipped on exit.
Key Code Snippets
Overflow and fake-node surface:
typedef struct Tasks { char task[80]; struct Tasks* next;} Tasks;
typedef struct { int sel; Tasks** head; char reminder[72];} TaskHead;
read(0, temp->task, 88);read(0, task_pointer->reminder, 72);ROP endgame:
cmd = b"cat flag*; exit\x00"cmd_addr = saved_rip + 0x40
chain = flat( libc.address + ret, libc.address + pop_rdi, cmd_addr, libc.sym.system, libc.address + pop_rdi, 0, libc.sym.exit)Analysis
The interesting part is not just the heap corruption. The program state has to remain consistent enough to reach the overwritten return address. That is why this exploit is better viewed as state manipulation, not just memory corruption.
The 88-byte add bug writes exactly one pointer-sized field past the end of task, which is enough to corrupt the list topology. Because TaskHead contains a pointer and enough trailing bytes to fake a next field, it can be treated as a synthetic Tasks object. Once that clicks, the challenge opens up.
That gives a fake list of the form task1 -> sentinel -> taskPointer, and by changing the forged next pointer the exploit can force show() to print arbitrary memory as if it were Task #4. This yields the heap, stack, libc, and PIE leaks needed for a saved-RIP overwrite in a fully protected binary.
The real twist is cleanup. A lot of people would stop at “I can write a ROP chain onto saved RIP.” That still loses here. If the cleanup path explodes before main returns, your chain never executes.
So the exploit fixes the program’s state before leaving. Overwriting the global size variable with -1, then letting the next increment wrap it to 0, disables the cleanup loop and makes the saved-RIP overwrite reachable. That is the whole solve: leak well, write carefully, and leave the process in a state where it can actually die the way you want.
Exploitation and Flag Recovery
- Leak the heap by printing through an unmodified
task1->next - Build the forged list using the 8-byte
nextoverwrite - Pivot
Task #4reads to leak&tasks, thenmain’s saved RIP, then a stack-storedmainpointer - Compute libc and PIE bases
- Overwrite
main’s saved RIP with a ret2libc chain - Overwrite global
sizewith0xffffffffffffffff - Trigger one more increment so
sizewraps to0 - Exit cleanly and return into the chain
Solver
The solver does exactly what the challenge asks for and nothing extra: build the fake list, turn show() into a read primitive, compute bases, write the ROP chain, fix size, and exit. The last step matters as much as the first.
Exploit script: taskmanager.py
Final Result
gigem{f4s7b1N5_0f_5p141t_hAuN7_8s_d1A593c6CeF}
Goodbye libc — Writeup
This writeup details the solution for the Goodbye libc challenge. The binary is a small calculator PIE linked against a custom libbye-libc.so, and the key primitive comes from a signed-overflow bug in index parsing.
Summary
input_index() parses into a signed 32-bit integer and mishandles overflow near the lower bound. That turns huge unsigned inputs into useful negative indices:
4294967295becomes-24294967294becomes-3
Those out-of-bounds indices reach stack slots in the active write_num() frame and allow both leaks and a direct saved-RIP overwrite. So despite the interface looking like a number editor, what you really have is a stack corruption bug with a menu wrapped around it.
The exploit then pivots to a staged stack payload, uses gadgets from the PIE and libbye-libc.so, and performs a deterministic ORW chain to read flag.txt.
Key Code Snippets
Buggy write path:
unsigned long nums[3] = {0};index = input_index();write_num(&nums[index]);High-level chain:
pop rdx ; retread(0, buf, len(stage1))pop rbp ; clc ; leave ; retAnalysis
The challenge is really about stack-relative out-of-bounds access rather than the calculator abstraction. With the overflowed index values, nums[-2] lands on write_num()’s saved RIP and nums[-3] lands on its saved RBP. Once you understand that, the menu stops mattering.
The print path leaks stale stack values, which exposes PIE and a stable stack address. A second control-flow redirection leaks a return address inside libbye-libc.so, giving the custom library base. That is enough to bootstrap a controlled pivot without ever needing a clean arbitrary read.
From there the exploit uses a staged pivot because large single-shot payloads were unreliable over the network. A small bootstrap stage reads a larger stage into a safer area and pivots again. That is the kind of adjustment that turns a local proof-of-concept into a working remote exploit.
The final chain uses ORW plus SROP to close fd 3, reopen the real flag file onto fd 3, read it, and write it back to stdout. Reclaiming fd 3 matters because service environments are messy, and assuming the next open() gives you the descriptor you want is how exploits become flaky.
Exploitation and Flag Recovery
- Leak PIE from an out-of-bounds print
- Leak a stack frame pointer from another out-of-bounds print
- Overwrite the current saved RIP to force a code path that leaks a
libbye-libc.soreturn address - Compute the custom libc base
- Overwrite saved RIP with a small pivot chain
- Read a larger second stage into a safer stack region
- Use ORW/SROP to
close(3),open("flag.txt", 0, 0),read(3, ...), andwrite(1, ...)
The remote reliability fix was important: fd 3 was not guaranteed to be free, so reclaiming it first made the open-read-write sequence deterministic.
Solver
The solver is built for reliability, not elegance. It leaks the three addresses it needs, pivots in stages, normalizes the file descriptor state, and then performs a boring ORW chain. Boring is good when you want the remote to behave.
Exploit script: goodbye.py
Final Result
gigem{flamepyromancer_didnt_change_the_default_flag}
Military System — Writeup
This writeup details the solution for the Military System challenge. The target is a 64-bit AArch64 PIE binary with Full RELRO, NX, and stack canaries, but it exposes a stale-draft use-after-free and a very convenient status leak.
Summary
Each channel stores an open flag, a heap-backed draft, and metadata. Closing a channel frees the draft but forgets to clear the stale pointer and size. That creates a use-after-free write primitive through the draft editor.
The program also leaks two values in the status view for closed channels:
- The freed draft pointer, which gives a heap leak for safe-linking
- The diagnostic hook pointer, which gives a PIE leak
With those two leaks, the exploit performs straightforward tcache poisoning to redirect a draft allocation onto g_auth + 0x20, writes the expected clearance value, and triggers the built-in flag path.
This is a good challenge because it rewards taking the shortest path. You can chase full code execution if you want, but the binary already contains the privileged action. You just need to satisfy its check.
Key Code Snippets
Channel and auth state:
typedef struct { int open; size_t draft_size; char *draft; char label[32];} Channel;
typedef struct { char operator[32]; int verified; int clearance; char mission[16]; char padding[72];} AuthBlock;Safe-linking poison:
chunk_b, hook = leak_state(io, 1)
elf.address = hook - elf.sym.render_statustarget = elf.sym.g_auth + 0x20encoded_target = target ^ (chunk_b >> 12)
edit_draft(io, 1, p64(encoded_target) + p64(0))Analysis
The clean exploit surface is the channel lifecycle. Closing a channel calls free(channel->draft) and only clears open, leaving both draft and draft_size intact. Since the editor checks only those stale fields, it can still write into a freed tcache chunk.
That alone would not be enough against modern glibc-style defenses, but the status view prints the freed pointer and a function pointer from the ops table. So the challenge hands out exactly the heap and PIE leaks needed to satisfy safe-linking and compute the global overwrite target.
Rather than aiming for code execution, the exploit takes the easier win condition. Transmit report already opens and prints /flag.txt if an 8-byte field inside g_auth equals 0x00000007434f4d44, so the poisoned allocation is simply used to write that value.
That is the right call. If the binary already has a “print flag” branch, your job is to force that branch, not to show off with a harder exploit than the problem needs.
Exploitation and Flag Recovery
- Allocate two same-sized drafts and free both
- Leak the freed chunk pointer and
render_statusfromView status - Compute the PIE base and
g_auth + 0x20 - Use the stale editor on the freed chunk to poison the tcache forward pointer
- Reallocate twice so the second allocation returns a pointer to
g_auth + 0x20 - Write
0x00000007434f4d44 - Trigger
Transmit report
This is a textbook example of a hardened binary still falling over once a UAF write is paired with the exact leaks needed for safe-linking and PIE recovery.
Solver
The solver is short because the path is clean: make two chunks, free them, leak state, poison tcache, land on g_auth, write the magic value, and call the report function. If a challenge gives you that route, take it.
Exploit script: military.py
Final Result
gigem{st4le_dr4ft_tcache_auth_bypass}