← Writing
ctfforensicssteganography

I Found a Hidden Message in a Logo

by Gilang Windu Asmara


CTF: TJCTF, Thomas Jefferson High School CTF
Challenge: forensics/check-the-fine-print
Category: Forensics


The Challenge

what? the details matter? but why can't i see them...

We're given a single file: logo.png, the TJCTF logo.

TJCTF logo


Recon

First thing I always do with a forensics file: run strings on it.

001.png
IHDR
IDATx
IEND
002.png
IHDR
...
248.png
...
001.pngPK
002.pngPK
...

Multiple PNG filenames, and PK magic bytes at the end - that's a ZIP archive appended after the image data. The IDATx showing up as one string is just the zlib magic byte 0x78 sitting immediately after the IDAT marker in raw bytes, nothing special.

I confirmed with zsteg:

[?] 50741 bytes of extra data after image end (IEND), offset = 0x37c4
extradata:0  .. file: Zip archive data, at least v2.0 to extract

The ZIP starts at byte offset 0x37c4 (14276). Pulled it out:

dd if=logo.png bs=1 skip=14276 of=extracted.zip
unzip extracted.zip -d extracted/

248 PNG files. 001.png through 248.png.

Extracted PNG files


The Red Herring

Each image is a tiny 19×9 pixel RGB frame showing its own number, 1 through 248, as black text on a white background. No hidden chunks, nothing suspicious in the pixel data. I spent a bit of time staring at pixel values before stepping back.

Chunk structure was clean: IHDR → IDAT → IEND. That's it.

But then I compared the raw IHDR bytes across files:

FileIHDR byte 11 (hex)
001.png00
002.png01
003.png01

That byte is the compression method field. Per the PNG spec it must always be 0x00 (deflate is the only defined compression method and has been since PNG was standardized). Here it was either 0x00 or 0x01.

One bit per image. 248 images. 248 bits = 31 bytes of ASCII.


Decoding

The compression method byte sits at file offset 26:

FieldSizeOffset
PNG signature8 bytes0
IHDR chunk length4 bytes8
IHDR chunk type4 bytes12
Width4 bytes16
Height4 bytes20
Bit depth1 byte24
Color type1 byte25
Compression method1 byte26 ← target
import glob

files = sorted(glob.glob('[0-9]*.png'))
bits = []
for f in files:
    with open(f, 'rb') as fh:
        data = fh.read(27)
        bits.append(data[26])  # compression_method byte (0 or 1)

flag = ''.join(
    chr(int(''.join(str(b) for b in bits[i:i+8]), 2))
    for i in range(0, len(bits), 8)
)
print(flag)
tjctf{wow_you_actually_read_it}

I actually did read it.


The Other Way to See It

After submitting, I realized there was a much more intuitive way to spot the encoding without touching a hex editor.

Some of the 248 images open fine in any viewer. Others are broken: your OS thumbnail fails, your browser shows a broken image icon, Preview refuses to load them.

If you just sort the files and mark each one as openable (0) or broken (1), you get the exact same bit sequence. No scripts, no offset math. Just a file manager and pattern recognition.

The challenge name starts making a lot more sense when you look at it this way. The detail you couldn't see wasn't hidden in the pixels or in a metadata chunk. It was in which files your computer refused to open.

The other way to see it