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.
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
__pycache__/
|
||||||
|
.DS_Store
|
||||||
|
*.pyc
|
||||||
80
POWERSHELL.md
Normal file
80
POWERSHELL.md
Normal file
@@ -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-версией — можно паковать на одной ОС, распаковывать на другой.
|
||||||
193
README.md
Normal file
193
README.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
271
pack.ps1
Normal file
271
pack.ps1
Normal file
@@ -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"
|
||||||
|
}
|
||||||
462
pack.py
Normal file
462
pack.py
Normal file
@@ -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()
|
||||||
90
unpack.ps1
Normal file
90
unpack.ps1
Normal file
@@ -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)"
|
||||||
|
}
|
||||||
282
unpack.py
Normal file
282
unpack.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user