TJCTF 2026: Thomas Schools of China
Challenge Overview
Thomas Schools of China is a forensics challenge that combines two distinct skill sets: custom binary format reverse engineering and pixel-level cryptanalysis. The challenge provides a file in an obscure, non-standard image format — neither PNG nor JPEG nor GIF, but a bespoke container with a custom header and raw RGBA pixel data. When rendered, the image depicts a simple pastel-green duck measuring 60×61 pixels, seemingly innocent and unremarkable.
The flag, however, is not hidden in metadata, LSB planes, or appended data. It is encoded directly in the color channel values of specific “speckle” pixels — tiny dots of color that differ subtly from the uniform pastel-green background. Each speckle pixel carries three ASCII characters (one per RGB channel), and the complete flag is recovered by scanning all pixels, filtering out the background using a channel-delta threshold, and concatenating the character values from the surviving data pixels.
The challenge name — “Thomas Schools of China” — is a whimsical reference that provides no technical hint. The duck motif reinforces the absurd, playful aesthetic, which contrasts with the precise, technical nature of the underlying steganography. This is a challenge where the math is straightforward but the recognition of what to look for — channel disagreement as a data carrier — is the real insight.
In a uniform-color region, all RGB channels should have nearly identical values (a greyish or pastel color where R≈G≈B). If one channel deviates significantly from the others, that deviation is not natural — it is injected data. The flag is hidden in the pixels where the color channels “disagree” with each other.
Custom Container Reverse Engineering
Identifying the Format
The first obstacle is that the file is not in any standard image format. Running file thomas.tsc returns data — a generic classification that means the magic bytes do not match any known format signature. Opening it in an image viewer fails silently. This immediately signals that we are dealing with a custom binary container, and we must reverse-engineer its structure from scratch.
$ file thomas.tsc
thomas.tsc: data
$ xxd thomas.tsc | head -5
00000000: 5443 5346 0100 0000 3c00 0000 3d00 5447 TCSF....<...=TG
00000010: 4d92 92a0 8c92 92a0 8c92 92a0 8c92 92a0 M...............
Header Structure Analysis
By examining the hex dump, we can identify a structured header with fields at fixed offsets. The first four bytes 54 43 53 46 decode to the ASCII string TCSF — likely standing for “Thomas Container Sprite Format” or similar. This is the magic number that identifies the file type. Subsequent fields follow at predictable offsets:
| Offset | Size | Field | Value |
|---|---|---|---|
| 0x00 | 4 bytes | Magic Number | TCSF |
| 0x04 | 4 bytes | Version (uint32 LE) | 1 |
| 0x08 | 4 bytes | Image Width (uint32 LE) | 60 |
| 0x0C | 2 bytes | Image Height (uint16 LE) | 61 |
| 0x0E | 3 bytes | Format Specifier | TGM |
| 0x11 | N bytes | Raw RGBA Pixel Data | 60 × 61 × 4 = 14640 bytes |
The header totals 17 bytes (0x11 in hex), after which the raw pixel data begins. Each pixel is stored as 4 consecutive bytes in RGBA order (Red, Green, Blue, Alpha), with no compression, no row padding, and no interlacing. The total pixel data size is straightforward to verify: 60 × 61 × 4 = 14,640 bytes. Adding the 17-byte header gives a total file size of 14,657 bytes.
Reverse-engineering a custom binary format is a core skill in firmware analysis and malware reverse engineering. The approach is always the same: examine the hex dump, identify repeating patterns, correlate field values with known quantities (image dimensions, pixel counts), and validate your assumptions against the file size. The key constraint here is that width × height × bytes_per_pixel + header_size = total_file_size, which provides a checksum for your header parsing logic.
Extracting the Pixel Matrix
Once the header structure is understood, extracting the pixel data is a simple slice operation. The raw RGBA bytes start at offset 0x11 and continue for exactly 14,640 bytes. We can reconstruct the image by reading these bytes into a 60×61×4 array and interpreting each group of 4 bytes as one pixel:
import struct
with open("thomas.tsc", "rb") as f:
blob = f.read()
# Parse header fields via static offsets
magic = blob[0:4] # b'TCSF'
version = struct.unpack('<I', blob[4:8])[0] # 1
width = struct.unpack('<I', blob[8:12])[0] # 60
height = struct.unpack('<H', blob[12:14])[0] # 61
fmt_spec = blob[14:17] # b'TGM'
# Slice the raw RGBA payload
rgba_start = 17 # 0x11
rgba_end = rgba_start + (width * height * 4)
pixel_data = blob[rgba_start:rgba_end]
print(f"Dimensions : {width}x{height}")
print(f"Pixel data : {len(pixel_data)} bytes (expected {width * height * 4})")
print(f"Magic : {magic}")
print(f"Fmt spec : {fmt_spec}")
With the pixel data extracted, we can either reconstruct the image using PIL (saving it as a standard PNG for visual inspection) or proceed directly to the cryptanalysis phase by iterating over the raw bytes.
RGBA Threshold Analysis
Visual Appearance: The Pastel Duck
When the pixel data is reconstructed into a viewable image, the result is a small (60×61 pixel) depiction of a duck in pastel green. At normal zoom, the image appears to be a solid, uniform color with perhaps a few slightly different shades for the outline and eye. There is nothing visually that suggests hidden data — no obvious patterns, no suspicious artifacts, no visible text.
However, when you zoom to maximum magnification (pixel-level view), a different picture emerges. Scattered among the uniform background pixels are tiny “speckles” — individual pixels whose color is slightly off from the surrounding pastel green. These speckles are not random noise; they are the data carriers. Their RGB values encode ASCII characters directly.
The Channel Delta Metric
The key insight is that the background pixels and the data pixels have fundamentally different channel statistics. In a pastel-green background pixel, the three color channels (R, G, B) are all relatively close to each other because the color is a desaturated, nearly-grey tone with a slight green tint. In a data pixel, however, one or more channels have been deliberately set to ASCII character values, which causes the channels to “disagree” — the spread between the highest and lowest channel values becomes anomalously large.
We formalize this observation with the channel delta metric:
delta = max(R, G, B) - min(R, G, B)
For background pixels, delta is typically 0-4 (the channels are nearly equal). For data pixels, delta is 5 or higher (at least one channel has been perturbed significantly from the others). The threshold value of 5 provides a clean separation between the two populations.
Pixel Classification Rules
| Pixel Type | Channel Delta | Appearance | Action |
|---|---|---|---|
| Background (Noise) | delta < 5 | Uniform pastel, channels agree | Skip |
| Data (Signal) | delta ≥ 5 | Colored speckle, channels disagree | Extract R, G, B as chr() |
The Alpha channel (A) is not used for data encoding. In this format, alpha controls transparency/opacity of the pixel and is set to a consistent value (typically 255 or a near-maximum value) for all pixels. Including alpha in the delta calculation would introduce false positives, and interpreting it as an ASCII character would inject garbage into the output. Only R, G, and B carry flag data.
Multi-Channel Steganography Extraction
The Encoding Scheme
Each data pixel encodes three ASCII characters — one per color channel. The Red channel value is interpreted as chr(R), the Green channel as chr(G), and the Blue channel as chr(B). Since printable ASCII ranges from 32 (space) to 126 (tilde), the channel values for data pixels will fall within this range, making them easily distinguishable from the background pixel values (which are typically in the 140-160 range for a pastel color).
The extraction process is elegantly simple: scan the pixel matrix from left to right, top to bottom. For each pixel, compute the channel delta. If the delta meets the threshold, emit three characters; if not, skip the pixel. The resulting concatenation of characters forms the complete flag string.
Walkthrough Example
Consider a hypothetical data pixel with RGBA values (116, 106, 99, 255). The channel delta is max(116, 106, 99) - min(116, 106, 99) = 116 - 99 = 17, which exceeds the threshold of 5. We therefore extract:
chr(116)='t'chr(106)='j'chr(99)='c'
Three characters from a single pixel — this is a remarkably dense encoding. A 60×61 image contains 3,660 pixels, and even if only a fraction are data pixels, the three-character-per-pixel yield provides ample capacity for the 55-character flag.
Full Extraction Pipeline
The complete extraction algorithm iterates over the raw RGBA byte array in 4-byte strides, classifies each pixel, and accumulates the decoded characters:
def decode_pixel_channels(rgba_bytes, threshold=5):
"""
Walk the RGBA byte array, classify each pixel by channel delta,
and concatenate ASCII characters from data pixels.
"""
result = []
for offset in range(0, len(rgba_bytes), 4):
r = rgba_bytes[offset]
g = rgba_bytes[offset + 1]
b = rgba_bytes[offset + 2]
# Alpha at offset+3 is ignored
delta = max(r, g, b) - min(r, g, b)
if delta >= threshold:
result.append(chr(r))
result.append(chr(g))
result.append(chr(b))
return "".join(result)
Running this extraction on the pixel data from thomas.tsc produces the complete flag in a single pass, with no additional processing required. The flag is not fragmented, obfuscated, or encrypted beyond the channel-encoding scheme — it is embedded as plaintext ASCII, pixel by pixel, in the color channels of the speckle pixels.
tjctf{c0ngr4ts_u_s0lv3d_my_f1st_CTF_chall!_btw_1_l1ke_b1rds}
The tail of the flag — btw_1_l1ke_b1rds (“by the way, I like birds”) — is a self-referential nod to the duck image. The challenge author is telling you that the duck was not just decorative: it was the carrier for the hidden data all along. Birds, speckles, pixels — everything connects.
Exploit Code
The following script implements the complete end-to-end solve: parsing the custom .tsc header, extracting the raw RGBA payload, classifying pixels by channel delta, and decoding the multi-channel steganography. It uses only the Python standard library (struct for binary parsing) with no external dependencies.
#!/usr/bin/env python3
"""
TJCTF 2026 - Thomas Schools of China
Custom container parsing + multi-channel pixel steganography
Author: QA210
"""
import struct
def dissect_tsc_archive(archive_path):
"""
Parse the proprietary .tsc binary container format.
Extracts header metadata and the raw RGBA pixel payload
from static offsets identified via hex analysis.
"""
with open(archive_path, "rb") as handle:
blob = handle.read()
# Header fields at fixed offsets
signature = blob[0:4] # Magic: TCSF
revision = struct.unpack('<I', blob[4:8])[0] # Version
frame_w = struct.unpack('<I', blob[8:12])[0] # Width (60)
frame_h = struct.unpack('<H', blob[12:14])[0] # Height (61)
codec_tag = blob[14:17] # Format: TGM
# Validate expected payload size
rgba_head = 17 # 0x11
expected = frame_w * frame_h * 4
rgba_blob = blob[rgba_head:rgba_head + expected]
# Sanity check: file must be exactly header + pixel data
if len(rgba_blob) != expected:
raise ValueError(
f"Pixel data size mismatch: got {len(rgba_blob)} bytes, "
f"expected {expected} ({frame_w}x{frame_h}x4)"
)
print(f"[*] Signature : {signature}")
print(f"[*] Revision : {revision}")
print(f"[*] Frame : {frame_w} x {frame_h}")
print(f"[*] Codec : {codec_tag}")
print(f"[*] RGBA size : {len(rgba_blob)} bytes (expected {expected})")
return frame_w, frame_h, rgba_blob
def harvest_speckle_text(rgba_bytes, spread_cutoff=5):
"""
Scan the RGBA pixel buffer and decode multi-channel steganography.
A pixel is classified as 'data' when its channel spread
(max - min of R, G, B) meets or exceeds the cutoff threshold.
Each data pixel yields three printable-ASCII characters (R, G, B).
The Alpha byte is always ignored.
"""
decoded_chars = []
for cursor in range(0, len(rgba_bytes), 4):
red = rgba_bytes[cursor]
green = rgba_bytes[cursor + 1]
blue = rgba_bytes[cursor + 2]
# alpha = rgba_bytes[cursor + 3] # Not used for data
channel_spread = max(red, green, blue) - min(red, green, blue)
if channel_spread >= spread_cutoff:
decoded_chars.append(chr(red))
decoded_chars.append(chr(green))
decoded_chars.append(chr(blue))
return "".join(decoded_chars)
def main():
tsc_path = "thomas.tsc"
# Phase 1: Dissect the custom binary container
width, height, rgba_payload = dissect_tsc_archive(tsc_path)
# Phase 2: Apply channel-delta threshold and decode
print("\n[*] Scanning pixel matrix for data speckles...")
hidden_message = harvest_speckle_text(rgba_payload, spread_cutoff=5)
print(f"[+] Decoded message: {hidden_message}")
if __name__ == "__main__":
main()
Running the Exploit
$ python3 thomas_solve.py
[*] Signature : b'TCSF'
[*] Revision : 1
[*] Frame : 60 x 61
[*] Codec : b'TGM'
[*] RGBA size : 14640 bytes (expected 14640)
[*] Scanning pixel matrix for data speckles...
[+] Decoded message: tjctf{c0ngr4ts_u_s0lv3d_my_f1st_CTF_chall!_btw_1_l1ke_b1rds}
Visual Reconstruction (Optional)
For verification purposes, it can be helpful to reconstruct the pixel data as a standard PNG image and visually identify the speckle pixels. The following snippet converts the raw RGBA buffer into a PIL Image object, making it easy to zoom in and confirm the data pixel locations:
from PIL import Image
import numpy as np
# Reconstruct RGBA array -> PIL Image
frame_w, frame_h, rgba_bytes = dissect_tsc_archive("thomas.tsc")
# np.frombuffer on bytes returns a read-only array; .copy() makes it writable
pixel_array = np.frombuffer(rgba_bytes, dtype=np.uint8).reshape((frame_h, frame_w, 4)).copy()
reconstructed = Image.fromarray(pixel_array, mode="RGBA")
reconstructed.save("thomas_reconstructed.png")
print("[+] Saved thomas_reconstructed.png")
# Create a highlight mask: mark data pixels in red, background in black
mask = np.zeros((frame_h, frame_w, 3), dtype=np.uint8)
for y in range(frame_h):
for x in range(frame_w):
r, g, b, a = pixel_array[y, x]
if max(r, g, b) - min(r, g, b) >= 5:
mask[y, x] = [255, 0, 0] # Data pixel = red dot
Image.fromarray(mask).save("thomas_speckle_map.png")
print("[+] Saved thomas_speckle_map.png (data pixels highlighted)")
The speckle map image renders each data pixel as a single red dot against a black background, producing a sparse scatter plot that visually confirms the distribution of hidden data across the image. The dots are not randomly scattered — they follow the raster order of the pixel matrix, appearing in the sequence needed to spell out the flag character by character.
The threshold of 5 is robust for this challenge, but what if a different challenge uses a subtler encoding? You can determine the optimal threshold empirically by plotting a histogram of channel deltas across all pixels. The background pixels form a tight cluster near delta=0-2, while data pixels form a separate cluster at higher deltas. The “valley” between the two clusters is the natural threshold. If the clusters overlap significantly, the encoding is more ambiguous and may require additional heuristics (e.g., checking if decoded characters fall in the printable ASCII range).