“GraphQL is a query language for APIs and a runtime for fulfilling those queries with your existing data” - graphql.org
The GraphQL query language allows developers to easily write front-end queries, using a JSON like syntax, to retrieve data from the back-end. The big plus here that a single API end-point can return multiple types and formats of data based on the contents of the query.
This simplicity has resulted in a steady adaption of GraphQL. With this adoption, we as security testers need to understand GraphQL and how it can lead to unauthorised data access and modification. Over the last year there have been a few blog posts touching on GraphQL and exploitation of GraphQL end-points. These are recommended reading, but maybe wait until after reading this post. You’ll understand why shortly.
- http://www.petecorey.com/blog/2017/06/12/graphql-nosql-injection-through-json-types/
- https://raz0r.name/articles/looting-graphql-endpoints-for-fun-and-profit/
- https://labs.detectify.com/2018/03/14/graphql-abuse/
GraphQL Basics
I’m not going to go too deeply into GraphQL and how to interact with it, the above posts do an excellent job of this and I don’t want to create duplicate work. The GraphQL documentation is also a great resource and I highly recommend reading through this to get a better understanding of the underlying concepts.
The few basics I do want to touch on are:
- Schemas
- Types
- Fields
- Arguments
- Mutations
Again, this is just a quick overview from my point of view, it is probably best that you read the official documentation to get a firm grasp on the concepts.
Schemas
When you interact with GraphQL you do so through the query language. Since this query language is providing an interface to your backend data, it needs a definition of how the data and queries should be structured. This is where the schema comes in, it simply contains the information about what queries are supported. This serves as a validation layer to ensure incoming queries are well formed and supported.
Types
Since GraphQL provides an interface to data, it needs a type system to define the different data-types that are supported. The supported types can be found here. Essentially you are looking at things scaler types such as Int
, String
, Float
, Boolean
and ID
. And a few other types, enum
, lists
, interfaces
.
Fields
These are the actual data values that are available in a data Object. In any GraphQL query you will see fields, if you ask for a field, and it is defined in the schema, it will be return to you.
Arguments
Just like other query languages, GraphQL provides a mechanism for limiting the data that is returned. Arguments are used for this purpose and are defined in the schema. Here again GraphQL uses the schema to validate a given query, and set of arguments, before being passed to the API responsible for retrieving the relevant data. This forces strong typing and is just another mechanism that prevents injection attacks.
Mutations
Sometimes you don’t want to query data, you want to modify it. This is where mutations come in. Mutations allow you to define the “functions” that can be executed through GraphQL, including the fields that are modified and the arguments that are allowed.
Introspection
This is where GraphQL gets really interesting, even more so if you are doing a security audit. GraphQL can be used to query itself, more specifically, the introspection system allows us to retrieve the full GraphQL schema. This is another piece of well documented information, but not many web app testers seem to be aware of it.
Let’s assume we have defined the following GraphQL schema:
type Account {
id: String!
name: Account
active: Boolean!
}
type Query {
getAccount(id: String!): Account
}
How do we do an introspection query? Using the exact same GraphQL end-point the API does of course! The most simple introspection query you could do is:
{
__schema {
types {
name
}
}
}
What this does is query the __schema
field, which is always available on the root type of a Query. We want the schema to return the field name
from the field object types
.
The results of the above query would be something along the lines of:
{
"data": {
"__schema": {
"types": [
{
"name": "Query"
},
{
"name": "Account"
},
{
"name": "ID"
},
{
"name": "String"
},
{
"name": "Int"
},
.... <SNIP> ....
{
"name": "__DirectiveLocation"
}
]
}
}
}
This isn’t all that helpful, right? No not really, what we need is to improve the introspection query to return more information. We want to know all about mutations, queries, fields, arguments etc. Fortunately for us there is “The introspection query to end all introspection queries”, which can be found in the the GraphQL-JS repository.
This query is available below:
Attacking with Introspection
If we had to run the above query against a GraphQL end-point we would get an extremely useful response. The response is essentially a full API listing, and sometimes even includes helpful comments. This means we are able to see all the possible queries
and mutations
the GraphQL end-point can handle and we can start looking for flaws. The best thing about this is we get don’t need any additional privileges and we have been able to map out all API end-points using a single request (no more missing an API request because you have the wrong user or didn’t visit the correct page).
A useful example of this would be the end-point discussed in the Detectify blog post by Jon Bottarini (@jon_bottarini), where he found the bug by comparing the GraphQL request for an Admin user versus that of a normal user. With introspection, we would be able to find all the fields
that could be queried, even if we only had the normal user. It would still be up to us to test for missing authorisation checks, but we would have much better insight into the possible request that could be made.
Imagine we do the introspection query and get back the following result:
...<SNIP>...
{
"possibleTypes": null,
"name": "RootQueryType",
"kind": "OBJECT",
"interfaces": [],
"inputFields": null,
"fields": [
{
"type": {
"ofType": null,
"name": "Account",
"kind": "OBJECT"
},
"name": "account",
"isDeprecated": false,
"description": null,
"deprecationReason": null,
"args": [
{
"type": {
"ofType": {
"ofType": null,
"name": "Int",
"kind": "SCALAR"
},
"name": null,
"kind": "NON_NULL"
},
"name": "id",
"description": null,
"defaultValue": null
}
]
},
{
"type": {
"ofType": null,
"name": "User",
"kind": "OBJECT"
},
"name": "user",
"isDeprecated": false,
"description": null,
"deprecationReason": null,
"args": [
{
"type": {
"ofType": null,
"name": "String",
"kind": "SCALAR"
},
"name": "email",
"description": null,
"defaultValue": null
},
{
"type": {
"ofType": null,
"name": "String",
"kind": "SCALAR"
},
"name": "supersecret",
"description": null,
"defaultValue": null
},
{
"type": {
"ofType": null,
"name": "Int",
"kind": "SCALAR"
},
"name": "id",
"description": null,
"defaultValue": null
}
]
},
...<SNIP>...
Firstly, we are looking at the available query
types, since this is under the “RootQueryType”. In this snippet we have two queries, account
and user
. The account
query is of kind Object
and thus returns an Account
object. The fields which can be queried are found in"args"
and is only the id
field of type Int
. Similarly the user
query returns a User
object and has the fields email
, supersecret
and id
.
Now imagine we are doing a blackbox review and using the application we only ever see the following GraphQL request:
{
user {
id
email
}
}
We might never know that there is the third field supersecret
unless we did the introspection query. With the information from the schema, we would know about supersecret
and be able to get the value with:
{
user {
id
email
supersecret
}
}
Similarly we can look through the schema to find all mutation
types and then start investigating those for security issues (IDOR, authorisation checks etc).
{ "possibleTypes": null,
"name": "RootMutationType",
"kind": "OBJECT",
"interfaces": [],
"inputFields": null,
"fields": [
{
"type": {
"ofType": null,
"name": "SecretKey",
"kind": "OBJECT"
},
"name": "updateSecretKey",
"isDeprecated": false,
"description": "Update the secret API key.",
"deprecationReason": null,
"args": [
{
"type": {
"ofType": {
"ofType": null,
"name": "Int",
"kind": "SCALAR"
},
"name": null,
"kind": "NON_NULL"
},
"name": "accountId",
"description": "The account ID of the user updating the key.",
"defaultValue": null
},
{
"type": {
"ofType": {
"ofType": null,
"name": "String",
"kind": "SCALAR"
},
"name": null,
"kind": "NON_NULL"
},
"name": "secretKey",
"description": "The value of the new API key.",
"defaultValue": null
}
]
},
Here we have a mutation
called updateSecretKey
which takes two arguments; accountId
and apiKey
. We could now try invoke this through GraphQL with:
{
mutation doUpdate() {
updateSecretKey(accountId: 1234, secretKey: "AAAA==") {
secretkey
lastupdated
}
}
}
This would try call our mutation, and update the secretKey
for the accountId
1234
. And we want the fields
, secretkey
and lastupdated
to be returned if the mutation was successful. These two fields are found in the definition of the SecretKey
object in the schema, not shown here.
Turning off Introspection
Introspection is built into the GraphQL language and I haven’t found options to turn it off. What you could do is to use something like the third-party package, graphql-disable-introspection. This blocks introspection using a simple validation rule. Queries that contain __schema
or __type
will fail validation with this rule.
That’s that, nothing too fancy but a super useful way to quickly map out an attack surface through GraphQL. Maybe someone writes a “schema to query” converter to make this even easier. Hey, maybe I’ll do it if I find some time. Happy pwnage!