Category: Deep Dives

Long form technical deep dives into one mechanism at a time: cloud, kernel, IoT, and privacy internals.

  • Client Side Desync Attack

    Client Side Desync Attack

    Classic request smuggling needs two servers that disagree and a connection shared between strangers. A client side desync attack throws out both requirements. The desync happens on the one connection the victim’s own browser holds open to the server, so there is no shared back end hop to abuse and no second server that has to parse length differently. If the ideas here feel new, our primer on http request smuggling sets up the vocabulary first.

    What makes a client side desync attack different

    In the usual story a front end forwards your request to a back end over a connection many users share, and the attack is a disagreement between those two servers. A client side desync removes the middle. It only needs one server that mishandles the body of a request: a server that sends its response and moves on without reading the whole body you declared. The bytes you declared but the server never consumed do not disappear. They sit in the connection, waiting to be read as the beginning of whatever comes next.

    The connection in question is the one between the victim’s browser and the server. Browsers keep connections alive and reuse them for several requests to the same origin. So if the first request leaves unread bytes in that socket, the browser’s next request on the same connection lands right behind them. The server reads the attacker’s leftover bytes first, then the browser’s real request, and fuses the two into one request it then runs.

    The poisoned connection does not live on a server shared by many users. It lives inside the victim’s own browser, which is why a single endpoint that forgets to read a body is enough to pull off the whole attack.

    How a single server ends up desynced

    The flaw is a server that commits to a response before it finishes reading the request. A common shape is an endpoint that replies early for certain requests, a redirect, a static asset, or an error page, while the client is still allowed to send a body the server said it would read. The server declared through its handling that a body of some length was coming, then answered without draining it. Now the body is stranded in the connection.

    An attacker builds a request where that stranded body is not junk but a valid request line and headers. Suppose the vulnerable endpoint is a redirect that ignores the body it was told to expect. The attacker sends a POST to it with a Content-Length that covers an extra request hidden in the body:

    POST /redirect HTTP/1.1
    Host: acme-notes.example
    Content-Length: 34
    
    GET /private HTTP/1.1
    Foo: bar

    The server answers the redirect and never reads the 34 byte body. Those 34 bytes, the smuggled GET /private and a dangling header, stay in the connection. When the browser sends its next request to the same origin, that request is appended to the smuggled lines and the server runs the attacker’s GET /private carrying the victim’s session.

    Why it reaches sites a classic desync cannot

    Front end desync bugs have been getting fixed, and plenty of sites now run a single well behaved server or a chain that agrees on length. A client side desync does not care about any of that. It needs only one request handler on one server that answers before draining the body. Because the vulnerable behavior is per endpoint, a site that is clean everywhere else can still expose one redirect or one static path that detaches the body, and that single path is enough. The attack also reaches the victim directly, since the poisoned connection is theirs. You are not waiting to land a prefix on a random stranger sharing a back end socket. You are steering one specific person’s browser.

    That direct reach is what makes it dangerous. Once the attacker controls the first request on the victim’s connection, they can prefix the victim’s next navigation with a request to any path, capture the victim’s own request and its cookies into a stored field, or redirect the victim’s browser to a response the attacker chose. It is a close relative of capturing other users requests, except the captured user is the one the attacker lured to a page rather than whoever happened to share a connection.

    Finding and shutting it down

    You confirm a client side desync by checking whether one request leaves the connection in a state where the next request is misread. A safe way to test is to open a single connection, send the suspect request followed by a plain request, and watch whether the second response answers the smuggled line instead of the plain one. If it does, the server detached the body and the socket is poisoned. This is an injection and input problem at heart: bytes that were supposed to be an inert body get read as a request because the server stopped reading at the wrong place.

    The fix is to make the server read or discard the entire declared body before it reuses or closes the connection, and to close the connection outright whenever it answers early rather than leaving unread bytes behind. A handler that will not consume a body should refuse the request or drain it, never reply and leave the remainder parked in the socket.

    A client side desync is easy to miss with a scanner because every byte in the attacker’s request is individually legal and the bug only appears when one real browser reuses one real connection. You find it by asking what a specific endpoint assumes about the body it was promised, then arranging for that promise to be broken. That is the kind of per endpoint assumption an autonomous researcher is built to test, and at UnboundCompute we verify a desync is actually reachable before reporting it, so a finding is a proven path and not a guess. The delivery side, how an attacker page actually makes the browser send these bytes, is the subject of browser powered request smuggling.

    Frequently asked questions

    What is a client side desync attack?

    It is a desync that happens on the single connection between a victim’s own browser and a server, instead of on a connection shared by many users behind a front end. One server mishandles a request body, leaving bytes stranded in the connection, and the browser’s next request on that same connection is read as a continuation of the attacker’s stranded bytes. Because it is the victim’s connection, the smuggled request carries the victim’s cookies.

    How is it different from classic request smuggling?

    Classic smuggling needs a front end and a back end that read length differently and a connection shared between strangers. A client side desync needs neither. One server that answers before reading the whole body is enough, and the poisoned connection is the victim’s own, so the attacker targets one chosen person rather than whoever happens to share a back end socket.

    Why does it reach sites with no front end desync?

    Because the vulnerable behavior is a single endpoint that detaches the body, not a parser mismatch between two servers. A site can run one well behaved server, or a chain that agrees on length everywhere, and still expose one redirect or static path that answers without draining the body. That single path is all a client side desync needs.

    How do you test for it safely?

    Open one connection, send the suspect request followed by a plain request, and check whether the second response answers the smuggled line instead of the plain request. If it does, the server detached the body and left the socket poisoned. A safe probe smuggles an inert request so you confirm the desync without causing harm.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.

  • HTTP/2 CRLF Header Injection

    HTTP/2 CRLF Header Injection

    In HTTP/1.1 a header ends when the parser sees a carriage return and a line feed, so a value is never allowed to contain those bytes. HTTP/2 does not work that way. It carries each header name and each header value as a counted binary field, which means a value can legally hold bytes that HTTP/1.1 treats as structure. HTTP/2 CRLF header injection is the trick of smuggling those forbidden bytes through an HTTP/2 front end so that, when the request is rewritten for an older back end, they turn into real header lines. If this family is new to you, start with our primer on http request smuggling, then come back for the injection primitive itself.

    Why HTTP/2 lets a value carry bytes HTTP/1.1 forbids

    Text framing and binary framing treat the same characters very differently. In HTTP/1.1 the message is one text stream. A header line is a name, a colon, a value, then a carriage return and a line feed. The line feed is the delimiter. Put a raw line break inside a value and you have not sent a long value, you have ended the header and started another one. Every careful HTTP/1.1 parser rejects control characters in a value for exactly this reason.

    HTTP/2 drops the text stream. Headers travel in a compressed binary format where each name and each value is written as a length followed by that many bytes. The length field decides where the value ends, not any delimiter inside it. So a value of twenty bytes is twenty bytes, even if three of them are a carriage return, a line feed, and a colon. Nothing in the framing stops you. The HTTP/2 standard does say an implementation must treat a field that contains a carriage return, a line feed, or a null byte as malformed, but that is a rule each server has to enforce, and plenty of them do not.

    The whole primitive lives in one gap: HTTP/2 says a value is however many bytes you counted, while HTTP/1.1 says a value ends at the first line break. The attacker supplies bytes that are data on one side of the hop and structure on the other.

    How the downgrade turns a value into HTTP/2 CRLF header injection

    Most front ends still speak HTTP/2 to the internet and HTTP/1.1 to the back end. To forward a request the front end flattens every HTTP/2 field back into a text line: it writes the name, a colon and a space, the value, then a carriage return and a line feed. If it copies the value across without checking for embedded control bytes, any carriage return and line feed you hid in that value is written verbatim into the HTTP/1.1 stream. The back end reads that stream as text and obeys the line breaks. Your data just became new header lines.

    Picture a request to a typical notes app. You send an HTTP/2 request and set one ordinary looking header, call it foo, to a value that contains a line break in the middle:

    foo: bar\r\nX-Injected: 1

    On the wire that is a single HTTP/2 field whose value is the counted bytes bar, a carriage return, a line feed, and X-Injected: 1. The front end validates nothing and flattens it. The back end receives this:

    foo: bar
    X-Injected: 1

    One header went in. Two came out. The back end now processes an X-Injected header the client never legitimately sent, because the front end wrote the attacker’s bytes as structure. That is the injection. You can aim it at a pseudo header too. HTTP/2 carries the method, path, scheme and authority as the fields :method, :path, :scheme and :authority, and each of those is flattened into the request line or a header during the downgrade. A carriage return and line feed smuggled through :path can append a header of your choosing, or begin a whole new line the back end reads as the start of another request.

    Header names, header values, and the request line

    There are three distinct spots where the forbidden bytes can land, and they differ in what they let you forge.

    • Inside a header value. The simplest case, shown above. A carriage return and line feed inside the value close the current header and let you write one or more fresh header lines that the back end trusts.
    • Inside a header name. Some stacks let you register a name that itself contains a colon or a space. After flattening, the back end may read the extra colon as the start of a value, splitting one field into two and letting you control a header the front end thought it owned.
    • Inside a pseudo header. The :path and :authority fields become the request line and the Host header. A control byte here rewrites the first line of the HTTP/1.1 request, which is the richest target because the request line is where a second request would begin.

    This post stops at the primitive on purpose. Injecting a header or a stray line is the raw capability. Turning it into two complete requests on one connection, so a prefix waits in the back end buffer for the next user, is the next step, and we cover that in the sibling piece on http2 request splitting. Keeping the two ideas apart matters, because a stack can be open to the injection yet shrug off a full split, or the reverse.

    Spotting it without breaking anything

    You do not need a dangerous payload to tell whether a front end validates these bytes. Send an HTTP/2 request whose header value contains a carriage return and line feed followed by a header that produces a visible, harmless effect, then watch whether the back end acts on the injected header. A safe probe injects something inert, like a duplicate Accept or a vendor header the app echoes back, and checks the response for proof the extra line was parsed. If the injected header shows up in a reflected value or changes a cache key, the front end passed your control bytes through unchanged. This sits squarely in the family of injection and input flaws, where untrusted bytes cross a boundary and get read as structure on the far side.

    Defenses are about validation at the hop. Per the HTTP/2 standard a front end must reject any field whose name or value contains a carriage return, a line feed, or a null byte, and must treat a colon inside a name as malformed. Enforce that before flattening, not after. Where you can speak HTTP/2 to the back end as well, there is no text rewrite and the primitive has nowhere to land. And normalize the request the front end emits so it owns the framing rather than copying attacker bytes into a text line.

    CRLF injection on a downgrade is a good example of a bug that hides between two components that each look correct on their own. The HTTP/2 side counted its bytes honestly. The HTTP/1.1 side read its line breaks honestly. The flaw is only in the handoff, which is exactly where a check on paper and the real behavior tend to drift apart. It is the kind of assumption an autonomous researcher that tests how each hop actually parses bytes is built to probe, and at UnboundCompute we verify a desync is actually reachable before reporting it, rather than flagging a header that merely looks suspicious.

    Frequently asked questions

    What is HTTP/2 CRLF header injection?

    It is injecting carriage return and line feed bytes through an HTTP/2 front end so that, when the request is downgraded to HTTP/1.1, those bytes turn into new header lines. HTTP/2 carries a header value as a counted binary field, so the value can hold a carriage return or a line feed that HTTP/1.1 would treat as the end of a header. If the front end copies the value into text without checking, the back end reads your bytes as structure.

    Why can an HTTP/2 value contain bytes HTTP/1.1 forbids?

    HTTP/1.1 is a text stream where a line feed ends a header, so a value may never contain one. HTTP/2 writes each value as a length followed by that many bytes, so the length decides where the value ends, not any character inside it. A value of twenty bytes is twenty bytes even if some are a carriage return or a colon. The HTTP/2 standard says to reject such fields as malformed, but not every server enforces it.

    Is this the same as HTTP/2 request splitting?

    No, and keeping them apart matters. CRLF header injection is the primitive: you get the back end to read bytes you chose as a header line or a stray line. Request splitting is what you build when that injected line becomes a full second request on the connection, so a prefix waits for the next user. A stack can be open to the injection yet resist a full split. See http2 request splitting for the next step.

    How do you defend against it?

    Validate at the hop that downgrades. Per the HTTP/2 standard a front end must reject any header whose name or value contains a carriage return, a line feed, or a null byte, and must treat a colon inside a name as malformed, and it must do this before flattening the request to HTTP/1.1. Speaking HTTP/2 to the back end as well removes the text rewrite entirely, so there is nowhere for the injected bytes to land.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.

  • HTTP/2 Request Splitting Explained

    HTTP/2 Request Splitting Explained

    When a front end downgrades HTTP/2 to HTTP/1.1, it rewrites a binary message into text, and that rewrite can be abused. HTTP/2 request splitting happens when carriage return and line feed sequences hidden inside HTTP/2 header names or values get written literally into the HTTP/1.1 stream, so one request the client sent arrives at the back end as two. It is a precise and common form of http request smuggling that exists only because the downgrade has to flatten structured frames into a text format where newlines carry meaning.

    Why the downgrade is where splitting happens

    HTTP/2 carries headers as binary name and value pairs. A newline inside a value is just data there; it has no structural role, because the framing, not the bytes, decides where one header ends and the next begins. HTTP/1.1 is the opposite. Its headers are lines of text, and a carriage return and line feed is exactly what separates one header from the next, and a blank one separates the headers from the body.

    So when a front end accepts an HTTP/2 request and rewrites it for an HTTP/1.1 back end, it takes values where newlines were harmless and writes them into a format where newlines are structure. If it copies a value across without checking for those bytes, any carriage return and line feed the attacker placed inside a header value is now a real line break in the HTTP/1.1 request. The attacker gets to write new lines into the stream the back end parses.

    In HTTP/2 a newline inside a header is data. After the downgrade it is punctuation. Splitting is the attack that lives in that change of meaning.

    How HTTP/2 request splitting forges a second request

    Consider a request against an invented app, Acme Notes, where the attacker controls one header value. In HTTP/2 the attacker sets that value to a string that contains encoded carriage return and line feed sequences followed by a complete second request:

    :method GET
    :path /
    host: acme-notes.example
    x-trace: abc\r\n\r\nGET /admin HTTP/1.1\r\nHost: acme-notes.example\r\nx: x

    Inside HTTP/2 this is a single valid request with one oddly long x-trace value. The front end accepts it, then downgrades. If it writes the value out verbatim, the HTTP/1.1 request it sends to the back end looks like this instead:

    GET / HTTP/1.1
    host: acme-notes.example
    x-trace: abc
    
    GET /admin HTTP/1.1
    Host: acme-notes.example
    x: x

    The injected blank line ended the first request early, and everything after it is read by the back end as a second, independent request. One HTTP/2 message became two HTTP/1.1 requests. The second one, the smuggled GET /admin, never passed through the front end as a request, so none of the front end rules that guard that route ever ran. The split request then sits on the shared connection and can be glued onto the next user’s traffic, the same prefix poisoning any smuggle relies on.

    How it differs from header injection

    Request splitting has a close sibling that uses the same injected bytes for a smaller effect, and it is worth keeping them apart. In HTTP/2 CRLF header injection the attacker injects a single carriage return and line feed to add or overwrite one header on the existing request. The request stays one request; the attacker has just slipped an extra header into it, perhaps to set a value the back end trusts or to override something the front end added.

    Request splitting goes further. It injects a full blank line, the double carriage return and line feed that ends a request, and then writes an entire new request line and header block after it. The difference is the blank line. One extra newline forges a header; two in a row forge a request boundary. Both start from the same failure to sanitize values during the downgrade, and both belong to the family we group under injection and input, but splitting turns one message into two while injection keeps one message and edits it.

    Where the bytes hide

    The injected sequence does not have to live in an ordinary header value. The downgrade has to flatten everything into text, so every field that gets written out is a candidate.

    • Header values. The common case, as above, where a value the attacker controls carries the newline payload.
    • Header names. Some rewriters copy names across with as little checking as values, so a crafted name can carry the break just as well.
    • Pseudo headers. The HTTP/2 :path and :method fields become the request line during the downgrade. A newline that survives into the path can split the request line itself.

    The common thread is that HTTP/2 lets these fields hold bytes HTTP/1.1 treats as structural, and the downgrade is the moment those bytes change from data into punctuation. This is why validating input at the HTTP/2 edge is not enough on its own; the dangerous transformation happens later, at the rewrite.

    Stopping the split

    The defense is to refuse control characters anywhere they could survive the downgrade. A front end should reject any HTTP/2 header name, header value, or pseudo header that contains a carriage return or a line feed, rather than copying it into an HTTP/1.1 line. Because the whole bug is the rewrite, the check has to run at the rewrite, on every field, not only on the fields the application happens to read. Speaking HTTP/2 to the back end removes the rewrite entirely and with it the place the split is forged.

    What makes request splitting slip past a scanner is that the HTTP/2 message is well formed; the second request does not exist until the downgrade writes it. UnboundCompute reasons about that reachability rather than matching header patterns, following whether a client controlled byte actually survives the rewrite and reaches a back end parser that reads it as a new request line. You can read more about how that works on our about page. The client sent one request. The downgrade is what turned it into two.

    Frequently asked questions

    What is HTTP/2 request splitting?

    It is a smuggling attack where carriage return and line feed sequences hidden inside HTTP/2 header names or values get written literally into the HTTP/1.1 stream during a downgrade, splitting one request the client sent into two at the back end. In HTTP/2 a newline inside a header is just data, but after the downgrade it becomes structure, and that change of meaning is the whole attack.

    Why does the downgrade cause the split?

    HTTP/2 keeps headers as binary name and value pairs where a newline has no structural role. HTTP/1.1 separates headers with a carriage return and line feed and ends the header block with a blank line. When the front end flattens an HTTP/2 value into an HTTP/1.1 line without checking for those bytes, an injected blank line ends the first request early and everything after it is read as a second request.

    How is it different from HTTP/2 CRLF header injection?

    The difference is the blank line. HTTP/2 CRLF header injection uses a single carriage return and line feed to add or overwrite one header, so the request stays one request. Request splitting injects a full blank line, the double carriage return and line feed that ends a request, then writes a whole new request line after it. One extra newline forges a header; two in a row forge a request boundary.

    How do you defend against request splitting?

    Refuse any HTTP/2 header name, header value, or pseudo header that contains a carriage return or line feed, rather than copying it into an HTTP/1.1 line. Because the bug is the rewrite, the check has to run at the rewrite, on every field, not only the ones the application reads. Speaking HTTP/2 to the back end removes the rewrite entirely and with it the place the split is forged.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.

  • How h2c Smuggling Works

    How h2c Smuggling Works

    Most smuggling bugs come from two servers disagreeing about where a request ends. h2c smuggling is different: it comes from a front end that blindly forwards an upgrade handshake, letting a client open a cleartext HTTP/2 tunnel straight through to the back end. Once that tunnel exists, the client talks to the back end directly and the front end stops inspecting anything, which makes it a quiet cousin of ordinary http request smuggling that bypasses routing and controls instead of poisoning a queue.

    What h2c is and how the upgrade works

    h2c is cleartext HTTP/2, meaning HTTP/2 spoken over a plain TCP connection with no TLS. A client that wants to move a normal HTTP/1.1 connection up to h2c asks for it with an upgrade handshake. It sends a request with two headers that work together:

    GET / HTTP/1.1
    Host: acme-notes.example
    Upgrade: h2c
    Connection: Upgrade, HTTP2-Settings
    HTTP2-Settings: AAMAAABkAAQAAP__
    

    The Upgrade header names the protocol the client wants to switch to. The Connection: Upgrade header marks the upgrade as a hop by hop instruction, meaning it is addressed to the next server in the chain and should not be passed further. If a server agrees, it replies with a 101 Switching Protocols status and from that point the connection is no longer HTTP/1.1; it is a raw h2c connection carrying HTTP/2 frames.

    Why h2c smuggling works

    The Connection and Upgrade headers are hop by hop by design. A front end that handles its own connections correctly should consume the upgrade request, decide for itself whether to switch protocols, and never forward those headers to the back end. The bug appears when the front end treats the upgrade request as an ordinary request to proxy and passes the Upgrade and Connection headers straight through.

    When that happens, the back end receives the handshake, agrees to it, and returns 101 Switching Protocols. The front end relays that response to the client and, because it thinks it is just proxying an ordinary exchange, it keeps the underlying connection open as a dumb pipe. The client now has an h2c connection that runs end to end to the back end, with the front end relaying bytes it no longer parses.

    The front end did not get tricked into misreading a boundary. It got tricked into stepping out of the way entirely.

    What the tunnel lets an attacker do

    The reason this matters is that the front end is usually where the security controls live. It enforces path rules, strips dangerous headers, applies rate limits, and decides which routes the public is allowed to reach. All of that runs on HTTP/1.1 requests the front end parses. Once the client has an h2c tunnel, every request it sends inside that tunnel is framed HTTP/2 that the front end is only relaying, so none of those controls apply.

    • Reaching blocked routes. A front end that refuses /admin or an internal metrics path checks the HTTP/1.1 request line. Inside the tunnel the attacker sends an HTTP/2 request straight to the back end for exactly those paths, and the front end never sees a request line to block.
    • Undoing header rules. Headers the front end would normally add, strip, or rewrite, such as an authentication header it injects or a client address header it overwrites, are skipped, because the front end is no longer building the request the back end reads.
    • Escaping rate and access limits. Controls keyed on parsed requests stop counting once the traffic is opaque frames the front end just forwards.

    The effect is close to reaching the back end as if the front end were not there. That is why h2c smuggling is sometimes described as request tunnelling rather than a desync, and our note on the broader request tunnelling attack covers the same shape of problem from the tunnelling side.

    A concrete walk through

    Picture Acme Notes behind a front end that routes by path and blocks anything under /internal/. The attacker first sends the upgrade request shown above against a path the front end does allow, so the request passes the route check. The front end, not realizing the Upgrade and Connection headers are meant for it, forwards them to the back end. The back end supports h2c, answers 101 Switching Protocols, and the tunnel is open.

    Now the attacker sends a second request inside the tunnel as an HTTP/2 frame, this time for /internal/config. The front end is relaying raw bytes and never parses that path, so its block rule never fires. The back end receives a perfectly valid HTTP/2 request and serves the internal page. The attacker reached a route the front end exists to protect, without ever smuggling a byte past a parser, because the parser was switched off by the handshake.

    How to shut it

    The fix is to treat the upgrade headers as what they are. A front end should strip Connection, Upgrade, and the HTTP2-Settings header from requests before forwarding them, so a back end never sees an upgrade the client was not allowed to negotiate. If the front end does not itself support switching a connection to h2c, it should refuse the request rather than proxy the handshake. Back ends that do not need to accept h2c from a proxy should have it turned off, so a forwarded handshake has nothing to agree to. These are all ways of saying the same thing: the server that owns the controls must also own the decision to change protocols, and this family of input handling bugs is the kind we group under injection and input.

    What makes h2c smuggling easy to miss is that nothing in the request is malformed. The handshake is legal, the paths are legal, and the only mistake is a forwarding decision about which headers are hop by hop. UnboundCompute reasons about that reachability rather than matching header patterns, asking whether an upgrade a client sends can actually reach a back end that will accept it and open a path around the controls. You can read more about that approach on our about page. The request never breaks the rules. It just arranges to be read by a server that was never told the rules.

    Frequently asked questions

    What is h2c smuggling?

    h2c is cleartext HTTP/2, meaning HTTP/2 over a plain TCP connection with no TLS. h2c smuggling happens when a front end blindly forwards an upgrade handshake to the back end instead of handling it itself. The back end agrees, a cleartext HTTP/2 tunnel opens end to end, and the front end relays raw frames it no longer parses, so its routing and security controls stop applying.

    How does the upgrade let a client build a tunnel?

    A client sends a request with Upgrade: h2c and Connection: Upgrade. Those headers are hop by hop, meaning they are addressed to the next server and should not be forwarded. A front end that proxies them anyway lets the back end reply 101 Switching Protocols. From then on the connection is raw h2c and the client talks straight to the back end through the front end, which has become a dumb pipe.

    What can an attacker do through the tunnel?

    Because the front end usually holds the controls, a tunnel lets the client skip them. The attacker can reach routes the front end blocks such as an admin or internal path, undo headers the front end would add or strip, and escape rate and access limits that only count parsed requests. The effect is close to reaching the back end as if the front end were not there, which is why it is a form of request tunnelling.

    How do you prevent h2c smuggling?

    Strip Connection, Upgrade, and HTTP2-Settings from requests before forwarding, so the back end never sees an upgrade the client was not allowed to negotiate. If the front end does not itself support switching to h2c, it should refuse the request rather than proxy the handshake, and back ends that do not need h2c from a proxy should have it turned off.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.

  • HTTP/2 Downgrade Smuggling Explained

    HTTP/2 Downgrade Smuggling Explained

    HTTP/2 was meant to end request smuggling for good, and inside its own framing it does. HTTP/2 downgrade smuggling is what happens when a front end speaks HTTP/2 to the browser but rewrites each request as HTTP/1.1 before handing it to a back end, and that rewrite quietly reintroduces the length ambiguity HTTP/2 had removed. It is one of the most common modern paths into http request smuggling, because it reopens the bug on chains that look modern from the outside.

    Why HTTP/2 was supposed to fix smuggling

    Classic smuggling comes from HTTP/1.1 having two ways to say how long a body is. Content-Length gives a byte count, and Transfer-Encoding: chunked sends sized chunks ending in a zero chunk. When a request carries both and they disagree, two servers can slice the stream at different bytes, and the leftover becomes a request the attacker wrote.

    HTTP/2 does not send headers and bodies as a text stream you have to slice. Each message body travels in binary data frames, and the framing itself carries the length. There is one unambiguous answer to where a message ends, set by the frames, not by two text headers that have to agree. End to end over HTTP/2 there is simply no place for a length disagreement to hide. If the whole chain spoke HTTP/2 from the browser to the back end, this class of bug would mostly be finished.

    Where HTTP/2 downgrade smuggling reopens it

    The chain rarely speaks HTTP/2 the whole way. Most front ends accept HTTP/2 from the internet and then rewrite each request into HTTP/1.1 for a back end that still speaks the older protocol. That rewrite is the downgrade, and it is where HTTP/2 downgrade smuggling lives.

    To downgrade, the front end has to invent the HTTP/1.1 length headers from the HTTP/2 frame data. It writes a Content-Length, or it copies across a length header the request carried. If it does this carelessly, the back end is once again reading length from a text header that may not match the real body, and the attacker has a boundary to forge.

    HTTP/2 removed the ambiguity from the wire. The downgrade puts it back by hand, on the one hop you cannot see.

    H2.CL, a forged content length

    In HTTP/2 the true body length is fixed by the data frames, so the content-length field a client sends is only a claim the server is supposed to check against those frames. An attacker sends a request whose declared content-length is smaller than the actual body carried in the frames.

    :method POST
    :path /
    content-length: 4
    
    GET /admin HTTP/1.1
    Host: acme-notes.example
    ...

    If the front end fails to validate that claim and trusts it during the downgrade, it writes Content-Length: 4 into the HTTP/1.1 request it forwards. The back end reads only four bytes as the body and treats the rest, the smuggled GET /admin lines, as the start of the next request on the connection. Same prefix, same poisoned queue, reached through a length the front end should have recomputed from the frames.

    H2.TE, a transfer encoding that should not exist

    The HTTP/2 standard says a request carrying a transfer-encoding header is malformed and must be rejected, because chunked encoding has no meaning inside HTTP/2 framing. A front end that forwards that header anyway hands the back end a Transfer-Encoding: chunked on a downgraded request. The back end honors it, reads the body as chunks regardless of the length the front end wrote, and desyncs. The attacker reached a classic chunked smuggle through a header the front end should have thrown away at the door.

    Splitting a request through injected newlines

    Length is not the only way in. HTTP/2 keeps header names and values in a binary format where a newline is just data, so a value is allowed to contain bytes that would be structural in HTTP/1.1. When the front end flattens that value into a single text line during the downgrade, any carriage return and line feed the attacker hid inside it gets written literally into the HTTP/1.1 stream. That lets the attacker end one header early and start new lines the back end reads as a second request. This splitting path is a close relative of plain HTTP/2 CRLF header injection; the difference is whether the injected bytes forge a whole new request boundary or just inject one extra header.

    Why the downgrade case matters so much

    Pure HTTP/1.1 smuggling needs two HTTP/1.1 servers that parse length differently, and careful operators had been closing those gaps for years. The downgrade widened the target again. It reopened the bug on deployments that look safe from the outside, where the public facing server speaks HTTP/2 and only the internal hop still speaks HTTP/1.1. The front end that was supposed to be the fix became the place the request gets rewritten into an ambiguous form.

    The rewrite also carries more than length. Header names, header values, and the HTTP/2 pseudo headers for method and path all have to collapse into one text request line and a block of HTTP/1.1 headers. Anywhere a special character survives that collapse, a new request boundary can be forged, which is why downgrade smuggling and request splitting keep showing up together. Our walk through CL.TE request smuggling covers the length half of this in the HTTP/1.1 setting the downgrade lands in, and the whole family sits under injection and input.

    Closing the gap

    The defenses are about the rewrite, not about blocking payloads. Validate the content-length a client sends against the real frame data and recompute it rather than trusting it. Drop any transfer-encoding the HTTP/2 standard calls malformed instead of forwarding it. Refuse header names and values that carry control characters so nothing survives the flatten into a forged boundary. Best of all, speak HTTP/2 to the back end too, because a chain with no downgrade has no place to forge a length header.

    What makes this bug hard for a scanner is that every byte is legal HTTP/2 on its own; the flaw only appears after the rewrite, on a hop the scanner never sees. UnboundCompute reasons about that reachability rather than matching header patterns, following whether a client controlled value actually survives the downgrade and reaches a second parser that reads it as a new boundary. You can read more about how that works on our about page. HTTP/2 closed the door. The downgrade is someone propping it back open on the way to the back end.

    Frequently asked questions

    What is HTTP/2 downgrade smuggling?

    It is request smuggling that appears when a front end speaks HTTP/2 to the client but rewrites each request as HTTP/1.1 for a back end. HTTP/2 carries body length unambiguously in its binary frames, but the rewrite has to invent HTTP/1.1 length headers, and if it does that carelessly the back end reads length from a text header that no longer matches the real body, which reopens the old desync.

    Why was HTTP/2 supposed to fix smuggling?

    Classic smuggling comes from HTTP/1.1 having two ways to declare body length, Content-Length and Transfer-Encoding: chunked, which can disagree. HTTP/2 sends each body in binary data frames whose length is set by the framing, not by text headers, so there is one unambiguous answer to where a message ends. End to end over HTTP/2 there is no place for a length disagreement to hide.

    What are H2.CL and H2.TE?

    They name the two downgrade desyncs. H2.CL is when the front end trusts a content-length the client sent instead of validating it against the frames, then writes that wrong length into the forwarded HTTP/1.1 request. H2.TE is when the front end forwards a transfer-encoding header the HTTP/2 standard calls malformed, and the back end honors it as chunked. Both leave a smuggled prefix in the back end buffer.

    How do you stop downgrade smuggling?

    Recompute the content-length from the real frame data rather than trusting the client claim, drop any transfer-encoding the HTTP/2 standard calls malformed, and refuse header names and values that carry control characters so nothing survives the flatten into a forged boundary. Best of all, speak HTTP/2 to the back end too, because a chain with no downgrade has no place to forge a length header.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.

  • Transfer Encoding Obfuscation Explained

    Transfer Encoding Obfuscation Explained

    When a request passes through a front end and then a back end, both servers read the same bytes and both decide where the body ends. transfer encoding obfuscation is the family of tricks that makes them decide differently by mangling the Transfer-Encoding header so one server honors it and the other throws it away. It is the mechanism behind the TE.TE flavor of http request smuggling, and it turns a header both servers claim to support into a boundary they no longer agree on.

    Why one mangled header splits the chain

    Chunked encoding tells a server to read the body as a series of sized chunks ending in a zero length chunk, rather than counting a fixed Content-Length. When a request carries both headers, the standard says Transfer-Encoding wins and Content-Length is ignored. So if an attacker can make just one server in the chain stop seeing the Transfer-Encoding header, that server falls back to Content-Length while its neighbor still reads chunks. The two now slice the stream at different bytes, and the leftover becomes a smuggled prefix waiting for the next user.

    The attacker never has to defeat chunked parsing itself. They only have to write the header so that one parser reads it as Transfer-Encoding: chunked and the other does not. Every technique below is a different way to hide that header in plain sight.

    The techniques of transfer encoding obfuscation

    Whitespace and tabs before the colon

    A header name is supposed to be followed immediately by a colon. Some parsers are strict about that; others tolerate a space or a tab wedged between the name and the colon. An attacker sends this:

    Transfer-Encoding : chunked

    A lenient parser reads it as a normal Transfer-Encoding header and switches to chunked. A strict parser sees a name it does not recognize and refuses to treat it as Transfer-Encoding at all, so it reads the Content-Length instead. One request, two readings.

    A leading space in the value

    The same idea moves to the value side. A keyword padded with extra space can slip past one parser and trip another, so one server matches the chunked token and the other decides the value is malformed and ignores the header.

    Bogus values and misspellings

    A value the back end does not recognize can still change what the front end does. Writing Transfer-Encoding: xchunked gives one server a token it treats as unknown, so it ignores the header and falls back to length, while the other server matches on the chunked substring and reads chunks. A value list such as Transfer-Encoding: chunked, identity does the same by handing each parser a different thing to anchor on.

    Line folding

    Obsolete line folding let a header value continue on the next line when that line began with a space or tab. It was removed from the standard, but old parsers still honor it. Fold the header and one server joins the lines while the other does not:

    Transfer-Encoding:
     chunked

    A server that supports folding joins the two lines into Transfer-Encoding: chunked. A server that does not treats the first line as an empty value and the indented line as junk, then reads length instead. This is the same obsolete folding that has turned up in real edge platform advisories.

    Duplicated headers

    Nothing stops a request from carrying the header twice:

    Transfer-Encoding: chunked
    Transfer-Encoding: identity

    One server may act on the first occurrence, another on the last. If the front end honors identity and reads Content-Length while the back end honors chunked, the chain desyncs. A duplicate header is a boundary question dressed up as a formatting detail.

    Every one of these tricks is valid enough to forward and broken enough to be read two ways. That gap between forwarded and understood is the whole attack.

    From obfuscation to a desync

    Put the pieces together against an invented app, Acme Notes. The attacker sends a request whose Transfer-Encoding header carries a tab before the colon, and also a Content-Length that covers only the first few bytes of the body. The front end has the strict parser. It rejects the odd header name, trusts Content-Length, reads a short body, and forwards the whole request on the shared connection believing it sent one ordinary POST.

    The back end has the lenient parser. It accepts the tabbed header as Transfer-Encoding: chunked, ignores the length, and reads the body as chunks. It stops at the zero chunk early, and the bytes the attacker placed after it stay in the buffer as the start of a new request. When the next real user arrives, the back end glues the smuggled prefix onto that victim request. The attacker wrote a request that runs under someone else’s session, and the front end never saw it as a request because it was hiding in a body the front end approved.

    This is exactly the desync described in our note on TE.TE request smuggling, and it sits in the same family of boundary bugs we group under injection and input. The header obfuscation is only the key; the lock is two parsers that disagree.

    What actually stops it

    There is no bad string to block here. Each mangled header is legal enough that a signature would either miss it or flag ordinary traffic. The fix is to make the front end own the framing: parse every header strictly, reject requests whose Transfer-Encoding is malformed or duplicated instead of guessing, and rewrite each request into one unambiguous form before forwarding. If the front end will not honor a header the way the back end does, it should strip the header, not pass it along for the back end to read differently. A request that carries both length headers should be refused and its connection closed, so no leftover prefix has anywhere to wait.

    The reason these tricks keep working is that a filter sees a header string, not whether that string reaches a second parser that reads it a different way. UnboundCompute reasons about that reachability rather than matching header patterns, asking whether a mangled header actually crosses a parser boundary and desyncs the chain before it calls anything a finding. You can read more about that approach on our about page. The header is just bytes. The bug is two readers who will never agree on them.

    Frequently asked questions

    What is transfer encoding obfuscation?

    It is the set of tricks that mangle the Transfer-Encoding header so one server in a chain honors it and another ignores it. The attacker does not break chunked parsing; they write the header so that one parser reads it as Transfer-Encoding: chunked and the other treats it as unrecognized and falls back to Content-Length. That disagreement is what lets a request smuggle work.

    Which header tricks actually cause the disagreement?

    The common ones are a space or tab wedged before the colon, a leading space in the value, a bogus or misspelled value such as xchunked, a value list like chunked, identity, obsolete line folding that continues the value on an indented next line, and the header sent twice so one server reads the first copy and another reads the last. Each one is valid enough to forward and broken enough to be read two ways.

    How does this relate to TE.TE smuggling?

    TE.TE is the smuggling variant where both the front end and the back end support chunked encoding, so in theory they agree. Transfer encoding obfuscation is the mechanism that breaks that agreement. By mangling the header so one server stops honoring it, the attacker turns a TE.TE pair back into a plain length disagreement, which is exactly the desync described in our note on TE.TE request smuggling.

    How do you defend against it?

    There is no bad string to block, because each mangled header is legal enough to miss or to flag ordinary traffic. The fix is to make the front end parse headers strictly, reject requests whose Transfer-Encoding is malformed or duplicated, strip any encoding it will not honor the same way the back end does, and rewrite each request into one unambiguous form before forwarding so the back end never has to choose.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.

  • Duplicate Content Length Smuggling

    Duplicate Content Length Smuggling

    duplicate content length smuggling is a desync that does not need Transfer-Encoding at all. It happens when one request carries two Content-Length headers with different values. The standard says a server must reject that outright, but not every server does. Some take the first value, some take the last, and the moment two servers in a chain make opposite choices they disagree about where the body ends. That is the same split that drives every other form of http request smuggling, reached through one duplicated header instead of two competing ones.

    How duplicate content length smuggling creates a split

    Every request with a body needs to tell the server how many bytes to read. Normally one Content-Length header does that job. The attack puts in two, with different numbers, and lets the front end and the back end each pick whichever one they prefer. If the front end picks the larger value and the back end picks the smaller, the front end reads and forwards more bytes than the back end will consume, and the extra bytes become a request the attacker wrote.

    Here is a request with two length headers:

    POST / HTTP/1.1
    Host: acme-notes.example
    Content-Length: 53
    Content-Length: 0
    
    GET /admin HTTP/1.1
    Host: acme-notes.example
    Foo: x

    Read it as the front end, which here honors the first header, Content-Length: 53. It reads 53 bytes of body after the blank line, which is the whole GET /admin block, and forwards everything as one POST. Read it as the back end, which here honors the last header, Content-Length: 0. It decides the body is empty and the POST ends at the blank line. The 53 bytes the front end thought were a body are still in the back end buffer:

    GET /admin HTTP/1.1
    Host: acme-notes.example
    Foo: x

    Those bytes become the next request on the connection. The dangling Foo: x with no finished value swallows the first line of the next victim request, so the back end runs the attacker GET /admin stitched onto the victim headers, cookies and all. Flip which server reads which header, start the first value at zero and the last at 53, and the split still works. The only requirement is that the two servers disagree.

    The standard is unambiguous: reject a request that carries two different Content-Length values. The bug is every server that decides to be helpful and picks one instead.

    Why servers disagree even without Transfer Encoding

    RFC 9112 is direct about this. If a message has more than one Content-Length header, or a single header with a list of different values, the server must treat the message as unrecoverable and reject it. The rule exists precisely because duplicate lengths are a smuggling vector. The gap is between the rule and the field. Real servers have historically handled the duplicate in several ways: use the first, use the last, use the largest, reject the message, or stop at the first value and ignore the rest. None of those is safe if the server next to it in the chain chose differently.

    This variant is in some ways simpler to trigger than the chunked ones, because it needs no Transfer-Encoding support anywhere. There is no chunk size to compute and no hexadecimal math. You are relying purely on two plain headers and the chance that two parsers resolve the conflict in opposite directions. That makes it a good thing to check for early, and a clean illustration of why a request that looks valid to each server on its own can still be read two ways across the pair. If you are putting together a test plan, it pairs naturally with the techniques in detecting http request smuggling.

    It rhymes with the duplicate Transfer Encoding trick

    Sending two of the same header to split two parsers is the same mindset behind one branch of TE.TE request smuggling, where a second Transfer-Encoding header with a junk value makes one server ignore the encoding. Duplicate Content-Length is the blunter cousin: no obfuscation, just two honest looking numbers that happen to contradict each other. In both cases the attacker is not sending anything malformed in an obvious way. Each header is well formed. The contradiction only appears when you read the request as the whole chain reads it.

    Defenses that hold

    The defense is the one the standard already states: reject the message. A request with two different Content-Length values should get a single error response and a closed connection, never a best guess. Closing the connection is the part that matters, because a leftover prefix has nowhere to wait once the socket is gone. The front end should also normalize every request into one unambiguous framing, with a single length header it computed itself, before it forwards anything to the back end, so the back end is never handed a contradiction to resolve. These are parser discipline problems at heart, which is why we keep them under injection and input alongside the other ways untrusted bytes get read wrong.

    A scanner can flag that a request carried two Content-Length headers. What it cannot tell you is whether the specific front end and back end in your path resolve that duplicate in opposite directions, which is the only thing that turns a rejected looking request into a live desync. Answering that means reasoning about how each real parser in the chain behaves and proving the request boundary actually moves, not matching a pattern. That is the work UnboundCompute is built for: deciding whether a desync is genuinely reachable rather than pattern matching headers, which you can read about on our about page.

    Frequently asked questions

    What is duplicate content length smuggling?

    It is a request desync caused by sending one request with two Content Length headers that hold different values. The standard requires a server to reject such a message, but some servers instead pick a value. When the front end picks one value and the back end picks the other, they disagree about where the body ends, and the extra bytes become a request the attacker hid inside the first one.

    Does this attack need Transfer Encoding?

    No. That is what sets it apart from the chunked variants. It relies only on two plain Content Length headers and the chance that two parsers in the chain resolve the contradiction in opposite directions. There is no chunk size to compute and no hexadecimal math, which makes it simpler to trigger and a good thing to check for early in a test plan.

    What does the RFC say about two Content Length headers?

    RFC 9112 is direct: if a message has more than one Content Length header, or a single header listing different values, the server must treat the message as unrecoverable and reject it. The rule exists because duplicate lengths are a known smuggling vector. The problem is that real servers have historically used the first value, the last value, the largest, or rejected outright, and any mismatch across the chain is exploitable.

    How do you prevent duplicate content length smuggling?

    Follow the standard and reject the message. A request with two different Content Length values should get one error response and a closed connection, never a best guess, because closing the socket leaves a leftover prefix nowhere to wait. The front end should also normalize every request into a single unambiguous framing with one length header it computed itself before forwarding, so the back end never has to resolve a contradiction.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.

  • TE.TE Request Smuggling

    TE.TE Request Smuggling

    TE.TE request smuggling is the variant where both servers already agree, and you break the agreement on purpose. The front end and the back end both understand Transfer-Encoding: chunked, so a clean request desyncs nothing. The trick is to obfuscate the header just enough that one server still recognizes it and the other does not, which drops that second server back to Content-Length and reopens the split. If the underlying idea of a two server desync is unfamiliar, our primer on http request smuggling explains why two readers of one stream is the whole problem.

    Why TE.TE request smuggling needs obfuscation

    Start with what does not work. If both servers read Transfer-Encoding: chunked the same way, they agree on where the body ends, and there is no gap to smuggle through. A plain chunked request is just a plain chunked request. The desync only appears when exactly one server stops honoring the header. The other falls back to the length rule it has left, which is Content-Length, and now you are back to a split between chunked and byte counting, the same split that drives every other variant.

    So the goal of TE.TE is narrow: write the Transfer-Encoding header in a form that one parser accepts and the other rejects or fails to see. You are not inventing a new attack. You are manufacturing a CL versus TE disagreement between two servers that would otherwise parse the request identically.

    Here is the shape of it, using a second header with a value one server will not understand:

    POST / HTTP/1.1
    Host: acme-notes.example
    Content-Length: 6
    Transfer-Encoding: chunked
    Transfer-Encoding: cow
    
    0
    
    GET /admin HTTP/1.1
    Host: acme-notes.example
    Foo: x

    Two Transfer-Encoding headers arrive. One server combines them, decides the encoding list ends in a value it does not recognize, and declares the whole thing unusable, so it falls back to Content-Length: 6. The other server reads the first header, sees chunked, and processes the body as chunks. From that point the request behaves exactly like the classic case: one server honors chunked and one honors the byte count, so a smuggled prefix is left in the buffer for the next request.

    TE.TE is not a new desync. It is a way to manufacture a split between chunked and byte counting on two servers that would otherwise read the request the same way.

    The obfuscation surface, at a high level

    Which server breaks, and in which direction, depends entirely on the two parsers in your path. The useful spellings all target places where HTTP parsers quietly differ on what counts as a valid Transfer-Encoding header. At a high level they fall into a few groups:

    • Unusual whitespace. A space or a tab in a spot a strict parser rejects but a lenient one trims, such as before the colon or inside the value.
    • A second header with a junk value. Two Transfer-Encoding lines where the second carries something unrecognized, as in the example above.
    • Odd casing or trailing junk. A value like chunked with extra characters stuck to it, or an unexpected mix of upper and lower case that one parser normalizes and the other does not.
    • Line folding and control bytes. Obsolete continuation lines or stray control characters that a tolerant parser accepts while a strict one treats the header as malformed.

    Each of those is a bet that two servers in the chain draw the line in different places. The full catalogue of spellings, and the specific parser quirks each one targets, is its own post: transfer encoding obfuscation. The point to carry away here is that the obfuscation is only a means. Its single job is to make one server ignore Transfer-Encoding so the two servers end up on opposite length rules.

    Once the split exists, it is CL versus TE again

    After the obfuscation lands, a TE.TE attack collapses into one of the two directions you already know. If the back end is the one that fell back to Content-Length, you are running a CL.TE style smuggle, walked byte by byte in CL.TE request smuggling. If the front end fell back instead, you craft the body the way you would for TE.CL request smuggling, with the careful chunk size that case demands. The leftover prefix, the poisoned queue, and the bypass of front end controls all follow from there.

    The defense is to refuse the ambiguity rather than parse around it. A front end should reject a Transfer-Encoding it will not honor, including obfuscated spellings, instead of forwarding a header it parsed loosely for the back end to interpret differently. A request with a malformed or duplicated Transfer-Encoding is a request to drop and respond to with a closed connection, not one to clean up and pass along. These framing failures live next to the input handling bugs we file under injection and input, because both come down to a parser being too forgiving with untrusted bytes.

    Spotting a weird header spelling is easy. Knowing whether a given spelling actually desyncs the two servers in front of a specific application is the hard part, and it is not something you can read off a signature. It depends on how each real parser in the path treats that exact byte sequence, which is a question you answer by testing the chain and proving the boundary moves. That is what UnboundCompute is built to do: reason about whether a desync is actually reachable rather than pattern matching headers, which you can read about on our about page.

    Frequently asked questions

    What makes TE.TE different from the other variants?

    In TE.TE both the front end and the back end support Transfer Encoding chunked, so a clean request does not desync them at all. The attacker has to obfuscate the Transfer Encoding header so that one server still honors it and the other stops recognizing it. That second server falls back to Content Length, and only then do the two servers disagree about where the body ends.

    What does header obfuscation mean here?

    It means writing the Transfer Encoding header in a form that one parser accepts and the other rejects. Common ideas include unusual whitespace around the name or value, a second Transfer Encoding header carrying a value one server does not understand, odd casing or trailing junk on the chunked value, and obsolete line folding or stray control bytes. Each spelling is a bet that two parsers in the path draw the line in different places.

    Does TE.TE become one of the other variants?

    Yes. Once obfuscation makes one server fall back to Content Length, the attack is a plain split between chunked and byte counting. If the back end fell back you run it like a CL.TE smuggle, and if the front end fell back you craft the body like a TE.CL smuggle with its careful chunk size. The obfuscation is only the means of creating that split.

    How do you defend against TE.TE request smuggling?

    Refuse the ambiguity instead of parsing around it. A front end should reject any Transfer Encoding header it will not honor, including obfuscated spellings, rather than forwarding a header it parsed loosely for the back end to read differently. Treat a malformed or duplicated Transfer Encoding header as a request to drop, respond, and close the connection, so no leftover bytes can wait for the next user.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.

  • TE.CL Request Smuggling

    TE.CL Request Smuggling

    TE.CL request smuggling is the mirror image of the classic desync, and the one where you have to do real arithmetic. Here the front end honors Transfer-Encoding: chunked and the back end honors Content-Length. The roles from the common case are swapped, so the attacker frames the smuggled request as a chunk the front end reads in full, while the back end, counting a small Content-Length, stops early and leaves the rest behind. If request desync is new to you, our primer on http request smuggling sets up the two server disagreement this builds on.

    How TE.CL request smuggling frames the prefix

    The front end reads the body as a chunked stream and the back end reads it as a fixed number of bytes. To exploit that, the attacker sends one chunk whose declared size covers the entire smuggled request, followed by the zero terminator. The front end, reading chunks, consumes all of it and forwards the lot. The back end, reading Content-Length, takes only the first few bytes and treats everything after as a brand new request.

    A TE.CL request looks like this:

    POST / HTTP/1.1
    Host: acme-notes.example
    Content-Length: 4
    Transfer-Encoding: chunked
    
    5c
    GPOST /admin HTTP/1.1
    Host: acme-notes.example
    Content-Type: application/x-www-form-urlencoded
    Content-Length: 15
    
    x=1
    0
    
    

    Read it as the front end first. It honors chunked, so it reads the chunk size line 5c. That is hexadecimal for 92, meaning the next 92 bytes are one chunk. Those 92 bytes are the whole GPOST /admin block down to and including x=1. After the chunk comes 0, the terminator, so the front end decides the request is finished and forwards every byte to the back end.

    Now read it as the back end. It honors Content-Length: 4 and ignores the chunked header. The four bytes after the blank line are 5c followed by a carriage return and a line feed. That is the entire body as far as the back end is concerned, so the POST ends there. Everything from GPOST onward is still in the buffer, waiting to be read as the next request:

    GPOST /admin HTTP/1.1
    Host: acme-notes.example
    Content-Type: application/x-www-form-urlencoded
    Content-Length: 15
    
    x=1

    The whole exploit hinges on one number. The chunk size has to count the smuggled bytes exactly, or the front end reads a body of the wrong length and the desync falls apart.

    Why the length math is unforgiving

    The classic case gets away with a tiny body because the front end only has to count a short chunk terminator. TE.CL is stricter. The chunk size you write must equal the exact byte count of the smuggled request, including every header line and the blank line before its body. Add a header, and the count goes up. Change the host name length, and it changes again. Line endings matter too: each line break in an HTTP message is two bytes, a carriage return and a line feed, and if your tooling rewrites them to one byte the count is wrong and the front end reads a body of the wrong size. When that happens, the chunk does not line up with the terminator and the request is either rejected or passed through clean.

    This is why TE.CL is fiddly to test by hand. An intercepting proxy that helpfully recomputes Content-Length or normalizes line endings will quietly rewrite the exact bytes the attack depends on. You either turn that behavior off, or you compute the hex chunk size yourself and send the raw bytes unaltered.

    The smuggled request stands on its own

    There is a structural difference worth noting against the classic case. In a CL.TE attack the leftover is usually an incomplete request that waits to swallow the next victim line. In the TE.CL example above, the smuggled GPOST /admin is a complete request with its own Content-Length: 15 and body, so it does not need to borrow anything from the victim. The back end runs it as a standalone request. That makes TE.CL a clean way to reach a route the front end would have blocked, since the inner request never passed the front end as a request at all.

    If you want to capture a victim request instead of just running your own, you leave the final smuggled header expecting a body longer than what you send, so the victim bytes get appended as that body. The sizing is the same discipline, only now the trailing Content-Length is deliberately too large.

    Defenses and the sibling variants

    The defenses are the same family as the other variants, because the root cause is identical: two servers reading one stream with two different length rules. Reject any request that carries both Content-Length and Transfer-Encoding rather than picking one, and close the connection after. Make the front end normalize each request into a single unambiguous framing before it forwards. The near twin of this bug, where the front end reads Content-Length and the back end reads chunked, is walked byte by byte in CL.TE request smuggling, and the obfuscation driven case is in TE.TE request smuggling. We group all of these under injection and input because they turn on how a parser reads untrusted bytes.

    The reason TE.CL rewards careful work is also why a scanner struggles with it. A tool can notice two length headers, but it cannot tell you whether the front end and the back end in your specific path disagree about this exact request, or whether your chunk size counts the bytes the way the live servers do. That is a question you answer by reasoning about the chain and proving the boundary moves, not by matching a signature. It is the kind of thing UnboundCompute is built to work out, by testing whether a desync is actually reachable rather than pattern matching headers, which you can read about on our about page.

    Frequently asked questions

    How is TE.CL different from CL.TE?

    The two servers trade roles. In TE.CL the front end honors Transfer Encoding chunked and reads the body as chunks, while the back end honors Content Length and reads a fixed byte count. In CL.TE it is the other way around. The outcome is the same kind of leftover prefix in the back end buffer, but the request is framed differently and the length math you need to make it work is stricter in the TE.CL direction.

    Why does TE.CL need careful length math?

    The attacker sends one chunk whose declared hexadecimal size must equal the exact byte count of the entire smuggled request, including every header line and the blank line before its body. If the count is off by even one byte, the front end reads a body of the wrong length and the chunk no longer lines up with its terminator, so the desync fails. Changing a header or a host name forces you to recompute the size.

    Why do line endings break TE.CL tests?

    Every line break in an HTTP message is two bytes, a carriage return and a line feed, and those bytes count toward the chunk size. If your tooling or an intercepting proxy rewrites line endings to a single byte or recomputes Content Length for you, it changes the exact bytes the attack depends on. You either disable that normalization or compute the chunk size yourself and send the raw bytes unchanged.

    What does a TE.CL smuggled request achieve?

    Because the smuggled request in the common TE.CL form is complete on its own, with its own length and body, the back end runs it as a standalone request. That reaches a route the front end was meant to block, since the inner request never passed the front end as a request. With a trailing length set too large you can instead capture the next victim request as the body of the smuggled one.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.

  • CL.TE Request Smuggling

    CL.TE Request Smuggling

    CL.TE request smuggling is the cleanest version of a request desync and the easiest one to picture. The name states the setup: the front end trusts the Content-Length header and the back end trusts Transfer-Encoding: chunked. Both read the same bytes off one shared connection, and they disagree about where the body of a request ends. That disagreement is the whole bug. If two servers reading one stream differently is a new idea, start with our primer on http request smuggling and then come back for the byte math.

    How CL.TE request smuggling splits one request in two

    A front end sits in front of your application: a proxy, a load balancer, or a content delivery node. It reads each request, maybe filters it, then forwards it to the back end over a connection the two of them keep open and reuse. The back end reads that stream and slices it back into requests on its own. As long as both slice at the same byte, nothing goes wrong. CL.TE is the case where the front end measures the body with Content-Length and the back end measures it with the chunked encoding, so they cut at different places.

    Here is a request built to make them disagree. It carries both length headers on purpose:

    POST / HTTP/1.1
    Host: acme-notes.example
    Content-Length: 6
    Transfer-Encoding: chunked
    
    0
    
    GET /admin HTTP/1.1
    Host: acme-notes.example
    Foo: x

    Read it once as the front end. The front end honors Content-Length: 6, so it reads exactly six bytes of body after the blank line. Those six bytes are the 0, a carriage return, a line feed, and the blank line that follows. To the front end the body is that short string and the request is complete. It forwards every byte it received, because it believes the whole thing is one ordinary POST.

    Now read the same bytes as the back end. The back end honors Transfer-Encoding: chunked and ignores Content-Length entirely. It reads the body as chunks. The first chunk header is 0, which is the terminator that marks the end of a chunked body. So the back end decides the body is empty and the POST ends right there. The bytes after the 0 did not disappear. They sit at the front of the back end buffer:

    GET /admin HTTP/1.1
    Host: acme-notes.example
    Foo: x

    The front end forwarded one request. The back end read one and a fragment. The fragment left over is attacker chosen, and it is waiting for the next person in line.

    What the leftover prefix does next

    The back end treats those leftover bytes as the start of the next request on the connection. It does not blame the attacker for them. It glues them onto whatever request arrives next. The dangling Foo: x line has no finished value, so it swallows the first line of the victim request that follows, which keeps the smuggled request syntactically valid.

    Say a normal user sends a request a moment later:

    GET / HTTP/1.1
    Host: acme-notes.example
    Cookie: session=victim-session

    What the back end parses is the attacker lines fused with the victim lines. The request it runs is a GET /admin carrying the victim session cookie. The user asked for the home page and instead drove a request the attacker wrote. The damage depends on the app:

    • Bypassing front end controls. The front end is often where path rules and header filters live. A smuggled request never passed the front end as a request, so a route it would have blocked reaches the back end unfiltered.
    • Stealing a victim request. Craft the prefix to end in a header that expects a long value, and the victim request, cookies and all, gets captured as that value and stored where the attacker can read it back.
    • Poisoning the response queue. Once the request boundary is off by one, responses fall out of step with who asked for them, and a later user receives a reply meant for a different request. We cover that in our note on response queue poisoning.

    Why CL.TE is the textbook case

    CL.TE is the variant people learn first because the length math is trivial. The smuggled chunk terminator is tiny and the Content-Length only has to cover it. The mirror image, where the front end honors chunked and the back end honors Content-Length, needs more careful sizing and is worth reading as its own case in TE.CL request smuggling. There is also the variant where both servers support chunked and you obfuscate the header to break their agreement, which lives in TE.TE request smuggling.

    The fix is not a payload filter. There is no bad string to match, because every byte in the attack is valid on its own. The defense is to stop the two servers from ever reading length differently. A request that carries both Content-Length and Transfer-Encoding should be rejected, not interpreted, and the connection closed afterward so no leftover can wait for a victim. The front end should also normalize every request into one unambiguous form with a single length header it computed itself. These framing bugs sit next to classic input handling flaws, which is why we file them under injection and input.

    A pattern match can tell you that a request carries two length headers. It cannot tell you whether the specific front end and back end in your path actually disagree about that request, which is the only thing that makes the desync real. That gap, between a header that looks suspicious and a boundary that is genuinely read two ways, is where real testing has to live. It is why UnboundCompute reasons about whether a desync is reachable in a given chain rather than flagging every request that matches a known shape, which you can read about on our about page. The two servers are sure they agree. The exploit is proof that they do not.

    Frequently asked questions

    What does CL.TE mean in request smuggling?

    CL.TE names which header each server trusts. The front end honors the Content Length header and reads a fixed number of body bytes, while the back end honors Transfer Encoding chunked and reads the body as a series of chunks. Because they measure the body differently, they disagree about where one request ends and the next begins, and that disagreement is the desync an attacker uses to hide a second request inside the first.

    Why does the chunk terminator matter in a CL.TE attack?

    The attacker sends a body that starts with a zero sized chunk, which is the chunked terminator. The back end, reading chunks, sees that zero and decides the body is finished immediately. The front end, counting bytes with Content Length, reads past the zero and forwards everything. The bytes after the zero chunk are left in the back end buffer and become the start of the next request on the shared connection.

    What can an attacker do with a CL.TE desync?

    Three things, depending on the app. They can reach routes the front end was supposed to block, since the smuggled request never passed the front end as a request. They can capture a victim request, cookies included, by ending the prefix with a header that expects a long value. Or they can poison the response queue so a later user receives a reply meant for a different request.

    How do you prevent CL.TE request smuggling?

    Do not let the two servers read length differently. Reject any request that carries both a Content Length and a Transfer Encoding header rather than guessing which one wins, and close the connection afterward so no leftover bytes can wait for a victim. Have the front end normalize every request into one unambiguous framing with a single length value it computed itself before forwarding to the back end.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.