avatar
Cyscom
Cybersecurity Student Community of VIT Chennai
  • CTF EVENTS
  • CATEGORIES
  • TAGS
  • ARCHIVES
  • POSTS
  • ABOUT
Home Ciphercase 2026 The Archivist's Last Secret
Writeup
Cancel

The Archivist's Last Secret

The Archivist’s Last Secret

Author: Om

This is a steganography challenge combining DCT-based steganography, PNG chunk analysis, ML-KEM-768 decapsulation, HKDF-SHA256 key derivation, and AES-256-GCM decryption.

For the seed identification part:

The story gives the date 21-08-2026 and indicates that it should be written without separators.

This gives the deterministic seed:

1
20260821

The same seed is used when shuffling the candidate DCT coefficients.

For the AES payload extraction from Image 1:

The first image hides data in JPEG DCT coefficients. The DCT position is (1,2), zero coefficients are ignored, and the parity of each remaining coefficient encodes a bit.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
from pathlib import Path
import jpegio as jio
import random

IMAGE = "FINAL_CHALL_1.jpg"
SEED = 20260821
DCT_POS = (1, 2)
PAYLOAD_SIZE = 60

jpeg = jio.read(IMAGE)
Y = jpeg.coef_arrays[0]

candidates = []

height, width = Y.shape

for block_y in range(0, height, 8):
    for block_x in range(0, width, 8):
        y = block_y + DCT_POS[0]
        x = block_x + DCT_POS[1]

        if Y[y, x] != 0:
            candidates.append(Y[y, x])

rng = random.Random(SEED)
rng.shuffle(candidates)

bits = [
    abs(value) % 2
    for value in candidates[:PAYLOAD_SIZE * 8]
]

payload = bytes(
    sum(bits[i + j] << (7 - j) for j in range(8))
    for i in range(0, len(bits), 8)
)

Path("aes_payload.bin").write_bytes(payload)

The recovered payload is 60 bytes. The first 48 bytes contain the AES-GCM ciphertext and authentication tag, while the final 12 bytes are the nonce.

1
2
3
4
5
6
from pathlib import Path

payload = Path("aes_payload.bin").read_bytes()

Path("aes_ciphertext.bin").write_bytes(payload[:48])
Path("aes_nonce.bin").write_bytes(payload[48:])

For the ML-KEM private key extraction from Image 2:

The second image uses six DCT positions. The same non-zero coefficient rule, parity extraction, and deterministic shuffle are applied.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import jpegio as jio
import random
from pathlib import Path

IMAGE = "FINAL_CHALL_2.jpg"
SEED = 20260821

POSITIONS = [
    (0, 1),
    (0, 2),
    (1, 1),
    (1, 2),
    (2, 1),
    (2, 2),
]

PAYLOAD_SIZE = 2498

jpeg = jio.read(IMAGE)
Y = jpeg.coef_arrays[0]

candidates = []

height, width = Y.shape

for block_y in range(0, height, 8):
    for block_x in range(0, width, 8):
        for row, col in POSITIONS:
            y = block_y + row
            x = block_x + col

            if Y[y, x] != 0:
                candidates.append((y, x))

rng = random.Random(SEED)
rng.shuffle(candidates)

required_bits = PAYLOAD_SIZE * 8

if len(candidates) < required_bits:
    raise RuntimeError(
        f"Not enough DCT candidates: "
        f"{len(candidates)} available, "
        f"{required_bits} required"
    )

selected = candidates[:required_bits]

bits = [
    abs(Y[y, x]) % 2
    for y, x in selected
]

payload = bytearray()

for i in range(0, len(bits), 8):
    byte = 0

    for bit in bits[i:i + 8]:
        byte = (byte << 1) | bit

    payload.append(byte)

Path("mlkem768.der").write_bytes(payload)

The extracted payload is 2498 bytes, which matches the ML-KEM-768 private-key size.

For the Image 3 inspection:

The third image is a PNG containing an additional custom chunk. Its chunk structure can be inspected with pngcheck.

1
pngcheck -v FINAL_CHALL_3.png

The output reveals the custom chunk containing the ML-KEM ciphertext.

For the ML-KEM ciphertext extraction:

The custom chunk can be extracted directly from the PNG.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from pathlib import Path
import struct

PNG = "FINAL_CHALL_3.png"
TARGET = b"ctXT"

data = Path(PNG).read_bytes()
offset = 8

while offset < len(data):
    length = struct.unpack(">I", data[offset:offset + 4])[0]
    chunk_type = data[offset + 4:offset + 8]
    chunk_data = data[offset + 8:offset + 8 + length]

    if chunk_type == TARGET:
        Path("mlkem_ciphertext.bin").write_bytes(chunk_data)
        break

    offset += 12 + length

The extracted ciphertext is 1088 bytes, matching the ML-KEM-768 ciphertext size.

For the ML-KEM-768 decapsulation:

The 2498-byte private key and the 1088-byte ciphertext are used together to recover the ML-KEM shared secret.

1
2
3
4
5
6
openssl pkeyutl \
    -decap \
    -inkey mlkem768.der \
    -keyform DER \
    -in mlkem_ciphertext.bin \
    -secret shared_secret.bin

The resulting shared secret can be checked with:

1
xxd -p shared_secret.bin

For the AES-256 key derivation:

The ML-KEM shared secret is used as the input key material for HKDF-SHA256. The information string is CTF-AES-256-GCM, and the output length is 32 bytes.

1
2
3
4
5
6
7
openssl kdf -binary \
    -keylen 32 \
    -kdfopt digest:SHA256 \
    -kdfopt key:$(xxd -p -c 256 shared_secret.bin) \
    -kdfopt info:CTF-AES-256-GCM \
    -kdfopt mode:EXTRACT_AND_EXPAND \
    HKDF > aes_key.bin

Verify the derived key:

1
xxd -p aes_key.bin

The resulting AES-256 key is:

1
b7a85ccc454bae8b8eab9af0fe946ddbcc51538eb5557895ffd16a7f050ba06f

For the AES-256-GCM decryption:

The derived AES key is combined with the ciphertext and nonce recovered from Image 1.

1
2
3
4
5
6
7
8
9
from pathlib import Path
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

key = Path("aes_key.bin").read_bytes()
ciphertext = Path("aes_ciphertext.bin").read_bytes()
nonce = Path("aes_nonce.bin").read_bytes()

flag = AESGCM(key).decrypt(nonce, ciphertext, None)
print(flag.decode())

AES-GCM verifies the authentication tag during decryption. A mismatch in the key, nonce, ciphertext, or authentication tag causes decryption to fail.

The successful AES-256-GCM decryption reveals the final flag:

1
CYS{1_@m_iN_+h3_3mp1r3_8u$in388}

Flag

CYS{1_@m_iN_+h3_3mp1r3_8u$in388}
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.