← Back to Writeups
CUSTOMMediumForensics

Ever-broken

XESXOR9/2/20265 min read
#forensics#custom#medium#ihdr_crc_recalculation#ocr_extraction#png_dimension_bruteforce#png_signature_repair#linux

Ever-broken

Platform: hackerlab | Category: Forensics | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-04-05 | Status: Solved Techniques: ihdr_crc_recalculation, ocr_extraction, png_dimension_bruteforce, png_signature_repair

Summary

Task: recover a corrupted PNG stored as an untyped data file where both the signature and IHDR metadata are damaged. Solution: repair the magic bytes and CRC, brute-force valid image dimensions from decompressed scanlines, then OCR the restored image to read the flag.

Ever-broken — Hackerlab

Description

Ever-broken

English summary: the challenge provided a file named task that no longer opened as an image. The goal was to recover the original PNG, repair enough metadata for a viewer to parse it correctly, and extract the CODEBY{...} flag from the restored picture.

Analysis

Reconnaissance

The first clue was that the file did not identify cleanly as a known format:

bash
$ file task
task: data

$ strings task
IHDR
pHYs
IDATx
IEND

Even though file only said data, the chunk names in strings strongly suggested a broken PNG container. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Broken PNG signature

Dumping the first bytes confirmed that the PNG signature was almost correct, but the last byte was wrong:

bash
$ xxd -l 64 task
00000000: 8950 4e47 0d0a 1aa0 0000 000d 4948 4452  .PNG........IHDR
00000010: 0000 0200 0000 0300 0800 0000 00a4 5824  ..............X$

Valid PNG magic bytes should be:

text
89 50 4e 47 0d 0a 1a 0a

but the file had:

text
89 50 4e 47 0d 0a 1a a0

So the first repair step was simply restoring the last signature byte from a0 to 0a. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

IHDR inconsistency and wrong dimensions

After fixing the signature, the next problem was the IHDR chunk. Parsing it showed width and height values of 512 x 768, grayscale, 8-bit, but the CRC no longer matched:

text
width=512, height=768, bit_depth=8, color_type=0
stored CRC = a45824bf
calc   CRC = 77648092

Recomputing the IHDR CRC fixed that part, but pngcheck still rejected the file with invalid row-filter values in the decompressed image stream. That is a classic sign that the raw IDAT bytes are being split into scanlines at the wrong boundaries, so the image dimensions in IHDR are also corrupted. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Because the PNG was 8-bit grayscale, each row should be:

text
1 filter byte + width bytes of pixel data

If the dimensions are correct, then every scanline must begin with a valid filter byte in the range 0..4. Brute-forcing plausible width / height pairs against the decompressed IDAT stream quickly reveals the correct geometry.

The correct dimensions were:

  • width = 750
  • height = 1000

That also explained the damaged original fields: the header contained rounded or zeroed values 512 x 768 instead of the real 750 x 1000. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

After updating those two fields and recomputing the IHDR CRC again, the file validated successfully with:

bash
$ pngcheck -vp restored.png
750 x 1000 image, 8-bit grayscale, non-interlaced
No errors detected

The restored image visibly contained the flag, and OCR made it easy to read reliably.

Solution

  1. Run file task and see only data.
  2. Run strings task and notice IHDR, pHYs, IDAT, and IEND, which identify the file as a damaged PNG.
  3. Inspect the first bytes with xxd -l 64 task and repair the final byte of the PNG signature.
  4. Recompute the IHDR CRC.
  5. Test the partially repaired file with pngcheck; invalid row-filter values indicate the dimensions are still wrong.
  6. Decompress the IDAT payload and brute-force dimensions so every scanline starts with a legal filter byte.
  7. Replace the IHDR width and height with 750 x 1000 and recompute the CRC again.
  8. Validate the final file with pngcheck -vp restored.png.
  9. Open the image or run tesseract restored.png stdout to recover the flag text. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍
python
#!/usr/bin/env python3
import binascii
import struct
import zlib

PNG_SIG = b"\x89PNG\r\n\x1a\n"


def parse_chunks(data: bytes):
    off = 8
    chunks = []
    while off < len(data):
        length = struct.unpack(">I", data[off:off + 4])[0]
        ctype = data[off + 4:off + 8]
        cdata = data[off + 8:off + 8 + length]
        crc = struct.unpack(">I", data[off + 8 + length:off + 12 + length])[0]
        chunks.append([off, length, ctype, cdata, crc])
        off += 12 + length
        if ctype == b"IEND":
            break
    return chunks
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍


def crc32_png(ctype: bytes, cdata: bytes) -> int:
    return binascii.crc32(ctype + cdata) & 0xFFFFFFFF


def rebuild_png(chunks):
    out = bytearray(PNG_SIG)
    for _, length, ctype, cdata, _ in chunks:
        out += struct.pack(">I", len(cdata))
        out += ctype
        out += cdata
        out += struct.pack(">I", crc32_png(ctype, cdata))
    return bytes(out)


with open("task", "rb") as f:
    data = bytearray(f.read())

# Step 1: fix signature
data[:8] = PNG_SIG

chunks = parse_chunks(data)
ihdr = bytearray(chunks[0][3])
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# Existing IHDR says: 512 x 768, 8-bit grayscale
old_width, old_height, bit_depth, color_type, comp, filt, interlace = struct.unpack(">IIBBBBB", ihdr)
print(f"Original IHDR: {old_width}x{old_height}, bit_depth={bit_depth}, color_type={color_type}")
assert bit_depth == 8 and color_type == 0 and comp == 0 and filt == 0

# Step 2: inflate concatenated IDAT stream
idat = b"".join(cdata for _, _, ctype, cdata, _ in chunks if ctype == b"IDAT")
raw = zlib.decompress(idat)

# Step 3: brute-force dimensions
candidates = []
for height in range(1, 5000):
    if len(raw) % height != 0:
        continue
    rowlen = len(raw) // height
    width = rowlen - 1  # grayscale: 1 filter byte + width bytes
    if width <= 0:
        continue
    ok = True
    for y in range(height):
        if raw[y * rowlen] > 4:
            ok = False
            break
    if ok:
        candidates.append((width, height))
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

print("Candidates:", candidates[:20])

# Correct pair from the valid scanline layout
width, height = 750, 1000
chunks[0][3] = struct.pack(">IIBBBBB", width, height, bit_depth, color_type, comp, filt, interlace)

# Step 4: rebuild PNG with corrected CRCs
restored = rebuild_png(chunks)
with open("restored.png", "wb") as f:
    f.write(restored)

print("Wrote restored.png")
print("Flag: CODEBY{REDACTED}")

Useful validation and extraction commands:

bash
xxd -l 64 task
strings task
pngcheck -vp restored.png
tesseract restored.png stdout

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