Neven Marić
Hidden Investigations CTF 2026

CryptoHidden Investigations CTF 2026

Rocinate

Number-theory problem that is really an MT19937 state-recovery challenge — 100 public C_i values reconstruct the PRNG state, then running it backward reveals every secret.

  • crypto
  • mt19937
  • prng-state-recovery
  • bezout
  • rsa
  • lcg
  • python-random

What this one teaches

  • When all randomness comes from Python's random module, the 'crypto' problem is an MT19937 state-recovery problem in disguise.
  • randint(1, N) rejects draws >= N, so visible outputs are not contiguous in the MT stream — you need to find a gap-free run and brute-force the missing low bits.
  • Once the state is recovered, running the stream backward by inverting the recurrence gives secrets generated before the first public output.

Task description

🤭😂

  • Category: crypto (hard)

Spot the real weakness

In chall.py, r, e, a, b and every C_i come from random.randint, i.e. MT19937. The 100 C_i are public, so they are 100 near-full MT outputs — plenty to reconstruct the generator state. The LCG / e / Bezout machinery is a distraction that only matters for the final arithmetic.

Find a gap-free run of outputs

Each value is 1 + getrandbits(2047), which is 64 words of 32 bits with the low bit of the top word lost. randint(1, N) rejects any draw >= N, and since N / 2^2047 ~= 0.78, roughly 22% of draws are discarded — so the visible outputs are not contiguous in the MT stream. Testing the MT recurrence x[t+624] = x[t+397] ^ F(x[t], x[t+1]) across windows of chunks finds that C15..C33 form a run with no rejected draws in between.

Recover the MT state

Take 624 consecutive words starting at C15, brute-force the 2^9 combinations of the 9 missing top-word low bits, and keep the single candidate whose tempered output matches all 19 gap-free chunks.

MT19937 helpers (temper/untemper, forward recurrence, backward recovery):

M32 = 0xffffffff

def untemper(y):
    y ^= y >> 18
    y ^= (y << 15) & 0xefc60000
    t = y
    for _ in range(5): t = y ^ ((t << 7) & 0x9d2c5680)
    y = t
    t = y
    for _ in range(3): t = y ^ (t >> 11)
    return t & M32

def temper(y):
    y ^= y >> 11
    y ^= (y << 7) & 0x9d2c5680
    y ^= (y << 15) & 0xefc60000
    y ^= y >> 18
    return y & M32

def F(xt, xt1):
    y = (xt & 0x80000000) | (xt1 & 0x7fffffff)
    return (y >> 1) ^ (0x9908b0df if y & 1 else 0)

def fwd(x, t):
    return x[t+397] ^ F(x[t], x[t+1])

def y_from(x624, x397):
    v = x624 ^ x397
    if v & 0x80000000:
        return (((v ^ 0x9908b0df) << 1) | 1) & M32
    return (v << 1) & M32

Full solver

The solver recovers the state, runs the stream backward to find e and r0, then uses Bezout combination over s^C_i mod N to recover s:

import sys, os, itertools
from math import gcd
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from parse import load
from mt import *

a, b, N, Zs, Cs, Ts = load(sys.argv[1])
K = N.bit_length(); W = (K - 1) // 32 + 1
vals = [a - 1, b - 1] + [c - 1 for c in Cs]

# Collect tempered words from C15..C33 (contiguous run)
start = 2 + 15  # index of C15 in vals
tw = []; part = []
for j in range(19):
    for (w, m) in chunk_words(vals[start + j], K):
        if m: part.append(len(tw))
        tw.append(w)

# Brute-force the 9 missing low bits (one per chunk's top word)
p624 = [i for i in part if i < 624]
sol = None
for bits in itertools.product([0, 1], repeat=len(p624)):
    t2 = tw[:]
    for i, bb in zip(p624, bits): t2[i] |= bb
    x = [untemper(w) for w in t2[:624]]
    good = True
    for t in range(624, len(tw)):
        nx = fwd(x, t - 624); x.append(nx)
        if t in part:
            if temper(nx) >> 1 != tw[t] >> 1: good = False; break
        elif temper(nx) != tw[t]: good = False; break
    if good:
        sol = [untemper(w) for w in t2[:624]]

# Extend stream backward to find e and r0
B = 64 * 60
x = {i: sol[i] for i in range(624)}
for t in range(624, 624 + 64 * 400): x[t] = fwd(x, t - 624)
for t in range(-1, -B, -1):
    hi = y_from(x[t + 624], x[t + 397]) & 0x80000000
    lo = y_from(x[t + 623], x[t + 396]) & 0x7fffffff
    x[t] = hi | lo

def chunk_val(t0):
    v = 0
    for j in range(W):
        o = temper(x[t0 + j]); kk = K - 32 * j
        if kk < 32: o >>= (32 - kk)
        v |= o << (32 * j)
    return v

# Locate every known value in the stream and walk back for e, r0
lo_k = -(B // 64) + 1
seq = [(k, chunk_val(64 * k)) for k in range(lo_k, 64 * 400 // 64 - 1)]
idx = {v: k for k, v in seq}
locs = [idx.get(v) for v in vals]

def prev_accepted(k):
    while chunk_val(64 * k) >= N: k -= 1
    return k

ke = prev_accepted(locs[0] - 1); e = chunk_val(64 * ke) + 1
kr = prev_accepted(ke - 1); r0 = chunk_val(64 * kr) + 1

Rs = [r0]
for _ in range(99): Rs.append((a * Rs[-1] + b) % N)
U = [Z // r for Z, r in zip(Zs, Rs)]

# Bezout combine: s^(gcd of all C_i) = s^1 mod N
g, acc = Cs[0], U[0]
for c, u in zip(Cs[1:], U[1:]):
    if g == 1: break
    def eg(a_, b_):
        x0, x1, y0, y1 = 1, 0, 0, 1
        while b_:
            q_ = a_ // b_; a_, b_ = b_, a_ - q_ * b_
            x0, x1 = x1, x0 - q_ * x1; y0, y1 = y1, y0 - q_ * y1
        return a_, x0, y0
    d, xx, yy = eg(g, c)
    acc = pow(acc, xx, N) * pow(u, yy, N) % N
    g = d

s = acc
print(s.to_bytes((s.bit_length() + 7) // 8, "big"))

Run the stream both ways to get e and r0

With the state known, the stream runs forward normally and backward by inverting the recurrence (high bit of x[t] from y_t, low 31 bits from y_{t-1}). Locate a, b and every C_i in the stream; every chunk sitting between two accepted values is a draw >= N, i.e. a rejection. Walking back from a and skipping rejections gives e (chunk -27) and then r0 (chunk -28). Verified by pow(r_i, e, N) == T_i for all 100 samples and r_i | Z_i over the integers.

Recover s

Since Z_i = r_i * s^C_i is an exact integer product, U_i = Z_i / r_i = s^C_i mod N. The exponents are coprime overall (gcd(C_0, C_1, ...) = 1), so iterated Bezout combination prod U_i^{k_i} = s^{sum k_i C_i} = s^1 = s mod N recovers s directly. Confirmed s^C_i == U_i for all 100 samples; long_to_bytes(s) is the flag.

Lessons

  • When all randomness comes from Python’s random module, the problem is an MT19937 state-recovery problem in disguise — the number-theory dressing is a decoy.
  • randint(1, N) rejects draws >= N, creating gaps in the MT output stream. Finding a contiguous run and brute-forcing the few missing low bits is tractable.
  • Running the MT recurrence backward recovers secrets generated before any public output.
The flagRevealhiCTF{lmaoooo_w0t???y0u_d1d_17_??435p37++}

This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.