The Polyglot Paradox: How I Made a Normal Image Extract as a Secret ZIP Archive
A while back, I was staring at a hex dump in VS Code late at night trying to answer a ridiculous question: Can a single file live a double life without corrupting itself? Here's the scenario I wanted: You double-clic
A while back, I was staring at a hex dump in VS Code late at night trying to answer a ridiculous question:
Can a single file live a double life without corrupting itself?
Here's the scenario I wanted:
- You double-click
wallpaper.png. Windows Photos or macOS Preview opens it immediately. It's a clean, high-res photo. Zero visual glitches, zero missing chunks, zero warnings. - You right-click that exact same file, hit Open with 7-Zip (or rename the extension to
.zip), and 7-Zip pops open displaying an AES-encrypted archive containing your.envbackups, private SSH keys, and confidential notes.
No custom proprietary file format. No special decoding script required on the other end. Just standard system tools reading the exact same bytes and seeing two completely different realities.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β THE DUAL-PARSER REALITY β
βββββββββββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββ€
β Image Viewer (Reads from Head) β Archive Reader (Reads from EOF) β
β β β
β 0x00 βββΊ [ PNG Magic: \x89PNG ] β [ Local File Headers ] β
β [ IHDR Metadata ] β [ Deflated Secret Payload ] β
β [ IDAT Pixel Chunks ] β [ Central Directory Headers ] β
β 0x2A βββΊ [ IEND: "File Done!" ] β [ EOCD: PK\x05\x06 ] βββ Reads EOF β
β (Ignores trailing bytes) β (Ignores leading image bytes) β
βββββββββββββββββββββββββββββββββββββββ΄ββββββββββββββββββββββββββββββββββββββ
While building InvisioVaultβmy open-source steganography and privacy appβI wanted to take a break from tweaking least-significant bits in image pixels and tackle Universal Polyglots.
Here's how file parsers disagree on where files actually begin, why the classic terminal trick you see on forums fails on almost every modern unzipper, and how I used zero-copy memory mapping (mmap) in Python to pull off binary surgery without crashing my 512 MB server.
1. The Head-First vs. Tail-First Clash
To understand how a file can be an image and an archive at the same time, you have to look at how different file formats were invented. Parsers don't even agree on which side of the file to read first.
The "Head-First" Camp: PNG, JPEG, PDF, MP4
Media formats were designed for immediate rendering and streaming over slow networks.
When an image viewer opens a PNG:
- It looks at the first 8 bytes for the PNG magic number (
\x89PNG\r\n\x1a\n). - It walks forward through chunk headers:
IHDR(dimensions),IDAT(compressed pixel rows), and color profiles. - Once it hits the
IENDchunk (49 45 4E 44), it considers the image complete and stops reading right there.
Anything appended after the IEND chunk is treated as trailing data and quietly ignored.
The "Tail-First" Camp: ZIP, 7-Zip, WinRAR
ZIP archives were born in the late 1980s (thanks to Phil Katz and PKWARE) when files had to span multiple floppy disks and you couldn't know the final archive size until compression finished.
Because of that design choice, ZIP readers do not read from byte 0.
When you open a ZIP, the unzipper jumps straight to the very end of the file and scans backwards hunting for a 4-byte signature:
PK\x05\x06 (0x06054b50) β the End of Central Directory (EOCD) record.
The EOCD is the brain of the archive. It stores:
- The byte offset of where the Central Directory starts.
- The total count of files inside.
- Archive comments.
Notice the opening here?
- The image viewer starts at the front and stops in the middle.
- The archive reader starts at the back and ignores the front.
Naturally, the first thing anyone tries is slamming them together.
2. Why the Classic cat Command Blew Up in My Face
If you search how to do this on Reddit or StackOverflow, someone will inevitably tell you to run this one-liner:
# The classic terminal trick:
cat wallpaper.png secrets.zip > polyglot.png
# Or on Windows CMD:
copy /b wallpaper.png + secrets.zip polyglot.png
I tried this early on. I opened polyglot.png. The wallpaper loaded fine.
Then I tried extracting it with macOS Archive Utility and Python's zipfile module.
zipfile.BadZipFile: File is not a zip file
[Archive Utility] Unable to expand "polyglot.png". (Error 1 - Operation not permitted / corrupted header)
Why did it break?
Because of The Offset Displacement Problem.
Inside a ZIP file, pointers aren't relativeβthey are absolute byte offsets from byte 0 of the file.
Original ZIP:
Byte 0 Byte 8000 (EOF)
βββββββββββββββββββββββββββββββββββββββββ¬βββββββ¬ββββββββββββββ
β Local File Header #1 β ... β EOCD Record β
βββββββββββββββββββββββββββββββββββββββββ΄βββββββ΄ββββββββββββββ
β² β
βββββββββββ Offset points to Byte 0 ββββββββββββ
After 'cat' (Prepending a 2 MB wallpaper):
Byte 0 Byte 2,097,152 (2 MB) Byte 2,105,152
βββββββββββββββββββββββ¬βββββββββββββββββββββββββββ¬ββββββ¬ββββββββββββββ
β 2 MB Image Data β Local File Header #1 β ... β EOCD Record β
βββββββββββββββββββββββ΄βββββββββββββββββββββββββββ΄ββββββ΄ββββββββββββββ
β² β
β Still points to Byte 0 (which is now PNG header!) ββββ
When you prepend a 2 MB image ($S_{carrier} = 2,097,152$ bytes), your ZIP structures don't know they got pushed down the street!
- The EOCD record still says: "Hey, the Central Directory starts at byte 7,200." But byte 7,200 is in the middle of a PNG color chunk!
- Each Central Directory File Header still says: "File #1 lives at byte 0." But byte 0 is
\x89PNG!
Forgiving GUI tools like WinRAR might scan through and try to guess where things moved, but strict tools (7-Zip, macOS Archive Utility, Linux unzip, and standard programming libraries) will throw corruption errors.
To make a polyglot that actually works everywhere, you have to patch the binary offsets by hand.
3. The Binary Surgery: Patching ZIP Offsets in Python
To fix the broken pointers, every single stored offset has to be shifted forward by exactly the length of the carrier file ($S_{carrier}$).
In InvisioVault (backend/utils/polyglot.py), I wrote an in-memory offset repair engine based on the PKWARE APPNOTE specification:
The Offset Patch List:
1. End of Central Directory (EOCD: PK\x05\x06)
βββ Field at offset +16 (uint32 LE): Central Directory Offset += S_carrier
2. Each Central Directory File Header (CDFH: PK\x01\x02)
βββ Field at offset +42 (uint32 LE): Local Header Offset += S_carrier
3. (If ZIP64) Locator (PK\x06\x07) & ZIP64 EOCD (PK\x06\x06)
βββ Field at offset +48 (uint64 LE): 64-bit Central Directory Offset += S_carrier
Finding the EOCD Record
A ZIP comment can be up to 65,535 bytes long, so the EOCD record must sit somewhere within the last 65,535 + 22 bytes of the file.
We search backward from the end:
_SIG_EOCD = b"PK\x05\x06"
eocd_pos = data.rfind(_SIG_EOCD)
if eocd_pos == -1 or len(data) - eocd_pos < 22:
raise ValueError("EOCD missing or corrupted")
Unpacking and Shifting with struct
The offset to the Central Directory lives at byte +16 inside the EOCD record as a 32-bit Little-Endian unsigned integer (<I).
We unpack it, add the carrier file size, and pack it right back:
import struct
# Read original 32-bit offset
cd_offset_32 = struct.unpack_from("<I", data, eocd_pos + 16)[0]
# Shift by carrier byte size
new_cd_offset = cd_offset_32 + carrier_size
# Write it back into the binary buffer
struct.pack_into("<I", data, eocd_pos + 16, new_cd_offset)
Walking the Central Directory Headers
Next, we jump to that new cd_offset position and walk through every file listed in the Central Directory (PK\x01\x02).
Because filenames and metadata fields have variable lengths, we parse the lengths on the fly, patch the pointer to the Local File Header at +42, and hop to the next record:
pos = new_cd_offset
while pos + 46 <= len(data) and data[pos:pos+4] == b"PK\x01\x02":
# Patch the pointer to this file's Local Header
old_lho = struct.unpack_from("<I", data, pos + 42)[0]
struct.pack_into("<I", data, pos + 42, old_lho + carrier_size)
# Read variable lengths to calculate the jump to next header
name_len = struct.unpack_from("<H", data, pos + 28)[0]
extra_len = struct.unpack_from("<H", data, pos + 30)[0]
comment_len = struct.unpack_from("<H", data, pos + 32)[0]
# Hop to the next Central Directory entry
pos += 46 + name_len + extra_len + comment_len
Once those bytes are patched, the file satisfies both parsers completely.
4. The 512 MB Free-Tier Constraint (Or: How mmap Saved My Server)
Here is where production reality hit.
I host InvisioVault's backend on Render's free tier, which gives you 512 MB of RAM. If your process crosses that limit, Linux sends SIGKILL (Exit Code 137) and drops your service instantly.
Now think about what happens if someone uploads a 30 MB raw wallpaper and hides a 20 MB archive:
- Reading both into RAM: ~50 MB
- Concatenating them into a new byte array: another 50 MB
- Decompressing or checking contents: easily 100+ MB
- Add two simultaneous requests: Server dead.
The solution was Python's mmap (Memory Mapping):
import mmap
def _fix_zip_offsets(file_path: str, offset: int) -> None:
with open(file_path, "r+b") as fh:
# Map the file directly into virtual address space
with mmap.mmap(fh.fileno(), 0, access=mmap.ACCESS_WRITE) as data:
_fix_zip_offsets_mm(data, offset)
data.flush()
Why is mmap such a cheat code here?
With mmap, the OS doesn't load the whole file into physical memory. It maps the file directly to virtual address space and pages bytes in on-demand.
Because our patching script only touches:
- The EOCD record (at the very end of the file)
- The Central Directory records (near the end of the file)
The operating system only loads those specific 4 KB memory pages into RAM.
Whether the file is 5 MB or 500 MB, the RAM footprint of the patching operation stays practically zero.
5. The macOS Finder Quick Look Weirdness
While testing polyglot files across different operating systems, I hit a bizarre bug:
- Windows Photos & Explorer: 100% fine.
- 7-Zip, WinRAR, Linux
unzip: Extracted cleanly. - macOS Preview app: Opened normally.
- macOS Finder Quick Look (hitting Spacebar): Gave a broken file icon!
I spent hours wondering what was wrong with my PNG chunks.
Turns out, Apple's Finder Quick Look daemon uses a much stricter parser than the actual Preview app. On certain PNG files, if Quick Look notices extra bytes trailing after the IEND chunk, it gets paranoid, flags the file stream as unexpected, and refuses to render the quick preview thumbnail.
If you use a PDF or JPEG or MP4 as your carrier, macOS Quick Look handles it without complaining. But for PNGs, Quick Look's strictness is a quirky reminder that different parsers on the same OS can have different levels of tolerance.
6. Polyglots vs. LSB Steganography: Which One Makes Sense?
Inside InvisioVault, you can hide files using two completely different approaches:
| Feature | LSB Steganography (Pixel Drift) | Universal Polyglot (Dual Format) |
|---|---|---|
| Capacity | Limited (~10-15% of pixel count) | Unlimited (MBs or GBs) |
| Visual Quality | Imperceptible micro-changes | 100% Bit-for-Bit Identical Carrier |
| File Size | File size does not change | File size grows by hidden payload |
| Extraction | Needs specialized algorithm & password | Any standard archive app (7-Zip) |
| Lossy Compression | Destroyed by WhatsApp/Twitter re-encoding | Destroyed if compressed as image |
| Antivirus Scan | Invisible to heuristic file scans | Heuristic scanners may flag ZIP trailer |
If you want plausible deniability where the file size doesn't change by a single byte, LSB Steganography is the tool.
If you need to bundle large project backups or encrypted archives inside a legitimate-looking carrier document or media file, Polyglots win hands down.
Try It Out
If you want to test this without writing hex-patching scripts by hand:
- Open invisio-vault.vercel.app.
- Head to the Polyglot tab.
- Pick any carrier image or PDF, upload the file you want to hide (with optional AES-256 encryption), and download the result.
- Double-click it to see your carrier; open it in 7-Zip or WinRAR to extract your payload.
The full source code, API docs, and architectural breakdown are open-source on GitHub:
π github.com/rolan-r-n_r/InvisioVault
Have you ever run into weird file format quirks or dual-format bugs in production? Drop your stories in the comments!
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.