Security Research
One Upload, Two Filenames: The Parser Differential Behind Multiple WAF Bypasses

Introduction
This research started while we were investigating something else.
During an engagement, we found an absolute-URL override that led to an account takeover. The application accepted what the developer believed was a path, joined it to a trusted base URL, and then requested the result. The behavior can be reduced to a few lines of Python:
from urllib.parse import urljoin
BASE_URL = "https://trusted.example/api/"
user_input = "https://attacker.example/collect"
print(urljoin(BASE_URL, user_input))What would you expect this code to print?
If your first guess is https://trusted.example/api/https://attacker.example/collect , you are not alone. But the actual result is https://attacker.example/collect.
At first, we wondered whether we had found a vulnerability in urljoin() itself. We had not. The function is behaving exactly as defined by RFC 3986’s reference-resolution rules: when the second value is an absolute URL, its scheme and host take precedence over the base URL.
The function was not the vulnerable part. The assumption surrounding it was. The developer treated the input as a path; urljoin() treated it as a URL reference. It was accepted as a path, parsed as a URL, and used as a destination.
That small disagreement left us with a much bigger question:
What happens when a web application firewall and the application behind it receive the exact same HTTP request, but disagree about what it means?
The short answer: the WAF can inspect one perspective of the request while the application acts on another. What looks harmless at the security layer may become dangerous one layer later.
Before we get into filenames, multipart requests, and RFCs, let’s step back and look at how data gets its meaning in the first place.
How data gets meaning
Every piece of data consumed by an application, including a raw HTTP request, moves through the same basic lifecycle:
Input → Parsing → Processing → Output- Input: Raw data enters a layer.
- Parsing: The layer reads that data and gives it structure and meaning.
- Processing: The layer applies its rules and logic to the parsed result.
- Output: The layer produces a result, passes the data onward, or performs an action.
Nothing unusual so far.
The interesting part is that this cycle does not happen only once. A request may pass through a WAF, reverse proxy, web server, framework, and finally the application itself. Each layer may parse some or all of the request again, using its own standards, libraries, assumptions, and edge-case handling.
Everyone receives the same bytes. Surely everyone gives them the same meaning, right? Not always.
A WAF might parse an uploaded filename as safe.jpg and allow the request. The application behind it might parse the very same request as shell.php and use that value instead. Neither component needs to crash or report an error. They simply disagree.
That disagreement is a parser differential: the security layer makes a decision based on one interpretation, while the protected application acts on another.
A parser differential in real life
Imagine saying the same word to two people who speak different languages:

Both people hear the same input: “Gift.” To the English speaker, it means a present. To the German speaker, it means poison.
Did either person mishear the word? No. Each person understood it correctly according to the rules of their own language. The input stayed the same; the receiver changed, so the meaning changed with it.
Web components behave in much the same way. Their “language” is defined by protocol standards, parsing libraries, and implementation choices. Two components may follow different protocol rules, or they may follow the same RFC but handle an ambiguous edge case differently.
When those components sit on opposite sides of a security boundary, that small disagreement becomes a security problem. The WAF sees safe.jpg and says, “Looks fine.” The application sees shell.php and carries on processing it.
Same input. Two meanings. One gap an attacker can walk straight through. That is the idea at the heart of this research.
The asterisk that started it all
With that model in mind, we spent days hunting for a useful differential in URL handling. Then one small character pulled the research in a completely different direction: the asterisk in filename*.
At first glance, filename* looks like a variation of the familiar filename parameter. Web frameworks use it to decode filenames that contain non-English letters or other special characters. For example, filename*=UTF-8''r%C3%A9sum%C3%A9.pdf becomes résumé.pdf inside the application.
The standards tell a more complicated story. Real-world parsers still encounter it, but libraries, frameworks, and WAFs across ecosystems such as Python, Go, and Node.js do not all make the same decision about what it means or which filename should win.
One upload. Two filename parameters. Several perfectly confident parsers and several different answers.
What the backend actually sees
Before looking at the results, there is an important standards detail. RFC 6266 defines filename* for HTTP response headers and says it should take precedence over filename, but it explicitly excludes multipart/form-data. Multipart uploads are governed by RFC 7578, which says senders must not use filename* there.
Real applications do not always follow that restriction. Several popular frameworks accept filename* in multipart requests, decode it, and expose it as the uploaded filename. That gap between what the standard permits and what deployed software accepts is exactly why the parameter matters to security products.
To make that disagreement concrete, we sent the same two multipart parts to several common backend stacks. The first contained both parameters:
Content-Disposition: form-data; name="file"; filename="safe.jpg"; filename*=UTF-8''shell.phpThe second contained only the extended parameter:
Content-Disposition: form-data; name="file"; filename*=UTF-8''shell.phpHere, supported means that the backend decodes filename*, exposes shell.php as the uploaded filename, and still recognizes a filename*-only part as a file. It does not mean that accepting the parameter is standards-compliant; RFC 7578 explicitly says not to use it in multipart/form-data.

Three version details matter. Go accepts the parameter but its standard library decodes only UTF-8 and US-ASCII, which is the charset gap that originally affected Coraza. The FastAPI/Starlette result above is historical behavior from python-multipart 0.0.21; python-multipart 0.0.30 and later ignore extended parameters and keep the plain filename authoritative. Rack 3.2.7 still accepts filename*, but Rack's unreleased main branch removed that support because RFC 7578 prohibits the parameter in multipart uploads.
We were not the first to notice the asterisk
After confirming the behavior, we found Andrea Menin's November 2024 article, Breaking Down Multipart Parsers: File upload validation bypass. He had already documented the interesting half of the puzzle: parsers such as Busboy and Flask understand filename* decode it, and may prefer it over the ordinary filename value.
He also tested the idea against ModSecurity and concluded it didn't work there. For the exact request he used, that conclusion was correct. His part contained only the extended parameter:
Content-Disposition: form-data; name="file"; filename*=UTF-8''shell%2EphpModSecurity treated that shape as a multipart error. If the request stops there, the technique does look like a dead end.
The missing condition was the legacy fallback pattern commonly paired with filename*. We added a harmless ordinary filename before the dangerous extended one:
Content-Disposition: form-data; name="file"; filename="safe.jpg"; filename*=UTF-8''shell.phpThat small addition changed the entire path through the prefix parser. ModSecurity found the ordinary filename= it expected, accepted the multipart part, and exposed safe.jpg to filename-based rules. The backend independently applied extended-parameter precedence and exposed shell.php to the application.

The percent-encoded dot was not the essential ingredient either. The bypass still worked with a literal shell.php; the real issue was that the two components selected different parameters. One parser treated filename as authoritative, while the other gave filename* precedence.
This distinction matters when researching parser differentials. A rejected mutation proves only that one spelling is rejected. It does not prove that every semantically equivalent spelling reaches the same code path. Adding a fallback parameter, changing the order, splitting the value into continuation segments, or using a different charset can move the request into an entirely different parser branch.
The earlier research deserves credit for identifying the broader technique. We found the condition that turned a previously dismissed ModSecurity case into an end-to-end WAF bypass, and it helped us find the same disagreement across other security products.
How the security layers behaved
We call a product vulnerable only when it blocks a normal shell.php upload but allows another form that the backend still reads as shell.php. If the normal upload passes too, there is no filename protection to bypass.

We intentionally leave most product-specific root causes out of this section. The goal is to explain the patterns clearly enough for readers to investigate them, and who knows perhaps discover variants that existing fixes still miss.
Secured CVEs
- https://github.com/corazawaf/coraza/security/advisories/GHSA-3wr7-993q-jrff
- https://github.com/owasp-modsecurity/ModSecurity/security/advisories/GHSA-5pww-8rfg-9crf
- https://github.com/samwafgo/SamWaf/security/advisories/GHSA-rp64-v92w-p23w
- https://github.com/OISF/suricata/security/advisories/GHSA-7fvh-vrjw-pq5r (Pending)
- https://github.com/bunkerity/bunkerweb/security/advisories/GHSA-cvfx-2ffg-cqmj
Conclusion
The lesson is simple: a security product does not protect the bytes it receives; it protects the meaning it assigns to them. If the backend assigns a different meaning, that gap becomes part of the attack surface.
This is why parser-differential testing needs more than malformed requests. A strong test starts with a dangerous control that the security layer blocks, changes how the same value is represented, confirms that the alternative passes, and then checks what the backend actually received. Without that final comparison, it is easy to mistake a parsing error for protection or a harmless difference for a vulnerability.
The safest design is for every security layer to interpret the request the same way as the application. When that cannot be guaranteed, ambiguous input should be rejected before it reaches the backend. A one-character difference such as the asterisk in filename* should not be enough to give the security layer and the application two different versions of the truth.