419 words
2 minutes
ApoorvCTF 2026 - The Gotham Files - Forensics Writeup

Category: Forensics Flag: apoorvctf{th3_c0m1cs_l13_1n_th3_PLTE}

Challenge Description#

A mysterious panel surfaced at this year’s ComiCon. The artist left something behind.

Analysis#

The file looked like a normal PNG at first, so I started with quick triage to avoid trusting the extension blindly.

file challenge.png
challenge.png: PNG image data, 1920 x 1200, 8-bit colormap, non-interlaced

That 8-bit colormap detail matters because indexed PNGs store colors in a PLTE palette table, and hidden data is often placed in palette bytes rather than pixel bytes.

exiftool challenge.png
Artist                          : The Collector
Comment                         : Not all colors make it to the page. In Gotham, only the red light tells the truth.
Color Type                      : Palette

The comment was basically a roadmap: use palette data, and specifically red-channel values. Before committing to that path, I confirmed there was no simple appended file trick.

/home/rei/.cargo/bin/binwalk challenge.png
DECIMAL  HEXADECIMAL  DESCRIPTION
0        0x0          PNG image, total size: 921475 bytes
pngcheck -v challenge.png
chunk PLTE at offset 0x00025, length 768: 256 palette entries
chunk tEXt at offset 0x00351, length 90, keyword: Comment
No errors detected in challenge.png

I still ran a broad stego sweep to make sure I wasn’t missing an obvious extraction path, but it was mostly noisy output.

zsteg -a challenge.png
meta Comment        .. text: "Not all colors make it to the page. In Gotham, only the red light tells the truth."
... (many heuristic hits, no direct apoorvctf{...} extraction)

At that point, the elegant route was to read the PNG palette directly and test red-byte streams from all entries and unused entries.

smile

# extract_red_palette.py
from PIL import Image
import re

img = Image.open("challenge.png")
pal = img.getpalette()[:768]
triplets = [tuple(pal[i:i+3]) for i in range(0, 768, 3)]

idx = list(img.getdata())
used = sorted(set(idx))
unused = [i for i in range(256) if i not in set(used)]

reds_all = bytes([r for r, g, b in triplets])
reds_unused = bytes([triplets[i][0] for i in unused])

for name, blob in [("reds_all", reds_all), ("reds_unused", reds_unused)]:
    m = re.search(rb"apoorvctf\{[^}]+\}", blob, re.I)
    if m:
        print(name, "FLAG", m.group(0).decode())

print("used", len(used), "unused", len(unused))
python extract_red_palette.py
reds_all FLAG apoorvctf{th3_c0m1cs_l13_1n_th3_PLTE}
reds_unused FLAG apoorvctf{th3_c0m1cs_l13_1n_th3_PLTE}
used 200 unused 56

The flag was literally present in red palette bytes, matching the clue about the red light and colors that never make it to the final rendered page.

Solution#

# extract_red_palette.py
from PIL import Image
import re

img = Image.open("challenge.png")
pal = img.getpalette()[:768]
triplets = [tuple(pal[i:i+3]) for i in range(0, 768, 3)]

idx = list(img.getdata())
unused = [i for i in range(256) if i not in set(idx)]

reds_all = bytes([r for r, g, b in triplets])
reds_unused = bytes([triplets[i][0] for i in unused])

for blob in (reds_all, reds_unused):
    m = re.search(rb"apoorvctf\{[^}]+\}", blob, re.I)
    if m:
        print(m.group(0).decode())
        break
python extract_red_palette.py
apoorvctf{th3_c0m1cs_l13_1n_th3_PLTE}
ApoorvCTF 2026 - The Gotham Files - Forensics Writeup
https://blog.rei.my.id/posts/99/apoorvctf-2026-the-gotham-files-forensics-writeup/
Author
Reidho Satria
Published at
2026-03-10
License
CC BY-NC-SA 4.0