From 6e54e0f4112966747c2e92790af489b7fd043e29 Mon Sep 17 00:00:00 2001 From: Struchkov Mark Date: Sat, 21 Feb 2026 14:48:13 +0300 Subject: [PATCH] png-zip: pack/unpack files inside PNG images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 3 + POWERSHELL.md | 80 +++++++++ README.md | 193 +++++++++++++++++++++ pack.ps1 | 271 +++++++++++++++++++++++++++++ pack.py | 462 ++++++++++++++++++++++++++++++++++++++++++++++++++ test.txt | 1 + unpack.ps1 | 90 ++++++++++ unpack.py | 282 ++++++++++++++++++++++++++++++ 8 files changed, 1382 insertions(+) create mode 100644 .gitignore create mode 100644 POWERSHELL.md create mode 100644 README.md create mode 100644 pack.ps1 create mode 100644 pack.py create mode 100644 test.txt create mode 100644 unpack.ps1 create mode 100644 unpack.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0ab14f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +.DS_Store +*.pyc diff --git a/POWERSHELL.md b/POWERSHELL.md new file mode 100644 index 0000000..a616523 --- /dev/null +++ b/POWERSHELL.md @@ -0,0 +1,80 @@ +# png-zip: PowerShell версия + +Упаковка и распаковка файлов внутри PNG без Python. Только PowerShell 5.1+ (встроен в Windows 10/11). + +## Упаковка + +```powershell +powershell -File pack.ps1 cover.png secret.txt -Output packed.png +``` + +### Self-extract + +Встраивает `unpack.ps1` внутрь PNG — на целевой машине не нужны никакие скрипты: + +```powershell +powershell -File pack.ps1 cover.png secret.txt -Output bootstrap.png -SelfExtract +``` + +Внутри `bootstrap.png`: `unpack.ps1` + `secret.txt`. Извлечение на целевой машине — см. раздел «Bootstrap». + +При `-SelfExtract` все bootstrap-однострочники (Python, Python CMD, PowerShell, PowerShell CMD) автоматически сохраняются в метаданных PNG — eXIf (EXIF UserComment) и tEXt (Comment). Их видно в свойствах файла: + +- **Windows**: правый клик → Свойства → Details → Comments +- **PowerShell**: `exiftool bootstrap.png` + +> Base64/EncodedCommand варианты генерируются динамически с реальным именем выходного файла. + +## Распаковка + +```powershell +powershell -File unpack.ps1 packed.png +powershell -File unpack.ps1 packed.png -OutDir .\out +powershell -File unpack.ps1 packed.png -List +powershell -File unpack.ps1 packed.png -All +``` + +## Bootstrap: доставка без ничего + +На целевой машине есть только `bootstrap.png` (созданный с `-SelfExtract`) и PowerShell. + +### 1. Извлечь unpack.ps1 однострочником + +Замените `IMG` на имя вашего файла: + +```powershell +$d=[IO.File]::ReadAllBytes('IMG');$t=[Text.Encoding]::GetEncoding(28591).GetString($d);$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));$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" +``` + +Ищет первый `PNGZIP` с байтами `\x00\x01` после — это встроенный `unpack.ps1`. + +**Windows CMD** — готовый EncodedCommand-однострочник (с уже подставленным именем файла) выводится при паковке и сохраняется в метаданных PNG. Скопируйте из консоли или из свойств файла (Details → Comments). + +### 2. Извлечь основной файл + +```powershell +powershell -File unpack.ps1 bootstrap.png +``` + +### Однострочник (без bootstrap) + +Извлечь последний payload напрямую (если self-extract не использовался). Замените `IMG` на имя файла: + +```powershell +$d=[IO.File]::ReadAllBytes('IMG');$t=[Text.Encoding]::GetEncoding(28591).GetString($d);$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));$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" +``` + +## Очистка PNG + +Удалить все встроенные данные (payload, bootstrap-метаданные eXIf/tEXt): + +```powershell +powershell -File pack.ps1 packed.png -Clean -Output clean.png +``` + +## Ограничения + +- Только append-режим. Для chunk и lsb используйте Python-версию (`pack.py` / `unpack.py`). +- CRC32 и Adler32 реализованы через inline C# (`Add-Type`), первый запуск чуть медленнее из-за компиляции. +- Для файлов > 100 MB может быть медленнее Python-версии. +- Формат payload полностью совместим с Python-версией — можно паковать на одной ОС, распаковывать на другой. diff --git a/README.md b/README.md new file mode 100644 index 0000000..dfbdb52 --- /dev/null +++ b/README.md @@ -0,0 +1,193 @@ +# png-zip + +Утилита для упаковки произвольных файлов внутрь PNG-изображений. Выходной файл — валидный PNG, который открывается в любом просмотрщике как обычная картинка. + +Зависимости: Python 3.6+ (только стандартная библиотека). + +## Режимы упаковки + +| Режим | Флаг | Где хранится payload | Устойчивость | +|-------|------|---------------------|-------------| +| **append** (по умолчанию) | `-m append` | После IEND маркера | Базовые проверки `file`, imagemagick | +| **chunk** | `-m chunk` | Приватный PNG-чанк `pnZp` | Строгая PNG-валидация (libpng, pngcheck) | +| **lsb** | `-m lsb` | Младшие биты пикселей | CDR, re-encode, строгие валидаторы | + +### append (по умолчанию) +PNG-файлы заканчиваются маркером IEND. Все PNG-декодеры игнорируют данные после него. Утилита дописывает сжатый payload после IEND. + +``` +[PNG: signature + chunks + IEND] [magic + имя файла + zlib-payload + CRC32] +``` + +### chunk +Payload упаковывается в приватный PNG-чанк `pnZp` перед IEND. Файл полностью валиден по спецификации PNG — приватные чанки разрешены и игнорируются декодерами. Если payload > 2 MB, разбивается на несколько чанков с sequence number. + +``` +[signature] [IHDR] ... [pnZp: payload] [IEND] +``` + +### lsb +Payload записывается в младшие биты (LSB) каждого байта пикселей. Визуально изображение неотличимо от оригинала (изменение ±1 в каждом канале). Поддерживаются 8-bit RGB и RGBA PNG без interlacing. + +Ёмкость: `width × height × channels / 8` байт. Например, 1000×1000 RGB = ~375 KB. + +## Использование + +### Упаковка + +```bash +# Append mode (по умолчанию) +python3 pack.py cover.png secret.tar.gz -o output.png + +# Chunk mode — проходит строгую PNG-валидацию +python3 pack.py -m chunk cover.png secret.tar.gz -o output.png + +# LSB mode — выживает CDR и re-encode +python3 pack.py -m lsb cover.png secret.tar.gz -o output.png +``` + +`output.png` выглядит как `cover.png`, но содержит `secret.tar.gz` внутри. + +### Распаковка + +```bash +python3 unpack.py output.png +``` + +Извлекает файл с оригинальным именем в текущую директорию. Режим определяется автоматически. + +### Распаковка в заданную директорию + +```bash +python3 unpack.py output.png -o ./extracted/ +``` + +### Просмотр содержимого + +```bash +python3 unpack.py output.png -l +``` + +``` + secret.tar.gz (148230 bytes) +``` + +### Извлечь все вложенные файлы + +```bash +python3 unpack.py output.png -a +``` + +Полезно для self-extract образов, где внутри несколько файлов (unpack.py + payload). + +## Bootstrap: доставка на VM без ничего + +Если на целевой машине нет `unpack.py` и передать можно только картинки: + +### 1. На хосте — создаём bootstrap-образ + +```bash +# Встроить unpack.py (для машин с Python) +python3 pack.py --self-extract cover.png secret.tar.gz -o bootstrap.png + +# Встроить unpack.ps1 (для Windows без Python) +python3 pack.py --self-extract-ps cover.png secret.tar.gz -o bootstrap.png + +# Встроить оба распаковщика +python3 pack.py --self-extract --self-extract-ps cover.png secret.tar.gz -o bootstrap.png +``` + +> `--self-extract` / `--self-extract-ps` работают только с append режимом. + +При использовании любого `--self-extract` флага bootstrap-однострочники автоматически сохраняются в метаданных PNG — в eXIf (EXIF UserComment) и tEXt (Comment) чанках. Их можно посмотреть: + +- **Windows**: правый клик → Свойства → Details → Comments (читает eXIf) +- **macOS/Linux**: `identify -verbose bootstrap.png | grep -A 20 Comment` +- **Любая ОС**: `exiftool bootstrap.png` + +> Base64/EncodedCommand варианты генерируются динамически — в метаданных будет реальное имя выходного файла, а не `bootstrap.png`. Примеры ниже приведены для имени `bootstrap.png`. + +### 2. Передаём `bootstrap.png` на VM + +Это валидный PNG — пройдёт любую проверку на изображение. + +### 3. На VM — извлекаем распаковщик однострочником + +Однострочник извлекает **первый** встроенный файл (распаковщик). Замените `IMG` на имя вашего файла: + +**Python (Linux / macOS / PowerShell):** + +```bash +python3 -c "d=open('IMG','rb').read();i=d.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)" +``` + +**PowerShell (без Python):** + +```powershell +$d=[IO.File]::ReadAllBytes('IMG');$t=[Text.Encoding]::GetEncoding(28591).GetString($d);$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));$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" +``` + +**Windows CMD** — готовые однострочники с base64/EncodedCommand (не нужно менять имя файла) выводятся при паковке и сохраняются в метаданных PNG. Скопируйте из: +- Вывода `pack.py` / `pack.ps1` в консоли +- Свойств файла: правый клик → Свойства → Details → Comments + +### 4. Извлекаем основной файл + +```bash +# Python +python3 unpack.py bootstrap.png + +# PowerShell +powershell -File unpack.ps1 bootstrap.png +``` + +Дальше распаковщик уже есть на VM и можно передавать обычные packed-образы. + +### Очистка PNG от встроенных данных + +Удалить все payload'ы (append, chunk) и bootstrap-метаданные (eXIf, tEXt) из PNG: + +```bash +python3 pack.py --clean packed.png -o clean.png +``` + +После очистки PNG возвращается к исходному виду (без trailing data, без pnZp-чанков, без bootstrap-комментариев). + +## Советы + +**Упаковка нескольких файлов** — сначала создайте архив: +```bash +tar czf bundle.tar.gz file1.txt file2.bin dir/ +python3 pack.py cover.png bundle.tar.gz -o output.png +``` + +**Выбор обложки** — используйте любой PNG. Чем крупнее картинка, тем естественнее выглядит увеличение размера файла. Для LSB режима нужна достаточно большая картинка (ёмкость ≈ `W×H×channels/8` байт). + +**Проверка целостности** — при распаковке автоматически проверяется CRC32. Если файл повреждён при передаче, будет предупреждение. + +## PowerShell версия + +Для Windows без Python есть полная PowerShell-реализация (`pack.ps1` / `unpack.ps1`) — см. [POWERSHELL.md](POWERSHELL.md). + +Формат payload полностью совместим — можно паковать Python'ом, распаковывать PowerShell'ом и наоборот: + +| Упаковка | Распаковка | | +|----------|-----------|---| +| `pack.py` | `unpack.ps1` | да | +| `pack.ps1` | `unpack.py` | да | +| `pack.py --self-extract` | Python однострочник | да | +| `pack.py --self-extract-ps` | PS однострочник | да | +| `pack.ps1 -SelfExtract` | PS однострочник | да | + +> PowerShell версия поддерживает только append-режим. Для chunk и lsb нужен Python. + +## Ограничения + +- **append/chunk**: если система передачи пережимает PNG (ресайз, re-encode) — данные будут потеряны. Для file-based трансферов (SCP, cloud storage, вложения в email, передача «как файл» в мессенджерах) проблем нет. +- **lsb**: выживает re-encode без потерь (PNG→PNG), но не выживает lossy конвертацию (PNG→JPEG). Ёмкость ограничена размером изображения. Поддерживаются только 8-bit RGB/RGBA PNG без interlacing. +- Payload загружается в память целиком. Для файлов > 500 MB может потребоваться много RAM. +- Данные не шифруются. Для конфиденциальности — зашифруйте файл перед упаковкой: + ```bash + gpg -c secret.tar.gz # создаст secret.tar.gz.gpg + python3 pack.py cover.png secret.tar.gz.gpg -o output.png + ``` diff --git a/pack.ps1 b/pack.ps1 new file mode 100644 index 0000000..d8e6489 --- /dev/null +++ b/pack.ps1 @@ -0,0 +1,271 @@ +# pack.ps1 — Pack a file inside a PNG image (append after IEND) +# Usage: powershell -File pack.ps1 cover.png secret.txt [-Output packed.png] [-SelfExtract] +# powershell -File pack.ps1 packed.png -Clean [-Output clean.png] +param( + [Parameter(Mandatory, Position=0)][string]$Cover, + [Parameter(Position=1)][string]$Payload, + [string]$Output = "packed.png", + [switch]$SelfExtract, + [switch]$Clean +) + +Add-Type -TypeDefinition @" +using System; +public static class PngZipHelper { + static readonly uint[] crcTable; + static PngZipHelper() { + crcTable = new uint[256]; + for (uint i = 0; i < 256; i++) { + uint c = i; + for (int j = 0; j < 8; j++) + c = (c & 1) != 0 ? 0xEDB88320u ^ (c >> 1) : c >> 1; + crcTable[i] = c; + } + } + public static uint Crc32(byte[] data) { + uint crc = 0xFFFFFFFF; + foreach (byte b in data) crc = crcTable[(crc ^ b) & 0xFF] ^ (crc >> 8); + return crc ^ 0xFFFFFFFF; + } + public static uint Adler32(byte[] data) { + uint a = 1, b = 0; + foreach (byte d in data) { a = (a + d) % 65521; b = (b + a) % 65521; } + return (b << 16) | a; + } +} +"@ + +function Write-BE16([System.IO.Stream]$s, [uint16]$v) { + $s.WriteByte(($v -shr 8) -band 0xFF) + $s.WriteByte($v -band 0xFF) +} +function Write-BE32([System.IO.Stream]$s, [uint32]$v) { + $s.WriteByte(($v -shr 24) -band 0xFF) + $s.WriteByte(($v -shr 16) -band 0xFF) + $s.WriteByte(($v -shr 8) -band 0xFF) + $s.WriteByte($v -band 0xFF) +} +function Write-BE64([System.IO.Stream]$s, [uint64]$v) { + for ($i = 56; $i -ge 0; $i -= 8) { $s.WriteByte(($v -shr $i) -band 0xFF) } +} + +# ── Read cover PNG ── +$coverData = [IO.File]::ReadAllBytes((Resolve-Path $Cover)) +$sig = [byte[]](0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A) +for ($i = 0; $i -lt 8; $i++) { + if ($coverData[$i] -ne $sig[$i]) { Write-Error "Not a valid PNG"; exit 1 } +} + +# ── Clean PNG: strip pnZp chunks + bootstrap comments + trailing data after IEND ── +function Get-CleanPng([byte[]]$Data) { + $ms = New-Object IO.MemoryStream + $ms.Write($sig, 0, 8) + $pos = 8 + while ($pos + 8 -le $Data.Length) { + $cLen = $Data[$pos]*16777216 + $Data[$pos+1]*65536 + $Data[$pos+2]*256 + $Data[$pos+3] + $cType = [Text.Encoding]::ASCII.GetString($Data, $pos+4, 4) + $cSize = 4 + 4 + $cLen + 4 # len + type + data + crc + $skip = $false + if ($cType -eq "pnZp") { $skip = $true } + if ($cType -eq "tEXt" -and $cLen -gt 0) { + $chunkText = [Text.Encoding]::GetEncoding(28591).GetString($Data, $pos+8, $cLen) + if ($chunkText.Contains("png-zip bootstrap")) { $skip = $true } + } + if ($cType -eq "eXIf" -and $cLen -gt 0) { + $chunkText = [Text.Encoding]::GetEncoding(28591).GetString($Data, $pos+8, $cLen) + if ($chunkText.Contains("png-zip bootstrap")) { $skip = $true } + } + if (-not $skip) { + $ms.Write($Data, $pos, $cSize) + } + $pos += $cSize + if ($cType -eq "IEND") { break } + } + return $ms.ToArray() +} + +# ── Build a raw PNG chunk: len(4) + type(4) + data + crc(4) ── +function Build-PngChunk([string]$Type, [byte[]]$ChunkData) { + $typeBytes = [Text.Encoding]::ASCII.GetBytes($Type) + $crcMs = New-Object IO.MemoryStream + $crcMs.Write($typeBytes, 0, 4) + $crcMs.Write($ChunkData, 0, $ChunkData.Length) + $crc = [PngZipHelper]::Crc32($crcMs.ToArray()) + $ms = New-Object IO.MemoryStream + Write-BE32 $ms ([uint32]$ChunkData.Length) + $ms.Write($typeBytes, 0, 4) + $ms.Write($ChunkData, 0, $ChunkData.Length) + Write-BE32 $ms $crc + return $ms.ToArray() +} + +# ── Insert chunk(s) into PNG after IHDR ── +function Add-ChunksAfterIHDR([byte[]]$Data, [byte[][]]$Chunks) { + $ms = New-Object IO.MemoryStream + $ms.Write($Data, 0, 8) # PNG signature + $pos = 8 + while ($pos + 8 -le $Data.Length) { + $cLen = $Data[$pos]*16777216 + $Data[$pos+1]*65536 + $Data[$pos+2]*256 + $Data[$pos+3] + $cType = [Text.Encoding]::ASCII.GetString($Data, $pos+4, 4) + $cSize = 4 + 4 + $cLen + 4 + $ms.Write($Data, $pos, $cSize) + if ($cType -eq "IHDR") { + foreach ($chunk in $Chunks) { $ms.Write($chunk, 0, $chunk.Length) } + } + $pos += $cSize + if ($cType -eq "IEND") { break } + } + return $ms.ToArray() +} + +# ── Build minimal eXIf chunk data with UserComment ── +function Build-ExifUserComment([string]$Text) { + $ucPrefix = [Text.Encoding]::ASCII.GetBytes("ASCII") + $ucText = [Text.Encoding]::ASCII.GetBytes($Text) + # UserComment = "ASCII\x00\x00\x00" + text + $ucMs = New-Object IO.MemoryStream + $ucMs.Write($ucPrefix, 0, 5) + $ucMs.WriteByte(0); $ucMs.WriteByte(0); $ucMs.WriteByte(0) + $ucMs.Write($ucText, 0, $ucText.Length) + $uc = $ucMs.ToArray() + $ucLen = $uc.Length + # TIFF big-endian header + IFD0 (1 entry: ExifIFD ptr) + ExifIFD (1 entry: UserComment) + $exifIfdOffset = 26 + $ucDataOffset = 44 + $ms = New-Object IO.MemoryStream + # TIFF header + $ms.WriteByte(0x4D); $ms.WriteByte(0x4D) # "MM" big-endian + Write-BE16 $ms ([uint16]42) + Write-BE32 $ms ([uint32]8) # IFD0 offset + # IFD0: 1 entry + Write-BE16 $ms ([uint16]1) + Write-BE16 $ms ([uint16]0x8769) # ExifIFD tag + Write-BE16 $ms ([uint16]4) # type LONG + Write-BE32 $ms ([uint32]1) # count + Write-BE32 $ms ([uint32]$exifIfdOffset) # value + Write-BE32 $ms ([uint32]0) # next IFD + # ExifIFD: 1 entry + Write-BE16 $ms ([uint16]1) + Write-BE16 $ms ([uint16]0x9286) # UserComment tag + Write-BE16 $ms ([uint16]7) # type UNDEFINED + Write-BE32 $ms ([uint32]$ucLen) # count + Write-BE32 $ms ([uint32]$ucDataOffset) # value offset + Write-BE32 $ms ([uint32]0) # next IFD + # UserComment data + $ms.Write($uc, 0, $uc.Length) + return $ms.ToArray() +} + +# ── Helper: write one payload block to stream ── +$magic = [byte[]](0x50,0x4E,0x47,0x5A,0x49,0x50,0x00,0x01) # PNGZIP\x00\x01 + +function Write-PayloadBlock([System.IO.Stream]$s, [string]$Name, [byte[]]$Raw) { + # Compress + $ms = New-Object IO.MemoryStream + $ds = New-Object IO.Compression.DeflateStream($ms, [IO.Compression.CompressionMode]::Compress) + $ds.Write($Raw, 0, $Raw.Length) + $ds.Close() + $deflated = $ms.ToArray() + + # Build zlib stream: header(2) + deflate + adler32(4) + $adler = [PngZipHelper]::Adler32($Raw) + $zlibMs = New-Object IO.MemoryStream + $zlibMs.WriteByte(0x78); $zlibMs.WriteByte(0x9C) + $zlibMs.Write($deflated, 0, $deflated.Length) + Write-BE32 $zlibMs $adler + $compressed = $zlibMs.ToArray() + + $crc = [PngZipHelper]::Crc32($Raw) + $nameBytes = [Text.Encoding]::UTF8.GetBytes($Name) + + $s.Write($magic, 0, 8) + Write-BE16 $s ([uint16]$nameBytes.Length) + $s.Write($nameBytes, 0, $nameBytes.Length) + Write-BE64 $s ([uint64]$compressed.Length) + $s.Write($compressed, 0, $compressed.Length) + Write-BE32 $s $crc +} + +# ── Clean mode ── +if ($Clean) { + $cleaned = Get-CleanPng $coverData + [IO.File]::WriteAllBytes($Output, $cleaned) + $beforeKB = [math]::Round($coverData.Length / 1024, 1) + $afterKB = [math]::Round($cleaned.Length / 1024, 1) + Write-Host "Cleaned: $Output ($afterKB KB, was $beforeKB KB)" + return +} + +# ── Pack mode — payload required ── +if (-not $Payload) { Write-Error "Payload argument is required (unless -Clean)"; exit 1 } + +# Auto-clean: strip existing payloads (append + pnZp) before packing +$cleanCover = Get-CleanPng $coverData + +# Embed all bootstrap one-liners in PNG metadata (tEXt Comment) for self-extract +if ($SelfExtract) { + $outName = [IO.Path]::GetFileName($Output) + # Common tails + $pyTail = '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)"' + $psTail = '$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"' + $psHead = '$d=[IO.File]::ReadAllBytes(''IMG'');$t=[Text.Encoding]::GetEncoding(28591).GetString($d);' + $psHead = $psHead.Replace('IMG', $outName) + $psFindFirst = '$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));' + $psFindLast = '$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));' + # --- First payload (extract unpacker) --- + $pyCmd = ('python3 -c "d=open(''IMG'',''rb'').read();i=d.find(b''PNGZIP\x00\x01'');' + $pyTail).Replace('IMG', $outName) + $pyScript = "import zlib`nd=open('$outName','rb').read()`nm=b'PNGZIP'+bytes([0,1])`ni=d.find(m)`nnl=int.from_bytes(d[i+8:i+10],'big')`nn=d[i+10:i+10+nl].decode()`npl=int.from_bytes(d[i+10+nl:i+18+nl],'big')`nopen(n,'wb').write(zlib.decompress(d[i+18+nl:i+18+nl+pl]))`nprint('Extracted:',n)" + $pyB64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($pyScript)) + $pyCmdB64 = 'python -c "import base64;exec(base64.b64decode(''' + $pyB64 + '''))"' + $psCmd = $psHead + $psFindFirst + $psTail + $psB64 = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($psCmd)) + $psCmdB64 = 'powershell -EncodedCommand ' + $psB64 + # --- Last payload (extract file directly) --- + $pyCmdLast = ('python3 -c "d=open(''IMG'',''rb'').read();i=d.rfind(b''PNGZIP\x00\x01'');' + $pyTail).Replace('IMG', $outName) + $pyScriptLast = "import zlib`nd=open('$outName','rb').read()`nm=b'PNGZIP'+bytes([0,1])`ni=d.rfind(m)`nnl=int.from_bytes(d[i+8:i+10],'big')`nn=d[i+10:i+10+nl].decode()`npl=int.from_bytes(d[i+10+nl:i+18+nl],'big')`nopen(n,'wb').write(zlib.decompress(d[i+18+nl:i+18+nl+pl]))`nprint('Extracted:',n)" + $pyB64Last = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($pyScriptLast)) + $pyCmdB64Last = 'python -c "import base64;exec(base64.b64decode(''' + $pyB64Last + '''))"' + $psCmdLast = $psHead + $psFindLast + $psTail + $psB64Last = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($psCmdLast)) + $psCmdB64Last = 'powershell -EncodedCommand ' + $psB64Last + # --- Build bootstrap text --- + $bsText = "png-zip bootstrap`n`n=== Extract unpacker (first payload) ===`n`nPython:`n" + $pyCmd + "`n`nPython (Windows CMD):`n" + $pyCmdB64 + "`n`nPowerShell:`n" + $psCmd + "`n`nPowerShell (Windows CMD):`n" + $psCmdB64 + $bsText += "`n`n=== Extract file (last payload) ===`n`nPython:`n" + $pyCmdLast + "`n`nPython (Windows CMD):`n" + $pyCmdB64Last + "`n`nPowerShell:`n" + $psCmdLast + "`n`nPowerShell (Windows CMD):`n" + $psCmdB64Last + # Build tEXt chunk + $kwBytes = [Text.Encoding]::GetEncoding(28591).GetBytes("Comment") + $txtBytes = [Text.Encoding]::GetEncoding(28591).GetBytes($bsText) + $textCdMs = New-Object IO.MemoryStream + $textCdMs.Write($kwBytes, 0, $kwBytes.Length) + $textCdMs.WriteByte(0) + $textCdMs.Write($txtBytes, 0, $txtBytes.Length) + $textChunk = Build-PngChunk "tEXt" $textCdMs.ToArray() + # Build eXIf chunk (Windows reads UserComment from EXIF) + $exifData = Build-ExifUserComment $bsText + $exifChunk = Build-PngChunk "eXIf" $exifData + $cleanCover = Add-ChunksAfterIHDR $cleanCover @($exifChunk, $textChunk) +} + +$fs = [IO.File]::Create($Output) +$fs.Write($cleanCover, 0, $cleanCover.Length) # clean cover up to IEND + +# Self-extract: embed unpack.ps1 first +if ($SelfExtract) { + $unpackPath = Join-Path $PSScriptRoot "unpack.ps1" + if (-not (Test-Path $unpackPath)) { Write-Error "unpack.ps1 not found next to pack.ps1"; exit 1 } + $unpackRaw = [IO.File]::ReadAllBytes($unpackPath) + Write-PayloadBlock $fs "unpack.ps1" $unpackRaw +} + +# Main payload +$raw = [IO.File]::ReadAllBytes((Resolve-Path $Payload)) +Write-PayloadBlock $fs ([IO.Path]::GetFileName($Payload)) $raw +$fs.Close() + +$coverKB = [math]::Round($cleanCover.Length / 1024, 1) +$totalKB = [math]::Round((Get-Item $Output).Length / 1024, 1) +Write-Host "Packed: $Output ($totalKB KB, cover $coverKB KB)" +if ($SelfExtract) { + Write-Host "Self-extract: unpack.ps1 embedded" + $displayText = $bsText.Replace("png-zip bootstrap`n", "Bootstrap one-liners:`n") + Write-Host "`n$displayText" +} diff --git a/pack.py b/pack.py new file mode 100644 index 0000000..f18cd6b --- /dev/null +++ b/pack.py @@ -0,0 +1,462 @@ +#!/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() diff --git a/test.txt b/test.txt new file mode 100644 index 0000000..30d74d2 --- /dev/null +++ b/test.txt @@ -0,0 +1 @@ +test \ No newline at end of file diff --git a/unpack.ps1 b/unpack.ps1 new file mode 100644 index 0000000..7150cff --- /dev/null +++ b/unpack.ps1 @@ -0,0 +1,90 @@ +# unpack.ps1 — Extract file(s) hidden inside a PNG image +# Usage: powershell -File unpack.ps1 packed.png [-OutDir .] [-List] +param( + [Parameter(Mandatory, Position=0)][string]$Image, + [string]$OutDir = ".", + [switch]$List, + [switch]$All +) + +Add-Type -TypeDefinition @" +using System; +public static class PngZipHelper2 { + static readonly uint[] crcTable; + static PngZipHelper2() { + crcTable = new uint[256]; + for (uint i = 0; i < 256; i++) { + uint c = i; + for (int j = 0; j < 8; j++) + c = (c & 1) != 0 ? 0xEDB88320u ^ (c >> 1) : c >> 1; + crcTable[i] = c; + } + } + public static uint Crc32(byte[] data) { + uint crc = 0xFFFFFFFF; + foreach (byte b in data) crc = crcTable[(crc ^ b) & 0xFF] ^ (crc >> 8); + return crc ^ 0xFFFFFFFF; + } +} +"@ + +$d = [IO.File]::ReadAllBytes((Resolve-Path $Image)) +$text = [Text.Encoding]::GetEncoding(28591).GetString($d) +$sig = 'PNGZIP' + +$payloads = @() +$offset = 0 +while ($true) { + # Find next 'PNGZIP' and validate \x00\x01 suffix via byte array + $i = $offset + while (($i = $text.IndexOf($sig, $i, [StringComparison]::Ordinal)) -ge 0) { + if ($d[$i+6] -eq 0 -and $d[$i+7] -eq 1) { break } + $i++ + } + if ($i -lt 0) { break } + $p = $i + 8 + + # name + $nl = $d[$p] * 256 + $d[$p+1]; $p += 2 + $name = [Text.Encoding]::UTF8.GetString($d, $p, $nl); $p += $nl + + # compressed length (big-endian uint64) + [uint64]$pl = 0 + for ($k = 0; $k -lt 8; $k++) { $pl = $pl * 256 + $d[$p+$k] }; $p += 8 + + # decompress zlib: skip 2-byte header, exclude 4-byte adler32 + $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) + $raw = $os.ToArray() + $p += $pl + + # CRC check + [uint32]$storedCrc = $d[$p]*16777216 + $d[$p+1]*65536 + $d[$p+2]*256 + $d[$p+3] + $p += 4 + $actualCrc = [PngZipHelper2]::Crc32($raw) + if ($actualCrc -ne $storedCrc) { Write-Warning "CRC mismatch for $name" } + + $payloads += @{ Name = $name; Data = $raw } + $offset = $p +} + +if ($payloads.Count -eq 0) { Write-Error "No payload found"; exit 3 } + +if ($List) { + foreach ($p in $payloads) { Write-Host " $($p.Name) ($($p.Data.Length) bytes)" } + return +} + +$toExtract = if ($All) { $payloads } else { @($payloads[-1]) } +$null = New-Item -ItemType Directory -Force -Path $OutDir + +foreach ($entry in $toExtract) { + $safeName = [IO.Path]::GetFileName($entry.Name) + $outPath = Join-Path $OutDir $safeName + [IO.File]::WriteAllBytes($outPath, $entry.Data) + Write-Host "Extracted: $outPath ($($entry.Data.Length) bytes)" +} diff --git a/unpack.py b/unpack.py new file mode 100644 index 0000000..a5698e7 --- /dev/null +++ b/unpack.py @@ -0,0 +1,282 @@ +#!/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()