For those wondering what GraphQL is…
“GraphQL is a query language for your API, and a server-side runtime for executing queries using a type system you define for your data. GraphQL isn't tied to any specific database or storage engine and is instead backed by your existing code and data.” (Taken from https://graphql.org/learn/)
For those who are already familiar with GraphQL, especially from a security perspective, the first thing we tend to think about is “Introspection” – the ability for a user to get the server to hand over all the information related to the schema and queries by issuing a magic request. This blog post is not about that aspect. In general, you should disable “Introspection” on production systems, but from an attacker (and pentester!) point of view, there are still ways around this to get things back, due to helpful and verbose error messages from GraphQL – but that’s for another blog post.
This blog post is instead about looking at the normal operation of a GraphQL query, looking closely at the response and then using that information to feed back into the request (the GraphQL query itself), to, you got it, make it do something it wasn’t intended to do – resulting in account take over in this instance.
Let’s jump right in at the deep end.
Imagine a typical (retail) web application; customers have accounts (or they can register), they log in and buy stuff with their payment details held on file. The company then ships products out to the customer’s address (again on file) once ordered. Amazon was onto something here… Within this application the user has the ability to view/update their ‘profile’ via a profile page, they can change their name, e-mail address, home address, security question and security answer. Now, this profile page (and the whole application) ‘under the hood’ makes use of GraphQL to retrieve this goodness from the server. When the user selects their profile page the following GraphQL query is sent off by the application.
Figure 1. GraphQL request sent off when the user visits their profile page.
Based on the user’s request for the profile page, the subsequent response (shown below) is returned by the server (from GraphQL), which the application then takes and populates into the UI elements in the profile page itself.
Figure 2. GraphQL response returned which is used to populate UI elements in the profile page.
So if we look at the response, our spidey senses should immediately be tingling when we see the “id” reference and what looks like an incremental/predictable identifier per customer – what we call Indirect Object Reference (IDOR) vulnerabilities in pentester land. As a pentester proof of concept, we’d usually increase/decrease this “id” on our own value (e.g., “12342” becomes “12343”), access another user’s profile and clock off, job done. But hold up there, partner, not so fast! A slight problem exists, we’re used to seeing these kinds of things in the request and in this instance it lives in the response. Hacking up responses isn’t going to pay those bills.
Park that thought for a moment. It is at this point that we look more closely at the request and break it down a little, zooming in (literally) on the first part.
Figure 3. The baseline request to retrieve user details has no variables configured.
This first part of this request is creating an operation name called “myProfile” and then a query of the same name is created within this operation. The data is to be returned back within “current_user”, the data being the user’s id, their e-mail address, etc. That’s not the interesting bit. What is interesting is that the “variables” section which we can see is currently blank.
The developer intended this (getter) call to populate the profile page with the data to take no (user-defined) arguments. In the design of the application (of the profile page), the web application (via GraphQL) makes a call to the server endpoint in the context of the requesting user and retrieves the data belonging to them. Going back to the variables thing before (or lack of), think of these as arguments (or ‘argvs[]’ if you like) to a computer program. In GraphQL we can use variables within a query, just like arguments. Just because the developer didn’t intend on the call to pass any arguments doesn’t mean it has to stay that way. Let’s grab that black hoody from the back of the chair, put it on and hood up, hacker mode enabled.
Now, we know that the application uses the “id” key as an identifier for customers – we learned that from the response data for our own user. Now with the hacker hoody on, we can re-write the request (shown below) as follows, taking that “id” value from the response and adding it into the request, but now as an argument, adding it into the “variables” bit as “id”. We then tweak the query to define and reference this new variable as an argument to use when it makes the call, via the addition of “($id: Int!)” and the “user(id: $id)” bits. Lastly, we change the value from our own (“12342”) to one number above it (we could go below also) to stumble upon another user. In this instance we change this from “12342” to “12343”. We then light the fuse and fire the canons delivering the GraphQL ball - fingers in those ears.
Figure 4. Rolling our own GraphQL query for the profile page, now with added variables, id=12343.
Meanwhile, the server receives this request (the same endpoint as before) but this time it sees that this query contains the variable “id” of value “12343” and uses this in plucking the corresponding data from its store. Microseconds later and we (the client) receive the response, with the profile data relating to other user who has an “id” of “12343” (our next door neighbor in “id” terms) which happens to be “venom”.
Figure 5. User profile details relating to another user (venom, id=12343) are returned.
Not shown here, but next steps, we would then use the forgotten password functionality, piping in venom’s e-mail address and provide the correct security answer (“me uhdfhgfhguha234”) (which we learned of via this attack) and reset their password to take over their account.
Taking this further (again, not shown here), we could take over all customer accounts in the application by sending this baseline request to something like Burp Intruder (or busting out Python/curl) to increment through all the “id” values of “00000” to “99999” to return us the e-mail addresses and corresponding security answers for every customer.
Time to celebrate with a cup of tea and a few biscuits.
As you can see, the attack isn’t all that complex, we’re not spitting out shellcode or spraying any heaps. It isn’t all that different to a usual IDOR you’d usually see, however, it requires a little more effort to exploit. Sometimes these things can be overlooked because the baseline request lacks such an argument to manipulate in the first place. The endpoint, GraphQL in this instance (but it could be any endpoint), is however happy to support such a request but no one ever sends it one – poor endpoint. What I’m trying to say (the purpose of this blog post), rather than conveying any pentester technical wizardry (because this isn’t all that complex), is that sometimes it is worth trying those requests – re-creating those baseline requests, based on things you learn in the responses of the application or elsewhere (JavaScript, etc). You may just retrieve that treasure.
From a defensive standpoint, why is this happening and how does one stop it? This happens because on the server side, code (and logic) exists to parse any user-defined “id” values in order to be more useful elsewhere in the application. Perhaps (and likely) there is an administrative interface which exists whereby an administrator needs to make queries on arbitrary users; pull up their details, reset their password, etc. which means that this same GraphQL endpoint is also used (and therefore shared) by that functionality too. If that’s the case, it needs to be locked down or perhaps divided up and moved to an administrator only endpoint – not available in a general customer (publicly accessible) one.
As always, thanks for reading!