SafeNotes 2.0

Writeup for Safenotes 2.0 (Web) - 1337UP LIVE CTF (2024) ๐Ÿ’œ

Video walkthrough

VIDEO

Challenge Description

After receiving numerous bug bounty reports through the intigriti platform, the developer has implemented some security fixes! They are inviting bug hunters to have another go, so do your thing..

DOWNLOAD DOCKER/SOURCE HERE

This one might have looked familiar if you play the monthly web challenges released by Intigriti. As the challenge name indicates, this is a new "secure" version of Safe Notes.

I'm not going to recap the previous challenge - if you missed it, it's still available to play. Alternatively, you might prefer to review the official writeup or video walkthrough ๐Ÿ˜‡

Since source code is provided, I expect many players will diff the two projects to get a quick insight into the developer's adjustments. Let's start by going through each change and evaluating the impact.

Codebase Updates

Fixes

  1. Prevented CSPT by stripping ../ after URL decoding user input so that hackers can no longer URL-encode to bypass the filter.

// Checking "includes" wasn't sufficient, we need to strip ../ *after* we URL decode
const decodedNoteId = decodeURIComponent(noteId);
const sanitizedNoteId = decodedNoteId.replace(/\.\.[\/\\]/g, '');
fetch("/api/notes/fetch/" + sanitizedNoteId, {
	method: "GET",
	headers: {
		"X-CSRFToken": csrf_token,
	},
  1. Improved the regex for validating the note ID (added ^ at the start), ensuring players cannot insert potentially dangerous input into the noteId parameter.

  1. Removed the entire data.debug section. It was not sanitised correctly, and hackers were exploiting an XSS vulnerability.

  1. Added a temporary logging mechanism to monitor suspicious activity until the hacking situation is under control!

Client

Server

  1. Removed the open-redirect from /contact. Hackers were chaining this with other vulnerabilities to exfiltrate sensitive information.

Changes

  1. Since all user input now goes through the latest DOMPurify, bleach is no longer required for server-side sanitisation.

  2. Improved the /contact form. It now provides customised feedback to users.

Solution

CSPT

The developer said validating the nodeId with includes wasn't sufficient since attackers were URL-encoding the ../ to bypass the check.

They mitigated this by URL-decoding the input first and then stripping ../

A classic mistake! The replace function will replace all instances of ../ in the string but it's not recursive ๐Ÿ‘€

What does that mean? If we enter ....//, it will match like ..(../)/, and remove the characters inside the brackets. What does that leave us with? That's right, ../ ๐Ÿ˜Ž

There are some more problems we need to address:

  1. The regex for the noteId was corrected and doesn't allow any input before the UUID, preventing us from entering ....//

  2. The open redirect was patched on /contact, so even without (1), we no longer have a vulnerability to chain our CSPT with.

  3. Even without (1) and (2), the data.debug section was removed from the note viewing functionality, so all note contents will be sanitised with DOMPurify.

Let's look at some of the other changes made by the developer. One was adding a new "logging functionality" to monitor potential attacks.

The logNoteAccess function first retrieves the current username from the HTML. However, if this doesn't exist, it reads the username from a GET parameter.

Regardless of the source, the username is sanitised.

We know the sanitisation process is flawed, so we should be able to supply a path traversal payload, but to where? The username is included in an API call, but it's not a GET request this time; it's a POST.

We have a new dangerous sink, though! The JSON response of the API call is being assigned to outerHTML without any validation.

One thing at a time, let's test the CSPT. If we create a note and add the name GET parameter as discussed (http://127.0.0.1/view?note=0c7cb26e-9095-48dc-b984-8cc9fcc76b4c&name=....//....//....//), we'll find the CSPT failed.

We wanted it to traverse back three directories from /API/notes/log to /, but it didn't, and here's why.

Remember that the username is only read from the GET parameter if it can't be found in the HTML. Sure, we can delete it from the HTML in our browser, but we need to think about the end game; how will we remove it from the victim's browser when the time comes?

HTML Injection

DOMPurify will sanitise our input to prevent XSS, but it will allow basic HTML, e.g., if we create a new note with some tags.

HTML injection confirmed! According to Cure53:

DOMPurify by default prevents DOM clobbering attack. However, there is a missing check for certain elements that allows clobbering via the name attribute. Normally, only the elements with an id attribute are able to clobber window properties.

DOM Clobbering

What is DOM clobbering anyway?

a technique in which you inject HTML into a page to manipulate the Document Object Model (DOM) and ultimately change the behaviour of JavaScript on the page

In our case, we want to ensure the username element doesn't exist on the page (or that it is null), enabling us to specify our own user via the GET parameter.

Let's create a new note with the following contents.

It still shows Logged in as: cat, but let's check the console.

Nice! How's our API call looking?

Perfect! It made the request using our injected username ๐Ÿ˜Ž

We'll change the payload slightly so that the username is null and will instead be read from the URL (see if you can work out why we need this step later ๐Ÿ˜‰).

Now let's try our CSPT again.

http://127.0.0.1/view?note=0c7cb26e-9095-48dc-b984-8cc9fcc76b4c&name=....//....//....//

CSPT confirmed โœ…

There's a 405: METHOD NOT ALLOWED because we traversed back to the homepage, which doesn't support POST requests.

XSS

We've got DOM clobbering and CSPT at our disposal, but we need XSS. We know this because the code hasn't changed much since v1; the admin/bot has a flag in their cookie, and they will visit the URL of a note that we provide.

We no longer have our open redirect in /contact, and it wouldn't help us at this stage anyway. However, some other changes were made to the contact form - "user feedback".

When the contact form is submitted, it will take the submitter's name and message and then return a response.

So, it's a POST form that takes a name and some content ๐Ÿค”

We've already hijacked the POST request meant for /api/notes/log which also takes a name and some content. What if we redirect that request to /contact using our CSPT?

Without updating our note contents, let's try it!

http://127.0.0.1/view?note=0c7cb26e-9095-48dc-b984-8cc9fcc76b4c&name=....//....//....//contact

We get a 200 OK, and the message is returned from the contact form!

That's our user input right there! It's not displayed on the screen, though. If we want XSS, we will need it to render. Checking the console, there's an error.

It's triggered from this section of code.

The debug-content element doesn't exist, so the response cannot be assigned to it.

Looking through the HTML, it becomes clear why.

The developer commented it out in production. Well, what about that DOM clobbering thing?

Let's update the note contents to ensure the element will exist on the page.

When we load the note this time (http://127.0.0.1/view?note=0c7cb26e-9095-48dc-b984-8cc9fcc76b4c) it looks a little different. Since the debug-content element exists on the page, the output from the /api/notes/log call is displayed.

Let's test our path traversal again.

http://127.0.0.1/view?note=0c7cb26e-9095-48dc-b984-8cc9fcc76b4c&name=....//....//....//contact

Our input is reflected! Now it's time to try a different URL ๐Ÿ˜ˆ

We want to add an XSS payload to the name parameter without interfering with the CSPT path. The best way to do this is by adding a query parameter, e.g., /contact?xss=<img src=x onerror=alert(1)/>, but we need to URL-encode the ? (or #) to ensure it's not processed by the /view endpoint.

http://127.0.0.1/view?note=0c7cb26e-9095-48dc-b984-8cc9fcc76b4c&name=....//....//....//contact%3fxss=<img src=x onerror=alert(1)>

We popped an alert! Now we'll set up an attacker server to exfiltrate the cookie and update our payload one final time ๐Ÿ˜Œ

Note: you do need to URL-encode the + to %2b in the fetch request.

Checking the server logs, we get a hit containing the user cookie!

Finally, we just send the exact same URL to admin/bot using the /report endpoint and receive the real flag ๐Ÿšฉ

Flag: INTIGRITI{54f3n0735_3_w1ll_b3_53cur3_1_pr0m153}

Last updated