This challenge was made as an interesting client-side attack to leak the credentials of an admin. An unintended solution was quickly found that resulted in a 'Revenge' version being published which patched it, and only after that I started on this challenge. In the end, though, I found a completely different unintended solution that works on both versions and is quite simple once you see it. Not even a bot is required!

The Challenge

For this challenge, we get the full source code of the Python Flask application and even a docker setup to easily start it locally. When first viewing the website, a login and register page is available to create and log into an account. Afterwards, the following dashboard will be shown with a chat window:

Whenever we send a message here, a few seconds later a bot responds with a random answer. The bot seems interesting because we might be able to attack their browser with an attack like XSS or CSRF. But first, let's see where the flag is to find our target:

Python

# Route for retrieving the flag
@app.route('/flag', methods=["GET"])
def flag():
    authentication_cookie = request.cookies.get("authentication")
    user = Users.query.filter_by(authentication_cookie=authentication_cookie).first()
    if not user:
        flash("You're not logged in", "danger")
        return redirect("/login", code=302)

    username = user.username
    perms = is_admin(request.cookies.get("authorization"))
    if username != "admin" and perms:
        return Config.FLAG
    else:
        return "Nope"

This code seems a bit unusual. Read it carefully to understand fully, and notice that there are actually 2 cookies here: authentication and authorization. For clarity, I will call them "n-cookie" and "z-cookie" respectively. To get the flag it takes our n-cookie and checks if any user matches this cookie. The authentication_cookie value it queries for here is set when you log in:

Python

# Route for user login
@app.route('/login', methods=["GET", "POST"])
def login():
    ...
    elif request.method == "POST":
        username = request.form.get('username')
        password = request.form.get('password')

        user = Users.query.filter_by(username=username).first()
        if not user or not check_password_hash(user.password, password):
            flash("Invalid username or password", "danger")
            return render_template('login.html')

        flash("You have been logged in", "success")
        authentication_cookie = create_cookie()
        response = make_response(redirect("/chat", code=302))
        response.set_cookie('authentication', authentication_cookie, httponly=True)
        # n-cookie is set here
        user.authentication_cookie = authentication_cookie
        # current z-cookie is set for this user
        user.authorization_cookie = request.cookies.get("authorization")
        db.session.commit()
        return response

# in utils.py
def create_cookie():
    cookie = token_hex(16)
    return cookie

That means the n-cookie we have right now with the registered user will get through this check fine. We will be able to authenticate as any user.
The tricky bit comes with the z-cookie right after. perms is set to the result of the is_admin() check, defined in utils.py:

Python

def is_admin(cookie):
    user = Users.query.filter_by(authorization_cookie=cookie).first()
    if user.is_admin:
        return True
    return False

Your specified cookie must match the authorization_cookie value set in the database for this user, and they must have the is_admin property set to ensure this is the admin user. Whenever we register an account this is False by default and in the whole application there is no way to change this ever, so we must target the admin account specifically.

Python

...
    username = user.username
    perms = is_admin(request.cookies.get("authorization"))
    if username != "admin" and perms:
        return Config.FLAG
    else:
        return "Nope"

Finally, for the flag check, the username corresponding to your n-cookie must not be admin, and your perms (from z-cookie) must be admin, an interesting situation. We'll either have to upgrade our z-cookie to become an admin, or downgrade the admin's n-cookie to any other user and retrieve the flag with the bot.

Bypassing is_admin()

It is easy to instantly focus on the bot, because why would it be there if it is not required for the exploit? But it is also important to fully understand the whole application to make there no other weird edge cases exist.

Since we need to match our z-cookie with the admin's authorization_cookie property, let's check where this is set and see if it is predictable:

Python

@app.route('/login', methods=["GET", "POST"])
def login():
    ...
    elif request.method == "POST":
        ...
        user = Users.query.filter_by(username=username).first()
        if not user or not check_password_hash(user.password, password):
            flash("Invalid username or password", "danger")
            return render_template('login.html')

        ...
        user.authentication_cookie = authentication_cookie
        # 1. After logging in correctly as a user, their `authorization_cookie` is set to the current z-cookie
        user.authorization_cookie = request.cookies.get("authorization")
        db.session.commit()
        return response

@app.route('/logout', methods=["GET"])
def logout():
    authentication_cookie = request.cookies.get("authentication")
    user = Users.query.filter_by(authentication_cookie=authentication_cookie).first()
    if not user:
        flash("You're not logged in", "danger")
        return redirect("/login", code=302)

    user.authentication_cookie = None
    # 2. After logging out with a valid n-cookie, the corresponding user's `authorization_cookie` will be unset
    user.authorization_cookie = None
    db.session.commit()
    return redirect("/login", code=302)

These two routes both alter the authorization_cookie value for a user, but the login function requires us to know the randomly generated credentials, and the logout function requires the n-cookie of the admin which will unset their cookie to None.
One last place where something is said about this property is in the database definition below:

Python

# db.py
class Users(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(32), unique=True, nullable=False)
    password = db.Column(db.String(128), nullable=False)
    # z-cookie is defined as nullable
    authorization_cookie = db.Column(db.String(16), nullable=True)
    authentication_cookie = db.Column(db.String(16), nullable=True)
    is_admin = db.Column(db.Boolean, default=False, nullable=False)

# app.py
with app.app_context():
    db.create_all()
    # Create the initial data
    if not Users.query.filter_by(username="admin").first():
        # No z-cookie value is provided here, so it will be None by default
        admin_user = Users(id=1, username="admin", password=Config.ADMIN_PASSWORD_HASH, is_admin=True)
        db.session.add(admin_user)
        db.session.commit()

Interesting to note here is that the initial value is not set, and will be None as the application starts. The only thing preventing us from receiving the flag is being able to match the z-cookie of the admin user. Could we set our z-cookie to None and get a match?

In the /flag endpoint it is retrieved using the request.cookies.get("authorization") call. If that would return None, it should search for users that have this set to None as well, which will be the admin if they have not yet logged in!

Solution

This is the solution. We start the challenge, making sure to leave the admin logged out and create a valid account for ourselves. We need to log in and get our valid n-cookie for the request and as the z-cookie, we simply don't include it which will make it None.

HTTP

GET /flag HTTP/1.1
Host: localhost:5000
Cookie: authentication=97784142c6c32430d084ec2286999506
Connection: close

That's it! One request without ever interacting with the bot. This is definitely an unintended solution but a fun one nonetheless, even hackers make mistakes.