Quarantine Key Dump
Challenge Description
A WHAMazon fulfillment center went dark after the warehouse AI initiated a "Profit Continuity Quarantine." During the panic, an on-call engineer tried to push a remote kill-switch through an old maintenance channel. You intercept a key dump from the maintenance process memory and a single encrypted payload labeled
override_packet.
Flag: Raptor{RSA_WhaMmI3_AWS}
Initial Attempt
We have p, q, d, e, and the ciphertext, everything needed for RSA decryption. The one missing piece is n, but that's trivial: in RSA, n = p * q. I plugged the full parameter set into dcode.fr.
The output was garbage:
.UH|PzKsXLN萆p]}Q^6y$Cym˯{
Raw RSA decryption worked, the math ran without error, but the result isn't plaintext. The notes said "no fancy padding" on Crypto 2 specifically. The absence of that note here is the hint: there is padding, and we need to strip it properly. The garbled output is characteristic of OAEP padding bytes sitting in front of the actual message.
Why OAEP?
OAEP (Optimal Asymmetric Encryption Padding) is the standard padding scheme for RSA encryption in modern systems. Raw/textbook RSA (m = c^d mod n) is deterministic and malleable, the same plaintext always produces the same ciphertext, and ciphertexts can be manipulated mathematically. OAEP fixes both problems by mixing in random bytes and a hash before encryption, making the scheme non-deterministic and CCA-secure.
When you see RSA in a real system (as opposed to a challenge specifically calling out "raw RSA") the safe assumption is OAEP. The garbage output from dcode.fr was the confirmation, those are the padding bytes that a proper OAEP-aware decryption routine knows to remove.
OAEP comes in variants depending on the hash function used. SHA-1 is the historical default and still the most common in older implementations, which fits the "maintenance channel" flavor of this challenge. If SHA-1 doesn't work, SHA-256 is the next candidate.
The Script
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_OAEP
from Crypto.Hash import SHA1
from Crypto.Util.number import long_to_bytes
p = 11821566304258158024709931463235350107228854372723952537135429807985593453766193173159581667573881930140150534278106291559781776681167611414886790855481613
q = 10829945739893576139659657186301947799985286236702653436215558098429742823552630397871691140639420079783850842342723366215752974894203404199118307624844967
d = 62164467404524062212049269988856942926222373861940502116178828703498382472219709413885934872662490258299357591798775700908789706465021275630274152384848338241430537998278175735054632735771348992756849888228918516475143931281848112150396905491355294438171008521295327620071615482115426748724649625715286762225
e = 65537
ciphertext = 127974842512202112473587790334783773346670761795759046414623695839496043468222403790451552474739904900390332972375535796923793206477842478783991909466364763985066237453906922603357908928437544136331537266064116237302681079373907754027244093053030761202924608464154683835523929853636660114921819971092276829473
# Step 1: Reconstruct n
n = p * q
# Step 2: Build the RSA key object from raw components
# RSA.construct takes (n, e, d), PyCryptodome handles the rest internally
key = RSA.construct((n, e, d))
# Step 3: Create an OAEP cipher instance with SHA-1
# PKCS1_OAEP.new() wraps the key with padding-aware encrypt/decrypt methods
cipher = PKCS1_OAEP.new(key, hashAlgo=SHA1)
# Step 4: Convert the ciphertext integer to a byte string
# RSA operates on bytes, not Python integers, long_to_bytes handles the conversion
ct = long_to_bytes(ciphertext)
# Step 5: Decrypt and strip OAEP padding in one call
plaintext = cipher.decrypt(ct)
print(plaintext.decode())
Step by step:
1. n = p * q: Reconstructs the modulus. RSA key generation starts here; n is public but not always included in a memory dump if only the private components were captured.
2. RSA.construct((n, e, d)): Builds a PyCryptodome RSA key object from raw integer components. PyCryptodome can work with just (n, e, d) without needing p and q separately, though providing them would speed up private-key operations via the Chinese Remainder Theorem.
3. PKCS1_OAEP.new(key, hashAlgo=SHA1): Creates a cipher object that knows how to remove OAEP padding after decryption. The hashAlgo parameter must match whatever was used during encryption, SHA-1 here.
4. long_to_bytes(ciphertext): Converts the ciphertext from a Python integer (as given in the challenge) to a big-endian byte string. cipher.decrypt() expects bytes, not an int.
5. cipher.decrypt(ct): Performs c^d mod n internally and validates and strips the OAEP padding, returning only the original plaintext bytes. This is the step dcode.fr skipped.
Output
Raptor{RSA_WhaMmI3_AWS}
A Note on Tooling
Writing a fresh decryption script for every RSA variant was getting old by this point. Crypto 1 needed XOR brute force, Crypto 2 needed a partial-d recovery loop, and now Crypto 3 needed OAEP-aware decryption. Each script was a small variation on the same theme: fetch the right library, wire up the parameters, handle the edge case.
This was the challenge that pushed the decision to build a CTF Toolkit. All three of these RSA patterns (raw decrypt, incomplete-d brute force, OAEP unwrap) are now modules in my toolkit. Next time, it's a parameter paste away instead of a script from scratch. I encourage others to build up a list of reusable tools to aid them in future CTFs.
Key Takeaways
The diagnostic for "is there padding?" is simple: if raw RSA decryption runs without error but produces garbage bytes, the padding is still there. OAEP is the default assumption for anything modern; PKCS#1 v1.5 is the other common option for legacy systems. When in doubt, try both.
Always reconstruct n = p * q when p and q are available but n isn't explicitly given, it's a one-liner and eliminates that variable immediately.