Introduction
This year, I was happy to be part of the organization committee for the GreHack conference and I created a few challenges for the CTF. Thanks to all the participants and organizers, the event was once again awesome π₯ π Real thanks to Elweth who make a big part of this challenge.
Challenge
- Name :
Beer Me Up Before You Format
- Category :
Web
- Solves :
12
- Points :
350
- Author :
Elweth, Nishacid
- Sources : Beer_Me_Up_Before_You_Format.zip
Beer me up before you go-go, Leave a pint for me, donβt be a no-no. Beer me up before you go-go, I don’t want to miss out when you pour that froth
With this challenge, the Beer_Me_Up_Before_You_Format.zip
archive is provided. This is the source code for the active application on https://beer-me-up-before-you-format.ctf.grehack.fr/.
This archive contains the following tree structure :
.
βββ app
β βββ api
β β βββ queries.py
β β βββ Secret.py
β βββ app.py
β βββ db
β β βββ database.db
β βββ templates
β βββ index.html
β βββ secret.html
βββ docker-compose.yml
βββ Dockerfile
βββ flag.txt
βββ requirements.txt
This is a python application, using the Flask Framework to start a web server.
An analysis of the source code reveals the following endpoints:
@app.route("/")
@app.route("/api/<endpoint>/<id_user>")
@app.route("/api/password-reset", methods=["POST"])
@app.route("/api/login", methods=["POST"])
@app.route("/api/admin", methods=["POST"])
@app.route(f"/api/{SECRET_ENDPOINT}", methods=["POST"])
The api/queries.py
file contains the SQL queries made to the SQLite database, which appears to be secured using prepared queries.
In the api/secret.py
file, we note the presence of the “Secret” class, as well as a SECRET_ENDPOINT
global variable containing the route we’re going to find.
import os
SECRET_ENDPOINT = "secret"
class Secret:
def __init__(self, secret):
self.secret = secret
def __repr__(self):
return f"The secret endpoint is : /{self.secret} !"
403 Bypass
The first accessible endpoint is :
# To manage multi-endpoints
@app.route("/api/<endpoint>/<id_user>")
def parse(endpoint, id_user):
if endpoint.lower() in ENDPOINTS:
if endpoint == "users":
return jsonify(error="This endpoint is for admins only."), 403
return jsonify(get_user(int(id_user)))
else:
return jsonify(error="This page does not exists."), 404
The function performs parsing on the endpoint
value in order to display the user information linked to the user_id
also passed as a parameter.
However, we are not allowed to query the URI /api/users/<id>
, since it checks whether the endpoint is set to “users”, which seems to be reserved for admins, and returns a 403 error.
However, the application does not take into account the “case sensitive” part of endpoints, and it is possible to bypass the following condition by using uppercase letters.
if endpoint == "users":
return jsonify(error="This endpoint is for admins only."), 403
return jsonify(get_user(int(id_user)))
It gives : /api/uSers/1
:
Insecure Direct Object Reference
Now that we have access to the endpoint users, we can list all the application’s users.
The aim is to target admin accounts in particular, characterized by the ADMIN
role in the JSON response.
To do this, we can use Burp’s intruder to increment the user_id
part, while at the same time match ADMIN
in the server response.
Once the intruder is complete, we recover the list of users with the ADMIN
role.
Another interesting result in the api response is the token
part, which appears to be a UUID.
Password Reset abuse
This returned token is far from uninteresting, as it is used to reset the password.
The endpoint /api/password-reset
expects 2 parameters: token
and password
.
Visit the update_password
function, we’ll notice that it’s possible to update an account’s password, assuming we know the token.
@app.route("/api/password-reset", methods=["POST"])
def password_reset():
json = request.get_json()
try:
token = json["token"]
password = json["password"]
if update_password(token, password):
return jsonify(success="The password has been reset.")
else:
return jsonify(error="An error has occured.")
except Exception as e:
print(e)
return jsonify(error="Parameter 'token' or 'password' are missing.")
In this way, we can forge the following query to reset an administrator’s password :
Once the password has been updated, it is possible to connect to the compromised account and thus obtain a valid session token:
Python Format String
To access the admin endpoint, you need to authenticate yourself by providing the token previously retrieved from the X-Api-Key
header.
First, the token’s integrity is checked, and the “ROLE” part of the data must be set to “ADMIN”.
Next, the API retrieves the secret
variable contained in the JSON sent.
If we take a closer look at how the passed content is rendered in the template, we notice that a format string is used:
return f"The secret endpoint is : /{self.secret} !"
Using this syntax in the format string is a vulnerability, as it allows access to the object’s properties directly in the format string.
- https://realpython.com/python-string-formatting/
- https://lucumr.pocoo.org/2016/12/29/careful-with-str-format
We can confirm the vulnerability by sending {secret}
to call the object itself.
Here we can see that the repr
method (equivalent to a toString
) is called 2 times, since we have 2 times the output.
Let’s dig a little deeper using a python debugger.
To do this, we’re going to use the ipdb and rich libraries.
With these libraries, we’ll set a breakpoint in the code, so we can inspect the secret
variable and see what we can learn from it.
Let’s add the following lines to the app.py
code:
import ipdb, rich
secret = request.get_json()["secret"]
secret = Secret(secret)
ipdb.set_trace()
When you restart the code, the process pauses and offers us a prompt.
We have access to a python-like console, allowing us to perform actions on the program. For example, we can display the contents of the secret
variable :
Using the rich library, you can list all the methods to which the object secret
has access
It is therefore possible to call all these methods, including the init
method for building the object Secret
.
Remember that our precious flag is stored in the global variables of the Secret
object,
And with Burp Suite :
File Read
Now we have retrive the secret
endpoint, we can access with the new feature used to read file from system.
filename = urllib.parse.unquote(request.get_json()['filename'])
data = "This file doesn't exist"
bad_chars = ["../", "\\", "."]
is_safe = all(char not in filename for char in bad_chars)
if is_safe:
filename = urllib.parse.unquote(filename)
if os.path.isfile('./'+ filename):
with open(filename) as f:
data = f.read()
return jsonify(data)
In the Dockerfile
file, we can see the flag is located at /flag.txt
.
COPY flag.txt /flag.txt
But with the filter, we can’t read it directly :
The filter uses the urllib.parse.unquote()
two times, so we can double url-encoded it (don’t forget the dot), bypass this filter and leak the flag :
- Flag :
GH{F0rm4t_Str1ng_t0_D4T4_L34K}
Ressources
- https://flask.palletsprojects.com/en/2.3.x/
- https://cheatsheetseries.owasp.org/cheatsheets/Insecure_Direct_Object_Reference_Preven tion_Cheat_Sheet.html
- https://podalirius.net/en/articles/python-format-string-vulnerabilities/
- https://ctftime.org/writeup/10851
- https://www.root-me.org/fr/Challenges/App-Script/Python-format-string