Description
My RSA key is corrupted. Can you fix it?
Setup
Download the provided RSA private key PEM file and the encrypted message from the challenge page.
Notice that openssl rejects the key: the lower bits of p are zeroed out, so the key is internally inconsistent.
# Download the broken key and ciphertext from the challenge page.Solution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1Parse the PEM with asn1parse to read the partial prime
Observationopenssl rsa rejects the key outright on internal inconsistency, so the corruption is structural. asn1parse skips that validation and reads the raw integer fields out of the DER encoding.Run openssl asn1parse on the corrupted PEM. The nine integers in order are: version, n, e, d, p, q, dp, dq, qinv. In this challenge p is present but its lower 256 bits are all zero - you will see a very large integer whose binary representation ends with a long run of zeros. Record n, e, and the partial p_high.bash# Dump the raw ASN.1 integers:bashopenssl asn1parse -in private.keybashbash# The printed integers map to: version, n, e, d, p, q, dp, dq, qinvbash# p will appear as a large number ending in many zero bits.What didn't work first
Tried: Run openssl rsa -in private.key -text to inspect the key fields
openssl rsa checks internal consistency before printing anything, so a corrupted key gets an error and no output. asn1parse skips that layer and reads the DER bytes directly, which is what you want when the values do not agree.
Tried: Try to identify which field is corrupted by looking at the PEM base64 visually
Base64 hides the byte structure entirely, so the PEM text says nothing about which integer was zeroed. Decode it and compare the nine integers against what a valid key holds; the corrupted prime stands out by the run of zeros at its end.
Learn more
A PKCS#1 RSA private key (RFC 8017) is an ASN.1
SEQUENCEof nineINTEGERvalues:version, n, e, d, p, q, dp, dq, qinv. The PEM file is that DER structure base64-encoded between-----BEGIN RSA PRIVATE KEY-----markers.openssl rsacross-checks all nine fields and refuses to load the file if they are inconsistent.openssl asn1parsedoes no such validation - it walks the raw ASN.1 and prints each integer as-is, letting you read whatever bytes survived the corruption.In corrupt-key-1 the corrupted field is p: the upper 256 bits are intact, but the lower bits are zeroed. This gives you p_high, the high-order portion, and leaves an unknown remainder x such that p = p_high + x where x is small compared to n.
Step 2Recover the full prime with Coppersmith's small-roots method
ObservationThe prime comes back with its lower 256 bits zeroed and only its high half intact. A partial prime like that is exactly the setup for Coppersmith's small-roots theorem, since the unknown portion is small relative to the modulus.Write f(x) = p_high * 2^(p_bits - p_high_bits) + x in SageMath's polynomial ring over Zmod(n). The unknown x equals the lower bits of p. Because x < 2^256 = n^(1/4), which is the Coppersmith bound for a 512-bit divisor of a 1024-bit modulus, small_roots finds x in seconds. A complication: the exact boundary between the known and unknown bits depends on how many trailing zeros you count, so the solve function brute-forces the last 7 uncertain bits before calling small_roots, checking each candidate against n with gcd.bash# Run in SageMath (sage script.sage or sage -python if adapted):bashpythonfrom tqdm import tqdmbashpythondef small_roots(f, X, beta=1.0, m=None):bashN = f.parent().characteristic()bashdelta = f.degree()bashif m is None:bashepsilon = RR(beta^2/f.degree() - log(2*X, N))bashm = max(beta**2/(delta * epsilon), 7*beta/delta).ceil()pythont = int((delta*m*(1/beta - 1)).floor())bashf = f.monic().change_ring(ZZ)bashP,(x,) = f.parent().objgens()bashg = [x**j * N**(m-i) * f**i for i in range(m) for j in range(delta)]bashg.extend([x**i * f**m for i in range(t)])bashB = Matrix(ZZ, len(g), delta*m + max(delta,t))bashfor i in range(B.nrows()):bashfor j in range(g[i].degree()+1):bashB[i,j] = g[i][j]*X**jbashB = B.LLL()bashf = sum([ZZ(B[0,i]//X**i)*x**i for i in range(B.ncols())])bashroots = set([f.base_ring()(r) for r,m in f.roots() if abs(r) <= X])bashreturn [root for root in roots if N.gcd(ZZ(f(root))) >= N**beta]bashpythondef recover(p_high, n, m):bashp_bits = (len(bin(n))-2)//2bashp_high_bits = len(bin(p_high)) - 2bashPR.<x> = PolynomialRing(Zmod(n))bashf = p_high * 2**(p_bits-p_high_bits) + xbashx = small_roots(f, X=2**(p_bits-p_high_bits), beta=0.4, m=m)bashif x == []:bashreturn Nonepythonp = int(f(x[0]))bashreturn pbashbash# Fill in n and _p_high from openssl asn1parse:bashn = <n_from_asn1parse>bash_p_high = <partial_p_from_asn1parse>bashpythondef solve(bits, m):bashfor x in tqdm(range(2**bits, -1, -1)):bash_p = _p_high + x * 2**(256-bits)pythonp_high = int(bin(_p)[:256+bits+2], 2)bashp = recover(p_high, n, m)bashif p is not None:bashreturn pbashbashp = solve(bits=7, m=18)Whensmall_rootsreturns a nonempty list, plug x back into f to get the full prime p. Verify withassert n % p == 0.What didn't work first
Tried: Call small_roots with beta=0.5 instead of beta=0.4 and skip the outer brute-force loop
The beta parameter bounds how the root relates to the modulus, and 0.5 tightens it enough that the unknown portion falls just outside, so the solver returns nothing. Lowering it to 0.4 relaxes the constraint at the cost of a larger lattice. The small outer loop matters too, because the boundary between intact and zeroed bits is ambiguous by a few positions.
Tried: Use factordb or yafu to factor n directly instead of applying Coppersmith
The modulus is 1024 bits, well past what yafu or factordb will finish inside a CTF. Coppersmith works only because you already hold the top half of the prime, which shrinks the search to something small. Without that, factoring is out of reach.
Learn more
Why does this work? Coppersmith's theorem (1996), in the divisor form due to Howgrave-Graham, says: given a monic degree-d polynomial f over Z/nZ and a divisor p of n with p >= n^beta, any root x0 of f modulo p with |x0| < n^(beta^2/d) can be found in polynomial time. Here d = 1, p is a 512-bit prime of a 1024-bit n so beta = 0.5, and the bound is n^(1/4) = 2^256. The unknown lower half of p is exactly 256 bits, so it sits right at the bound, which is why the script relaxes beta slightly and brute-forces a few boundary bits.
The algorithm builds a lattice from scaled shifts of f. By the LLL algorithm, the shortest vector in that lattice corresponds to a polynomial with integer coefficients that is divisible by a high power of p, and whose root can be read off directly without factoring n. This is fundamentally different from brute force: LLL runs in polynomial time regardless of how many bits are unknown.
The outer
solveloop brute-forces only 7 bits (128 candidates) because the exact bit boundary between the intact high part and the zeroed low part is ambiguous by a few bits. For each candidate high part,recovercallssmall_roots; the first call that returns a nonzero p wins.This technique is also used in the related challenge corrupt-key-2, where the missing bits are split across three non-contiguous chunks instead of one contiguous block, which calls for a multivariate version of the same lattice construction.
Step 3Reconstruct the key and decrypt the ciphertext
ObservationWith the full prime recovered, the other components follow by ordinary arithmetic, and decryption is textbook RSA with no cryptanalysis left.With p in hand, everything else follows from plain RSA arithmetic. Compute q = n // p (exact integer division), verify p * q == n, then derive d and decrypt.bashfrom Crypto.Util.number import long_to_bytesbashbashe = 0x10001bashq = n // pbashassert p * q == nbashd = pow(e, -1, (p-1)*(q-1))bashpythonc = open('msg.enc', 'rb').read()bashfrom Crypto.Util.number import bytes_to_longbashc = bytes_to_long(c)bashm = pow(c, d, n)pythonprint(long_to_bytes(m))Learn more
Once p is known, recovering q is a single integer division because n = p * q by definition. With both primes in hand, phi = (p-1)*(q-1) and d = e^-1 mod phi follow immediately. Decryption is then textbook RSA: m = c^d mod n.
The broader lesson: even a partial leak of one RSA prime - here only the high-order half - is enough to completely break the key via lattice methods. This is why implementations must never expose partial prime material, and why random padding (OAEP) alone does not compensate for a weak key generation process.
Interactive tools
- RSA CalculatorDecrypt RSA ciphertexts, factor n from the sum of primes, or generate key parameters. Handles arbitrarily large BigInt values.
Flag
Reveal flag
picoCTF{d741543f...}
Parse the PEM with openssl asn1parse to extract n, e, and the upper 256 bits of p. Define f(x) = p_high * 2^(unknown_bits) + x over Z/nZ, then use Coppersmith small_roots (LLL) in SageMath - bruting 7 boundary bits and calling small_roots for each candidate - to recover the full prime p. Standard RSA arithmetic then yields q, d, and the flag.