Description
RISC-V binary analysis: reverse engineer a stripped RISC-V executable that encrypts your input with a custom stream cipher before comparing against a stored ciphertext.
Setup
Download the RISC-V binary from the challenge page.
Install RISC-V toolchain and QEMU user-mode emulation: sudo apt install binutils-riscv64-linux-gnu qemu-user.
file riscy-business # confirm RISC-V ELFqemu-riscv64 ./riscy-business # test run under emulationSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1
Identify the architecture and load into a disassemblerObservationI noticed the challenge is named 'riscy-business' and the binary was unfamiliar to standard x86 tools, which suggested it targets the RISC-V ISA and requires a cross-architecture disassembler and the correct Ghidra processor module before any meaningful analysis can begin.Runfileto confirm the binary is a statically linked, stripped RISC-V 64-bit ELF with RVC (compressed instructions). Load it into Ghidra using the RISCV:LE:64:default language, or useriscv64-linux-gnu-objdump -dfor a quick look at the raw assembly.bashfile riscy-businessbashriscv64-linux-gnu-objdump -d riscy-business | head -100bashstrings riscy-business # spot the failure message and any embedded dataExpected output
riscy-business: ELF 64-bit LSB executable, UCB RISC-V, RVC, double-float ABI, version 1 (SYSV), statically linked, stripped
What didn't work first
Tried: Load the binary into Ghidra using the default x86 or x86-64 processor module.
Ghidra will parse the ELF headers correctly but then disassemble the RISC-V opcodes as garbage x86 instructions, producing nonsensical pseudocode. You must select RISCV:LE:64:default as the language during import; without it no instruction boundaries or register names will be correct.
Tried: Run objdump -d without the riscv64-linux-gnu- prefix, using the system default objdump.
The host system's objdump targets the host architecture (usually x86-64) and will either refuse the binary with 'File format not recognized' or produce completely wrong disassembly. The cross-architecture variant riscv64-linux-gnu-objdump must be used explicitly because it was built for the RISC-V target.
Learn more
RISC-V is an open-source instruction set architecture (ISA) based on reduced instruction set computing (RISC) principles. The standard integer register file has 32 registers named
x0-x31, with conventional aliases:a0-a7for function arguments,s0-s11for saved registers, andrafor the return address.Ghidra 9.2+ has built-in RISC-V support. When importing the binary, select RISCV:LE:64:default. Because the binary is stripped (no symbol names), you will need to rename functions manually as you identify them. The decompiler output will still be readable pseudo-C once you orient yourself in the call graph.
The binary is statically linked, so objdump output is large. Focus on the
.textsection and look for the function that reads from stdin and the two helper routines that implement the cipher.Step 2
Understand the two-stage stream cipherObservationI noticed the disassembly showed a 256-byte permutation table being initialized with the input as a key and then stepped through byte by byte during comparison, which suggested an RC4-like stream cipher where the flag prefix could be used as known plaintext to recover the remaining characters.The binary implements a custom stream cipher that closely resembles RC4. Two key functions carry the work:init_shuffle(orgenerate_shuffle) builds a 256-element permutation array using your input as the key - this is RC4's key-scheduling algorithm (KSA).shuffle_and_fetch(orstep) advances two index pointers through the permutation, swaps two bytes, and returns one derived byte - this is RC4's pseudo-random generation algorithm (PRGA). The main validation loop callssteponce per flag character and XORs (or otherwise combines) the result against a hardcoded expected ciphertext array embedded in the binary. Your raw input is never compared directly against plaintext bytes.Learn more
RC4-like key scheduling: The KSA initializes
S[0..255] = 0..255, then for each indexi, computesj = (j + S[i] + key[i % keylen]) % 256and swapsS[i]withS[j]. Because the entire permutation depends on every byte of the input key, changing one character of your guess changes all subsequent stream bytes - you cannot simply read the answer from hardcoded immediates in the comparison instructions.Why static analysis alone is hard: You could port the cipher to Python, initialize it with the known prefix
picoCTF{, and then for each subsequent position XOR the expected ciphertext byte with the next stream byte to recover the plaintext. This works because the flag prefix is known and the cipher is deterministic. Some solvers took this approach after disassembling the cipher logic in Ghidra.Alternative - dynamic analysis: Run the binary under QEMU + GDB and set a breakpoint at the comparison site (around address
0x101c0in common builds). At that breakpoint, registera5holds the expected ciphertext byte ands1holds the encrypted byte of your guess. By brute-forcing one character at a time and checking whether those registers match, you can recover each flag character without understanding the cipher internals at all.Step 3
Brute force the flag character by character under QEMU + GDBObservationI noticed the cipher validates input sequentially and a correct partial prefix keeps the comparison breakpoint reachable for the next position, which suggested that spawning the binary under QEMU's GDB stub and brute-forcing one character at a time would recover the full flag without needing to fully invert the cipher mathematically.The practical solving path is dynamic: emulate the binary with QEMU user-mode in debug mode (-g 1234), attach GDB-multiarch, and brute force each flag position. Because the cipher's state after the first N characters only depends on those N characters and the validation is sequential, a correct partial guess extends the accepted prefix by one - a classic oracle-based brute force.bash# Terminal 1: start the binary under QEMU with a GDB stub on port 1234bashqemu-riscv64 -g 1234 ./riscy-businessbash# Terminal 2: attach GDB-multiarchbashgdb-multiarch ./riscy-businessbash(gdb) target remote localhost:1234bash(gdb) break *0x101c0 # comparison sitebash(gdb) continueRather than stepping through GDB manually for every candidate character, use a pwntools script to automate the loop. The script below spawns QEMU in debug mode, attaches via the GDB stub, and iterates over all printable ASCII characters for each flag position, accepting the one whose ciphertext register value matches the expected register value at the breakpoint.
What didn't work first
Tried: Attach with plain gdb instead of gdb-multiarch when connecting to the QEMU stub.
A host x86-64 gdb cannot interpret RISC-V register files or instruction encodings, so commands like 'info registers' show garbage values and breakpoints may silently fail to fire. gdb-multiarch must be used because it includes the RISC-V target description that QEMU advertises over the remote-serial protocol.
Tried: Set the breakpoint at the hard-coded address 0x101c0 without first verifying the address in the actual downloaded binary.
The comparison site address depends on the specific build distributed by the challenge; different binary versions have different load addresses and text layouts. Running 'riscv64-linux-gnu-objdump -d riscy-business | grep -A5 bne' or finding the comparison in Ghidra first is necessary to confirm the correct address before attaching GDB.
Learn more
Pwntools skeleton for the brute force:
from pwn import * import string BINARY = "./riscy-business" CHARSET = string.printable.strip() BREAK_ADDR = 0x101c0 # adjust to match your build known = "picoCTF{" while not known.endswith("}"): for c in CHARSET: guess = known + c # spawn QEMU stub io = process(["qemu-riscv64", "-g", "1234", BINARY]) # attach GDB via pwnlib (sends RSP packets over the stub socket) # gdb.attach(io, exe=BINARY, gdbscript=f"target remote localhost:1234 break *{BREAK_ADDR} continue ") # ... send guess, read registers a5 and s1 from gdb output # if s1 == a5: known += c; break io.close()The key insight is that each correct character keeps the comparison breakpoint reachable for the next position, while a wrong character causes an early exit - making the oracle reliable.
Flag
Reveal flag
picoCTF{4ny0n3_g0t_r1scv_h4rdw4r3?_...}
The flag is validated via an RC4-like stream cipher: your input acts as the key, the cipher produces a ciphertext stream, and that stream is compared against hardcoded expected bytes. The flag is recovered by dynamic brute force under QEMU emulation, not by reading plaintext immediates from the disassembly.