Hello guys! In this post I’ll be covering three cryptography challenges I made for BSides Mumbai CTF.
The challenges are:
- Chebyshev’s Revenge
- GCD, but it’s ACD
- Heat Death of RSA
Let’s start without any further ado.
1. Chebyshev’s Revenge
This challenge gives us the following basic construction:
alice = randbits(768)
bob = randbits(768)
A = T(alice, x)
B = T(bob, x)
shared_alice = T(alice, B)
shared_bob = T(bob, A)
assert shared_alice == shared_bob
and then the shared value is converted into a SHAKE256 stream and XORed with the flag.
At first glance it basically looks like some weird Diffie-Hellman replacement using Chebyshev polynomials.
The important identity is:
T_m(T_n(x)) = T_(mn)(x)
which explains why:
T_alice(B) = T_bob(A)
works.
The obvious problem is that alice and bob are both random 768-bit values.
We aren’t brute forcing those.
My laptop heard ‘768-bit brute force’ and asked for early retirement.
Chebyshev polynomials are hiding a group
The important identity for the attack is:
T_n(x) = (α^n + α^-n) / 2
where:
α = x + sqrt(x² - 1)
Let:
D = x² - 1
In this particular challenge, D is intentionally a quadratic non-residue modulo p.
So:
sqrt(D)
doesn’t exist inside Fp.
But it does exist in the extension field:
Fp²
We can represent one element as:
a + b*s
where:
s² = D
and multiplication becomes:
(a + bs)(c + ds)
=
(ac + bdD) + (ad + bc)s
Now define:
alpha = x + s
The norm of this value is:
(x+s)(x-s)
= x² - s²
= x² - (x²-1)
= 1
So alpha lives inside the norm-one subgroup of Fp².
And:
T_n(x)
is exactly the first coordinate of:
alpha^n
This converts our weird Chebyshev problem into a normal discrete logarithm problem.
Recovering the missing Fp² coordinate
Suppose:
alpha^n = y + k*s
We only know:
y = T_n(x)
but because the norm is one:
y² - k²D = 1
therefore:
k² = (y² - 1) / D
So given the public value A:
k2 = (A*A - 1) * inverse(D, p) % p
k = sqrt_mod(k2, p)
AA = Fp2(A, k)
and we now have either:
alpha^alice
or:
alpha^-alice
because modular square roots give two possible signs.
This ambiguity doesn’t actually matter for us.
We repeat the same process for B.
The actual vulnerability
Now we seem to have turned the challenge into:
Solve a discrete logarithm in a ~514-bit group.
which doesn’t sound much better.
Except we factor the group order.
Because alpha has norm one, its order divides:
p + 1
Factoring p+1 gives a beautiful sight:
2^14
11
4933
10067
26321
31151
32801
39239
...
297991
The largest prime factor is only:
297991
Oops.
So the entire 514-bit group order is smooth.
And therefore Pohlig-Hellman absolutely destroys this.
Pohlig-Hellman
For every prime power:
q^e | p+1
we project the problem into that subgroup:
gg = alpha ** ((p + 1) // (q**e))
hh = AA ** ((p + 1) // (q**e))
and solve:
hh = gg^r
Because every subgroup is tiny, Baby-Step Giant-Step works perfectly fine.
A simple implementation looks like:
def bsgs(g, h, n):
m = isqrt(n) + 1
table = {}
cur = ONE
for j in range(m):
table.setdefault(cur, j)
cur *= g
factor = g ** (-m)
gamma = h
for i in range(m + 1):
if gamma in table:
x = i*m + table[gamma]
if x < n and g**x == h:
return x
gamma *= factor
We repeat this for every factor and obtain:
alice mod q1^e1
alice mod q2^e2
alice mod q3^e3
...
Then use CRT:
alice, modulus = crt(moduli, residues)
and do the same for Bob.
Recovering the shared value
Once we have a and b:
shared = (
alpha ** ((a*b) % (p+1))
).a
Remember that .a is the first coordinate in Fp².
Then the challenge’s original stream encryption was:
stream = shake_256(
b"chebyshevs-revenge-final|" +
shared.to_bytes(width, "big")
).digest(len(ct))
so we just XOR again.
Running the solver gives:
BSides_Mumbai{ch3by5h3v_l0gs_l1v3_1n_th3_tw1st}
So the flag is:
BSides_Mumbai{ch3by5h3v_l0gs_l1v3_1n_th3_tw1st}
Huge exponent != secure discrete log if you very kindly choose a completely smooth group order :)
2. GCD, but it’s ACD
This challenge is based around the Approximate Common Divisor problem, hence:
GCD
but it's
ACD
The public model literally tells us what’s happening:
sample[0] = p*q[0]
sample[i] = p*q[i] + error[i]
where:
abs(error[i]) < 2^52
If there was no error we could simply do:
gcd(sample[0], sample[1], sample[2], ...)
and recover p.
Unfortunately:
gcd(p*q1 + r1, p*q2 + r2)
doesn’t magically remove r1 and r2.
So direct GCD gives us nothing useful.
The samples themselves are roughly 600 bits while the hidden divisor is 288 bits, so brute forcing a 52-bit signed error for each sample is also not exactly the world’s greatest plan.
We get one exact sample
The first value is special:
x0 = p*q0
and all the others are:
xi = p*qi + ri
where:
|ri| < X
X = 2^52
If we can recover the errors ri, the problem is instantly over because:
gcd(x0, xi - ri)
contains the exact hidden p.
So really our goal is:
recover the small errors.
Constructing the lattice
Take t noisy samples and create the following basis:
[x0, 0, 0, ..., 0]
[x1, X, 0, ..., 0]
[x2, 0, X, ..., 0]
...
[xt, 0, 0, ..., X]
Any lattice vector is of the form:
[
a0*x0 + a1*x1 + ... + at*xt,
X*a1,
X*a2,
...
X*at
]
Substitute:
x0 = p*q0
xi = p*qi + ri
and the first coordinate becomes:
p(a0*q0 + a1*q1 + ... + at*qt)
+
a1*r1 + ... + at*rt
Now imagine coefficients where:
a0*q0 + a1*q1 + ... + at*qt = 0
The gigantic multiple of p vanishes.
And suddenly the first coordinate is only:
a1*r1 + ... + at*rt
which is very small compared to the original ~600-bit samples.
That is exactly the kind of structure LLL likes finding.
Running LLL
So we run lattice reduction on the basis.
After reduction, promising rows satisfy:
row[i] % X == 0
for the coefficient columns.
We obtain:
coeff_i = row[i] // X
and every genuine short relation gives an equation:
a1*r1 + a2*r2 + ... + at*rt
=
row[0]
The solver also applies a basic sanity filter:
abs(row[0]) <= 2 * sum(abs(c) for c in coeff) * X
because a real quotient-cancelling relation should leave a value bounded by the small errors.
Solve for the errors
Once we have enough independent relations we basically have:
A * r = b
where:
r = [r1, r2, ..., rt]
Solve this exactly over the rationals/integers and we recover every signed error.
Then:
p = x0
for xi, ri in zip(noisy_samples, errors):
p = gcd(p, xi - ri)
and eventually p becomes the hidden 288-bit divisor.
Decryption
The challenge generates the encryption stream from p:
pb = p.to_bytes((p.bit_length() + 7) // 8, "big")
stream = shake_256(
b"acd-revenge/key|" + pb
).digest(len(ciphertext))
XORing gives:
BSides_Mumbai{n01sy_gcD_n33ds_l4tt1c3s}
So our flag is:
BSides_Mumbai{n01sy_gcD_n33ds_l4tt1c3s}
The funny part is that adding noise absolutely does stop the normal GCD attack.
It just changes the challenge from:
math.gcd()
to:
lattice time
3. Heat Death of RSA
And finally we have Heat Death of RSA.
This challenge is what happens when you take RSA, a noisy physical-ish mathematical model, matrix exponentials and lattices and throw all of them inside the same file.
The final challenge parameters are:
NM = 16
NS = 48
RAW = 48
There are 32 hidden coefficients:
2 * NM = 32
Each coefficient is around:
2^190 <= coefficient < 2^192
These coefficients eventually determine the RSA primes.
In other words:
recover coefficients
↓
regenerate p and q
↓
decrypt RSA
That’s the entire attack.
Unfortunately recovering those coefficients is the slightly annoying part.
Public observations
The challenge gives 48 public observations.
They aren’t direct sensor readings though.
Every observation is passed through:
obs[i] = spin(reading_i, power(i))
The first thing we therefore have to do is undo spin.
Undoing spin()
The operation behaves like exponentiation in a finite abelian group where exponent arithmetic is modulo:
Q + 1
Luckily every public exponent:
power(i)
is invertible modulo Q+1.
Therefore:
e = model.power(i)
e_inv = pow(e, -1, model.Q + 1)
reading = model.spin(obs[i], e_inv)
After converting values larger than Q/2 back into signed integers we obtain a vector:
y
satisfying approximately:
y = B*s + error
where:
s = 32 secret coefficients
B = public transfer matrix
error = bounded noise
Now this starts looking much friendlier.
Except B isn’t directly given to us.
Reconstructing B
The public model gives enough individual functions to reconstruct it.
For every sensor site we get:
xn, xd, tn, td, kind, w = model.site(i)
which gives us a position:
x = pi * xn/xd
and time:
t = tn/td
For every mode:
j = 1..16
we also obtain a 2×2 linear ODE block:
A = model.block(j)
For each of the two basis states we need:
z(t) = exp(A*t) * basis
and also:
h(t) = integral from 0 to t exp(A*s) ds * basis
The integral can be calculated as:
A^-1 (exp(A*t) - I)
So for each mode/basis pair we calculate:
E = exp(A*t)
H = A^-1 * (E-I)
and feed everything through:
model.sense(...)
The result is scaled by the challenge’s fixed-point constant:
entry = round(model.FP * value)
Doing this for all 32 hidden basis coefficients produces one row of the transfer matrix.
Do it for every raw sensor and we get the raw matrix.
Finally the public:
model.wiring(i)
function mixes those rows into the actual 48 observations.
The result is:
B = 48 × 32 integer matrix
and:
y = B*s + e
where e is small.
And now LLL returns
The columns of B generate a 32-dimensional lattice inside Z^48.
The real observation vector is extremely close to one lattice point:
B*s
because:
y = B*s + error
This means we have a Bounded Distance Decoding problem.
The error is too annoying for direct linear algebra, but small enough for lattice reduction.
First reduce a row basis for the column lattice:
reduced = Matrix(B).T.lll(delta=0.75)
Then use Babai nearest-plane with target:
y
The nearest lattice vector should be:
B*s
and therefore the residual is:
error
Conceptually:
error = babai(reduced, y)
exact = [
y[i] - error[i]
for i in range(48)
]
Now:
exact = B*s
with absolutely no noise left.
Solve the exact system
At this point the scary crypto challenge has become a linear algebra problem.
secret, params = Matrix(B).gauss_jordan_solve(
Matrix(exact)
)
This recovers all 32 secret coefficients.
We also know their expected range so we can easily verify the result:
assert all(
model.LO <= x < model.HI
for x in coeffs
)
RSA falls apart
The public model contains:
p, q = model.finalize(coeffs)
So once we recover the hidden vector we simply regenerate the exact RSA primes:
p, q = model.finalize(coeffs)
assert p*q == N
Then normal RSA:
phi = (p-1)*(q-1)
d = pow(e, -1, phi)
m = pow(c, d, N)
Converting m back to bytes gives:
BSides_Mumbai{Y0u_kn0w_about_LLL!?!?}
So the final flag becomes:
BSides_Mumbai{Y0u_kn0w_about_LLL!?!?}
And that’s it for the three crypto challenges.
The basic ideas behind them were:
Chebyshev's Revenge
-> disguised DLP + smooth group order
GCD, but it's ACD
-> approximate multiples + LLL
Heat Death of RSA
-> noisy linear leakage + LLL/Babai + RSA key regeneration
I definitely used more lattices than a normal person should while making these.
Anyways, hope you enjoyed the challenges/writeups and maybe learnt something new from them :D

