Sypnosis
I was handed a corrupted RSA private key file, `fractured_seal.pem`, riddled with wildcard characters where data should have been. The task in front of me was to figure out how much of the key I could actually trust, and whether that was enough to rebuild the rest and recover the flag.
Description
One of the Registry's oldest key-scrolls survived the fall of Crownspire, though time and fire spared only fragments of its writing, and most in the vault dismissed it as useless. Caldrin didn't. She always said a seal doesn't have to be whole to still remember the door it once opened.
Skills Required
- Basic understanding of RSA key structure (PKCS#1 / ASN.1 DER encoding)
- Familiarity with Base64 encoding and byte-level parsing
- Basic Python scripting
Skills Learned
- Parsing and reconstructing ASN.1 DER structures from partially corrupted Base64 data
- Understanding Coppersmith's theorem and partial key exposure attacks
- Applying Howgrave-Graham's lattice-based formulation in SageMath to recover unknown bits of an RSA prime
- Using
small_roots()to solve for bounded unknowns modulo N
Enumeration
Code Analysis
The first thing I did was pull apart encrypt.py to understand exactly how the key and ciphertext were generated.
So this was standard textbook RSA: two 1024-bit primes and multiplied into a 2048-bit modulus , public exponent , and the flag encrypted as . The private key was exported in standard PKCS#1 DER format — nothing exotic there. The real puzzle was in the damaged key I'd actually been given.
I knew a PKCS#1 v1.5 RSA private key is just an ASN.1 SEQUENCE holding nine integers:
So I went through fractured_seal.pem byte by byte to figure out what had survived.
Header Block (Bytes 0 to 267): The first 356 Base64 characters were completely intact, decoding cleanly into the first 267 bytes of the DER structure:
I recognized 30 82 04 a3 as the SEQUENCE tag plus length, 02 01 00 as the version integer, and 02 82 01 01 as the tag and length for the modulus — followed by a leading 00 sign byte and then 255 bytes of itself.
The one gap was the 256th and final byte of , which straddled the boundary between the last intact Base64 character ('d') and the wildcard corruption that followed. But since I knew had to be odd, and (02 03 01 00 01) immediately followed it in the DER structure, I could narrow that final byte down to just two candidates: 0x75 or 0x77.
Prime Block (Base64 indices 884–990): A surviving chunk here, 2msCgYEAwLGxcJ7/YCgq..., decoded to the tail end of the private exponent , followed by:
— the ASN.1 header for a 128-byte prime factor — and then 72 bytes of real data. That gave me the most significant 576 bits of prime .
Putting it together: I had the complete 2048-bit modulus (modulo one byte with two candidates), and the top 576 bits of a 1024-bit prime , leaving 448 unknown bits at the bottom. That ratio was the detail that mattered most.
Solution
Finding the vulnerability
More than half the bits of were sitting right in front of me, and that rang a bell: Coppersmith's theorem, specifically the partial key exposure variant using Howgrave-Graham's lattice reduction technique.
I modeled as:
where was my known 576-bit prefix, shifted into position, and was the unknown 448-bit suffix, bounded by .
From there I built the polynomial:
Since , I had exactly the kind of small-root problem Coppersmith's method is built for.
Howgrave-Graham's formulation told me that for a monic polynomial of degree , any root with where can be recovered efficiently if:
Plugging in my numbers:
- Degree
- (since )
- Bound:
My actual unknown was , comfortably under . The condition held with room to spare, which meant LLL lattice reduction on the Howgrave-Graham matrix should be able to construct a polynomial over sharing the root . I had my path forward.
Exploitation
I wrote solve_sage.py to walk through the recovery step by step:
- Parse the Base64 key to pull out the 255-byte prefix of and the 72-byte prefix of .
- Formulate .
- Run SageMath's
f.small_roots(X=2^448, beta=0.45)to solve for . - Reconstruct , then recover .
- Compute the private exponent and decrypt
flag.enc.
Since I only had two candidates for that final ambiguous byte of , I just looped over both and let the lattice reduction sort out which one was correct.
Getting the flag
I ran the solver inside a SageMath container:
And it landed almost immediately:
Caldrin was right — the seal didn't need to be whole. 576 known bits out of 1024 was more than enough for Coppersmith's method to hand me back the rest.