avatar
Cyscom
Cybersecurity Student Community of VIT Chennai
  • CTF EVENTS
  • CATEGORIES
  • TAGS
  • ARCHIVES
  • POSTS
  • ABOUT
Home Ciphercase 2026 The Broken Formula
Writeup
Cancel

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

StepWhat you discover
105.png contains the authenticated index
2The real fragment chain is A17 → C04 → B29
3RSA A and B share a prime
4The decrypted protocol warns about nonce reuse
5Packets 13, 34, 47 reuse a nonce
6Known plaintext recovers the ChaCha20 keystream
7Reused Poly1305 material gives r and s
8Forge AUTH|MAINT|OPEN!
9Hash the forged transcript to derive the vault key
10Decrypt the vault
11Recover the flag

Flag

CYS{W@1t$_d3@D_dR0P_pR0t0c01_c0mpr0m1$3d_8y_J3$$3}
Edit on GitHub
Trending Tags
Admin Bot AES-GCM Algorithm Confusion authentication Broken Access Control CRT ECC ECDH ELF Ghidra

© 2026 Cyscom. Some rights reserved.

Using the Jekyll theme Chirpy.

A new version of content is available.