← Back to Writeups
CUSTOMMediumReversing

Зона риска (Risk Zone / riscy)

XESXOR9/2/20265 min read
#reversing#custom#medium#riscv_disassembly#glibc_type3_rand_reimplementat#per_position_bruteforce_invers#modular_add_rotate_xor_reversa#linux

Зона риска (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 / LLVM objdump refused to disassemble: can't find target: unable to get target for 'riscv64--'.
  • qemu-riscv64 user-mode was not installed (only qemu-system-riscv64), so dynamic execution was avoided — the solve is fully static + reimplementation.
  • Working disassembler: riscv64-unknown-elf-objdump from Homebrew's riscv-gnu-toolchain: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍
bash
riscv64-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 (with seed == 0 -> 1)
  • r[i] = (16807 * r[i-1]) % 2147483647 for i = 1..30
  • r[i] = r[i-31] for i = 31..33
  • r[i] = (r[i-31] + r[i-3]) & 0xffffffff thereafter
  • 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
#!/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]

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍