Trending Tags
The Broken Formula
The Broken Formula
Challenge Description
You are given six files:
1
2
3
4
5
6
01.jpg
02.jpg
03.jpg
04.png
05.png
audio.mp3
The goal is to recover the flag.
The challenge is a chain of clues and cryptographic mistakes. The main path is:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Find authenticated carrier
↓
Recover fragment order
↓
Break RSA
↓
Find nonce reuse
↓
Recover ChaCha20 keystream
↓
Recover Poly1305 parameters
↓
Forge command
↓
Derive vault key
↓
Decrypt vault
↓
Flag
audio.mp3 is a deliberate decoy. It contains a misleading flag-like result and is not part of the successful solve path.
1. Inspect the Files
Start by checking the files:
1
2
ls -lah
file 01.jpg 02.jpg 03.jpg 04.png 05.png audio.mp3
The interesting files are 02.jpg and 05.png.
They both contain hidden index data.
The important clue is that the two indexes contain the same candidate fragments, but only one index is authenticated.
The audio file is a decoy and does not contribute to the real solve.
So don’t simply choose the first index you can extract.
2. Find the Authenticated Index
05.png contains an IDX8 structure hidden in the image data.
The beginning of the hidden structure is:
1
IDX8
The PNG uses the image pixels themselves to hide the data. The relevant bit is the least significant bit of the red channel.
A simple extraction script can recover the bytes:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from PIL import Image
image = Image.open("05.png").convert("RGB")
bits = []
for r, g, b in image.getdata():
bits.append(r & 1)
raw = bytearray()
for i in range(0, len(bits) - 7, 8):
value = 0
for bit in range(8):
value |= bits[i + bit] << (7 - bit)
raw.append(value)
if raw[:4] == b"IDX8":
print(raw[:64].hex())
break
The structure contains an authentication value.
The equivalent decoy data is present in 02.jpg.
The important observation is:
1
2
05.png → authority VALID
02.jpg → authority INVALID
Therefore the authenticated index from 05.png is the one to trust.
3. Recover the Fragment Candidates
The authenticated index gives these candidates:
1
2
3
4
5
6
A17
C04
B29
D38
E52
F11
At this point, don’t assume all six are real.
The challenge contains continuity information that determines which fragment follows which.
The valid chain is:
1
A17 → C04 → B29
The other entries are decoys:
1
2
3
D38
E52
F11
The useful clue is the transition relationship:
1
2
3
A17 → C04
C04 → B29
B29 → END
So the real fragment order is:
1
A17 → C04 → B29
This establishes the correct path into the next layer.
4. Recover the RSA Private Key
The next clue comes from the recovered protocol information.
The protocol uses RSA keys A, B and C.
The important mistake is that RSA keys A and B share a prime factor.
For RSA:
1
n = p × q
If two moduli share p:
1
2
nA = p × qA
nB = p × qB
then:
1
gcd(nA, nB) = p
You can therefore recover the shared prime directly.
First load the two public moduli:
1
2
3
4
5
6
7
8
9
10
11
12
import json
import math
data = json.load(open("master.json"))
n_a = int(data["rsa"]["A"]["n"])
e_a = int(data["rsa"]["A"]["e"])
n_b = int(data["rsa"]["B"]["n"])
p = math.gcd(n_a, n_b)
q = n_a // p
Then calculate the private exponent:
1
2
3
4
5
6
7
phi = (p - 1) * (q - 1)
d = pow(
e_a,
-1,
phi
)
The RSA private key can then be reconstructed with the recovered p, q, d, e and n.
The encrypted protocol value is the top-level protocol field in the recovered data:
1
2
3
ciphertext = bytes.fromhex(
data["protocol"]
)
Decrypt it using RSA-OAEP with SHA-256:
1
2
3
4
5
6
7
8
9
10
11
12
plaintext = private_key.decrypt(
ciphertext,
padding.OAEP(
mgf=padding.MGF1(
algorithm=hashes.SHA256()
),
algorithm=hashes.SHA256(),
label=None
)
)
print(plaintext.decode())
The decrypted protocol contains the critical clue:
1
2
3
4
5
DEAD-DROP/07
AUTH=CHACHA20-POLY1305
WARNING=NONCE-REUSE
PACKETS=50
TARGET=OPEN-VAULT
The important line is:
1
WARNING=NONCE-REUSE
5. Parse the Traffic
The next artifact is the binary traffic.
It has the header:
1
DDPK
followed by:
1
2
Protocol version: 8
Packet count: 50
Each packet contains:
1
2
3
4
5
6
7
8
9
sequence number
AAD length
nonce length
ciphertext length
tag length
AAD
nonce
ciphertext
tag
Parse the packet header with:
1
2
3
4
5
6
import struct
seq, aad_len, nonce_len, cipher_len, tag_len = struct.unpack(
">HBBHB",
raw[offset:offset + 7]
)
The traffic contains several reused nonce groups.
The important one is:
1
[13, 34, 47]
All three packets use the same nonce.
The packet structure is:
1
2
3
4
5
Packet 13 → 16-byte ciphertext
Packet 34 → 16-byte ciphertext
Packet 47 → 16-byte ciphertext
This is the real nonce-reuse group.
6. Recover the ChaCha20 Keystream
ChaCha20 is a stream cipher.
The basic relationship is:
1
ciphertext = plaintext XOR keystream
Therefore:
1
keystream = ciphertext XOR plaintext
Packet 13 has known plaintext:
1
PING|NODE=07|OK!
Recover the keystream:
1
2
3
4
5
6
7
8
9
10
11
known = b"PING|NODE=07|OK!"
keystream = bytes(
a ^ b
for a, b in zip(
known,
ciphertext
)
)
print(keystream.hex())
The recovered 16-byte keystream is:
1
885516f46eb4af483e1bbc30598645c0
Because the nonce was reused, the same keystream is valid for packets 34 and 47.
Their first 16 plaintext bytes become:
1
2
3
AUTH|USER|STAT!!
AUTH|USER|READ!!
That confirms the nonce-reuse attack.
7. Recover Poly1305 r and s
ChaCha20-Poly1305 also authenticates the encrypted data using Poly1305.
Nonce reuse means the same Poly1305 one-time key is reused.
For this challenge, the three reused packets all have 16-byte ciphertexts, making the equations manageable.
The Poly1305 modulus is:
1
2
3
P = (1 << 130) - 5
MOD = 1 << 128
The challenge’s fixed-length polynomial can be represented as:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def poly_value(ciphertext, r):
message = (
int.from_bytes(
ciphertext,
"little"
) +
(1 << 128)
)
length_block = 16 << 64
h = 0
h = (
(h + message) * r
) % P
h = (
(h + length_block) * r
) % P
return h
For two packets:
1
2
3
tag1 = Poly1305(message1, r) + s
tag2 = Poly1305(message2, r) + s
Subtracting the equations removes s.
The final tag is reduced modulo:
1
2^128
so the solver also accounts for the small possible tag carries.
The recovered values are:
1
2
3
r = 0xd22de48026933b0053407780e113741
s = 0x190aef408628d7c22942d84aecf852c9
The important point is that the recovered candidate is checked against the third packet before it is accepted.
8. Forge AUTH|MAINT|OPEN!
Now we have both things needed for a forgery:
1
2
3
ChaCha20 keystream
Poly1305 r and s
The target command is:
1
AUTH|MAINT|OPEN!
It is exactly 16 bytes.
Generate its ciphertext:
1
2
3
4
5
6
7
8
9
10
11
target = b"AUTH|MAINT|OPEN!"
forged_ciphertext = bytes(
a ^ b
for a, b in zip(
target,
keystream
)
)
print(forged_ciphertext.hex())
The resulting ciphertext is:
1
c90042bc12f9ee01704fc07f09c30be1
Calculate the new Poly1305 tag:
1
2
3
4
5
6
7
8
9
10
11
12
13
forged_tag = (
(
poly_value(
forged_ciphertext,
r
) + s
) % (1 << 128)
).to_bytes(
16,
"little"
)
print(forged_tag.hex())
The resulting tag is:
1
3d4cb61e46cb99c0360f10207fc01b5f
So the forged packet represents:
1
AUTH|MAINT|OPEN!
with a valid authentication tag.
9. Derive the Vault Key
The challenge uses the forged packet to derive the vault key.
The transcript is:
1
nonce || forged ciphertext || forged tag
Build it:
1
2
3
4
5
transcript = (
bytes.fromhex(nonce) +
bytes.fromhex(forged_ciphertext) +
bytes.fromhex(forged_tag)
)
Then derive the key:
1
2
3
4
5
6
import hashlib
vault_key = hashlib.sha256(
b"DD07-VAULT-V1|" +
transcript
).digest()
This is the key used to protect the final vault.
10. Decrypt the Vault
The vault uses ChaCha20-Poly1305 again.
Load its nonce and ciphertext, then decrypt using the derived key:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
vault_nonce = bytes.fromhex(
data["vault"]["nonce"]
)
vault_ciphertext = bytes.fromhex(
data["vault"]["ciphertext"]
)
flag = ChaCha20Poly1305(
vault_key
).decrypt(
vault_nonce,
vault_ciphertext,
None
)
print(flag.decode())
The vault decrypts successfully.
11. Final Flag
1
CYS{W@1t$_d3@D_dR0P_pR0t0c01_c0mpr0m1$3d_8y_J3$$3}
Solve Summary
| Step | What you discover |
|---|---|
| 1 | 05.png contains the authenticated index |
| 2 | The real fragment chain is A17 → C04 → B29 |
| 3 | RSA A and B share a prime |
| 4 | The decrypted protocol warns about nonce reuse |
| 5 | Packets 13, 34, 47 reuse a nonce |
| 6 | Known plaintext recovers the ChaCha20 keystream |
| 7 | Reused Poly1305 material gives r and s |
| 8 | Forge AUTH|MAINT|OPEN! |
| 9 | Hash the forged transcript to derive the vault key |
| 10 | Decrypt the vault |
| 11 | Recover the flag |
Flag
CYS{W@1t$_d3@D_dR0P_pR0t0c01_c0mpr0m1$3d_8y_J3$$3}