# Private list members disclosure via GraphQL

Twitter's ListMembers GraphQL persisted query lacked a private-list check, letting anyone read private list members after enumerating list IDs via a timing side-channel.

- Payout: hidden
- Severity: low
- Program: X / xAI
- Found by: ryotak
- Disclosed: 2020-08-04
- Write-up quality: 8/10
- Original: https://hackerone.com/reports/885539
- Library page: https://bugbountylibrary.com/r/h1%3A885539
- Tags: area:web domain:api impact:data-leak kind:bounty platform:hackerone sev:low tech:graphql technique:chained technique:js-analysis technique:recon vuln:access-control vuln:idor vuln:info-disclosure

**Target:** GraphQL persisted query endpoint api.twitter.com/graphql/[queryId]/ListMembers
**Root cause:** The ListMembers persisted GraphQL query returned members for any listId without verifying the requesting user was permitted to view a private list.
**Steps:**
1. Extract queryId/endpoint pairs from main.[hash].js, l63.java (Android), and Tweetdeck bundle.[hash].js; collect 700+ endpoints.
2. POST to `https://api.twitter.com/graphql/[queryId]/ListMembers` with body `variables={"listId":"<snowflake>","count":20,...}`.
3. Observe `x-response-time` header differs 10-20ms between existing private list IDs and non-existent IDs.
4. Brute-force snowflake list IDs via a rate-limit-broken API while measuring `x-response-time` to confirm existence.
5. Open `https://twitter.com/i/lists/[ID]` or query ListMembers to read members of the confirmed private list.
**How found:** Noticed unusual `/graphql/[random]/[name]` endpoints, learned they were persisted queries, and since the random queryId blocked brute force, read Twitter web/Android/Tweetdeck JS to harvest queryId-endpoint pairs. Tested each query, spotted ListMembers lacked a private check, and when rate limits blocked direct ID brute force, pivoted to other APIs and noticed the `x-response-time` header leaking existence timing.
**Exploitation:** Attacker enumerates valid private list snowflake IDs using the x-response-time timing oracle on a rate-limit-broken endpoint, then calls the ListMembers persisted query with the discovered listId to retrieve members without authorization.
**Escalation:** Chained a low-value existence oracle (timing side-channel + broken rate limits) with the missing authorization check on ListMembers to turn ID enumeration into full private member disclosure.
**Insight:** Persisted-query GraphQL hides the schema but the queryId/operation pairs ship in client JS bundles (web, mobile, Tweetdeck) and old bundles via Wayback — harvest them to enumerate hidden operations. When a per-endpoint rate limit blocks ID brute force, look for the same object ID on other APIs and use response-time headers as an existence oracle.

