Challenge: Spoiled Cheese Pull · CTF: DalCTF 2026 · Category: Forensics · Difficulty: Medium

“My cheese got pulled and now I can’t eat it. Can you help me find out who did it?”

A short, silly prompt and a single file: chall.png. The whole challenge is a chain of file‑format trolls — the name “Spoiled Cheese Pull” is a hint in itself: the file is spoiled (corrupted on purpose), and we have to pull it back into shape. Let’s walk through it.


1. First contact — trust nothing about the extension

The file is named chall.png, but the first rule of forensics is to never trust an extension. Run file on it:

1
2
$ file chall.png
chall.png: JPEG image data, JFIF standard 13.73, density 17748x0, segment length 16, thumbnail 3x42

Interesting. The extension says PNG, but file swears it’s a JPEG. And even the JPEG identification is nonsense:

  • JFIF standard 13.73 — there is no JFIF version 13.73 (real ones are 1.00, 1.01, 1.02).
  • density 17748x0 — a zero density is invalid.
  • thumbnail 3x42 — garbage dimensions.

When file reports a “valid” type but every field inside it is impossible, that’s a giant red flag that the magic bytes were forged. The file is wearing a JPEG costume. Time to look at the actual bytes.


2. Reading the hex — a PNG hiding under a JPEG mask

1
2
3
4
5
$ xxd chall.png | head -8
00000000: ffd8 ffe0 0010 4a46 4946 000d 4948 4554  ......JFIF..IHET
00000010: 0000 032a 0000 006e 0806 0000 00c8 e8e7  ...*...n........
00000020: 2000 0004 0949 5341 4478 9ced dd31 6ee4  ....ISADx...1n.
...

The first 11 bytes are a textbook JPEG opener:

1
FF D8 FF E0 00 10 4A 46 49 46 00      →  SOI + APP0 + "JFIF\0"

But look at what follows. Right after the fake JFIF header we see strings that almost look like PNG chunk names:

Found in fileShould beNote
IHETIHDRPNG header chunk
ISADIDATPNG image-data chunk

And immediately after ISAD we find 78 9C — the unmistakable zlib “default compression” header. PNG image data (IDAT) is always a zlib stream, and 78 9C is its most common starting signature. There is no doubt now: this is a PNG, not a JPEG.

Someone took a normal PNG and:

  1. Replaced the 8‑byte PNG signature (89 50 4E 47 0D 0A 1A 0A) with a fake JPEG/JFIF header.
  2. Renamed the PNG chunk type names with subtle character swaps.

A nice detail: why the lengths still line up

Decode the IHDR data that follows the (broken) IHET name:

1
2
3
4
5
6
00 00 03 2A  →  width  = 0x32A = 810
00 00 00 6E  →  height = 0x6E  = 110
08           →  bit depth 8
06           →  color type 6 (RGBA)
00 00 00     →  compression / filter / interlace
C8 E8 E7 20  →  CRC

Those are perfectly sane image dimensions (810 × 110, 8‑bit RGBA). The IHDR data block was left completely intact — only the chunk name was vandalized. The forgery is purely cosmetic: replace the signature, scramble the 4‑letter chunk identifiers, and file’s signature database gets fooled into matching the JPEG magic at offset 0.

Let’s also check the tail of the file before fixing anything:

1
2
3
4
5
$ xxd chall.png | tail -3
00000430: 7edd d015 3dd6 0000 0000 5345 4e44 ae42  ~...=.....SEND.B
00000440: 6082 4e6f 7468 696e 6732 5365 6548 6572  `.Nothing2SeeHer
00000450: 6547 6f4c 6f6f 6b53 6f6d 6577 6865 7265  eGoLookSomewhere
00000460: 456c 7365                                Else

Two more trolls at the end:

  • SEND — the renamed IEND chunk (the PNG end-of-file marker).
  • Nothing2SeeHereGoLookSomewhereElse — junk bytes appended after the image ends, pure misdirection. (Classic.)

So the full list of damage is:

OriginalTampered
PNG signatureJPEG/JFIF header
IHDRIHET
IDATISAD
IENDSEND
(nothing)trailing Nothing2See... junk

3. Un-spoiling the cheese — rebuilding a valid PNG

The repair is mechanical. Restore the signature + IHDR header, fix the chunk names, and chop off the trailing junk at the end of the real IEND chunk:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import struct

data = bytearray(open('chall.png', 'rb').read())

# Restore the 8-byte PNG signature + IHDR length (13) + "IHDR" name
# (these occupy the first 16 bytes that were overwritten with the JPEG mask)
data[0:16] = bytes.fromhex('89504e470d0a1a0a') + struct.pack('>I', 13) + b'IHDR'

# Repair the scrambled chunk names
data = data.replace(b'ISAD', b'IDAT').replace(b'SEND', b'IEND')

# Truncate immediately after IEND + its 4-byte CRC, dropping the junk tail
idx = data.find(b'IEND')
data = data[:idx + 4 + 4]

open('chall_fixed.png', 'wb').write(data)

Why offset 16? A real PNG begins with the 8-byte signature, then 00 00 00 0D (IHDR length = 13), then IHDR — that’s 16 bytes before the width field at offset 0x10. The forger overwrote exactly that 16-byte region with the JPEG mask, keeping everything from the width onward intact. Restoring 16 clean bytes lines the whole file back up.

Verify:

1
2
$ file chall_fixed.png
chall_fixed.png: PNG image data, 810 x 110, 8-bit/color RGBA, non-interlaced

It opens cleanly. Rendering it gives a long, thin black-and-white image — clearly some kind of barcode:

1
810 × 110, a stacked grid of black and white modules

4. “Why so long?” — identifying the barcode

The image looks at first like a 1‑D barcode, so the instinct is to throw zbar at it:

1
2
3
$ zbarimg chall_fixed.png
scanned 0 barcode symbols from 1 images
WARNING: barcode data was not detected

Nothing. Scaling, padding with a quiet zone, binarizing — still nothing. zbar simply doesn’t support this symbology.

So let’s actually look at the structure instead of guessing. Sample the image down onto its module grid (each module is 10 px):

1
2
3
4
5
6
7
8
from PIL import Image
import numpy as np

a = np.array(Image.open('chall_fixed.png').convert('L'))
b = (a < 128).astype(int)
gh, gw = a.shape[0] // 10, a.shape[1] // 10
for i in range(gh):
    print(''.join('#' if b[i*10+5, j*10+5] else '.' for j in range(gw)))
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
.................................................................................
.................................................................................
..#######.#.#.#.#.#.#.#.#.###.#.#.#.#.#.#.#.#.#.#.#.###.#.#.#.#.#.#.#.#.#.#.###..
..#.....#.#..#.#..##....###.##...###..#..#...##.#..##.#...#.######..#.#.....#.#..
..#.###.#..########.#.###.##########.#...#..#####..####..##.###..#....#..######..
..#.###.#.###......#.##.#.#..#...#.#..########.####.#.#####.#..#..######..#...#..
..#.###.#.##..##.#......#####.##.#.###.##....#.###.####.#.....#.....#...###.#.#..
..#.....#.###.###....##...#.###...###..##.##.#.###.##.#.#.##.##..#.#.####.#...#..
..#######.#.#.#.#.#.#.#.#.###.#.#.#.#.#.#.#.#.#.#.#.###.#.#.#.#.#.#.#.#.#.#####..
.................................................................................
.................................................................................

Now the structure jumps out:

  • A solid black finder running down the left edge (#######).
  • Alternating timing patterns (#.#.#.#) along the top and bottom rows.
  • Only 9 module rows tall but 81 modules wide — a very rectangular code.

A square code (QR, Aztec, Data Matrix) is ruled out by the aspect ratio. A normal stacked code wider than it is tall, with a corner finder and edge timing patterns — this is a rMQR Code (Rectangular Micro QR), a newer ISO symbology designed for exactly these long, narrow label spaces.

And that is the punchline of the challenge title and the flag: the code is “long” because it’s the rectangular QR variant. “Why so long?”


5. Decoding the rMQR

zbar can’t read rMQR, but ZXing (via the zxing-cpp Python bindings) can:

1
$ pip install --break-system-packages zxing-cpp
1
2
3
4
5
6
import zxingcpp
from PIL import Image

im = Image.open('chall_fixed.png').convert('RGB')
for r in zxingcpp.read_barcodes(im):
    print(r.format, '|', repr(r.text))
1
rMQR Code | 'dalCTF{WhY_$O_L0N5}'

There it is — the symbology is confirmed as rMQR Code, and the payload is the flag.


Flag

1
dalCTF{WhY_$O_L0N5}

Takeaways

  • file reads magic bytes, not reality. Forged signatures plus impossible field values (JFIF 13.73, density 0) are a tell that the header was hand-edited. Always confirm with a hex dump.
  • PNG structure is easy to repair by hand. The signature is fixed, chunk names are fixed-length 4‑byte ASCII, and IHDR/IDAT/IEND are unmissable. Spotting the 78 9C zlib header right after a chunk name is a reliable “this is really a PNG” confirmation.
  • Trailing data after IEND is almost always a decoy (or sometimes a hidden payload — worth checking, but here it was just Nothing2SeeHere...).
  • When zbar fails, the symbology may simply be unsupported. Inspect the module grid to identify the format, then reach for ZXing / zxing-cpp, which covers PDF417, Aztec, Data Matrix, MaxiCode, and the rectangular Micro QR (rMQR) used here.

Cheese successfully un-spoiled. 🧀