This is an anonymized case study from a bug bounty engagement this year — the company name, domain, and internal hostnames have been redacted or replaced with placeholder values.
Below you'll find more details about it. Here we go...
Today we'll start here:
## Scope
Target: a private/public bug bounty program (HackerOne) run by a large e-commerce/travel company. The scope included, among other things, the company's internal partner/engineering console — a panel exposed under a subdomain like `console.targetcorp.example`, used internally to manage integrations, AI tooling, and agent configuration.
As is often the case, this kind of "internal" panel ends up publicly exposed because partners or remote teams need access to it. These are exactly the surfaces that tend to have weaker authorization controls than the consumer-facing product — and that's precisely what happened here.
## Plan: manually hunting for GraphQL
The standard process for a large scope:
1. Subdomain and endpoint recon (out of scope for this post).
2. For each host — check whether a GraphQL endpoint exists (`/graphql`, `/api/graphql`, `/query`, etc.).
3. If the endpoint responds — attempt schema introspection (`__schema { types { name } }`).
4. If introspection is enabled — manually review the schema for "interesting" fields (root queries/mutations that suggest access to sensitive data, etc).
During one of the tests for some host in the scope (`console.targetcorp.example/graphql`), introspection returned the full schema, and a number of field names immediately stood out: `get_all_tools`, `get_all_models`, `get_all_llm_integrations`, `get_all_api_integrations`, `get_all_tools_agents`, `get_jwt`, `identityApiClientCredentials`, `billingInvoice`.
That alone was a strong signal this was worth digging into further.
The first attempt to pull data from `get_all_tools` without any authentication, however, returned a generic error:
This looked like a functioning authorization layer at the gateway — and that's how I initially read it.
## Building a GraphQL vulnerability scanner
Since the scope covered dozens of hosts, manually probing each one wasn't practical — so I created a small, custom Python-based scanner (`gql_scan.py`) that, for a list of hosts (from argv[1], text file), automatically:
- checks whether a GraphQL endpoint responds at all (common paths + fingerprinting via headers/errors),
- tests whether introspection is enabled in production,
- fingerprints the engine/framework (Apollo Federation, Hasura, WPGraphQL, Shopify, Magento, GitLab, Bynder, and others — each has its own characteristic "tell"),
- checks for support of query batching (array batching), which is itself a separate attack vector (e.g. DoS or rate-limit evasion),
- **tests the characteristic bypass of adding an explicit `operationName` field to the request** — more on this below,
- tags findings by severity (including a dedicated CRITICAL tier for a confirmed `operationName` bypass).
The full scanner source (Python 3) is available as an attachment on Patreon for supporters — here I'll focus on the vulnerability logic itself, since that's the interesting part.
### The key observation: a false security gate
What initially looked like working authentication (a 400 error for "bare" requests) turned out to be **request-shape validation at the Apollo Federation gateway level**, not real authorization. The gateway was rejecting GraphQL requests that didn't explicitly include an `operationName` field in the JSON body — most likely as protection against a specific class of CSRF/request-smuggling attacks, not as an AuthN/AuthZ mechanism.
Adding a named operation was enough to get through:
The second version returned **HTTP 200** with real data — no `Authorization` header, no session cookie, no API key. The backend subgraphs that the gateway forwarded the request to, once it passed the shape check, performed no additional identity verification of their own.
## What the scanner detected vs. what we verified manually
The scanner (`gql_scan.py`), during its automated run:
- confirmed introspection was enabled in production,
- confirmed support for query batching,
- flagged the endpoint as CRITICAL after automatically detecting the `operationName` bypass pattern.
Everything after that was done manually via `curl`, so we had full control over exactly what was being pulled (critical when dealing with data this sensitive — we didn't want to bulk-pull everything, just gather the minimum needed to demonstrate impact):
1. **Confirming the scope of vulnerable types** — looping over the five main resource types (`get_all_tools`, `get_all_models`, `get_all_llm_integrations`, `get_all_api_integrations`, `get_all_tools_agents`), requesting only `__typename` so we could count records without pulling content:
The record count per category isn't just a curiosity — it's the basis for scoping the severity before going any further. Dozens or hundreds of records in a type that should normally only be reachable while authenticated immediately tells you this isn't a single misconfigured endpoint, but a systemic lack of authorization across the entire subgraph. It also naturally points to the next step: since `__typename` succeeds cleanly for all five types, the logical move is to check which specific *fields* (not entire records) are actually accessible — and only then decide which of those are worth pulling further versus leaving untouched until triage.
2. Next step?
**Pulling descriptive fields from one type** (`id`, `name`, `description`, `tool_instructions`) to prove real impact without dumping the entire dataset.
3. Next step?
**Introspecting a single type** (`APIIntegration`) looking for fields that suggested sensitive data (e.g. a field storing outbound HTTP request headers) — deliberately **without** retrieving that field's actual value, to avoid touching potential secrets before the security team's triage.
This principle — "prove the issue exists, don't exfiltrate more than necessary" — is essential in every bug bounty report and worth applying consistently, no matter how tempting it is to keep pulling the thread. ;)
## What this gave an attacker bughunter:
With no authentication at all, and one extra field in the JSON body, it was possible to:
- **Read the full GraphQL schema** of the internal console — a complete map of every available operation, including mutations (not tested, but present in the schema: creating/editing/deleting integrations, generating credentials, partner billing operations).
- **List and partially read 300+ records** describing internal AI tools, models, integrations, and agents — essentially the entire tooling estate normally reserved for internal teams.
- **Extract fragments of system prompts / business logic** from the customer-facing AI agent — including the full intent taxonomy (cancellation, refund, booking changes, etc.) and the escalation rules for GDPR/CCPA-related requests (recognizing data access requests, right-to-erasure, and the conditions for when to self-serve versus escalate to the compliance team).
- **Map a fragment of the internal infrastructure topology** — internal cloud-domain hostnames, not discoverable from outside via standard DNS recon, tied to specific backend microservices.
- **Identify a "headers"-type field on API integration objects**, which per the schema stores outbound HTTP request headers — very likely the place where API tokens/keys for backend services live. What matters here is that this field was **technically reachable** in the same query as everything else — nothing could stop 'the potential attacker' from querying it, just like `endpoint_url` or `request_method`.
That reachability, not merely the field's theoretical presence in the schema, is what made it a real seed for further escalation: knowledge of internal endpoints (`endpoint_url`) combined with potential access to credentials in `headers` could have opened a path to calling backend microservices directly, bypassing the AI layer entirely.
At this step I deliberately stopped at identifying the field and never retrieved any of its values — a decision driven by impact minimization ahead of triage, not by any technical limitation.
The real impact of a vulnerability like this goes beyond "data leak": knowing the exact routing logic of the AI agent (including compliance escalation rules and the trigger phrases that hand off to a human) is a ready-made map for far more effective prompt injection against the live, customer-facing agent. It's a good illustration that a company's "AI infra" isn't just the models — it's the entire integration layer around them, which can be just as critical, if not more so.
## How to avoid this — quick fix
1. **Don't treat request-shape validation as authorization.**
Requiring an explicit `operationName` is protection against a specific class of attacks (e.g. CSRF via simple HTML forms), not an AuthN/AuthZ mechanism. Authorization needs to live in the subgraph resolvers, not at the gateway.
2. **Disable introspection in production**
(`introspection: false` in the Apollo Server config), or restrict it to the internal network/VPN. This doesn't fix the root cause, but it substantially raises the bar for an attacker.
3. **Audit every root Query/Mutation field**
for the same bypass pattern — this is usually a systemic issue across the whole subgraph, not a single field.
4. **Disable query batching**
unless explicitly needed, and add per-operation rate limiting.
5. **Treat internal partner panels like production APIs**,
even if "nobody's supposed to know about them" — security through obscurity isn't a strategy.
## Summary
This small article is just a 'quick case-study example' of how easy it is to mistake request-shape validation for real authorization — and how expensive that mistake can be when what's sitting behind the gate is something as sensitive as internal AI infrastructure. One extra key in a JSON body (`operationName`) was enough to bypass the "wall" and reach hundreds of records, system prompts, and fragments of internal network topology.
The report was closed as a duplicate of an earlier one (same bypass pattern, reported a few months earlier and already validated at high severity) — which is itself an interesting lesson: even a well-documented, independently discovered finding can lose the race against time. Worth keeping in mind when planning your pace across a large scope.
---
*The `gql_scan.py` (Python 3) scanner source is available to supporters on Patreon.*
---
See you next time!
Cheers





Brak komentarzy:
Prześlij komentarz