Зона риска (Risk Zone / riscy)
Зона риска (Risk Zone / riscy)
Platform: bug-makers | Category: Reversing | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-05-29 | Status: Solved Techniques: riscv_disassembly, glibc_type3_rand_reimplementation, per_position_bruteforce_inversion, modular_add_rotate_xor_reversal
Summary
Task: stripped RISC-V (RV64C) C++ crackme that reads input, transforms it with a glibc-rand-derived key (modular add + byte rotate-left + XOR) and compares against fixed bytes. Solution: reimplement glibc TYPE_3 rand (handling the srand(0)->1 quirk), regenerate the key, and brute-force each input byte through the invertible per-position transform.
Зона риска (Risk Zone / riscy) — bug-makers
Description
Ты стоишь напротив Пикси. Весь мир медленно плывёт вокруг тебя. Ты понимаешь, что всё зависит от твоих слов. Готов пойти на риск?
English: "You stand before a Pixie. The whole world slowly floats around you. You understand that everything depends on your words. Are you ready to take a risk?"
The binary prints What would you say?, reads a line, and replies either She is proud of you (success) or She is confused / You didn't take a big enough risk (failure). It is a classic password/flag-check crackme.
Goal: find the input that the binary accepts; that input is the flag.
Analysis
file riscy reports an ELF 64-bit LSB PIE executable, UCB RISC-V (RVC, double-float ABI), dynamically linked, stripped, GNU/Linux, GCC 15.1.0 — a C++ crackme. The challenge name riscy is wordplay on RISC-V + "risky", matching the "take a risk" theme.
Tooling gotcha (RISC-V on macOS)
- Apple's
objdump/ LLVMobjdumprefused to disassemble:can't find target: unable to get target for 'riscv64--'. qemu-riscv64user-mode was not installed (onlyqemu-system-riscv64), so dynamic execution was avoided — the solve is fully static + reimplementation.- Working disassembler:
riscv64-unknown-elf-objdumpfrom Homebrew'sriscv-gnu-toolchain:
bash read-only • copy allowedriscv64-unknown-elf-objdump -d riscy > disasm.txt
Reversed algorithm
The program reads up to 32 bytes via fgets, copies 27 bytes to a working buffer dst, generates a 27-byte key, transforms dst, and compares it to 27 fixed expected bytes.
1) Key generation (function at 0xac4):
for i in 0..26: srand(i); key[i] = rand() % 100
sum = 0
for j in 0..26: sum += key[j]; key[j] = (key[j] + sum) % 100
The % 100 and % 10 are compiled to the classic magic-multiply constants 0x51eb851f (divide-by-100) and 0x66666667 (divide-by-10).
2) Transform (function at 0x966), per input byte index i:
d = (input[i] + key[i]) & 0xff # modular add
d = rotl8(d, key[i] & 7) # rotate-left by low 3 bits of key[i]
d = d ^ key[i % 10] # XOR with key indexed by i mod 10
The rotate is implemented with sllw/sraw plus sign-extension that does not affect the low byte.
3) Comparison: transformed dst[i] must equal 27 fixed bytes at .rodata 0xd70:
50 7c 4b 43 81 5a 47 12 d0 4b dd 51 1a 71 33 c0 f5 6c cf 7e 33 6e 8f af e8 78 26
The key insight / pitfall
glibc's srand(0) internally substitutes the seed with 1 (seed = seed ? seed : 1). A naive Python reimplementation of glibc's TYPE_3 additive-feedback rand() that omits this produces a wrong key[0] and non-printable output. Adding if seed == 0: seed = 1 fixes key[0] and yields a fully printable flag. This is the single subtle detail of the challenge.
The glibc rand() reimplementation (TYPE_3, 31-int additive feedback):
r[0] = seed(withseed == 0 -> 1)r[i] = (16807 * r[i-1]) % 2147483647fori = 1..30r[i] = r[i-31]fori = 31..33r[i] = (r[i-31] + r[i-3]) & 0xffffffffthereafter- first output
= ((r[313] + r[341]) & 0xffffffff) >> 1
Verified against the canonical srand(1); rand() == 1804289383.
Solution
The per-position transform is fully invertible (it only depends on key[i], key[i%10], and the input byte at position i). For each of the 27 positions, brute-force the 256 possible input bytes through the forward transform and pick the one whose output matches the expected byte.
python read-only • copy allowed#!/usr/bin/env python3 # glibc TYPE_3 (31-int additive feedback) rand() reimplementation. def gr(seed): if seed == 0: # glibc: seed = seed ? seed : 1 (the crucial quirk) seed = 1 r = [0] * 344 r[0] = seed & 0xffffffff for i in range(1, 31): r[i] = (16807 * (r[i-1] % 2147483647)) % 2147483647 for i in range(31, 34): r[i] = r[i-31] for i in range(34, 344): r[i] = (r[i-31] + r[i-3]) & 0xffffffff return ((r[313] + r[341]) & 0xffffffff) >> 1 N = 27 # 1) key[i] = (srand(i); rand()) % 100 key = [gr(i) % 100 for i in range(N)] # 2) running-sum accumulation pass s = 0 for j in range(N): s += key[j] key[j] = (key[j] + s) % 100 exp = bytes.fromhex('507c4b43815a4712d04bdd511a7133c0f56ccf7e336e8fafe87826') def fwd(c, i): d = (c + key[i]) & 0xff # modular add sh = key[i] & 7 if sh: d = ((d << sh) | (d >> (8 - sh))) & 0xff # rotate-left 8-bit d ^= key[i % 10] # XOR with key[i % 10] return d & 0xff rec = [] for i in range(N): f = 63 for c in range(256): if fwd(c, i) == exp[i]: f = c break rec.append(f) print(bytes(rec)) # b'BugCTF{REDACTED}\n'
The trailing \n is the fgets newline occupying input byte index 26, which is why the buffer is 27 bytes. The flag content REDACTED is self-referential: the scheme leans on a weak XOR step.
Recovered final key (after the sum-accumulation pass):
[66, 49, 46, 48, 4, 53, 60, 73, 88, 22, 97, 48, 45, 65, 41, 44, 39, 25, 56, 56, 29, 30, 14, 48, 92, 41, 94]