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.
463 lines
17 KiB
Python
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()
|