Codefest CTF 2025

RickRoll – Digital Forensics Challenge Writeup | Codefest CTF 2025

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.

0 people love this