Flag: hiCTF{lmaoooo_w0t???y0u_d1d_17_??435p37++}
1. Challenge
extracted/chall.py (also extracted/chall.txt = print(a, b, N, Zs, Cs, Ts)):
1flag = 'hiCTF{***}'
2def lcg(a, b, x, N): return (a*x + b) % N
3
4def gen_params(sz, n, s):
5 p, q = getPrime(sz), getPrime(sz)
6 N = p * q
7 r = random.randint(1, N); Rs = [r] # r0 -- FIRST random call
8 e = random.randint(1, N) # e -- SECOND
9 a, b = random.randint(1, N), random.randint(1, N) # a, b
10 for _ in range(n - 1):
11 Rs.append(lcg(a, b, Rs[-1], N))
12 Cs = [random.randint(1, N) for _ in range(n)]
13 Zs = [ri*pow(s, ci, N) for ri, ci in zip(Rs, Cs)] # NOTE: no final % N
14 Ts = [pow(ri, e, N) for ri in Rs]
15 return a, b, e, N, Zs, Cs, Rs, Ts
16
17a, b, e, N, Zs, Cs, Rs, Ts = gen_params(1024, 100, bytes_to_long(flag.encode()))
18print(a, b, N, Zs, Cs, Ts)
We are given a, b, N, Zs, Cs, Ts (space-separated; parse.py → data.json).
zs[i] = r[i]·s^{Cs[i]}, Ts[i] = r[i]^e, and r[i] is the LCG orbit of the secret r0.
r0, e, s are unknown.
Plan: recover r0 from the Mersenne-Twister stream used by random, then
r[i] = LCG^i(r0), so A_i := Zs[i]/r[i] = s^{Cs[i]} (mod N); since the published
Cs[i] are the exponents and gcd(Cs)=1, a single extended-Euclid combination gives s exactly.
2. Mersenne-Twister recovery (the hard part)
Zs[i] ~4090 bits, N is 2047-bit, so a,b,Cs[i] are 2047-bit random.randint(1,N) values.
Three traps:
randint(1,N)is not a baregetrandbits(2047). It is1 + _randbelow(N), i.e.g = getrandbits(2047); while g >= N: g = getrandbits(2047). With2^2047/N ≈ 1.28the rejection probability is ≈ 0.219, so consecutive published draws are not contiguous in the MT word stream (whole 64-word rejected attempts are missing).getrandbits(2047)= 64 genrand words, LSB-first, and the 64th word is>>1- its low bit is lost. So each draw exposes 64 words but the LSB of everyword[63]is unobservable from the value.- The twist is not injective: CPython's in-place twist never uses
state[0], so a 32-bit word is lost per inverse step - naive "untemper + inverse-twist all the way back" drifts. This is why earlier attempts failed.
Working method (solver.py):
- Find a contiguous run. Build the per-draw 64-word decomposition of
D = [a, b] + Cs; a runD[j..j+L-1]is contiguous iff untempering its first 624 words and twisting reproduces the following words. The data yields runs of length 11 atj = 20, 22and of length 10 atj = 17..26. - Fix the lost LSBs. Untemper the window (624 words) with the lost bits set to 0; the
true state differs only by
U1 = untemper(1)at the 9 truncated word positions (temper/untemper are GF(2)-linear). Brute-force the 2^9 choices, and keep the combo whose forward-generated MT stream reproduces all subsequent publishedCs(rejection-aware). Exactly one combo passes (for the winning run:j=17, combo=471). - Walk backwards to the start. For the anchor at absolute position
A = 64·T(T= number of attempts before the anchor), untwistK = ceil(A/624)times; if the residual "free-word" corruption lands at negative positions the state is exact on>=0. EnumerateTand the two leading draws;r0is the first accepted block (value< N),ethe second. Decisive check:pow(r0, e, N) == Ts[0].
Run: .venv/bin/python solver.py data.json
runs: [(20,11),(22,11),(17,10),(18,10),...,(26,10)]
run j=20 L=11: 1 LSB combos pass ([186])
run j=22 L=11: 1 LSB combos pass ([302])
run j=17 L=10: 1 LSB combos pass ([471])
MATCH j= 17 T= 28 combo= 471 g= 1
r0 = 1235310913929046981501713248276380234... (full value in final.py)
e = 1179908898524522142711694858107463158...
s = 57093359257736829004393844467680814825456194741017928601536104291193196400193820508821249982710098813
flag = b'hiCTF{lmaoooo_w0t???y0u_d1d_17_??435p37++}'
3. The exponent trick
With r0 known, Rs follow from the published LCG. Then for every i:
A_i = Zs[i] · r[i]^{-1} mod N = s^{Cs[i]} mod N
Run the iterative extended Euclidean algorithm over the published exponents Cs[0..99]
to get Bezout coefficients with Σ k_i·Cs[i] = g = gcd(Cs). Here g = 1, so
s = Π A_i^{k_i} mod N (modular exponentiation handles negative k_i via inverses)
s is a ~27-byte integer → long_to_bytes(s) = b'hiCTF{...}'.
4. Proof (re-encryption) - final.py
MT stream forward from anchor reproduces 85/85 published draws (Cs[15..99] and later) exactly
backward walk (untwist x3, T=28) recovers r0 and e; e is the 2nd stream draw, consistent
Zs reproduced: 100/100
Ts reproduced: 100/100
Zs[i]/r[i] == s**Cs[i] for all i: True
flag = b'hiCTF{lmaoooo_w0t???y0u_d1d_17_??435p37++}'
VERIFIED
That is: the reconstructed stream reproduces every published Cs value; the
recovered r0,e re-encrypt to all 100 published Ts; and the recovered s re-encrypts
all 100 published Zs exactly (Zs[i] = r[i]·pow(s,Cs[i],N), no final reduction).