Блендер (Blender)
Блендер (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
-
file blender.pngreports a valid PNG (1728x2304, palette). The PNG signature and chunk framing (length/type/CRC) are intact. -
Opening with Pillow fails:
OSError: broken data stream when reading image fileThe corruption is therefore inside the IDAT (compressed pixel) stream, not in the chunk framing.
-
Manual chunk parsing shows the structure:
IHDR(13 bytes)PLTE(768 bytes — 256 RGB palette entries)- 5 ×
IDATchunks (226102, 226103, 226102, 226103, 226103 bytes) IEND
-
Concatenating all IDAT payloads in file order and calling
zlib.decompressfails:zlib.error: Error -3 while decompressing data: incorrect header checkThe concatenated stream does not begin with a valid zlib header.
-
Inspecting the first bytes of each IDAT chunk:
IDAT index first bytes 0 08 00 d6 7c 74 31 68 f51 78 da ac 9d bb 92 33 35← zlib header78 da2 bd cc 06 98 41 e5 04 223 69 1c ab 20 fb ab 3c 004 3e 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 read-only • copy allowed#!/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}