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.
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:

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:

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
profoundis in the poem visible oncarrier.jpg.
The flagReveal
hiCTF{mu1t!14y3r3d_st3g0o_ju5t_l0v333}This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.