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 1
Parse the PEM with asn1parse to read the partial primeObservationI noticed that openssl rsa rejected the key entirely due to internal inconsistency, which suggested the corruption was structural and that a lower-level tool bypassing validation, specifically openssl asn1parse, was needed to read the raw integer fields directly from 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.Expected output
picoCTF{d741543f...}What didn't work first
Tried: Run openssl rsa -in private.key -text to inspect the key fields
openssl rsa validates internal consistency before printing anything, so it exits with an error like 'no start line' or 'bad end line' when the key is corrupted. asn1parse skips that validation layer and reads raw DER bytes directly, which is exactly what you need when the key values are inconsistent.
Tried: Try to identify which field is corrupted by looking at the PEM base64 visually
Base64 obscures the byte-level structure completely - you cannot tell from the PEM text which ASN.1 integer is zeroed. You must decode it with asn1parse and compare the nine integers against what a valid PKCS#1 key should contain; the corrupted p will stand out as an unusually large number whose value ends in a suspicious number of zeros.
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 2
Recover the full prime with Coppersmith's small-roots methodObservationI noticed that asn1parse revealed p with its lower 256 bits zeroed, meaning only the high-order portion was intact; this partial prime disclosure is exactly the setup for Coppersmith's small-roots theorem, which can recover the unknown lower bits because they are small relative to n.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 < n^(1/2) approximately, the Coppersmith bound is satisfied and 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 sets the lower bound on the root's relationship to n - using 0.5 tightens the Coppersmith bound and causes small_roots to return an empty list for this challenge because the unknown portion of p sits just outside that bound. Lowering beta to 0.4 relaxes the constraint enough to recover the root, at the cost of requiring a larger m lattice dimension. Skipping the 7-bit outer loop also fails 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 n in this challenge is 1024 bits, which is far beyond what general-purpose integer factoring tools like yafu or factordb can handle in a CTF timeframe. Coppersmith works because you already have the high-order half of p, reducing the search to a small unknown; without that partial knowledge, factoring n would be computationally infeasible.
Learn more
Why does this work? Coppersmith's theorem (1996) says: given a monic degree-d polynomial f over Z/nZ, any root x0 with |x0| < n^(1/d - epsilon) can be found in polynomial time. Here d = 1 and x is roughly n^(1/2) in size (the unknown lower half of a 512-bit prime), so the bound is satisfied.
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, which has more bits unknown and requires tuning the m parameter higher.
Step 3
Reconstruct the key and decrypt the ciphertextObservationI noticed that once small_roots returned the full prime p, the remaining RSA components (q, phi, d) are derivable through standard arithmetic, which meant decryption of msg.enc was a straightforward application of textbook RSA with no further cryptanalysis needed.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.