What is a Polyglot File? (Security Testing Guide)
Checking magic bytes is a big improvement over trusting file extensions, but it answers only one question: what does the start of the file look like? A polyglot is a file built so that different programs read it as different formats. It passes the upload filter as an image and is then interpreted as something else by another program. This post explains why that is possible, how to make a harmless test file for your own staging environment, and which defences hold up.
Why one file can be two formats
File formats are anchored in different places, and many readers are tolerant about what surrounds the part they care about:
- Images (JPEG, PNG, GIF) are identified by a signature at offset 0 and read from the front. Most decoders stop at the image's end marker and ignore anything after it.
- ZIP archives (and everything built on ZIP: JAR, DOCX, XLSX, APK) are read from the end. A reader finds the "end of central directory" record near the end of the file and follows offsets from there. Data in front of the archive is often ignored.
- PDF readers are commonly tolerant of junk before the
%PDF-header, within the first part of the file, and locate the cross-reference table from the end. - HTML parsers accept almost anything and render whatever looks like markup.
Put an image at the front and a ZIP at the back, and an image decoder sees an image while a ZIP reader sees an archive. Both are right.
A historical example: GIFAR
A well-known case from around 2008 was the "GIFAR": a GIF image concatenated with a Java JAR file. Upload sites accepted it as an image; a Java applet could then load it from that site's domain as a JAR, running code with that domain's origin. Java applets are long gone from browsers, so GIFAR itself is history, but the pattern (content accepted as one type, consumed as another) is still the core idea behind modern polyglot tricks such as image files that also contain HTML or JavaScript served from a trusted origin.
Building a harmless probe for staging
You do not need a malicious payload to test your defences. A polyglot made of an innocent image and an innocent archive is enough to see what your pipeline does with trailing data:
# 1. A valid PNG and a small ZIP containing a text file
echo "polyglot probe" > note.txt
zip probe-inner.zip note.txt
# 2. Concatenate: PNG first, ZIP second
cat image.png probe-inner.zip > probe.png
# 3. Fix ZIP offsets so zip tools read it cleanly (Info-ZIP)
zip -A probe.png
# 4. Check both views
file --mime-type probe.png # image/png
unzip -l probe.png # lists note.txt
You can get the image from the generator (padded PNG and JPG files remain valid images). Check the result with the magic-byte sniffer: it reports a PNG, because the first bytes are 89 50 4E 47. Only upload probes like this to systems you own or are authorised to test.
What to test
Upload probe.png to an image field and follow it through every stage:
- Is it accepted? Most magic-byte checks will accept it. That is expected; this test is about what happens next.
- Is the stored file byte-identical to the upload? Download it and compare hashes. If it is identical, the ZIP payload survived storage.
- Is the served file identical? Many sites serve a re-encoded or resized version. Check both the original URL and any thumbnail URLs.
- What headers is it served with?
curl -sIthe file URL and look atContent-Type,X-Content-Type-OptionsandContent-Disposition. - What origin is it served from? Your main application domain, or a separate user-content domain?
Defences that hold up
1. Re-encode images
Decoding an image and encoding it again produces a new file that contains only pixel data (and whatever metadata you choose to keep). Trailing archives, appended scripts and odd chunks are dropped. In Python with Pillow:
from PIL import Image
def reencode(src, dst):
with Image.open(src) as im:
im.load() # force a full decode
im.save(dst, format="PNG") # new file, pixel data only
In Node.js, piping through sharp (sharp(input).png().toFile(out)) has the same effect, and sharp drops metadata by default. Re-encoding costs CPU and slightly changes lossy images, but it is the most robust single defence for image uploads.
2. Detect trailing data
If you must keep originals byte-for-byte, at least detect data after the end of the image. For PNG, the file should end right after the IEND chunk:
def png_trailing_bytes(data: bytes) -> int:
i = data.rfind(b"IEND")
if i == -1:
raise ValueError("no IEND chunk")
end = i + 4 + 4 # chunk type + CRC
return len(data) - end # 0 for a clean PNG
JPEG is harder, because embedded thumbnails contain their own end markers, which is one more reason to prefer re-encoding.
3. Serve with strict headers
Content-Typefrom the detected type, never from the upload.X-Content-Type-Options: nosniff, so browsers do not reinterpret the content based on what it looks like.Content-Disposition: attachmentfor anything that is not meant to be displayed inline.- A
Content-Security-Policy: sandboxheader on user content responses, which stops scripts from running even if a file is rendered as a document.
4. Use a separate origin
Serving user uploads from a different domain (not just a subdomain that shares cookies) means that even if a file is interpreted as HTML or script, it runs without access to your application's cookies or storage.
5. Inspect archives before extracting
If your application accepts ZIP-based formats (DOCX, XLSX, ZIP itself), check archive entries before extracting: reject absolute paths and ../ sequences, cap the total uncompressed size and the number of entries, and extract into an isolated directory.
Checklist
- Build a PNG+ZIP probe and confirm what your magic-byte check says about it.
- Upload it and compare stored and served files with the original.
- Re-encode images, or at minimum detect trailing data.
- Serve with detected type,
nosniff, andattachmentor CSP sandbox as appropriate. - Serve user content from a separate origin.
Related reading: why MIME type validation needs magic bytes and the extension versus sniffed MIME scenario.
