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
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 1Identify the architecture and load into a disassembler
ObservationThe name puns on RISC-V and standard x86 tools do not recognize the binary. Analysis needs a cross-architecture disassembler and the right Ghidra processor module before anything else.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 2Understand the two-stage stream cipher
ObservationThe disassembly builds a 256-byte permutation table keyed on your input and steps through it byte by byte during the comparison. That is RC4-shaped, and the known flag prefix gives you the plaintext to work from.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 3Brute force the flag character by character under QEMU + GDB
ObservationValidation runs sequentially, and a correct partial prefix keeps the comparison reachable for the next position. Run the binary under QEMU's GDB stub and brute-force one character at a time; the cipher never has to be inverted.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 read RISC-V registers or instruction encodings, so the register view shows garbage and breakpoints quietly fail to fire. gdb-multiarch carries the RISC-V target description QEMU advertises over the remote protocol.
Tried: Set the breakpoint at the hard-coded address 0x101c0 without first verifying the address in the actual downloaded binary.
The comparison address belongs to the specific build you were given; other versions load elsewhere and lay their text out differently. Find it in the disassembly or in Ghidra before attaching.
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.
Interactive tools
- Hex ViewerView text or raw hex bytes as a xxd-style hex dump with byte offset, hex columns, and ASCII sidebar. Highlights printable characters and null bytes.
- Number Base ConverterConvert numbers between binary, octal, decimal, and hexadecimal instantly. Enter any value and see all four bases update in real time.
- Endianness ConverterConvert between big-endian and little-endian byte order with visual byte layout. Supports 16-bit, 32-bit, and 64-bit words.
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.