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.


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 archive is provided. This is the source code for the active application on
This archive contains the following tree structure :

├── app
│   ├── api
│   │   ├──
│   │   └──
│   ├──
│   ├── 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("/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/ file contains the SQL queries made to the SQLite database, which appears to be secured using prepared queries.

In the api/ 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


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
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)))
        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()
        token = json["token"]
        password = json["password"]
        if update_password(token, password):
            return jsonify(success="The password has been reset.")
            return jsonify(error="An error has occured.")
    except Exception as 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.

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 code:

import ipdb, rich

secret = request.get_json()["secret"]
secret = Secret(secret)

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 =
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}