Files
png-zip/unpack.py
Struchkov Mark 6e54e0f411 png-zip: pack/unpack files inside PNG images
Supports three packing modes:
- append (default) — data after IEND marker
- chunk — private pnZp PNG chunk
- lsb — least significant bits of pixels

Includes Python (pack.py/unpack.py) and PowerShell (pack.ps1/unpack.ps1)
implementations with full cross-compatibility.

Features: self-extract bootstrap, metadata one-liners (eXIf/tEXt),
CRC32 integrity check, PNG clean mode.
2026-02-21 14:48:13 +03:00

283 lines
8.5 KiB
Python

#!/usr/bin/env python3
"""Extract file(s) hidden inside a PNG image.
Supports three embedding modes (auto-detected):
append — data after IEND
chunk — data in private pnZp PNG chunk
lsb — data in least-significant bits of pixels
Usage:
python3 unpack.py packed.png [-o output_dir] [-a]
"""
import argparse
import os
import struct
import sys
import zlib
MAGIC = b"PNGZIP\x00\x01"
PNG_SIG = b"\x89PNG\r\n\x1a\n"
# ── Shared helpers ────────────────────────────────────────────────
def find_iend(data: bytes) -> int:
"""Return offset of the first byte AFTER the IEND chunk, or -1."""
idx = data.rfind(b"IEND")
if idx == -1:
return -1
chunk_start = idx - 4
length = struct.unpack(">I", data[chunk_start:chunk_start + 4])[0]
return chunk_start + 4 + 4 + length + 4
def parse_chunks(data: bytes):
"""Parse PNG data into list of (chunk_type, chunk_data)."""
pos = 8 # skip PNG signature
chunks = []
while pos + 8 <= len(data):
length = struct.unpack(">I", data[pos:pos + 4])[0]
chunk_type = data[pos + 4:pos + 8]
chunk_data = data[pos + 8:pos + 8 + length]
pos += 4 + 4 + length + 4
chunks.append((chunk_type, chunk_data))
return chunks
def parse_payload_blocks(data: bytes):
"""Yield (filename, raw_bytes) for each payload block in data."""
offset = 0
while True:
idx = data.find(MAGIC, offset)
if idx == -1:
break
pos = idx + len(MAGIC)
if pos + 2 > len(data):
break
name_len = struct.unpack(">H", data[pos:pos + 2])[0]
pos += 2
if pos + name_len > len(data):
break
filename = data[pos:pos + name_len].decode("utf-8")
pos += name_len
if pos + 8 > len(data):
break
payload_len = struct.unpack(">Q", data[pos:pos + 8])[0]
pos += 8
if pos + payload_len > len(data):
break
compressed = data[pos:pos + payload_len]
pos += payload_len
if pos + 4 > len(data):
break
stored_crc = struct.unpack(">I", data[pos:pos + 4])[0]
pos += 4
raw = zlib.decompress(compressed)
actual_crc = zlib.crc32(raw) & 0xFFFFFFFF
if actual_crc != stored_crc:
print(f"Warning: CRC mismatch for {filename}", file=sys.stderr)
yield filename, raw
offset = pos
# ── Append mode ───────────────────────────────────────────────────
def find_append_payloads(data: bytes):
"""Find payloads appended after IEND."""
iend = find_iend(data)
if iend == -1 or iend >= len(data):
return []
return list(parse_payload_blocks(data[iend:]))
# ── Chunk mode ────────────────────────────────────────────────────
def find_chunk_payloads(data: bytes):
"""Find payloads stored in pnZp chunks."""
if data[:8] != PNG_SIG:
return []
chunks = parse_chunks(data)
pnzp = [(cd[0], cd[1:]) for ct, cd in chunks if ct == b"pnZp" and len(cd) > 0]
if not pnzp:
return []
pnzp.sort(key=lambda x: x[0])
combined = b"".join(frag for _, frag in pnzp)
return list(parse_payload_blocks(combined))
# ── LSB mode ──────────────────────────────────────────────────────
def _paeth(a, b, c):
p = a + b - c
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
if pa <= pb and pa <= pc:
return a
return b if pb <= pc else c
def _unfilter_row(ftype, row, prev, bpp):
out = bytearray(len(row))
for i in range(len(row)):
x = row[i]
a = out[i - bpp] if i >= bpp else 0
b = prev[i]
c = prev[i - bpp] if i >= bpp else 0
if ftype == 0:
out[i] = x
elif ftype == 1:
out[i] = (x + a) & 0xFF
elif ftype == 2:
out[i] = (x + b) & 0xFF
elif ftype == 3:
out[i] = (x + (a + b) // 2) & 0xFF
elif ftype == 4:
out[i] = (x + _paeth(a, b, c)) & 0xFF
else:
raise ValueError(f"Unknown PNG filter type {ftype}")
return out
def find_lsb_payloads(data: bytes):
"""Find payloads encoded in LSB of pixel data."""
if data[:8] != PNG_SIG:
return []
try:
chunks = parse_chunks(data)
except Exception:
return []
# Parse IHDR
ihdr = None
for ct, cd in chunks:
if ct == b"IHDR":
ihdr = cd
break
if ihdr is None or len(ihdr) < 13:
return []
width, height = struct.unpack(">II", ihdr[:8])
bit_depth, color_type = ihdr[8], ihdr[9]
interlace = ihdr[12]
if bit_depth != 8 or color_type not in (2, 6) or interlace != 0:
return []
channels = 3 if color_type == 2 else 4
bpp = channels
# Decompress IDAT
idat_data = b"".join(cd for ct, cd in chunks if ct == b"IDAT")
if not idat_data:
return []
try:
raw = zlib.decompress(idat_data)
except zlib.error:
return []
# Unfilter
stride = width * bpp
pixels = bytearray()
prev = bytearray(stride)
pos = 0
for _ in range(height):
if pos >= len(raw):
return []
ftype = raw[pos]; pos += 1
if pos + stride > len(raw):
return []
row = raw[pos:pos + stride]; pos += stride
prev = _unfilter_row(ftype, row, prev, bpp)
pixels.extend(prev)
# Quick check: extract MAGIC-length bytes first
total_pixels = len(pixels)
magic_len = len(MAGIC)
if total_pixels < magic_len * 8:
return []
quick = bytearray(magic_len)
for i in range(magic_len):
b = 0
base = i * 8
for bit in range(8):
b = (b << 1) | (pixels[base + bit] & 1)
quick[i] = b
if quick != bytearray(MAGIC):
return []
# MAGIC found — extract all available bytes
total_bytes = total_pixels // 8
extracted = bytearray(total_bytes)
for i in range(total_bytes):
b = 0
base = i * 8
for bit in range(8):
b = (b << 1) | (pixels[base + bit] & 1)
extracted[i] = b
return list(parse_payload_blocks(bytes(extracted)))
# ── Auto-detect all modes ────────────────────────────────────────
def find_all_payloads(data: bytes):
"""Try all detection methods: append → chunk → lsb."""
results = find_append_payloads(data)
if results:
return results
results = find_chunk_payloads(data)
if results:
return results
results = find_lsb_payloads(data)
if results:
return results
return []
# ── Main ──────────────────────────────────────────────────────────
def main():
ap = argparse.ArgumentParser(description="Extract file from PNG")
ap.add_argument("image", help="PNG with hidden payload")
ap.add_argument("-o", "--outdir", default=".", help="Output directory")
ap.add_argument("-a", "--all", action="store_true",
help="Extract all payloads (including embedded unpack.py)")
ap.add_argument("-l", "--list", action="store_true",
help="List payloads without extracting")
args = ap.parse_args()
with open(args.image, "rb") as f:
data = f.read()
payloads = list(find_all_payloads(data))
if not payloads:
print("No payload found in this PNG.", file=sys.stderr)
sys.exit(3)
if args.list:
for name, raw in payloads:
print(f" {name} ({len(raw)} bytes)")
return
# By default extract only the last payload (the main one).
# With --all, extract everything.
to_extract = payloads if args.all else [payloads[-1]]
os.makedirs(args.outdir, exist_ok=True)
for filename, raw in to_extract:
safe_name = os.path.basename(filename) # path traversal protection
out_path = os.path.join(args.outdir, safe_name)
with open(out_path, "wb") as f:
f.write(raw)
print(f"Extracted: {out_path} ({len(raw)} bytes)")
if __name__ == "__main__":
main()