commit 6e54e0f4112966747c2e92790af489b7fd043e29 Author: Struchkov Mark Date: Sat Feb 21 14:48:13 2026 +0300 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. 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()