Description
Can you get sense of this code file and write the function that will decode the given encrypted file content. Find the encrypted file here flag_info and code file might be good to analyze and get the flag.
Setup
Download enc_flag and custom_encryption.py locally.
Inspect the script to understand the generator parameters, the XOR key, and how the cipher list was produced.
wget https://artifacts.picoctf.net/c_titan/18/enc_flag && \
wget https://artifacts.picoctf.net/c_titan/18/custom_encryption.pySolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1
Rebuild the shared keyObservationI noticed that custom_encryption.py's test() routine contains explicit print('a:', a) and print('b:', b) calls, which suggested the private Diffie-Hellman exponents were exposed and I could reconstruct the shared key directly via generator(g, x, p) without solving any discrete log.custom_encryption.py prints a and b during test() (look for the print('a:', a) and print('b:', b) calls). Plug them into generator(g, x, p) to recover the same shared key that encrypt() used.Learn more
Diffie-Hellman key exchange lets two parties agree on a shared secret over an insecure channel. The
generator(g, x, p)function computesg^x mod p. When two parties computeg^a mod pandg^b mod pand swap results, each raises the received value to their own private exponent and lands on the same shared secretg^(ab) mod p.In this challenge
a,b,g, andpare all printed by the script (search forprint('a:', a)and the matching b line), so the "key exchange" is trivially reversible. Real DH keepsaandbprivate; onlyg^a mod pandg^b mod pever go on the wire. Its security rests on the discrete logarithm problem.Modern DH shows up in TLS handshakes, the Signal protocol, and WireGuard. The elliptic-curve variant (ECDH) gives equivalent security with much smaller keys and is the default in modern systems.
Step 2
Invert encrypt()ObservationI noticed the encrypt() function multiplied each character ordinal by key * 311 to produce the cipher list entries, which suggested the decryption step was straightforward integer division by that same factor to recover the intermediate semi_cipher values.Write a decrypt() that integer-divides each cipher entry by key * 311 with //. This yields the "semi_cipher" string prior to the dynamic XOR stage.bashsemi_cipher = [c // (key * 311) for c in cipher]What didn't work first
Tried: Use regular division (/) instead of integer division (//) when inverting the multiplication
Python's / operator returns a float, so each entry in semi_cipher becomes something like 112.0 instead of 112. When you pass that float to chr() downstream it raises a TypeError: 'float' object cannot be interpreted as an integer. The fix is // which performs floor division and returns an int, making chr() happy.
Tried: Guess that the magic constant 311 is a red herring and try dividing by just the key alone
Without the factor of 311, every divided value is off by a factor of 311, producing character ordinals well above 127 that either raise OverflowError in chr() or produce garbage Unicode. The constant 311 is hard-coded inside encrypt() on the multiplication line and must be included in the divisor as key * 311.
Learn more
The encryption multiplies each character's ordinal by
key * 311. Decryption divides. Use Python's integer division operator//here, not/: regular division returns a float, which then breaks thechr()call downstream because character codes have to be integers.The value
311is hard-coded in the encryption function. Custom ciphers often add "complexity" through multiplication by a magic constant, but this provides zero security once the script is in your hands. Security through obscurity is not a primitive.The cipher is layered: DH key exchange, then multiplication, then XOR. Decryption undoes each layer in reverse: derive the key, divide out
key * 311, then reverse the XOR. This compositional pattern is exactly how block-cipher modes and AEAD schemes are reasoned about.Step 3
Reverse dynamic_xor_encryptObservationI noticed dynamic_xor_encrypt() first reverses the plaintext with [::-1] and then XORs each character against the repeating key 'trudeau', which suggested that decryption must apply the same forward-cycling XOR to semi_cipher and then reverse the result to restore the original order.Write a dynamic_xor_decrypt that applies the same forward-cycling repeating-key XOR (using text_key[i % key_length]) to the semi_cipher string, then reverses the result with [::-1]. The key walk direction is unchanged from encrypt; the only difference is where the reversal happens. Applying it to semi_cipher with the key "trudeau" reveals the flag.pythonpython3 solver.py # uses decrypt + dynamic_xor_decryptExpected output
picoCTF{custom_d2cr0pt6d_751a...}What didn't work first
Tried: Skip reversing the string at the end and just return the XOR result directly
The encrypt() function reverses the plaintext with [::-1] before XORing, so the XOR was applied to the reversed string. If you XOR semi_cipher with the repeating key but forget to reverse the result afterward, you get the flag characters in reverse order - the output starts with } instead of picoCTF{. You must apply [::-1] after the XOR to undo the reversal that happened before the XOR.
Tried: Try the XOR key in reverse (walking text_key backward) instead of cycling forward through it
The encrypt function cycles text_key forward with text_key[i % key_length] regardless of the plaintext reversal. Because XOR is its own inverse, you need to use the same forward-cycling key direction on the semi_cipher - reversing the key walk produces mismatched key bytes at every position and yields garbled output instead of readable ASCII.
Learn more
XOR encryption with a repeating key is a simple stream cipher. Each character of plaintext is XORed with the corresponding character of the key, cycling if the key is shorter. XOR is its own inverse: if
cipher = plain XOR key, thenplain = cipher XOR key.The script's
text_keyvariable holds the repeating key bytes, derived from the literal string"trudeau". The encrypt function reverses the plaintext string first (plaintext[::-1]) and then XORs each character withtext_key[i % key_length], cycling forward. Decryption applies the same forward-cycling key XOR to the intermediate string, then reverses the result with[::-1]. There is no chained state; each position XORs independently withtext_key[i % key_length].The key
"trudeau"is a weak key: a short dictionary word. Real stream ciphers (RC4, ChaCha20) use much longer pseudorandom key streams. A repeating ASCII key is trivially broken by frequency analysis once the key length is guessed (Kasiski examination, index of coincidence).
Interactive tools
- XOR CipherXOR-decrypt hex or text ciphertext with a known key, or brute-force the single-byte key automatically.
Flag
Reveal flag
picoCTF{custom_d2cr0pt6d_751a...}
The decrypted semi_cipher plus the reversed XOR routine yields the flag above. If the output doesn't start with picoCTF{, recheck the DH key recovery and confirm you used // (integer division) when undoing key * 311.
Key takeaway
How to prevent this
How to prevent this
Do not roll your own crypto. The history of broken homemade ciphers is the entire field of cryptanalysis.
- Use a vetted library: libsodium (cross-language, opinionated), Tink (Google), or RustCrypto. These provide AEAD primitives (XChaCha20-Poly1305, AES-GCM-SIV) that handle key derivation, nonces, and authentication for you.
- If you absolutely must implement crypto for a constrained environment, get the design reviewed by a real cryptographer and use NIST-approved primitives only. Test against known answer test vectors.
- Authentication is non-negotiable. XOR + custom transform without a MAC means an attacker can also forge messages, not just decrypt them. AEAD modes solve confidentiality and integrity in one primitive.