HexBrain

X / xAI · Report #885539

Private list members disclosure via GraphQL

Validstatus
lowseverity
346upvotes
4 Aug 2020disclosed

Summary

Summary

By chaining the timing attack and broken rate limit with a vulnerable GraphQL query, it was possible to read members of the private list via GraphQL.

Description

Starting

On May 28th, I noticed that Twitter has some weird endpoints. That endpoint looked like this: https://api.twitter.com/graphql/[Random String]/[Endpoint name]. After doing a Google search, I found this page. According to this page, this is Twitter's GraphQL implementation, called stored operations or persisted queries. I haven't heard about them, so I've searched Google, and there seems to be no example (as far as I've seen) found a vulnerability in this feature. So I decided to dig deeper into this.

Reconnaissance

This endpoint has a Random String part that makes enumeration difficult. As a result, it is difficult to find an endpoint by brute force. So I started reading the JavaScript code for Twitter Web Client. After a while, I noticed that main.[Hex string].js stores Endpoint name and Random String as a pair. {F935327} Therefore, I used the Wayback Machine to enumerate the past main.[Hex string].js and took out all the Endpoint name and Random String from it. At this point, a sufficient amount was collected, but from the above article, it seems that GraphQL is used not only in Twitter Web Client but also in Twitter Mobile Client and Tweetdeck, so I decided to extract it from there. In the code of Twitter Android Client, the pair of Endpoint name and Random String was stored in l63.java, and in Tweetdeck, it was bundle.[Hex string].js. {F935329} {F935328} As a result, I have gathered a list of over 700 endpoints, so I decided to move to the next stage.

Testing

Testing over 700 endpoints can be a daunting task, but existing GraphQL clients don't support this type of endpoint, so I started testing with Firefox and the Burp Suite. The test was run for several hours, so I will only pick and explain what is important. First, it seems that there is no problem if GraphQL's endpoint is just /graphql. You can execute GraphQL by sending a POST request with queryId (this is the Random String above) and variables in Body to this endpoint. So the Endpoint name was not needed. {F935324} Second, some Query IDs will give an error when trying to reference a field that does not exist. This is probably due to an attempt to execute an old query on the current schema. {F935325} After testing for a while, I concluded that it is impossible to execute arbitrary GraphQL queries. That's why I decided to run the queries one by one and drill down into the interesting ones. After a while, I focused on the ListMembers query. It looks like this is a query that returns a list of members for a List. And there was no check that the target List was private or not in that query.

Reporting

I found that I could retrieve the members of a private List using the ListMembers query (iUmNRKLdkKVH4WyBNw9x2A), so I reported this to Twitter. A few hours later, there was a reply from the Twitter Security Team, but it seems that the Twitter team thinks the Twitter ID is difficult to brute force. I thought that brute force is possible, so I tried creating a script to try brute force. However, due to the existence of rate limits, it was not possible to brute force.

Bypassing

Direct brute force cannot be done because the rate limit of GraphQL itself cannot be avoided. However, the List-ID is not unique to GraphQL and is used in various APIs. Therefore, I decided to try using these APIs to see if brute force is possible. Fortunately, Twitter's API rate limits were mostly broken. Even though Twitter's developer docs said that there was a rate limit, there were many that didn't. However, it seems that there is no vulnerability in the API of Twitter that leaks information on the private List, and it is not possible to confirm the existence of the private List. At this point, I was considering self-closing the report. But it was not fun to give up here, so I decided to explore more possibilities. After a few minutes, I noticed the presence of the x-response-time header. This seems to be returning the time used for processing on the server-side. I immediately confirmed the difference in the x-response-time header when specifying the ID of the private List and when specifying the ID that does not exist. There was a slight difference (10-20ms) in processing time between private List ID and non-existent ID. Using this, I can enumerate the IDs on any private List. {F935330}

Exploiting

After creating a PoC that enumerates a List using the above vulnerabilities, I was hungry and decided to eat ramen. Before I went to eat ramen, I deployed PoC to AWS and ran it. After returning home, I checked my AWS instance and rejoiced. Potential List Detected: [Real List ID] (With 137ms delay) It looks like I found valid private List ID... And yes, I've confirmed that I can read members of any private List!

Conclusion

I think this report is a good report showing that even a small vulnerability (confirmation of the existence of a private List) that is not normally recognized as a valid report can produce a greater effect by being linked with other vulnerabilities. Also, by using the same technique like this one, you can enumerate all the Moments using the link-only sharing option that exists in Twitter's Moment function, but this does not seem to be a vulnerability (Twitter closed it as Informative.) {F935326} Unfortunately, when I was looking for a Twitter vulnerability after this report, my account was suspended. (I haven't received a reply from the support team yet...) (Edit: My account got unsuspended) So I can't find any more Twitter vulnerabilities. Therefore, I'll share here some of the interesting information I found during my research. (If you find vulnerabilities based on this information, I would be grateful for a word of thanks.)

PoC files

{F935333}: It'll find unlisted moments (Intended behavior) {F935334}: It'll find valid List ID

Twitter GraphQL Query IDs

{F935320} {F935321} {F935322} {F935323}

About protected tweets option

Twitter's protected tweets option only protects Tweets. It won't protect Lists, Followers... etc. If you're using a protected account and sharing some sensitive information in Lists, be careful with it. (Twitter said this is intended behavior.)

Broken rate limits

After sending this report, I deleted the list of endpoints that have the broken rate limit. And as my account got suspended, I can't test it anymore. So please find it by yourself.

If you have any questions, ask me on Twitter ;)

How it was found

Summary: Due to improper queries of GraphQL, the attacker can steal members of the private list.

Description: Twitter implements a unique GraphQL endpoint, which can use only the queries that Twitter specified. However, there is a flaw in the backend (https://api.████████.com/graphql/iUmNRKLdkKVH4WyBNw9x2A/ListMembers). This vulnerability requires snowflake brute-force, but it's not impossible. Snowflake is built from the timestamp, sequence id, and worker id. Sequence id is basically 0 because it'll reset automatically at the start of every millisecond. Worker id can have 2 ^ 10 = 1024 different values in principle. So you need to send 1024 * 1000 = 1024000 requests to perform brute-force snowflake which created in a sec. 1024000 * 60 = 61440000 requests will be enough to brute-force a list created in a minute. It takes a few hours to perform, but it's not impossible.

Steps To Reproduce:

To reproduce this:

  1. Create a private list in account A and add some people.
  2. Login to account B, and trigger ListMembers request.
  3. Intercept the request and replace ID to the list's one which you created in step 1.
  4. Now, you know the members of account A's private list from account B.

In real attack:

  1. Send requests to https://api.█████████.com/graphql/iUmNRKLdkKVH4WyBNw9x2A/ListMembers?variables=%7B%22listId%22%3A%22[Valid Snowflake Here]%22%2C%22count%22%3A20%2C%22includePromotedContent%22%3Atrue%2C%22withHighlightedLabel%22%3Atrue%2C%22withTweetQuoteCount%22%3Atrue%2C%22withTweetResult%22%3Atrue%7D until you got valid response.
  2. If you found a valid snowflake, open https://████████.com/i/lists/[ID Here].
  3. If the list is private, you know members of the list now.

Supporting Material/References:

██████

Impact

Leakage of private list members.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original