Table of Contents
- error-code — Merkle-Hellman implementation bug
- nito — truncated-state recovery with Coppersmith
- Lack of Entropy — deterministic RSA prime relation
- a-joke-cipher — broken key exchange leaks plaintext multiplier
- d-phi-enc — recovering RSA factors from encrypted φ and d
error-code — Merkle-Hellman implementation bug
Summary
- Category: Cryptography
- Challenge:
error-code - Core scheme: Merkle-Hellman knapsack (subset-sum)
- Given files:
chall.py,output - Recovered flag:
CBC{4_cl4ss1c_subs3t_sum_pr0blem_r1ght_9c209955}
Problem statement (practical)
The challenge author says they implemented Merkle-Hellman, but decryption does not work. The job is to identify the implementation error and recover the plaintext.
Root cause
In Merkle-Hellman, the private sequence W must be superincreasing:
$$ W_i > \sum_{j=0}^{i-1} W_j $$
But in chall.py, sequence generation only enforces monotonic growth from the previous term:
def getSequence(size):
W = []
w0 = 99999
for _ in range(size):
wi = random.randint(w0 + 1, 2 * w0)
W.append(wi)
w0 = wi
return W
This condition is too weak. It creates an increasing sequence, but not necessarily superincreasing. So the classic private-key greedy decryption path is invalid/unreliable.
Why encryption still leaks
Even with that bug, encryption is still linear subset sum over public key coefficients:
$$ c = \sum b_i \cdot pub_i $$
For this instance, each ciphertext block uses 48 coefficients (because len(flag) = 48), and there are 8 blocks in ct_lst.
That is directly solvable as subset-sum with meet-in-the-middle.
Solver approach (meet-in-the-middle)
Split public key into two halves of 24 + 24:
- Enumerate all subset sums of first half, map
sum -> mask. - Enumerate all subset sums of second half, map
sum -> mask. - For each ciphertext
ct, finds1 + s2 = ct. - Recover 48 bits for that block from masks.
- Reconstruct original integer/bytes according to the challenge bit-order.
Repro solver
# solve.py
pub = [399556779992238, 34298225407642, 20642131245786, 65490996326207, 122628372242215, 9829827117171,
396026745275353, 167294584165304, 296444912760811, 80351792351840, 409914932982222, 85802434589354,
401591010427922, 187008763237642, 152108391468386, 31583306493979, 25525613569306, 162082080588558,
126634456980331, 164430139182734, 83701623822886, 402679681495011, 192369423507295, 160258497915791,
258919777739363, 64531362352673, 306761417690632, 341920025542429, 26318306417850, 4818756642968,
207127269238936, 200786177672536, 179782888357779, 58758524994661, 278516593354891, 158487072996052,
291093591620507, 64579822017072, 210651490815487, 217428030502253, 258408901203031, 360703727369716,
29799816650929, 279304424652560, 34106935830323, 424511985165056, 296198681936242, 429117013029379]
cts = [4732215009804500, 4609442091199342, 5692746616356737, 3525483039088576,
6333124120174760, 5291548626672528, 4905835028190278, 4783246254953126]
HALF = 24
A, B = pub[:HALF], pub[HALF:]
sa = {0: 0}
for i, w in enumerate(A):
bit = 1 << i
for s, m in list(sa.items()):
ns = s + w
if ns not in sa:
sa[ns] = m | bit
sb = {0: 0}
for i, w in enumerate(B):
bit = 1 << i
for s, m in list(sb.items()):
ns = s + w
if ns not in sb:
sb[ns] = m | bit
chunks = []
for ct in cts:
found = None
for s1, m1 in sa.items():
need = ct - s1
m2 = sb.get(need)
if m2 is not None:
found = (m1, m2)
break
if found is None:
raise RuntimeError(f"No subset solution for ct={ct}")
m1, m2 = found
bits = [((m1 >> i) & 1) for i in range(24)] + [((m2 >> i) & 1) for i in range(24)]
chunks.append(bits)
msg = 0
for j, bits in enumerate(chunks):
val = sum((b << i) for i, b in enumerate(bits))
msg |= val << (48 * j)
raw = msg.to_bytes(48, 'big')
print(raw.decode())
Expected output:
CBC{4_cl4ss1c_subs3t_sum_pr0blem_r1ght_9c209955}
Correct key-generation fix
To make decryption valid in Merkle-Hellman, generate W as superincreasing:
def getSequence(size):
W = []
s = 1
for _ in range(size):
wi = random.randint(s + 1, 2 * s)
W.append(wi)
s += wi
return W
Keep:
q > sum(W)gcd(r, q) = 1
Then private-key decryption works as intended.
nito — truncated-state recovery with Coppersmith
Summary
- Category: Cryptography
- Challenge:
nito - Given files:
challenge.sage,output.txt - Core idea: recover unknown 64-bit tails from leaked high bits of modularly-related values
- Recovered flag:
CBC{cryp70_i5_ju57_AI_5p33drun?}
Given construction
Challenge generates:
- prime
p(256-bit) - random
a0 a1 = a0^2 mod p- leaked values:
b0 = a0 >> 64b1 = a1 >> 64
- ciphertext:
c = bytes_to_long(flag) ^^ pow(a1, 42, p)
Let unknown low 64-bit parts be x, y:
$$ a0 = (b0 « 64) + x, \quad a1 = (b1 « 64) + y, \quad 0 \le x,y < 2^{64} $$
From a1 = a0^2 mod p:
$$ ((b0 « 64) + x)^2 - ((b1 « 64) + y) \equiv 0 \pmod p $$
This is a bivariate small-root problem over bounds (2^64, 2^64).
Solve strategy
Use Coppersmith small-roots on the polynomial above to recover (x, y).
I used:
- Sage (local env at
/opt/micromamba/envs/sage) coppersmith.sagefromdefund/coppersmith(updated to Sage 10.7 API:coefficients_monomials, no deprecation warning)
Found root:
x = 8804081745998249949y = 10378144287870099762
Then reconstruct a1, compute pow(a1,42,p), XOR with c, and decode bytes.
Repro script (Sage)
load('coppersmith.sage')
p = 12721492641695199020302023719104846741933769337250722901191654350831830951811
c = 41191888024213095867034353749467947785476609376959645738506308145695929457707
b0 = 505745372664810403157022893345560792860796814985411337944
b1 = 55363083749705718410582971847380097690792442149083200327
kbits = 64
B0 = b0 << kbits
B1 = b1 << kbits
PR.<x,y> = PolynomialRing(Zmod(p))
f = (B0 + x)^2 - (B1 + y)
roots = small_roots(f, bounds=(2^64,2^64), m=3, d=3)
for xv, yv in roots:
a1 = B1 + int(yv)
m = c ^^ power_mod(a1, 42, p)
print(Integer(m).to_bytes((Integer(m).nbits()+7)//8, 'big'))
Expected output:
CBC{cryp70_i5_ju57_AI_5p33drun?}
Lack of Entropy — deterministic RSA prime relation
Summary
- Category: Cryptography
- Challenge:
firebird2022 - lack-of-entropy(CryptoHack archive) - Given files:
chall.py,output.txt - Recovered flag:
firebird{b1n4ry_s34rch_f0r_th3_w1n!}
Vulnerability
The challenge generates RSA values with:
p = random.getrandbits(256)
q = int(gmpy2.digits(p, 3))
n = p * q
So q is not independent. It is a deterministic function of p:
$$ q = f(p) = \text{int}(\text{base3}(p)) $$
Hence modulus has special form:
$$ n = p \cdot f(p) $$
f(p) is monotonic increasing for positive integers, therefore
$g(p) = p\cdot f(p)$ is also monotonic. We can recover p by binary search on 256-bit range.
Solver approach
- Parse
n, e, cfromoutput.txt. - Define
f(p)=int(base3(p)). - Binary search
pin[2^255, 2^256-1]with targetp*f(p)=n. - Recover
q=f(p), compute RSA private exponentd, decryptc.
Repro solver
#!/usr/bin/env python3
import re
from Crypto.Util.number import long_to_bytes
with open('output.txt','r') as f:
txt = f.read()
n = int(re.search(r"n\\s*=\\s*(\\d+)", txt).group(1))
e = int(re.search(r"e\\s*=\\s*(\\d+)", txt).group(1))
c = int(re.search(r"c\\s*=\\s*(\\d+)", txt).group(1))
def base3_str(x: int) -> str:
if x == 0:
return '0'
ds = []
while x:
x, r = divmod(x, 3)
ds.append(str(r))
return ''.join(reversed(ds))
def f(p: int) -> int:
return int(base3_str(p))
lo = 1 << 255
hi = (1 << 256) - 1
while lo <= hi:
mid = (lo + hi) // 2
g = mid * f(mid)
if g == n:
p = mid
break
if g < n:
lo = mid + 1
else:
hi = mid - 1
else:
raise RuntimeError('p not found')
q = f(p)
phi = (p - 1) * (q - 1)
d = pow(e, -1, phi)
m = pow(c, d, n)
print(long_to_bytes(m).decode())
Verification
Output:
firebird{b1n4ry_s34rch_f0r_th3_w1n!}
Lesson
Never derive one RSA prime from the other. Independent prime generation is mandatory; structure leaks can collapse factoring to search.
a-joke-cipher — broken key exchange leaks plaintext multiplier
Summary
- Category: Cryptography
- Challenge:
HKCERT 2021 - a-joke-cipher - Given files:
chall.py,output.txt - Recovered flag:
hkcert21{th1s_i5_wh4t_w3_c4ll3d_sn4k3o1l_crypt0sy5t3m}
Vulnerability
The custom key-exchange design publishes:
py_A = S_A mod py_B = S_B mod p
And computes Alice’s shared key as:
$$ S_{AB} = (y_A \cdot y_B \cdot y_B) \cdot S_A \mod p $$
Since y_A = S_A mod p, we can substitute directly:
$$ S_{AB} \equiv y_A^2 \cdot y_B^2 \pmod p $$
So the shared multiplier is fully recoverable from public values.
Encryption then does plain integer multiplication:
$$ c = m \cdot S_{AB} $$
No modular reduction is applied at encryption stage, so plaintext is recovered exactly by integer division.
Solver approach
- Parse
p,y_A,y_B,cfromoutput.txt. - Compute
sk = ((y_A * y_B) mod p)^2 mod p. - Recover
m = c // sk. - Convert
mto bytes and decode.
Repro solver
#!/usr/bin/env python3
import re
from pathlib import Path
text = Path('output.txt').read_text()
def hx(name):
m = re.search(rf"{name}\s*=\s*(0x[0-9a-f]+)", text)
if not m:
raise ValueError(f"missing {name}")
return int(m.group(1), 16)
p = hx('p')
y_A = hx('y_A')
y_B = hx('y_B')
c = hx('c')
sk = pow((y_A * y_B) % p, 2, p)
m = c // sk
print(m.to_bytes((m.bit_length() + 7) // 8, 'big').decode())
Verification
Running the solver prints:
hkcert21{th1s_i5_wh4t_w3_c4ll3d_sn4k3o1l_crypt0sy5t3m}
Lesson
Publishing linear key material plus using non-modular ciphertext multiplication collapses confidentiality. Custom cryptosystems without formal security proofs are usually breakable in exactly this way.
d-phi-enc — recovering RSA factors from encrypted φ and d
Summary
- Category: Cryptography
- Challenge:
HackTM CTF 2023 - d-phi-enc - Given files:
chall.py,output.txt - Recovered flag:
HackTM{Have you warmed up? If not, I suggest you consider the case where e=65537, although I don't know if it's solvable. Why did I say that? Because I have to make this flag much longer to avoid solving it just by calculating the cubic root of enc_flag.}
Given construction
The challenge does:
- RSA with
e = 3and 1024-bit strong primesp, q n = p*q,phi = (p-1)(q-1),d = e^{-1} mod phi- publishes:
enc_phi = phi^3 mod nenc_d = d^3 mod nenc_flag = m^3 mod n
At first glance this looks like “encrypted internals”, but with e=3 the algebra is highly constrained.
Core derivation
Let:
$$ s = p + q - 1, \quad \phi = n - s $$
Then:
$$ enc_\phi \equiv (n-s)^3 \pmod n $$
Also from RSA relation (e=3):
$$ 3d \equiv 1 \pmod \phi $$
Since phi is even and gcd(3,phi)=1, d is odd, and concretely for this instance:
$$ 3d = 1 + 2\phi $$
So modulo n:
$$ 27,enc_d \equiv (1+2\phi)^3 \equiv (2(n-s)+1)^3 \pmod n $$
Eliminating cubic terms between this and 8*enc_phi yields a quadratic in s up to an integer lift m:
$$ 12s^2 - 6s + \left(1 + 8,enc_\phi - 27,enc_d - m n\right) = 0 $$
Scan small integer m, require discriminant perfect square, and verify candidate with:
$$ (n-s)^3 \equiv enc_\phi \pmod n $$
Once s is found:
p+q = s+1- solve quadratic
X^2 - (p+q)X + n = 0to recoverp, q - compute
d, decryptenc_flag.
Repro solver
#!/usr/bin/env python3
from math import isqrt
import re
def parse_output(path: str):
txt = open(path, "r", encoding="utf-8").read()
def grab(name: str) -> int:
m = re.search(rf"{name}\s*=\s*(\d+)", txt)
if not m:
raise ValueError(f"missing {name}")
return int(m.group(1))
return grab("n"), grab("enc_d"), grab("enc_phi"), grab("enc_flag")
def recover_sum_pq_minus_1(n: int, enc_d: int, enc_phi: int) -> int:
C = 1 + 8 * enc_phi - 27 * enc_d
a, b = 12, -6
for m in range(-200, 400):
c = C - m * n
D = b * b - 4 * a * c
if D < 0:
continue
r = isqrt(D)
if r * r != D:
continue
for num in (-b + r, -b - r):
den = 2 * a
if num % den:
continue
s = num // den
if s <= 0:
continue
if pow((n - s) % n, 3, n) == enc_phi:
return s
raise RuntimeError("failed to recover s")
def factor_from_sum(n: int, s: int):
S = s + 1
D = S * S - 4 * n
r = isqrt(D)
if r * r != D:
raise RuntimeError("non-square discriminant")
p = (S + r) // 2
q = (S - r) // 2
if p * q != n:
raise RuntimeError("factorization mismatch")
return p, q
def i2b(x: int) -> bytes:
if x == 0:
return b"\x00"
return x.to_bytes((x.bit_length() + 7) // 8, "big")
def main():
n, enc_d, enc_phi, enc_flag = parse_output("output.txt")
s = recover_sum_pq_minus_1(n, enc_d, enc_phi)
p, q = factor_from_sum(n, s)
e = 3
phi = (p - 1) * (q - 1)
d = pow(e, -1, phi)
m = pow(enc_flag, d, n)
print(i2b(m).decode())
if __name__ == "__main__":
main()
Verification
Running the solver prints the full HackTM{...} flag above.
Lesson
“Encrypting sensitive RSA internals” is not a protection when algebraic relations remain intact. With low exponent and correlated values (phi, d), structure leaks can be enough to recover factors directly.