Files
png-zip/pack.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

463 lines
17 KiB
Python

#!/usr/bin/env python3
"""Pack a file inside a PNG image.
Modes:
append (default) — data after IEND
chunk — data in private pnZp PNG chunk
lsb — data in least-significant bits of pixels
Usage:
python3 pack.py cover.png payload [-o output.png] [-m mode] [--self-extract]
"""
import argparse
import os
import struct
import sys
import zlib
MAGIC = b"PNGZIP\x00\x01"
PNG_SIG = b"\x89PNG\r\n\x1a\n"
CHUNK_MAX = 2 * 1024 * 1024 # 2 MB per pnZp chunk
BOOTSTRAP_MARKER = "png-zip bootstrap"
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 field before "IEND"
length = struct.unpack(">I", data[chunk_start:chunk_start + 4])[0]
return chunk_start + 4 + 4 + length + 4 # len + type + data + crc
def make_payload_block(filename: str, raw: bytes) -> bytes:
compressed = zlib.compress(raw, 6)
name_bytes = os.path.basename(filename).encode("utf-8")
header = MAGIC
header += struct.pack(">H", len(name_bytes))
header += name_bytes
header += struct.pack(">Q", len(compressed))
crc = struct.pack(">I", zlib.crc32(raw) & 0xFFFFFFFF)
return header + compressed + crc
# ── PNG chunk helpers ─────────────────────────────────────────────
def parse_chunks(data: bytes):
"""Parse PNG data into list of (chunk_type, chunk_data)."""
if data[:8] != PNG_SIG:
raise ValueError("Not a valid PNG")
pos = 8
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 # len + type + data + crc
chunks.append((chunk_type, chunk_data))
return chunks
def make_png_chunk(chunk_type: bytes, chunk_data: bytes) -> bytes:
"""Build a valid PNG chunk: length + type + data + CRC32."""
length = struct.pack(">I", len(chunk_data))
crc = struct.pack(">I", zlib.crc32(chunk_type + chunk_data) & 0xFFFFFFFF)
return length + chunk_type + chunk_data + crc
def assemble_png(chunks) -> bytes:
"""Reassemble PNG from list of (type, data) tuples."""
result = bytearray(PNG_SIG)
for chunk_type, chunk_data in chunks:
result += make_png_chunk(chunk_type, chunk_data)
return bytes(result)
# ── Chunk mode ────────────────────────────────────────────────────
def pack_chunk(cover_bytes: bytes, payload_block: bytes) -> bytes:
chunks = parse_chunks(cover_bytes)
# Split payload into ≤ 2 MB fragments
pnzp_chunks = []
for seq, offset in enumerate(range(0, len(payload_block), CHUNK_MAX)):
fragment = payload_block[offset:offset + CHUNK_MAX]
pnzp_chunks.append((b"pnZp", struct.pack("B", seq) + fragment))
# Insert pnZp chunk(s) before IEND
result = []
for ct, cd in chunks:
if ct == b"IEND":
result.extend(pnzp_chunks)
result.append((ct, cd))
return assemble_png(result)
# ── 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 pack_lsb(cover_bytes: bytes, payload_block: bytes) -> bytes:
chunks = parse_chunks(cover_bytes)
# ── IHDR ──
ihdr = next(cd for ct, cd in chunks if ct == b"IHDR")
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):
print("Error: LSB mode requires 8-bit RGB or RGBA PNG", file=sys.stderr)
sys.exit(1)
if interlace != 0:
print("Error: LSB mode does not support interlaced PNG", file=sys.stderr)
sys.exit(1)
channels = 3 if color_type == 2 else 4
bpp = channels
# ── Collect & decompress IDAT ──
idat_data = b"".join(cd for ct, cd in chunks if ct == b"IDAT")
raw = zlib.decompress(idat_data)
# ── Unfilter ──
stride = width * bpp
pixels = bytearray()
prev = bytearray(stride)
pos = 0
for _ in range(height):
ftype = raw[pos]; pos += 1
row = raw[pos:pos + stride]; pos += stride
prev = _unfilter_row(ftype, row, prev, bpp)
pixels.extend(prev)
# ── Capacity check ──
capacity = len(pixels) # 1 bit per byte
needed = len(payload_block) * 8
if needed > capacity:
cap_kb = capacity // 8 // 1024
need_kb = len(payload_block) // 1024
print(f"Error: payload ({need_kb} KB) exceeds LSB capacity ({cap_kb} KB)",
file=sys.stderr)
sys.exit(1)
# ── Embed bits into LSB ──
bit_idx = 0
for byte in payload_block:
for shift in range(7, -1, -1):
pixels[bit_idx] = (pixels[bit_idx] & 0xFE) | ((byte >> shift) & 1)
bit_idx += 1
# ── Re-filter (type 0 = None) & compress ──
filtered = bytearray()
for y in range(height):
filtered.append(0)
filtered.extend(pixels[y * stride:(y + 1) * stride])
compressed = zlib.compress(bytes(filtered), 6)
# ── Reassemble PNG ──
result = []
idat_done = False
for ct, cd in chunks:
if ct == b"IDAT":
if not idat_done:
result.append((b"IDAT", compressed))
idat_done = True
else:
result.append((ct, cd))
return assemble_png(result)
# ── Clean mode ────────────────────────────────────────────────────
def clean_png(data: bytes) -> bytes:
"""Strip all embedded payloads (append + chunk) and bootstrap metadata from a PNG."""
marker = BOOTSTRAP_MARKER.encode("latin-1")
chunks = parse_chunks(data)
cleaned = []
for ct, cd in chunks:
if ct == b"pnZp":
continue # strip chunk-mode payloads
if ct == b"tEXt" and cd.startswith(b"Comment\x00") and marker in cd:
continue # strip old bootstrap tEXt comment
if ct == b"eXIf" and b"ASCII\x00\x00\x00" + marker in cd:
continue # strip old bootstrap eXIf
cleaned.append((ct, cd))
if ct == b"IEND":
break # stop here — strips append-mode trailing data
return assemble_png(cleaned)
def build_exif_user_comment(text: str) -> bytes:
"""Build minimal eXIf chunk data with UserComment field."""
user_comment = b"ASCII\x00\x00\x00" + text.encode("ascii")
uc_len = len(user_comment)
# TIFF header (8) + IFD0: count(2) + entry(12) + next(4) = 18
# ExifIFD: count(2) + entry(12) + next(4) = 18
# Total fixed = 8 + 18 + 18 = 44, then UserComment data
exif_ifd_offset = 26
uc_data_offset = 44
buf = bytearray()
buf += b"MM"
buf += struct.pack(">H", 42)
buf += struct.pack(">I", 8) # IFD0 offset
buf += struct.pack(">H", 1) # IFD0: 1 entry
buf += struct.pack(">HHI", 0x8769, 4, 1) # ExifIFD pointer tag
buf += struct.pack(">I", exif_ifd_offset)
buf += struct.pack(">I", 0) # next IFD = none
buf += struct.pack(">H", 1) # ExifIFD: 1 entry
buf += struct.pack(">HHI", 0x9286, 7, uc_len) # UserComment tag
buf += struct.pack(">I", uc_data_offset)
buf += struct.pack(">I", 0) # next IFD = none
buf += user_comment
return bytes(buf)
def _make_py_b64(fname: str, last: bool = False) -> str:
"""Generate base64-encoded Python bootstrap script for CMD."""
import base64
find = "d.rfind(m)" if last else "d.find(m)"
script = (
"import zlib\n"
"d=open('" + fname + "','rb').read()\n"
"m=b'PNGZIP'+bytes([0,1])\n"
"i=" + find + "\n"
"nl=int.from_bytes(d[i+8:i+10],'big')\n"
"n=d[i+10:i+10+nl].decode()\n"
"pl=int.from_bytes(d[i+10+nl:i+18+nl],'big')\n"
"open(n,'wb').write(zlib.decompress(d[i+18+nl:i+18+nl+pl]))\n"
"print('Extracted:',n)"
)
return base64.b64encode(script.encode()).decode()
# Common PowerShell payload-extraction tail (after $i is set to MAGIC position)
_PS_EXTRACT_TAIL = (
"$p=$i+8;"
"$nl=$d[$p]*256+$d[$p+1];$p+=2;"
"$n=[Text.Encoding]::UTF8.GetString($d,$p,$nl);$p+=$nl;"
"$pl=[uint64]0;for($k=0;$k-lt8;$k++){$pl=$pl*256+$d[$p+$k]};$p+=8;"
"$ms=New-Object IO.MemoryStream;$ms.Write($d,$p+2,$pl-6);"
"[void]$ms.Seek(0,0);"
"$ds=New-Object IO.Compression.DeflateStream($ms,"
"[IO.Compression.CompressionMode]::Decompress);"
"$os=New-Object IO.MemoryStream;$ds.CopyTo($os);"
"[IO.File]::WriteAllBytes($n,$os.ToArray());"
'Write-Host "Extracted: $n"'
)
# PowerShell: find FIRST MAGIC (for bootstrap — extracts unpacker)
_PS_FIND_FIRST = (
"$s='PNGZIP';$i=-1;"
"do{$i=$t.IndexOf($s,$i+1,[StringComparison]::Ordinal)}"
"while($i-ge0-and($d[$i+6]-ne0-or$d[$i+7]-ne1));"
)
# PowerShell: find LAST MAGIC (for direct extraction — extracts payload)
_PS_FIND_LAST = (
"$s='PNGZIP';$i=$d.Length;"
"do{$i=$t.LastIndexOf($s,$i-1,[StringComparison]::Ordinal)}"
"while($i-ge0-and($d[$i+6]-ne0-or$d[$i+7]-ne1));"
)
_PS_READ_HEAD = (
"$d=[IO.File]::ReadAllBytes('IMG');"
"$t=[Text.Encoding]::GetEncoding(28591).GetString($d);"
)
def _make_ps_encoded(fname: str, last: bool = False) -> str:
"""Generate base64-encoded PowerShell bootstrap script for CMD."""
import base64
find = _PS_FIND_LAST if last else _PS_FIND_FIRST
script = _PS_READ_HEAD.replace("IMG", fname) + find + _PS_EXTRACT_TAIL
return base64.b64encode(script.encode("utf-16-le")).decode()
def _py_oneliner(fname: str, last: bool = False) -> str:
"""Python one-liner for shell (Linux/macOS/PowerShell)."""
find = "d.rfind" if last else "d.find"
return (
"python3 -c \"d=open('IMG','rb').read();"
"i=" + find + "(b'PNGZIP\\x00\\x01');"
"nl=int.from_bytes(d[i+8:i+10],'big');"
"n=d[i+10:i+10+nl].decode();"
"pl=int.from_bytes(d[i+10+nl:i+18+nl],'big');"
"import zlib;"
"open(n,'wb').write(zlib.decompress(d[i+18+nl:i+18+nl+pl]));"
"print('Extracted:',n)\""
).replace("IMG", fname)
def _ps_oneliner(fname: str, last: bool = False) -> str:
"""PowerShell one-liner."""
find = _PS_FIND_LAST if last else _PS_FIND_FIRST
return (_PS_READ_HEAD + find + _PS_EXTRACT_TAIL).replace("IMG", fname)
def _add_oneliner_block(parts: list, fname: str, last: bool, label: str):
"""Append a full set of 4 one-liners to parts list."""
parts.append("")
parts.append(f"=== {label} ===")
parts.append("")
parts.append("Python:")
parts.append(_py_oneliner(fname, last))
parts.append("")
parts.append("Python (Windows CMD):")
parts.append(
'python -c "import base64;exec(base64.b64decode(\''
+ _make_py_b64(fname, last) + '\'))"')
parts.append("")
parts.append("PowerShell:")
parts.append(_ps_oneliner(fname, last))
parts.append("")
parts.append("PowerShell (Windows CMD):")
parts.append("powershell -EncodedCommand " + _make_ps_encoded(fname, last))
def build_bootstrap_comment(output_name: str, has_self_extract: bool = True) -> bytes:
"""Build tEXt chunk data with all bootstrap one-liners for PNG metadata."""
fname = os.path.basename(output_name)
parts = [BOOTSTRAP_MARKER]
if has_self_extract:
_add_oneliner_block(parts, fname, last=False,
label="Extract unpacker (first payload)")
_add_oneliner_block(parts, fname, last=True,
label="Extract file (last payload)")
text = "\n".join(parts)
return b"Comment\x00" + text.encode("latin-1")
# ── Main ──────────────────────────────────────────────────────────
def main():
ap = argparse.ArgumentParser(description="Pack file into PNG")
ap.add_argument("cover", help="Cover PNG image")
ap.add_argument("payload", nargs="?", help="File to hide")
ap.add_argument("-o", "--output", default="packed.png", help="Output PNG")
ap.add_argument("-m", "--mode", choices=("append", "chunk", "lsb"),
default="append", help="Embedding mode (default: append)")
ap.add_argument("--clean", action="store_true",
help="Strip all embedded payloads from PNG")
ap.add_argument("--self-extract", action="store_true",
help="Embed unpack.py for bootstrap (append mode only)")
ap.add_argument("--self-extract-ps", action="store_true",
help="Embed unpack.ps1 for PowerShell bootstrap (append mode only)")
args = ap.parse_args()
with open(args.cover, "rb") as f:
cover = f.read()
if cover[:8] != PNG_SIG:
print("Error: not a valid PNG", file=sys.stderr)
sys.exit(1)
# ── Clean mode ──
if args.clean:
result = clean_png(cover)
out = args.output if args.payload is None else args.output
with open(out, "wb") as f:
f.write(result)
before_kb = len(cover) / 1024
after_kb = len(result) / 1024
print(f"Cleaned: {out} ({after_kb:.1f} KB, was {before_kb:.1f} KB)")
return
# ── Pack mode — payload required ──
if args.payload is None:
print("Error: payload argument is required (unless --clean)", file=sys.stderr)
sys.exit(1)
if (args.self_extract or args.self_extract_ps) and args.mode != "append":
print("Error: --self-extract/--self-extract-ps works only with append mode",
file=sys.stderr)
sys.exit(1)
# Strip any existing payloads (append + chunk) before packing
cover = clean_png(cover)
with open(args.payload, "rb") as f:
raw = f.read()
block = make_payload_block(args.payload, raw)
if args.mode == "chunk":
result = pack_chunk(cover, block)
elif args.mode == "lsb":
result = pack_lsb(cover, block)
else: # append
se_blocks = b""
for flag, name in [
(args.self_extract, "unpack.py"),
(args.self_extract_ps, "unpack.ps1"),
]:
if flag:
path = os.path.join(os.path.dirname(__file__), name)
if not os.path.exists(path):
print(f"Error: {name} not found next to pack.py", file=sys.stderr)
sys.exit(1)
with open(path, "rb") as f:
se_blocks += make_payload_block(name, f.read())
# Embed bootstrap one-liners in PNG metadata (eXIf + tEXt)
if args.self_extract or args.self_extract_ps:
comment_text = build_bootstrap_comment(
args.output,
has_self_extract=args.self_extract or args.self_extract_ps)
exif_data = build_exif_user_comment(
comment_text.split(b"\x00", 1)[1].decode("latin-1"))
chunks = parse_chunks(cover)
new_chunks = []
for ct, cd in chunks:
new_chunks.append((ct, cd))
if ct == b"IHDR":
new_chunks.append((b"eXIf", exif_data))
new_chunks.append((b"tEXt", comment_text))
cover = assemble_png(new_chunks)
result = cover + se_blocks + block
with open(args.output, "wb") as f:
f.write(result)
cover_kb = len(cover) / 1024
total_kb = len(result) / 1024
print(f"Packed [{args.mode}]: {args.output} ({total_kb:.1f} KB, cover {cover_kb:.1f} KB)")
if args.self_extract:
print("Self-extract: unpack.py embedded")
if args.self_extract_ps:
print("Self-extract: unpack.ps1 embedded")
if args.self_extract or args.self_extract_ps:
text = comment_text.split(b"\x00", 1)[1].decode("latin-1")
print("\n" + text.replace(BOOTSTRAP_MARKER + "\n", "Bootstrap one-liners:\n", 1))
if __name__ == "__main__":
main()