Challenge: someone said steg? · CTF: DalCTF 2026 · Category: Miscellaneous / Stego · Difficulty: Hard

TL;DR

The challenge ships a single 90×90 PNG of a Suicune sprite. It’s not a plain PNG — it’s an APNG (animated PNG) with 16 frames. The flag is hidden one character per frame, written into a fixed byte position inside each frame’s decompressed pixel stream. Decompress all 16 frames, read byte index 4 of each, concatenate in order:

1
d a l c t f { p i a n o m a n }  →  dalctf{pianoman}

The whole thing is a misdirection: every “normal” stego avenue (LSB, appended data, EXIF, frame delays) is a dead end. The payload lives in the raw deflate streams.


1. First contact

We’re handed challenge.png — a cute, animated Suicune (the legendary Pokémon). The title “someone said steg?” and the description “everyone <3s steg right?” tell us exactly what flavor of pain to expect, but not where to look.

Rule one of any image challenge: don’t trust the extension, ask the file what it actually is.

1
2
3
$ file challenge.png
challenge.png: PNG image data, 90 x 90, 8-bit/color RGBA, non-interlaced,
               animated (16 frames, infinite repetitions)

There it is — animated (16 frames). This isn’t a static PNG, it’s an APNG. That single word reframes the entire challenge. A normal PNG has one image stream; an APNG has many. Whatever’s hidden here probably has something to do with those 16 frames.

A quick strings pass shows nothing but compressed garbage and an embedded ICC color profile — no flag sitting in plaintext, no obvious comment. Good. That would’ve been too easy for 487 points.


2. Understanding the container

Before reaching for tools, it pays to understand the PNG chunk layout. A PNG is just a signature followed by a series of length-prefixed chunks: [4-byte length][4-byte type][data][4-byte CRC].

1
2
3
4
5
6
7
8
9
import struct

data = open('challenge.png', 'rb').read()
i = 8  # skip the 8-byte PNG signature
while i < len(data):
    ln  = struct.unpack('>I', data[i:i+4])[0]
    typ = data[i+4:i+8].decode('latin1')
    print(f"{typ}  len={ln}  off={i}")
    i += 12 + ln

Output (trimmed):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
IHDR  len 13
acTL  len 8        ← Animation Control: this is what makes it an APNG
iCCP  len 389      ← ICC color profile
fcTL  len 26       ← Frame Control for frame 0
IDAT  len 2894     ← frame 0 image data
fcTL  len 26       ← Frame Control for frame 1
fdAT  len 2992     ← frame 1 image data
fcTL  len 26
fdAT  len 2943
...                ← repeats for all 16 frames
IEND  len 0

The APNG-specific chunks:

  • acTL (Animation Control) — declares the animation: 16 frames, infinite loops.
  • fcTL (Frame Control) — one per frame: dimensions, offsets, and display delay.
  • fdAT (Frame Data) — the compressed pixels for frames 1–15. Frame 0 reuses the standard IDAT.

So we have 16 image streams: one IDAT + fifteen fdAT. Hold that thought.


3. Ruling out the obvious (the rabbit holes)

The description practically dares you to throw the standard stego toolkit at it. So let’s burn down the usual suspects first — eliminating dead ends is half of misc.

Appended data after IEND? A classic trick is to staple a ZIP or a second file after the PNG’s end marker.

1
2
idx = data.rfind(b'IEND')
print('trailing bytes:', len(data) - (idx + 8))   # → 0

Nothing trailing. Dead end.

Frame delays as a covert channel? Each fcTL carries a delay_num / delay_den fraction. A cute trick is to smuggle bytes through those numbers. Let’s read all 16:

1
2
3
fcTL delay 10/1000
fcTL delay 10/1000
... (identical for all 16) ...

Every frame has the same 10/1000 delay. No data there. Dead end.

LSB / channel stego, EXIF, binwalk?

1
2
3
$ zsteg challenge.png        # nothing meaningful in the LSB planes
$ exiftool challenge.png     # just GIMP's sRGB profile + standard APNG tags
$ binwalk challenge.png      # only the expected zlib streams, one per frame

exiftool confirms the file type (MIME Type: image/apch/image/apng, Animation Frames: 16) but no hidden metadata. binwalk lists exactly 16 zlib streams — one per frame — and nothing extra carved out.

Every conventional avenue is empty. The flag has to be inside the frame image data itself.


4. The breakthrough: peeking inside the deflate streams

Here’s the key insight. Each frame’s pixels are zlib-compressed. Tools like zsteg and binwalk happily decompress those streams to peek at the first few bytes — and zsteg’s output had a tell that’s easy to miss in the noise:

1
2
3
4
5
6
7
chunk:6:fdAT   zlib: data="\x00\x00\x00\x00a\x00\x00\x00..."
chunk:8:fdAT   zlib: data="\x00\x00\x00\x00l\x00\x00\x00..."
chunk:10:fdAT  zlib: data="\x00\x00\x00\x00c\x00\x00\x00..."
chunk:12:fdAT  zlib: data="\x00\x00\x00\x00t\x00\x00\x00..."
chunk:14:fdAT  zlib: data="\x00\x00\x00\x00f\x00\x00\x00..."
chunk:16:fdAT  zlib: data="\x00\x00\x00\x00{\x00\x00\x00..."
...

Look at the fifth byte of each decompressed stream: a, l, c, t, f, { … that’s not pixel data, that’s ASCII, and it’s spelling something. ...alctf{... — that’s the back half of a flag prefix. The flag is being written one character at a time, one character per frame.

Why byte index 4? In a PNG, decompressed image data is a series of scanlines, and each scanline begins with a 1-byte filter type. For a 90×90 RGBA image, byte 0 is the first scanline’s filter byte, then bytes 1–4 are the RGBA of the very first pixel. The author parked one flag character in the top-left pixel of each frame — visually a single nearly-invisible pixel, but trivially readable once decompressed.


5. Extracting the flag

Now we just do it properly: walk every IDAT/fdAT chunk in order, decompress, and grab byte 4. The only subtlety is that fdAT chunks prepend a 4-byte sequence number before the actual compressed data, so we skip those 4 bytes (IDAT has no such prefix).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import struct, zlib

data = open('challenge.png', 'rb').read()
i = 8
frames = []

while i < len(data):
    ln  = struct.unpack('>I', data[i:i+4])[0]
    typ = data[i+4:i+8]
    payload = data[i+8:i+8+ln]
    if typ == b'IDAT':
        frames.append(payload)          # frame 0
    elif typ == b'fdAT':
        frames.append(payload[4:])      # skip the 4-byte sequence number
    i += 12 + ln

flag = b''
for idx, f in enumerate(frames):
    raw = zlib.decompress(f)
    ch  = raw[4]                         # 5th byte = top-left pixel value
    flag += bytes([ch])
    print(idx, repr(chr(ch)), 'head:', raw[:8])

print('FLAG:', flag.decode())

Output:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
0  'd'  head: b'\x00\x00\x00\x00d\x00\x00\x00'
1  'a'  head: b'\x00\x00\x00\x00a\x00\x00\x00'
2  'l'  head: b'\x00\x00\x00\x00l\x00\x00\x00'
3  'c'  head: b'\x00\x00\x00\x00c\x00\x00\x00'
4  't'  head: b'\x00\x00\x00\x00t\x00\x00\x00'
5  'f'  head: b'\x00\x00\x00\x00f\x00\x00\x00'
6  '{'  head: b'\x00\x00\x00\x00{\x00\x00\x00'
7  'p'  head: b'\x00\x00\x00\x00p\x00\x00\x00'
8  'i'  head: b'\x00\x00\x00\x00i\x00\x00\x00'
9  'a'  head: b'\x00\x00\x00\x00a\x00\x00\x00'
10 'n'  head: b'\x00\x00\x00\x00n\x00\x00\x00'
11 'o'  head: b'\x00\x00\x00\x00o\x00\x00\x00'
12 'm'  head: b'\x00\x00\x00\x00m\x00\x00\x00'
13 'a'  head: b'\x00\x00\x00\x00a\x00\x00\x00'
14 'n'  head: b'\x00\x00\x00\x00n\x00\x00\x00'
15 '}'  head: b'\x00\x00\x00\x00}\x00\x00\x00'

FLAG: dalctf{pianoman}

Reading the planted byte across all 16 frames, in animation order:

Frame0123456789101112131415
Chardalctf{pianoman}

Flag

1
dalctf{pianoman}

6. Lessons / takeaways

  • file first, always. The single most important step here was noticing it was an animated PNG. Everything downstream flows from that.
  • APNG = many image streams. When you see N frames, ask whether the secret is split across the frames. Per-frame stego (one byte / one row / one channel per frame) is a recurring CTF motif.
  • Decompress before you judge. The flag was invisible to strings and to a casual look at the pixels because it lived in the compressed stream. Tools like zsteg/binwalk that auto-inflate zlib gave the first hint — read their output carefully, the tell was a single column of ASCII.
  • Eliminate the cheap dead ends fast. Trailing data, frame delays, EXIF, and LSB were all clean. Knowing they’re empty narrows the search to “the pixel data itself” much faster than flailing.
  • Know your PNG internals. Understanding that decompressed PNG data is [filter byte][pixel bytes...] per scanline is what made “byte index 4 = top-left pixel” obvious rather than magic. And remembering that fdAT carries a 4-byte sequence-number prefix (while IDAT doesn’t) is what keeps the extraction aligned.

everyone <3s steg, right? 🐺