Forensics Mega-Writeup

TJCTF 2026: 3-Write-Up

QA210·May 2026·VoIP Steg · PNG Spec · ZIP Carving
Challenge
3-Write-Up
Category
Forensics
CTF
TJCTF 2026
Sub-Challs
3 (VoIP / PNG / ZIP)

This mega-writeup covers three distinct forensics challenges from TJCTF 2026, each testing a different aspect of digital forensic analysis: network protocol steganography, file format specification exploitation, and incomplete file carving with cryptanalysis. While the techniques vary widely — from RTP payload bit-flipping to PNG header abuse to ZIP local file header parsing — they share a common theme: the hidden data lives in places that standard tools either overlook or actively normalize away.

Challenge 1 — Voice-in-the-Packet
Name
voice-in-the-packet
Category
Network Forensics / VoIP
Flag
tjctf{h3y_v0ip_s73g_is_4_7hing}
PCAP 1000 RTP packets 995 byte-identical, 5 anomalous at even offsets only
XOR Anomalous XOR Template = 0x01 LSB steganography on G.711 μ-law samples
EXTRACT Pack bits MSB-first Base64 substring Flag!

Voice-in-the-Packet: Overview

The challenge provides a 20-second packet capture containing 1000 RTP (Real-Time Transport Protocol) packets carrying G.711 μ-law encoded audio. At first glance, this looks like a straightforward VoIP capture — perhaps you’re expected to extract and listen to the audio. Two “decoy” packets even contain flag-shaped strings to mislead anyone searching for plaintext flags in the payload.

The critical observation is statistical: 995 of the 1000 RTP payloads are byte-identical. Only five packets deviate from this template, and they deviate exclusively at even-numbered byte offsets, always by exactly 0x01. This is the unmistakable signature of LSB (Least Significant Bit) steganography. The attacker has flipped the lowest bit of selected audio samples to embed hidden data without producing audible artifacts in the reconstructed speech signal.

Warning — Decoy Packets

Two packets contain strings that look like flags but are deliberate traps. Searching for tjctf{ in the raw packet data will find these decoys first, wasting time. The real flag is encoded in the bit deviations, not stored as plaintext.

Why G.711 μ-law Hides LSB Well

G.711 μ-law is a companding codec that maps 14-bit linear PCM samples to 8-bit logarithmic values. The compression curve is designed so that a +1/−1 change in the encoded byte corresponds to a very small amplitude change — well below the threshold of human perception, especially in the context of speech. When the RTP payload is arranged as sequential samples (Sample 0 at Byte 0, Sample 1 at Byte 1, etc.), modifying the LSB of any sample shifts its decoded amplitude by an imperceptible amount. This makes μ-law an ideal cover medium for steganography: the hidden data rides in the noise floor of the codec itself.

The restriction to even offsets is an additional constraint that likely halves the available capacity but simplifies the extraction logic. Even offsets correspond to even-numbered samples (0, 2, 4, …), and the choice may be related to the RTP timestamp increment or the sender’s implementation convenience.

XOR Extraction and Base64 Recovery

The extraction algorithm is straightforward once the template is identified:

  1. Establish the template: Take the payload from any of the 995 identical packets (e.g., packet #6) as the baseline.
  2. XOR deviations: For each of the 5 anomalous packets, XOR every even-offset byte with the corresponding template byte. A result of 0x01 means the LSB was flipped (hidden bit = 1); a result of 0x00 means no change (hidden bit = 0).
  3. Pack bits MSB-first: Collect all extracted bits and group them into 8-bit chunks, most significant bit first, to form ASCII bytes.
  4. Decode Base64: The resulting byte sequence contains a Base64-encoded substring. Decoding it yields the flag.
Flag

tjctf{h3y_v0ip_s73g_is_4_7hing}

Exploit: Voice-in-the-Packet

python
#!/usr/bin/env python3
"""
TJCTF 2026 - voice-in-the-packet
G.711 mu-law RTP LSB steganography extractor
Author: QA210
"""
from scapy.all import rdpcap, RTP, UDP
import base64


def recover_hidden_flag(pcap_path):
    """Extract LSB-embedded data from VoIP RTP payloads."""
    packets = rdpcap(pcap_path)
    rtp_payloads = [
        RTP(bytes(p[UDP].payload)).payload
        for p in packets if p.haslayer(UDP)
    ]

    # Use packet #6 as the clean template (skip 5 anomalous)
    reference_payload = rtp_payloads[5]

    # Extract LSB bits from the 5 anomalous packets
    concealed_bits = []
    for pkt_data in rtp_payloads[:5]:
        for idx in range(0, len(pkt_data)):
            if idx % 2 == 0:  # Only even offsets carry hidden data
                deviation = pkt_data[idx] ^ reference_payload[idx]
                concealed_bits.append(1 if deviation == 0x01 else 0)

    # Pack bits MSB-first into bytes
    reconstructed = bytearray()
    for pos in range(0, len(concealed_bits), 8):
        bit_group = "".join(map(str, concealed_bits[pos:pos + 8]))
        if len(bit_group) == 8:
            reconstructed.append(int(bit_group, 2))

    # Find and decode the Base64 substring
    ascii_text = reconstructed.decode("ascii", errors="ignore")
    b64_segment = ascii_text.split("\n")[0]
    flag = base64.b64decode(b64_segment).decode()
    print(f"[+] Flag: {flag}")


if __name__ == "__main__":
    recover_hidden_flag("voip.pcap")
Challenge 2 — Check-the-Fine-Print
Name
check-the-fine-print
Category
Format Spec Violation / Steganography
Flag
tjctf{wow_you_actually_read_it}
PNG Appended ZIP 248 tiny PNGs (19×9) stego tools all fail
IHDR Byte 10 Compression method must be 0 per RFC 2083 set to 1 = hidden bit
EXTRACT 248 files × 1 bit = 31 bytes Flag!

Check-the-Fine-Print: Overview

This challenge starts with a PNG file that has a ZIP archive appended after the IEND chunk — a common hiding technique that binwalk detects immediately. Extracting the ZIP yields 248 tiny PNG images, each measuring 19×9 pixels. The natural instinct is to run every steganography tool in the book: zsteg, stegsolve, LSB extraction, palette manipulation, pixel-value differencing. All of them come up empty.

The reason they fail is that the hidden data is not in the pixel content at all. It’s in the PNG metadata — specifically in a field that virtually every image viewer and forensic tool silently normalizes or ignores. The challenge name “Check the Fine Print” is a direct hint: read the specification, not the pixels.

The IHDR Compression Method Abuse

Every PNG file begins with an IHDR (Image Header) chunk that specifies the image’s fundamental properties. The chunk layout is fixed by the PNG specification (RFC 2083):

Offset in IHDR DataFieldSizeValid Values
0–3Width4 bytesAny positive integer
4–7Height4 bytesAny positive integer
8Bit depth1 byte1, 2, 4, 8, 16
9Color type1 byte0, 2, 3, 4, 6
10Compression method1 byteMust be 0
11Filter method1 byteMust be 0
12Interlace method1 byte0 or 1

Byte 10, the compression method, is specified by the PNG standard as always 0 (meaning Deflate/inflate compression). There are no other valid values. However, because this field has only one legal value, most PNG decoders don’t even check it — they simply assume it’s 0 and proceed. This makes it an ideal steganographic channel: you can set it to 1 to encode a hidden bit, and every image viewer will still display the image correctly without complaint.

Hint — “Fine Print” = Read the Spec

The challenge name is telling you to read the PNG specification carefully. Fields that have only one legal value are often treated as “reserved” or “must be zero” — and they’re the most ignored fields in format forensic analysis. Any such field can be abused as a 1-bit steganographic channel per file.

Bit Extraction from 248 Files

With 248 PNG files, each contributing one bit from its IHDR compression method byte, we get 248 bits of hidden data — exactly 31 bytes of ASCII text. The extraction process is:

  1. Sort files alphabetically by filename to establish the correct bit ordering.
  2. Read byte 26 of each file. This corresponds to the compression method field in the IHDR chunk: 8 bytes PNG signature + 4 bytes chunk length + 4 bytes chunk type (“IHDR”) + 10 bytes into IHDR data = offset 26.
  3. Extract the LSB: byte_26 & 1 yields 0 or 1.
  4. Pack bits MSB-first into 8-bit bytes and convert to ASCII characters.

The result spells out the flag, rewarding those who actually read the specification rather than blindly running tools.

Flag

tjctf{wow_you_actually_read_it}

Exploit: Check-the-Fine-Print

python
#!/usr/bin/env python3
"""
TJCTF 2026 - check-the-fine-print
PNG IHDR compression method byte steganography extractor
Author: QA210
"""
from pathlib import Path


def extract_ihdr_concealed_data(png_directory):
    """
    Read byte 26 (compression method) from each sorted PNG,
    extract its LSB, pack into ASCII string.
    """
    png_files = sorted(Path(png_directory).glob("*.png"))

    bit_stream = []
    for filepath in png_files:
        with open(filepath, "rb") as f:
            raw = f.read()
            # Offset 26 = PNG sig(8) + len(4) + type(4) + IHDR data offset 10
            compress_byte = raw[26]
            bit_stream.append(compress_byte & 1)

    # Pack bits MSB-first
    message = []
    for i in range(0, len(bit_stream), 8):
        group = "".join(map(str, bit_stream[i:i + 8]))
        if len(group) == 8:
            message.append(chr(int(group, 2)))

    flag = "".join(message)
    print(f"[+] Flag: {flag}")


if __name__ == "__main__":
    extract_ihdr_concealed_data("./extracted_pngs/")
Challenge 3 — Unfinished-File
Name
unfinished-file
Category
File Carving / Cryptanalysis
Flag
tjctf{n3v3r_l3t_0ther_p30ple_t0uch_ur_c0mputer}
CRDOWNLOAD Skip Chrome CRDL header (0x100) partial ZIP data
CARVE Scan for PK\x03\x04 local headers parse self-describing fields .flagdata
XOR Known plaintext: 't'=0x74 XOR 0x36 = Key 0x42 Flag!

Unfinished-File: Overview

The challenge provides a .crdownload file — a partial download created by Google Chrome when a transfer is interrupted before completion. Chrome uses the .crdownload extension for in-progress downloads, prepending its own metadata header to track the download state. When the download completes, Chrome strips this header and renames the file. An incomplete download leaves the .crdownload file on disk with whatever data was received before the interruption.

In this case, the interrupted download was a ZIP archive. The critical consequence is that the ZIP’s End of Central Directory (EOCD) record — which standard ZIP tools require to locate and extract files — was never written. The EOCD sits at the end of a ZIP file, so if the download was truncated before the server finished sending, the EOCD is missing. Tools like unzip, 7z, and most GUI archive managers will refuse to open the file, reporting “End-of-central-directory not found” or similar errors.

However, the Local File Headers at the beginning of the ZIP data are intact and self-describing. Each Local File Header contains all the information needed to extract its corresponding file entry: compression method, compressed size, filename length, and extra field length. By manually parsing these headers, we can carve out the embedded files without needing the Central Directory at all.

Chrome CRDL Header Structure

Chrome prepends a CRDL (Chrome Download) header to track download metadata such as the original URL, target filename, and bytes received so far. This header occupies approximately 0x100 (256) bytes at the start of the file. The actual downloaded content begins immediately after this header, in sequential network byte order.

python
# Strip Chrome CRDL header
with open("unfinished.crdownload", "rb") as f:
    blob = f.read()
zip_data = blob[0x100:]  # Skip 256-byte Chrome metadata

ZIP Local File Header Carving

The ZIP Local File Header has a well-known structure that begins with the magic signature PK\x03\x04. After the signature, the header contains version flags, compression method, modification timestamps, CRC-32, compressed and uncompressed sizes, filename length, and extra field length. The file data immediately follows the header.

Our carving algorithm scans the ZIP data for PK\x03\x04 signatures, parses the fields at fixed offsets, and extracts the file data for any entry named .flagdata that uses compression method 0 (Store, meaning no compression — the data is stored verbatim):

Offset in Local HeaderFieldSize
0–3Signature (PK\x03\x04)4 bytes
4–5Version needed2 bytes
6–7General purpose flags2 bytes
8–9Compression method2 bytes
18–21Compressed size4 bytes
26–27Filename length2 bytes
28–29Extra field length2 bytes
30+fname_len+extra_lenFile datacomp_size bytes

Known-Plaintext XOR Attack

The extracted .flagdata entry contains 47 bytes of data that has been encrypted with a single-byte XOR cipher. Since we know the flag format begins with tjctf{, we can recover the XOR key by comparing the first byte of the ciphertext with the known plaintext character 't':

python
# Known-plaintext XOR key recovery
ciphertext_first_byte = 0x36
known_plaintext_char  = ord('t')  # 0x74
xor_key = ciphertext_first_byte ^ known_plaintext_char  # = 0x42

The XOR key is 0x42. Applying this key to all 47 bytes of the encrypted data recovers the full flag. This is a classic known-plaintext attack that works against any single-byte XOR cipher whenever even one byte of the expected output is known — and CTF flag formats always provide at least tjctf{ as a known prefix.

Flag

tjctf{n3v3r_l3t_0ther_p30ple_t0uch_ur_c0mputer}

Exploit: Unfinished-File

python
#!/usr/bin/env python3
"""
TJCTF 2026 - unfinished-file
Chrome .crdownload ZIP carving + known-plaintext XOR
Author: QA210
"""
import struct
from pathlib import Path


LOCAL_SIG = b"PK\x03\x04"


def carve_crdownload(crdownload_path):
    """
    Strip Chrome CRDL header, scan for ZIP local file headers,
    extract .flagdata, and decrypt via known-plaintext XOR.
    """
    blob = Path(crdownload_path).read_bytes()

    # Phase 1: Skip Chrome metadata header
    zip_blob = blob[0x100:]

    # Phase 2: Scan for Local File Headers
    cursor = 0
    while cursor < len(zip_blob) - 4:
        if zip_blob[cursor:cursor + 4] != LOCAL_SIG:
            cursor += 1
            continue

        # Parse Local File Header fields
        comp_method = struct.unpack_from("
bash
$ python3 unfinished_exploit.py
[+] XOR key: 0x42
[+] Flag: tjctf{n3v3r_l3t_0ther_p30ple_t0uch_ur_c0mputer}