Neven Marić
Hidden Investigations CTF 2026

SteganographyHidden Investigations CTF 2026

Echoes of a Forgotten Canvas

A PNG with a truncated IHDR hides the first flag half in its clipped rows; its trailing encrypted ZIP contains a JPEG with a steghide payload whose own JPEG height is also truncated, hiding the second half.

  • steganography
  • png
  • jpeg
  • height-repair
  • zip-cracking
  • steghide
  • stegseek
  • fcrackzip
  • multi-layer

What this one teaches

  • Checking the IHDR height against the actual decompressed IDAT size to detect truncated PNGs.
  • Looking past IEND for appended data — a trailing PK header means a ZIP archive is tacked onto the PNG.
  • Applying the same height-extension trick to JPEGs when the decoder reports extraneous bytes before the EOI marker.

Task description

A seemingly ordinary file has surfaced with traces of something unusual. It may look complete at first glance, but appearances can be deceiving. Explore the artifact carefully and uncover what was left behind.

Artifact

The supplied echoes-of-a-forgotten-canvas.zip contains chall.png.

Approach

Restore the PNG height

The PNG IHDR declares 4000 x 4000, 8-bit RGBA, but its CRC is invalid. Concatenating and inflating its IDAT chunks produces 72,004,500 bytes. Each scanline uses one filter byte plus 4000 four-byte pixels:

72004500 / (1 + 4000 * 4) = 4500 rows

Change the big-endian height at byte offset 20 from 4000 to 4500. This also restores the original IHDR CRC, 4675412a, without changing the checksum. Composite the resulting RGBA image onto white: black text in its transparent bottom-right region otherwise disappears against a black viewer background.

The enlarged crop reads:

Enlarged crop of the restored PNG bottom showing the first flag fragment: hiCTF{mu1t!14y3r3d_

Carve and decrypt the appended ZIP

The PNG’s IEND chunk starts at offset 5,518,394 and ends at 5,518,406. The remaining 3,359,095 bytes begin with PK\x03\x04 and form an encrypted ZIP containing carrier.jpg. Save these bytes as trailing.zip.

An offline dictionary attack recovered its password, dreamcatcher:

fcrackzip -D -u -p rockyou.txt trailing.zip

Use relative filenames with fcrackzip; its password-file argument was truncated when given the long absolute workspace path.

Extract the JPEG’s steghide payload

The decrypted JPEG shows a poem titled “Wilder echoes”. An offline steghide dictionary attack found the passphrase profound, a word appearing twice in the poem:

stegseek carrier.jpg rockyou.txt carrier_payload.txt

Reproduce the extraction directly with the recovered password:

steghide extract -sf carrier.jpg -p profound -xf finall.jpg

Extend the final JPEG’s canvas

finall.jpg appears to be an ordinary alley photograph, 495 x 647 pixels. However, the JPEG decoder reports:

Corrupt JPEG data: 3374 extraneous bytes before marker 0xd9

Its baseline SOF0 marker (FF C0) starts at byte offset 194. The height field starts five bytes after the marker, at offset 199, and contains 02 87 (647). Set this field to 03 20 (800) to expose the remaining encoded rows.

The recovered strip reads:

Enlarged crop of the extended JPEG bottom showing the second flag fragment: st3g0o_ju5t_l0v333}

Deterministic extraction script

This script reproduces the full extraction chain from the supplied ZIP using the recovered passwords, without needing a dictionary attack:

from pathlib import Path
import io
import struct
import subprocess
import zipfile
import zlib

from PIL import Image

ROOT = Path(__file__).resolve().parent
OUT = ROOT / "solve"


def main():
    OUT.mkdir(exist_ok=True)
    with zipfile.ZipFile(ROOT / "echoes-of-a-forgotten-canvas.zip") as archive:
        data = bytearray(archive.read("chall.png"))

    # Concatenate IDAT chunks and find IEND
    compressed = bytearray()
    offset = 8
    while offset < len(data):
        length = struct.unpack_from(">I", data, offset)[0]
        kind = data[offset + 4 : offset + 8]
        if kind == b"IDAT":
            compressed.extend(data[offset + 8 : offset + 8 + length])
        offset += length + 12
        if kind == b"IEND":
            break

    # Carve trailing ZIP after IEND
    (OUT / "trailing.zip").write_bytes(data[offset:])

    # Fix IHDR height from decompressed size
    width = struct.unpack_from(">I", data, 16)[0]
    raw = zlib.decompress(compressed)
    height, remainder = divmod(len(raw), 1 + width * 4)
    assert remainder == 0
    struct.pack_into(">I", data, 20, height)
    assert zlib.crc32(data[12:29]) == struct.unpack_from(">I", data, 29)[0]
    (OUT / "restored.png").write_bytes(data[:offset])

    # Composite RGBA onto white to reveal black text in transparent region
    rgba = Image.open(io.BytesIO(data[:offset])).convert("RGBA")
    white = Image.new("RGBA", rgba.size, "white")
    white.alpha_composite(rgba)

    # Decrypt trailing ZIP with recovered password
    with zipfile.ZipFile(OUT / "trailing.zip") as archive:
        (OUT / "carrier.jpg").write_bytes(
            archive.read("carrier.jpg", pwd=b"dreamcatcher")
        )

    # Extract steghide payload with recovered passphrase
    subprocess.run(
        ["steghide", "extract", "-sf", "carrier.jpg",
         "-p", "profound", "-xf", "finall.jpg", "-f"],
        cwd=OUT, check=True,
    )

    # Extend JPEG height to reveal hidden rows
    jpeg = bytearray((OUT / "finall.jpg").read_bytes())
    sof = jpeg.index(b"\xff\xc0")
    struct.pack_into(">H", jpeg, sof + 5, 800)
    (OUT / "finall_extended.jpg").write_bytes(jpeg)


if __name__ == "__main__":
    main()

Lessons

  • Multi-layer steganography: each container conceals the next. The PNG height hides the first flag half, the trailing encrypted ZIP holds a JPEG with a steghide payload, and that payload’s own JPEG height is also truncated to hide the second half.
  • When a JPEG decoder warns about extraneous bytes before the EOI marker, the same height-extension trick that works on PNGs applies — increase the SOF0 height field and re-decode.
  • For dictionary attacks, try words that appear in the artifact itself; the steghide passphrase profound is in the poem visible on carrier.jpg.
The flagRevealhiCTF{mu1t!14y3r3d_st3g0o_ju5t_l0v333}

This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.