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: August 31, 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 1Identify the architecture and load into a disassembler
    Observation
    The 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.
    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 2Understand the two-stage stream cipher
    Observation
    The 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 (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 3Brute force the flag character by character under QEMU + GDB
    Observation
    Validation 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 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 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.

Key takeaway

Cross-architecture work starts with the right processor module and the correct calling convention, after which the high-level logic, key scheduling and byte permutation and an XOR keystream, reads the same whatever the instruction set. RC4 inverts easily given the key, and when the key is what you are searching for, a known plaintext turns the program into an oracle: each correct character keeps the comparison reachable for the next. That works against any binary that validates incrementally and signals a per-character match.

Related reading

Useful tools for Reverse Engineering

Where to go next