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:
# 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:
# 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
:
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.
...
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:
@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:
# 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.
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.