Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 8 min read

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:

  1. 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.
  2. 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 .env backups, 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:

  1. It looks at the first 8 bytes for the PNG magic number (\x89PNG\r\n\x1a\n).
  2. It walks forward through chunk headers: IHDR (dimensions), IDAT (compressed pixel rows), and color profiles.
  3. Once it hits the IEND chunk (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!

  1. 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!
  2. 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:

  1. The EOCD record (at the very end of the file)
  2. 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:

  1. Open invisio-vault.vercel.app.
  2. Head to the Polyglot tab.
  3. Pick any carrier image or PDF, upload the file you want to hide (with optional AES-256 encryption), and download the result.
  4. 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!

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.