CodefestCTF — "Never Gonna Give You Up" (Steganography, 100 pts)
Overview
We are given two files: cover.jpg (an album cover image) and chall.mp3 (an audio file). The challenge description hints that the author 0xkn1gh7 documented a "rare tool" on their blog. Our goal is to find that tool, extract a hidden message from the MP3, and decrypt it.
Step 1: Metadata Analysis
The first thing to do with any stego challenge is inspect file metadata. Running exiftool on the image reveals an unusual field:
bashexiftool cover.jpg | grep -i artist
Artist : giveupplease
This is not a normal artist tag for a stock image. The value giveupplease is clearly planted — note it for later as a potential password or key.
Also run binwalk and file to confirm there are no appended files or polyglot tricks:
bashfile cover.jpg binwalk cover.jpg
Both return clean results — no hidden archives inside the image itself. The image is just a carrier for the metadata hint.
Step 2: OSINT — Finding the Tool
The challenge description says the author 0xkn1gh7 came across a "rare tool" and "documented this somewhere." This points to OSINT.
A simple Google search for the author's handle leads to their blog, where browsing through writeups reveals a tool called Stegonaut — a web-based audio steganography tool that hides text inside MP3 frame headers.
GitHub: https://github.com/knez/stegonaut Web app: https://stegonaut.com
Step 3: Understanding How Stegonaut Works
Cloning the repository helps understand the internals before attempting extraction:
bashgit clone https://github.com/knez/stegonaut.git
Reading src/static/app/mp3stego.js reveals the core mechanism:
Each MP3 frame header has two "unused" bits that Stegonaut repurposes — 1 bit from byte 2 and 4 bits from byte 3 — giving 5 bits per frame. These 5-bit chunks are Base32-decoded back into raw bytes. The first frame stores a 0x1F modified flag. The next 4 frames store the count of encoded chunks. Everything after that is the payload. If a password was used during embedding, the payload bytes are encrypted before being embedded.
Step 4: Identifying the Correct Version
This is the most important step, and the main trap in this challenge.
Check the git log with dates:
bashcd stegonaut git log --oneline --format="%H %ad %s" --date=short
Key output:
4fa323e 2026-04-13 Core: rewrite to ES modules and Web Crypto API b25f8b7 2020-01-18 Release v1.0
Now check the timestamp of the challenge file:
-rwxrwx--- 1 root vboxsf 3.3M Jan 24 2025 chall.mp3
The MP3 was created January 24, 2025. The Web Crypto API rewrite happened April 13, 2026 — over a year later. This means the CTF was built using Stegonaut v1.0, which used a completely different encryption scheme.
Inspect the v1.0 encryption code:
bashgit show 0c908c9:src/app/main.js | grep -A 20 "encryptText|decryptText"
v1.0 used CryptoJS AES in CTR mode with OpenSSL-compatible "Salted__" prefixing:
javascript// v1.0 encrypt var enc = CryptoJS.AES.encrypt(str, password, { mode: CryptoJS.mode.CTR, padding: CryptoJS.pad.NoPadding });
// v1.0 decrypt var str = String.fromCharCode.apply(String, bytes); str = btoa("Salted__" + str); var dec = CryptoJS.AES.decrypt(str, password, { mode: CryptoJS.mode.CTR, padding: CryptoJS.pad.NoPadding });
Why this matters: AES-CTR never throws an error on a wrong key — it silently produces garbage. If you use the current v2.0 Web Crypto API decryption with the right password, you get junk and no indication of failure. Version matching is essential.
Step 5: Extracting the Raw Bytes
Install the required dependency and write a Node.js extraction script:
bashnpm install crypto-js
Port the v1.0 extraction logic to a standalone script. The key parts are:
javascript// 1. Parse the MP3, skip ID3 tags // 2. Confirm the modified flag (first frame header & 0x1F == 0x1F) // 3. Read 4 frames to get the total encoded chunk count // 4. Read that many frames, extracting 5 bits each // 5. Base32-decode all chunks to recover raw encrypted bytes
function extractPayload(buffer) { const mp3 = new MP3Parser(buffer); // ... (mirror the mp3stego.js extractText logic) return Base32.decode(encodedChunks); }
Running this against chall.mp3 confirms isModified: true and returns 56 raw bytes — which is the AES-encrypted payload.
Step 6: Decrypting with v1.0 CryptoJS
Using the password found in Step 1 (giveupplease) and the v1.0 decryption method:
javascriptimport CryptoJS from 'crypto-js';
function decryptPayload(rawBytes, password) { // Reconstruct the OpenSSL-format ciphertext that CryptoJS expects var str = String.fromCharCode.apply(String, rawBytes); str = btoa("Salted__" + str);
var dec = CryptoJS.AES.decrypt(str, password, {
mode: CryptoJS.mode.CTR,
padding: CryptoJS.pad.NoPadding
});
return wordArrayToByteArray(dec);
}
const result = decryptPayload(rawBytes, "giveupplease"); console.log(result.map(b => String.fromCharCode(b)).join(''));
This outputs a Base64-encoded string.
Step 7: Final Decode
Pipe the Base64 output through standard decode:
bashecho "" | base64 -d
The result is the flag in CodefestCTF{...} format.