Skip to main content

riscy business picoMini by redpwn Solution

Reverse engineer a non-x86 binary implementing a stream cipher to recover the flag.

Published: April 2, 2026Updated: July 22, 2026

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.

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.

bash
file riscy-business   # confirm RISC-V ELF
bash
qemu-riscv64 ./riscy-business   # test run under emulation

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1
    Identify the architecture and load into a disassembler
    Observation
    I 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.
    Run file to 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 use riscv64-linux-gnu-objdump -d for a quick look at the raw assembly.
    bash
    file riscy-business
    bash
    riscv64-linux-gnu-objdump -d riscy-business | head -100
    bash
    strings riscy-business   # spot the failure message and any embedded data

    Expected 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-a7 for function arguments, s0-s11 for saved registers, and ra for 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 .text section and look for the function that reads from stdin and the two helper routines that implement the cipher.

  2. Step 2
    Understand the two-stage stream cipher
    Observation
    I 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 (or generate_shuffle) builds a 256-element permutation array using your input as the key - this is RC4's key-scheduling algorithm (KSA). shuffle_and_fetch (or step) 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 calls step once 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 index i, computes j = (j + S[i] + key[i % keylen]) % 256 and swaps S[i] with S[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 0x101c0 in common builds). At that breakpoint, register a5 holds the expected ciphertext byte and s1 holds 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.

  3. Step 3
    Brute force the flag character by character under QEMU + GDB
    Observation
    I 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 1234
    bash
    qemu-riscv64 -g 1234 ./riscy-business
    bash
    # Terminal 2: attach GDB-multiarch
    bash
    gdb-multiarch ./riscy-business
    bash
      (gdb) target remote localhost:1234
    bash
      (gdb) break *0x101c0   # comparison site
    bash
      (gdb) continue

    Rather 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.

Key takeaway

Cross-architecture reverse engineering requires selecting the correct processor module and understanding the calling convention before any analysis is possible, but the high-level logic (key scheduling, byte permutation, XOR keystream) reads the same regardless of ISA. Stream ciphers like RC4 are fully invertible given the key, but when the key is the secret being searched for, known-plaintext such as a flag prefix allows an oracle-based character-by-character brute force: each correct prefix character keeps the comparison reachable for the next position. This sequential oracle technique applies to any binary that validates input incrementally and produces a distinguishable signal on a per-character match.

Related reading

Want more picoMini by redpwn writeups?

Useful tools for Reverse Engineering

What to try next