How a Double-Encoded Null Byte Turns a ZIP File into an XSS Vector – CVE-2026-2790

MIME type confusion, content sniffing abuse, and a sneaky bypass of a previous Firefox patch

This bug is a bypass. It doesn’t introduce any new primitive on its own; it sidesteps a fix Mozilla shipped for CVE-2025-1936 without realising the sanitization was only half-done. To understand why it works, you need to dig into how Firefox resolves jar: URLs, how it decides what MIME type a resource is, and what happens when those two decisions quietly disagree with each other.

Here’s the short version: a percent-encoded null byte survives MIME detection, then gets decoded a second time during the actual resource fetch. That injects \x00 into the C string the content-type sniffer is working with. The null terminates the string early, the file extension vanishes, the sniffer falls back to reading raw bytes, spots <html> at the top, and serves the response as text/html. Script execution follows.

The Predecessor Bug, CVE-2025-1936

Before getting into this one, it’s worth spending a minute on CVE-2025-1936, because without that context the new bug barely makes sense.

CVE-2025-1936 showed you could confuse Firefox’s MIME detection by sticking a raw null byte (%00) inside a JAR entry name. Firefox used a plain C string for the extension lookup, and C strings terminate at \x00. So the lookup would see test.png\x00, read the extension as empty, and fall through to content sniffing. The bytes being fetched were HTML. The sniffer would call it text/html and the browser would execute whatever scripts were inside.

Mozilla’s fix was to strip literal null bytes from entry names during URL normalization. Reasonable enough. But it only caught literal null bytes. Nobody thought about what happens when the null is double-encoded and shows up after a second decode pass.

A Quick Map of Firefox’s JAR Stack

To follow what’s happening here, you need a rough picture of how Firefox handles jar: URLs. There are three moving parts:

  • nsJARProtocolHandler: registers the jar: scheme and creates a channel for each request.
  • nsJARChannel: the main workhorse. It opens the ZIP, resolves the entry name, and pipes raw bytes to whoever asked.
  • nsJARURI: holds the parsed URL. GetJAREntry() hands back the entry path percent-encoded. If you want the actual raw name, you call NS_UnescapeURL() yourself.

When the content type of an entry isn’t already known, the channel falls through to nsUnknownDecoder, which reads the first 512 bytes and makes a best-guess MIME type from what it finds. That sniffing fallback is what ultimately gets abused here.

The relevant call chain looks like this:

nsJARChannel::Open()
  └─ OpenLocalFile()
       ├─ nsJARInputStream::Open()        // raw bytes out of the ZIP
       └─ nsUnknownDecoder::OnStartRequest()
            └─ DetermineContentType()
                 ├─ registered sniffers (magic bytes, image headers...)
                 ├─ StartsWithHTMLTag()   // this is what kills us
                 └─ fallback: text/plain or application/octet-stream

The Double-Encoding Trick

How the entry name gets mangled

Here’s the detail that makes this work. When you write a ZIP entry named test.png%00, those three characters %, 0, and 0 are stored literally in the ZIP Central Directory as ASCII 37, 48, 48. That’s not a URL-encoded null byte. It’s just three printable characters sitting in a filename.

When Firefox renders the directory listing for a JAR, it calls NS_EscapeURL() on each raw entry name before turning it into a hyperlink. The % character gets escaped to %25, so the link ends up pointing to test.png%2500. That’s double-encoded now: %2500 encodes the three-character string %00, which is itself the percent-encoding of a null byte.

Where the two code paths diverge

This is where it actually breaks. The MIME detection path and the resource-fetch path both decode the URL, but not the same number of times. That single asymmetry is the whole bug.

// MIME detection: single decode, stops here
NS_UnescapeURL("test.png%2500")  ->  "test.png%00"
// extension lookup sees: .png
// MIME result:           image/png  (OK)
// Resource fetch: second decode applied
NS_UnescapeURL("test.png%2500")  ->  "test.png%00"
NS_UnescapeURL("test.png%00")   ->  "test.png\x00"   // null injected
// ZIP entry lookup finds "test.png%00" by raw name: match
// Body returned:  HTML bytes
// Extension seen by sniffer: ""  (C string cut off at \x00)
// -> UNKNOWN_CONTENT_TYPE -> falls into content sniffing
// -> sniffs <html> -> text/html -> script executes  (bad)

The root issue isn’t the null byte itself. It’s that two code paths processed the same input a different number of times and ended up with different strings. The null byte just happens to be the thing that falls through that gap.

Content Sniffing, the Final Piece

Once the extension lookup gives up and returns application/x-unknown-content-type, Firefox inserts an nsUnknownDecoder into the listener chain. It reads the first 512 bytes and tries to figure out what the content actually is. The logic, simplified, goes like this:

void nsUnknownDecoder::DetermineContentType() {
// 1. Try registered sniffers (image magic numbers, etc.)
for (auto& sniffer : mSniffers) {
if (sniffer->GetMIMETypeFromContent(mBuffer, mBufferLen, type))
return SetContentType(type);
}
// 2. HTML / XML signature check
if (StartsWithHTMLTag(mBuffer)) return SetContentType("text/html");
if (StartsWithXMLDecl(mBuffer)) return SetContentType("text/xml");
// 3. Binary vs text fallback
return SetContentType(IsTextData(mBuffer) ? "text/plain"
: "application/octet-stream");
}

Our payload starts with <html>, so StartsWithHTMLTag() returns true and the response gets labeled text/html. At that point the browser renders it in the jar: origin and runs whatever script is in the body.

Building the Proof of Concept

The basic case

The exploit is pretty compact. All you need is a ZIP file with an entry whose name contains the three ASCII characters %, 0, and 0. Not a URL-encoded null byte, just those three printable characters stored literally as the filename.

import zipfile
with zipfile.ZipFile("poc.zip", "w") as z:
# Entry name is the three ASCII chars: %, 0, 0 (ASCII 37, 48, 48)
# Not a null byte; just a percent sign followed by '00'
z.writestr("page.png%00", "<html><script>alert(document.domain)</script></html>")
# Triggering URL (note the double-encoded null in the entry path):
# jar:file:///path/to/poc.zip!/page.png%2500

When Firefox builds the listing link for that entry, it escapes the % to %25 and produces test.png%2500. MIME detection decodes that once and sees .png, so it returns image/png. The fetch path decodes it twice, the null gets injected, the extension drops off, and the sniffer takes over. From there you have script execution.

The hidden-file variant

There’s a stealth angle here too. If you use %00a/hiddenfile as the entry name, the file disappears from the directory listing entirely. Firefox’s listing iterator uses strcmp() on C strings when it groups entries into directories. After the first decode pass, the entry name starts with \x00, which cuts the C string right there. The prefix comparison that decides what folder the entry belongs to sees an empty string. The listing shows a/ as a normal folder with nothing in it, and the payload entry never shows up anywhere.

import zipfile
with zipfile.ZipFile("hidden.zip", "w") as z:
z.writestr("%00a/payload", "<html><script>alert('XSS')</script></html>")
# Visible listing shows only: a/
# Actual payload lives at: jar:file:///hidden.zip!/%2500a/payload
# The entry doesn't appear anywhere in the UI

Why Mozilla’s Fix Is the Right One

Mozilla’s patch takes a clean approach. Rather than trying to catch every possible encoding variation of a null byte, it removes the content sniffing fallback from JAR channels entirely. If a resource in a JAR archive doesn’t have a recognized file extension, it comes back as application/octet-stream, full stop. No fallback, no reading the body bytes.

// nsJARChannel.cpp (after the patch)
NS_IMETHODIMP
nsJARChannel::GetContentType(nsACString& aContentType) {
if (mContentType.IsEmpty() ||
mContentType.EqualsLiteral(UNKNOWN_CONTENT_TYPE)) {
// JAR resources without a recognized extension get octet-stream.
// No sniffing fallback. The caller decides what to do with it.
aContentType.AssignLiteral(APPLICATION_OCTET_STREAM);
return NS_OK;
}
aContentType = mContentType;
return NS_OK;
}

The invariant it enforces is simple to reason about: JAR resources either have a valid extension or they’re opaque data. There’s nothing left to manipulate. You can inject a null, mangle the extension however you like, and you still can’t get to text/html because the code path that would make that decision no longer exists.

There is a real trade-off here. Extensions that store resources without file extensions, like a stylesheet sitting at chrome://ext/content/style with no .css on the end, will now get served as application/octet-stream instead of the right type. Mozilla accepted that. Given what the alternative looked like in practice, it was the right call.

What This Bug Actually Teaches

A few things that stuck with me going through this:

One decode pass isn’t enough. If your normalization code runs NS_UnescapeURL() once and passes the result straight to a C-string API, anything double-encoded survives the first pass and injects whatever it wants on the second. You either need to keep decoding until the input stops changing, or refuse the problematic character at a level where it can’t sneak back in. The first approach is annoying to get right. The second is what Mozilla actually did.

When two subsystems process the same input a different number of times, you’re sitting on a latent bug. This isn’t really a null-byte story at its core. MIME detection and resource fetching both worked on the same URL but came out with different strings, because they ran different amounts of processing. That asymmetry is the bug. The null byte just happened to be the thing that made it visible.

Content sniffing is a footgun when you already control the namespace. For HTTP responses over the wire, sniffing as a fallback makes sense because you can’t always control what the server sends. But for a ZIP archive, you own the entry names. Falling back to reading raw bytes in that situation creates a path where attacker-controlled content determines the MIME type, which is exactly what happened here.

Subscribe to our Newsletter
Subscription Form

DOWNLOAD THE DATASHEET

Fill in your details and get your copy of the datasheet in few seconds

DOWNLOAD THE EBOOK

Fill in your details and get your copy of the ebook in your inbox

Ebook Download

DOWNLOAD A SAMPLE REPORT

Fill in your details and get your copy of sample report in few seconds

Download ICS Sample Report

DOWNLOAD A SAMPLE REPORT

Fill in your details and get your copy of sample report in few seconds

Download Cloud Sample Report

DOWNLOAD A SAMPLE REPORT

Fill in your details and get your copy of sample report in few seconds

Download IoT Sample Report

DOWNLOAD A SAMPLE REPORT

Fill in your details and get your copy of sample report in few seconds

Download Code Review Sample Report

DOWNLOAD A SAMPLE REPORT

Fill in your details and get your copy of sample report in few seconds

Download Red Team Assessment Sample Report

DOWNLOAD A SAMPLE REPORT

Fill in your details and get your copy of sample report in few seconds

Download AI/ML Sample Report

DOWNLOAD A SAMPLE REPORT

Fill in your details and get your copy of sample report in few seconds

Download DevSecOps Sample Report

DOWNLOAD A SAMPLE REPORT

Fill in your details and get your copy of sample report in few seconds

Download Product Security Assessment Sample Report

DOWNLOAD A SAMPLE REPORT

Fill in your details and get your copy of sample report in few seconds

Download Mobile Sample Report

DOWNLOAD A SAMPLE REPORT

Fill in your details and get your copy of sample report in few seconds

Download Web App Sample Report

Let’s make cyberspace secure together!

Requirements

Connect Now Form

What our clients are saying!

Trusted by