PlatypwnCTF2025 crypto/Cognitive Reminder Call
Observations
The server seems to be forgetful :( Can you help it remember?
Files provided :
- server.py
- Pipfile.lock
- Pipfile
- Dockerfile
We’re given a network service. When we connect, we see something like:
Here is the flag: <big hex>
Note: This message was sent over an authenticated channel. Its tag is <tag1> with nonce <nonce1>.
I have forgotten my key :(
But here are 4 congnitive reminders of my key:
Note: This message was sent over an authenticated channel. Its tag is <tag2> with nonce <nonce2>.
[crc0, crc1, crc2, crc3]
Note: This message was sent over an authenticated channel. Its tag is <tag3> with nonce <nonce3>.
Please remind me of my key:
Part 1 (hex):
1. What the server actually does
Internally, the server:
- Generates 4 random 16-byte chunks:
enc_key_parts = [os.urandom(16) for _ in range(4)]
- Derives an AES key from them:
key = SHA256(enc_key_parts[0] + enc_key_parts[1] +
enc_key_parts[2] + enc_key_parts[3])
- Encrypts the flag with AES-256-CBC and sends:
Here is the flag: <IV || ciphertext in hex>
- Uses a secret
mac_keyand “authenticates” several messages using:
def mac(key, message):
return crc32(key + message) # 32-bit CRC
So each “Note: … tag is … with nonce …” is basically:
tag = crc32(mac_key + nonce + message)
- Then it “forgets” the real
enc_key_parts, but gives us these 4 CRC32 values:
enc_key_parts_checksums = [crc32(part) for part in enc_key_parts]
and asks us:
- 4 parts, each 16 bytes, in hex
- a nonce (4 bytes in hex)
- a tag =
crc32(mac_key + nonce + part1 + part2 + part3 + part4)
-
If our tag is valid and all 4 parts have the correct CRC32, it accepts our “reminder”, recomputes the AES key from our parts, and sends:
Thanks for reminding me! Here is a reward: <IV || ciphertext’ hex>
This new ciphertext is the same plaintext flag, but encrypted with the key derived from our 4 parts.
If we can:
- forge a valid MAC, and
- craft 4 parts that match the required CRC32s,
we can decrypt this second ciphertext and get the flag.
2. Big picture of the attack
We want to:
-
Break the MAC:
crc32(mac_key || message)
→ forge a valid tag fornonce || part1 || part2 || part3 || part4without knowingmac_key. -
Forge the 4 key parts:
For each target CRCciin the list[c0, c1, c2, c3]we must build a 16-byte blockpart_isuch that:
crc32(part_i) == ci
- Get the final ciphertext, decrypt with our AES key and recover the flag.
Both come from the same fact: CRC32 is linear and totally not a secure MAC.
3. Understanding CRC32 as a linear function
In Python, binascii.crc32(data, seed) behaves like this:
crc32(data, seed) = A_data * seed XOR c_data
Where:
seedand the result are 32-bit integers- operations are over GF(2) (bitwise linear algebra)
A_datais a 32×32 binary matrixc_datais a 32-bit constant
We don’t need to derive them by hand; we can ask the function by probing different seeds.
3.1. Recovering the internal MAC state crc32(mac_key)
The MAC is:
tag = crc32(mac_key + data)
This is equivalent to:
Ck = crc32(mac_key)
tag = crc32(data, seed=Ck)
And because crc32(data, seed) = A * seed XOR c, for a given data we have:
tag = A_data * Ck XOR c_data
That’s a linear equation in 32 bits. If we know:
data(nonce || message)tag(given by the server)
we can solve for Ck = crc32(mac_key).
How do we get A_data and c_data?
c_data = crc32(data, 0)- For each bit
iof the seed:
s0 = 1 << i
sf = crc32(data, s0)
column_i = sf ^ c_data # how this seed bit influences the output
Stacking all columns gives us the 32×32 matrix A_data.
Then we invert it with Gaussian elimination over GF(2) (a 32×32 bit matrix is small) to recover:
Ck = A_data^{-1} * (tag ^ c_data)
Once we have Ck = crc32(mac_key), we can compute a valid MAC for any data M:
fake_tag = crc32(M, seed=Ck)
Exactly like the server would.
So:
- We use one of the authenticated messages from the server (e.g. the third one with the checksum list).
- We compute
Ck. - Then we can forge a tag for our own
(nonce || part1 || part2 || part3 || part4).
4. Building 16-byte blocks with a chosen CRC32
Now about the “cognitive reminders”:
The server gives us:
enc_key_parts_checksums = [c0, c1, c2, c3]
We must produce 4 blocks part_i such that:
len(part_i) == 16
crc32(part_i) == c_i
Again, CRC32 is linear, so we can treat this like solving a linear equation.
Idea
- Choose a random 12-byte prefix:
prefix = os.urandom(12)
-
Let the last 4 bytes be an unknown 32-bit integer
x(little-endian).
So the block isprefix || x. -
Define:
F(x) = crc32(prefix + x.to_bytes(4, 'little'))
Because CRC32 is linear, F(x) is an affine function:
F(x) = B * x XOR d
- Compute:
F0 = F(0)→ that’sd.- For each bit i of x:
- Let
x = (1 << i) - Compute
Fi = F(1 << i) - The difference
Fi ^ F0gives you how bit i affects the CRC → column i of matrixB.
- Let
- Now, to solve for a target CRC
target:F(x) = target → B * x XOR F0 = target → B * x = target XOR F0 → x = B^{-1} * (target XOR F0) - Then:
suffix = x.to_bytes(4, 'little')
block = prefix + suffix
And by construction:
crc32(block) == target
Repeat this 4 times, once for each target CRC in the list → we get 4 valid 16-byte parts.
5. Putting it all together: the exploit flow
Here’s the full logic step-by-step.
5.1. Connect and read data
We connect with:
nc 10.80.8.34 1337
We read:
- The initial encrypted flag + its tag and nonce
- The “forgotten key” message + tag/nonce
- The checksum list
[c0, c1, c2, c3]+ tag/nonce
We especially keep:
msg3= the line containing[c0, c1, c2, c3]as plain textnonce3,tag3= from the note after that
5.2. Recover crc32(mac_key)
We treat:
data3 = nonce3 + msg3.encode()
tag3 = int(tag3_hex, 16)
We call our helper:
Ck = recover_crc32_key_state(data3, tag3)
And now Ck is the internal CRC state after the MAC key.
We don’t know mac_key itself, but we don’t care; we can MAC like the server does.
5.3. Build 4 valid key parts
For each ci in the given list:
part_i = build_block_for_crc(ci)
Where build_block_for_crc implements the “prefix + 4-byte suffix + linear solve” trick.
We end up with a list:
parts = [part0, part1, part2, part3] # each 16 bytes
And we can verify locally:
for p, c in zip(parts, checksums):
assert crc32(p) == c
5.4. Choose our nonce and forge a MAC
We choose a random 4-byte nonce that we haven’t seen before:
new_nonce = os.urandom(4)
Build our message:
message = new_nonce + b"".join(parts)
Compute the forged tag:
fake_tag = crc32(message, seed=Ck)
We keep it as 4 bytes (big-endian or little-endian depending on the server; in our case, big-endian hex).
5.5. Talk to the server
The server asks:
Please remind me of my key:
Part 1 (hex):
We send:
a96cab226baf6aec2aab327c9d96b8ce
877434fb953113c148214dd625b83ab7
5c469566e4e8b6faf05a68aca7c006ca
f5786f66a44238d235621b94fd42df12
(Those were our specific valid parts for that instance.)
Then:
This is an authenticated channel!
Please provide your nonce (hex):
We send:
47483567
Then:
Please provide the tag of the concatenation of the nonce and the 4 parts (hex):
We send:
c569a991
This tag is exactly:
crc32(new_nonce + part1 + part2 + part3 + part4, seed=Ck)
So the server accepts it.
5.6. Get the reward and decrypt
The server answers:
Thanks for reminding me! Here is a reward: <reward_cipher_hex>
We copy <reward_cipher_hex> and decrypt locally.
To decrypt:
- Convert hex to bytes.
- Split:
iv = data[:16] ct = data[16:] - Re-derive the AES key exactly as the server does:
hasher = Hash(SHA256()) for p in parts: hasher.update(p) key = hasher.finalize() - Decrypt AES-CBC and unpad:
cipher = Cipher(algorithms.AES(key), modes.CBC(iv)) decryptor = cipher.decryptor() padded = decryptor.update(ct) + decryptor.finalize() unpadder = PKCS7(128).unpadder() plaintext = unpadder.update(padded) + unpadder.finalize()
The result is the flag:
PP{h4sh3s_4r3_m0r3_th4n_ch3cksums::9--CHRRGexlY}
Why this is insecure ?
CRC32 is not a cryptographic function. It’s linear and has a very simple algebraic structure.
- Using
crc32(key || message)as a MAC is completely broken:- You can model it as a matrix multiplication.
- With one authenticated message you can recover the internal state after the key.
- Then you can forge valid tags for any message, without knowing the key.
On top of that:
- CRC32 is easy to invert on small messages; we can craft data that has any CRC32 we want.
- That’s how we built 16-byte blocks matching the “cognitive reminder” checksums.
So the whole challenge is basically: “Look what happens when you treat a checksum like a cryptographic hash/MAC.”
Bye Roockbye