TJCTF 2026: 3-Write-Up
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.
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.
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:
- Establish the template: Take the payload from any of the 995 identical packets (e.g., packet #6) as the baseline.
- XOR deviations: For each of the 5 anomalous packets, XOR every even-offset byte with the corresponding template byte. A result of
0x01means the LSB was flipped (hidden bit = 1); a result of0x00means no change (hidden bit = 0). - Pack bits MSB-first: Collect all extracted bits and group them into 8-bit chunks, most significant bit first, to form ASCII bytes.
- Decode Base64: The resulting byte sequence contains a Base64-encoded substring. Decoding it yields the flag.
tjctf{h3y_v0ip_s73g_is_4_7hing}
Exploit: Voice-in-the-Packet
#!/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")
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 Data | Field | Size | Valid Values |
|---|---|---|---|
| 0–3 | Width | 4 bytes | Any positive integer |
| 4–7 | Height | 4 bytes | Any positive integer |
| 8 | Bit depth | 1 byte | 1, 2, 4, 8, 16 |
| 9 | Color type | 1 byte | 0, 2, 3, 4, 6 |
| 10 | Compression method | 1 byte | Must be 0 |
| 11 | Filter method | 1 byte | Must be 0 |
| 12 | Interlace method | 1 byte | 0 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.
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:
- Sort files alphabetically by filename to establish the correct bit ordering.
- 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.
- Extract the LSB:
byte_26 & 1yields 0 or 1. - 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.
tjctf{wow_you_actually_read_it}
Exploit: Check-the-Fine-Print
#!/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/")
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.
# 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 Header | Field | Size |
|---|---|---|
| 0–3 | Signature (PK\x03\x04) | 4 bytes |
| 4–5 | Version needed | 2 bytes |
| 6–7 | General purpose flags | 2 bytes |
| 8–9 | Compression method | 2 bytes |
| 18–21 | Compressed size | 4 bytes |
| 26–27 | Filename length | 2 bytes |
| 28–29 | Extra field length | 2 bytes |
| 30+fname_len+extra_len | File data | comp_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':
# 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.
tjctf{n3v3r_l3t_0ther_p30ple_t0uch_ur_c0mputer}
Exploit: Unfinished-File
#!/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("
$ python3 unfinished_exploit.py
[+] XOR key: 0x42
[+] Flag: tjctf{n3v3r_l3t_0ther_p30ple_t0uch_ur_c0mputer}