Back to writeups
FlagYardcryptoHardsolved

Real Number Generator

12 Jul 2026 · by 0xIDA

Solvers

Real Number Generator

Author: 0xIDA
Platform: FlagYard · Category: crypto · Difficulty: Hard

Solvers

Summary

48-bit seed feeds a Decimal LCG-like map state = (e·state) mod π, then sqrt(|sin+cos|) packed as IEEE-754 doubles for a keystream. Known plaintext The flag is: recovers the first stream float; inverting the exact rational form of float(e)/float(π) yields a modular equation that pins the seed uniquely. Flag is unwrapped with shake_256(str(seed)).

Challenge

class RealNumberGenerator:
    def random(self):
        self.state = Decimal(e) * Decimal(self.state) % Decimal(pi)
        return sqrt(abs(sin(state) + cos(state)))  # math.* → float
    def next_bytes(self):
        yield from struct.pack('d', self.random())  # 8 bytes per call

seed = secrets.randbits(48)
msg = b'The flag is: ' + xor(flag, shake_256(str(seed)).digest(len(flag)))
enc = xor(msg, rng.next_bytes())

Attack

1. Known plaintext → first double

stream[:13] = enc[:13] XOR b'The flag is: '
F0 = struct.unpack('d', stream[:8])

2. Invert sqrt(|sin+cos|)

sin s + cos s = √2 sin(s+π/4) = ±F0² gives two real roots in [0,π):

s ∈ {0.5369…, 1.0338…} (and a few adjacent IEEE floats that still pack to F0).

3. Exact modular form of the state update

Decimal(math.e) / Decimal(math.pi) are exact float dyadics:

e = e_num/e_den,  π = pi_num/pi_den
(e·seed) mod π = (A·seed mod B) / DEN
A = e_num·pi_den, B = e_den·pi_num, DEN = e_den·pi_den

gcd(A,B) = 2^48. With Ag=A/G, Bg=B/G (~53-bit modulus):

(Ag·seed) mod Bg ≈ T·DEN/G
seed ≡ (target)·Ag⁻¹  (mod Bg)

Because Bg > 2^48, at most one seed per target residue; a ±4 window around each good T is enough.

4. Verify & decrypt

Match 13 stream bytes, then:

msg = enc XOR stream
flag = msg[13:] XOR shake_256(str(seed)).digest(...)

Result

SEED 22933217634325
FLAG FlagY{r34lly_n0t_g00d_RNG_9aedfc5b67}

Flag

FlagY{r34lly_n0t_g00d_RNG_9aedfc5b67}

Offline from handout; submitted successfully to Flagyard.

Key Takeaways

  1. “Real” RNGs that mix IEEE floats with Decimal are still finite-state and invertible when the seed is small.
  2. Known-plaintext on the first struct.pack('d', …) block reduces the problem to recovering state preimages under a smooth function.
  3. Representing float constants via as_integer_ratio() turns % π into a pure integer modular condition; large gcd with 2^48 collapses the search.
  4. Always confirm the full known-plaintext prefix before SHAKE unwrap.

Tools

  • Python 3, numpy (nextafter), pycryptodome not required
  • Solver: /root/flagyard/real-number-generator/solve.py