Akwaaba! I've been having some luck with GraphQL API vulnerabilities lately and this would be the first writeup on an incoming series. I farmed a lot of $500 IDORs but checkout how Notifications in an Android device can be exploited.

‼️ Disclaimer: I've changed specific details about the application in this write-up for confidentiality. Even the screenshots shown are from a different, similar application that doesn't have a bug bounty program — they're only used for illustration. Also, all GraphQL operations mentioned have been renamed to avoid revealing the actual program.

The Program

This is an AI platform designed for model management. Inside the workspace, you can deploy models, invite collaborators, and assign roles. To handle all these moving parts, the application relies heavily on GraphQL for its API.

A bit about GraphQL

GraphQL is a developer-friendly query language that lets you specify exactly what data you want to retrieve or modify. Originally developed by Facebook and open-sourced in 2015, it was designed to fix REST's biggest headaches: over-fetching (getting too much data) and under-fetching (not getting enough). Instead of chaining multiple REST calls, GraphQL uses a single endpoint to deliver exactly what you ask for in one shot.

REST API's Over-Sharing

In a typical application, the UI is usually restrictive. You might see a list of friends, and clicking a profile only shows you a name and a "message" icon. On the surface, it looks like the app is only sharing basic public info.

None
Front-end shows limited info

In REST, the request that retrieves this friend's info is usually a simple GET to an endpoint using their UUID. The oversharing happens when the API returns a much larger object than the UI actually needs. Even though you only see a name and a message icon on the screen, the background response might include their email, role, and account creation date, etc. At this point, the frontend is doing all the work, filtering the data to show only the name while the sensitive details sit unused, but fully exposed, in the response.

None
The REST API retrieved more info than the front-end needed

How GraphQL Solves REST API's Over-Sharing

GraphQL is direct. You tell it you only want a user's first name, and it returns exactly that. This is done through a query operation. In our case, since the web page only needs to render the friend's name, we pass their ID to the query and request only that specific field.

Unlike REST, where the server decides what you get, GraphQL puts the control in the hands of the request. This removes the need for the frontend to filter out a massive, data-heavy response just to show a single name.

None
GraphQL solves REST over sharing

Reformatting the GraphQL body, we see a query operation named FriendAccount. In this request, we query the user object by passing the friend's UUID as a variable. Following the GraphQL standard, we specify exactly which fields we want to return, in this case, just the name. The API responds with exactly that and nothing more.

None

This solves a massive problem found in most REST API implementations. But GraphQL introduces its own set of challenges. In a recent bug hunt, I identified a High-rated vulnerability by exploiting a common misconfiguration in how permissions are implemented.

The vulnerability

As a workspace admin, you can create either Private or Public webhooks. Private webhooks are locked to specific AI model projects, while Public webhooks are available to the entire organization. Per their permission model, a low-privileged user should never be able to query webhooks associated with projects they aren't assigned to.

I identified two vulnerabilities where a low-level user could not only query private webhooks from other projects but also escalate that access to retrieve sensitive information about other users.

How a Low Privileged User can query Private Webhooks

The GetProjectWebhooks query is used to retrieve private webhooks assigned to a specific project. Because of this, the operation requires us to pass a project ID as a variable. Under normal conditions, low-level users can only successfully run this request using the ID of a project they are actually assigned to. Notice how the project object is the root of the query, and the webhooks exist as a nested field directly under it.

None
Return webhooks for projects we are assigned to

When a low-level user attempts to retrieve webhooks on a project they haven't been assigned to, the API responds with an error showing they lack the appropriate VIEW_PROJECT permissions.

None
can't retrieve webhooks of unassigned projects

However, this restriction can be bypassed by calling a different operation: GetOrgWebhooks. Originally intended for Admins, this query returns all global webhooks, including those assigned to projects a low-level user isn't supposed to see.

Notice the shift in the hierarchy: here, the organization object is the root of the query, and the project object exists as a sub-object within it. While the API blocks a direct query to the project, it fails to verify permissions when that same project is accessed through the organization parent. By pivoting through the organization object, a restricted user can reach the exact same data that was previously forbidden.

None
return all Organizational webhooks

From the Organization object, there is a webhook field that references the Webhook object and a project field that references the Project object. This means if you query the Organization, you can pull both Webhooks and Projects at the same time.

The Project object works the same way. You can call the Webhook object directly under it. The data is mapped in a circle: you can reach the same webhook data whether you start your query at the Organization level or the Project level.

None
Relationship schema

The application lacked Field-level Permissions. Even though the API blocks a direct query to a Project with an ID, it fails to verify those same permissions when the Project is requested as a nested field under the Organization. By simply shifting the starting point of the query to the Organization level, you can bypass the restriction and pull the exact same project data that was previously "forbidden."

Another shameless plug of my writeup on how I Bypassed Rate Limit with a classic secret header.

Escalating this to Retrieve other User Info

Yes, a low-level user has been able to query all projects to see their webhooks. That's not enough to be considered high. On this platform, even if two low-level users are assigned to the exact same project, they should remain completely invisible to each other. The workspace is designed so that every user works isolated, unaware that anyone else is even part of the project.

The Organization object has other interesting fields, like members. This field is designed to return everyone in the entire organization, an object with its own sensitive fields like IDs, emails, and roles.

As a low-level user, I tried to inject this members field into the GetOrgWebhooks operation to see if it would leak the directory. It failed with a permission error! In this specific instance, they actually had field-level permissions implemented correctly to block unauthorized access to the member list.

None
Can't query members object as a field

JavaScript to the Rescue, again!

Just as I was about to report this as a possible Low, I downloaded the bundled index.js file. It was a massive wall of code that contained the schema definitions for all queries and mutations. On line 3193, I spotted a query operation called GetSuggestedCollaborators.

What made this weird was the structure. This query took a projectId and targeted the Project object but this time, the Project object had a new field called suggestedCollaborators. This field was an object itself, and its structure was nearly identical to the members object I had just been blocked from accessing.

None
JS leaks interesting query

I took the suggestedCollaborators field and nested it under the Project object within the GetOrgWebhooks query. Since the API was already failing to check permissions on projects accessed through the Organization, it also failed to check permissions on this new "suggested" field.

I was able to retrieve a complete list of member IDs and emails, right alongside the private project names and secret webhooks I wasn't supposed to see.

Failed: organizations->projects->members ❌
Valid: organizations->projects->suggestedCollaborators ✅

The Exploit

To confirm my theory that members and suggestedCollaborators were the same objects under different names, I passed the sensitive fields I'd seen earlier in the members object into the suggestedCollaborators object.

It worked.

The API didn't just return a name; it returned the full profile for every user. By querying the Organization, I could now see every project in the entire company and, for each one, a complete list of every user assigned to it regardless of whether I was supposed to know they existed.

None
Retrieve members under suggestedCollaborators

I suspect both objects either implement the same interface or suggestedCollaborators is simply a duplicate of members created without any permission checks. By passing member-specific fields like IDs, intercomHash and emails into the suggestedCollaborators object, I confirmed they were pulling from the same data source.

First time Reading JavaScript earned me $1,500 in a bug hunt, I've never looked back since.

Impact and Payment

In this application context, this attack allows the attacker to steal sensitive PII for all registered users (emails, names, roles, etc.) and gain access to confidential Project and User info.

I reported this as High, but HackerOne triage did their thing and downgraded it, which was bizarre.

None
severity downgrade

I didn't bother arguing the severity at first since the program's own policy classified leaking other users' basic info as PII, so I waited for them to just come see this. A few days later, the same HackerOne triager returned and upgraded the report back to High. The program was impressed with the depth of the bypass and awarded me a $1,500 bounty for my troubles.

None
severity upgrade & getting $paid$

Android app hacking is not limited to proxying traffic in Burpsuite only, you can read APK code to Exploit misconfigured Content Providers.

Until next time…bye

None
Mr. Marsh

Thanks for reading this, if you have any questions, you can DM me on X @tinopreter. Connect with me on LinkedIn Clement Osei-Somuah.