← Back to Writeups
CUSTOMEasyForensics

Блендер (Blender)

XESXOR9/2/20264 min read
#forensics#custom#easy#idat_chunk_reordering#manual_png_defiltering#png_chunk_analysis#qr_code_decoding#zlib_header_identification#zlib_stream_reconstruction

Блендер (Blender)

Platform: bug-makers | Category: Forensics | Type: Challenge | Difficulty: Easy | OS: NA | Author: XESXOR | Date: 2025-10-04 | Status: Solved Techniques: idat_chunk_reordering, manual_png_defiltering, png_chunk_analysis, qr_code_decoding, zlib_header_identification, zlib_stream_reconstruction

Summary

Task: a palette PNG that file(1) reports as valid but Pillow refuses to open with 'broken data stream'. Solution: identify that the IDAT chunks were shuffled, brute-force the permutation by anchoring the chunk that starts with the zlib header (78 da), validate via exact decompressed length, manually de-filter scanlines, and decode the recovered QR code.

Блендер (Blender) — bug-makers

Description

Картинка попала в блендер. Но на ней была важная информация.

English: "The picture went through a blender. But it had important information on it." The challenge provides blender.png (1131398 bytes, 1728x2304, 8-bit palette/colormap, non-interlaced). The goal is to repair the image and read the information hidden in it.

The challenge name "Блендер/Blender" is the key semantic clue: the PNG was "blended" — its internal data (the IDAT chunks) was shuffled out of order. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Analysis

  1. file blender.png reports a valid PNG (1728x2304, palette). The PNG signature and chunk framing (length/type/CRC) are intact.

  2. Opening with Pillow fails:

    OSError: broken data stream when reading image file
    

    The corruption is therefore inside the IDAT (compressed pixel) stream, not in the chunk framing.

  3. Manual chunk parsing shows the structure:

    • IHDR (13 bytes)
    • PLTE (768 bytes — 256 RGB palette entries)
    • 5 × IDAT chunks (226102, 226103, 226102, 226103, 226103 bytes)
    • IEND ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍
  4. Concatenating all IDAT payloads in file order and calling zlib.decompress fails:

    zlib.error: Error -3 while decompressing data: incorrect header check
    

    The concatenated stream does not begin with a valid zlib header.

  5. Inspecting the first bytes of each IDAT chunk:

    IDAT indexfirst bytes
    008 00 d6 7c 74 31 68 f5
    178 da ac 9d bb 92 33 35 ← zlib header 78 da
    2bd cc 06 98 41 e5 04 22
    369 1c ab 20 fb ab 3c 00
    43e b4 a3 44 73 5c 4e 4f

‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

The chunk that holds the real zlib stream header (78 da) is at index 1, not index 0. The IDAT chunks were shuffled — exactly the "blender" hint.

Solution

Anchor the chunk with the zlib header (78 da) as first, then brute-force the order of the remaining four chunks. The correct order (1, 3, 4, 0, 2) decompresses cleanly into exactly 3,983,616 bytes = H × (W+1) = 2304 × (1728+1), matching a palette PNG (1 filter byte + 1728 index bytes per row). The exact-length match confirms the permutation. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Then manually apply PNG row de-filtering (filter types 0–4: None / Sub / Up / Average / Paeth), reshape to a 2304×1728 index array, reapply the original PLTE palette, and save. The recovered image shows an anime-style character (in a "BugCTF" cap) holding a sign with a QR code, which decodes to the flag.

python
#!/usr/bin/env python3
import struct, zlib
from itertools import permutations
import numpy as np
from PIL import Image

data = open('blender.png', 'rb').read()
assert data[:8] == b'\x89PNG\r\n\x1a\n'
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

# --- parse chunks ---
off = 8
idat, plte, ihdr = [], None, None
while off < len(data):
    ln  = struct.unpack('>I', data[off:off+4])[0]
    typ = data[off+4:off+8]
    payload = data[off+8:off+8+ln]
    if   typ == b'IHDR': ihdr = payload
    elif typ == b'PLTE': plte = payload
    elif typ == b'IDAT': idat.append(payload)
    off += 12 + ln

W, H = struct.unpack('>II', ihdr[:8])   # 1728 x 2304

# --- find chunk starting with a zlib header and brute-force the rest ---
anchor = next(i for i, c in enumerate(idat) if c[:2] in (b'\x78\xda', b'\x78\x9c', b'\x78\x01'))
others = [i for i in range(len(idat)) if i != anchor]
expected = H * (W + 1)   # palette PNG: 1 filter byte + W index bytes per row
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

raw = None
for rest in permutations(others):
    order = (anchor,) + rest
    blob  = b''.join(idat[i] for i in order)
    try:
        out = zlib.decompress(blob)
    except Exception:
        continue
    if len(out) == expected:
        print('SUCCESS', order, len(out))
        raw = out
        break
assert raw is not None

# --- PNG de-filtering (palette => bpp = 1) ---
bpp    = 1
stride = W * bpp
prev   = bytearray(stride)
rows   = []
pos    = 0
def paeth(a, b, c):
    p  = a + b - c
    pa, pb, pc = abs(p-a), abs(p-b), abs(p-c)
    return a if (pa <= pb and pa <= pc) else (b if pb <= pc else c)
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

for _ in range(H):
    ft  = raw[pos]; pos += 1
    cur = bytearray(raw[pos:pos+stride]); pos += stride
    for x in range(stride):
        a = cur[x-bpp] if x >= bpp else 0
        b = prev[x]
        c = prev[x-bpp] if x >= bpp else 0
        v = cur[x]
        if   ft == 1: v += a
        elif ft == 2: v += b
        elif ft == 3: v += (a + b) // 2
        elif ft == 4: v += paeth(a, b, c)
        cur[x] = v & 0xFF
    rows.append(cur)
    prev = cur

idx = np.frombuffer(b''.join(rows), dtype=np.uint8).reshape(H, W)
pal = np.frombuffer(plte, dtype=np.uint8).reshape(-1, 3)
img = Image.fromarray(pal[idx], 'RGB')
img.save('recovered.png')
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

# --- decode QR ---
from pyzbar.pyzbar import decode
print(decode(img))   # -> BugCTF{REDACTED}

‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍