August 18th 2023
Feeld Reveal
Feeld is a dating app.
If you do not have a paid profile on Feeld, you cannot see the picture of the user who liked you. It is blurred. The only visible part is the name and profile type (paid users marked with M icon).
Feeld iPhone app, "like" screen
You cannot like or dislike these profiles unless you accidentally discover them in a feed.
With proxy running in the middle, you can unlock this functionality.
Looking around
I used Charles to spy on requests coming from my iPhone and observe what is exposed by Feeld's API.
-
Once you reach the "likes" screen (screenshot above),
[GET] /curiosity
endpoint is being called. It gives back the list of 12 users who liked you.
{
"user_ids": [
"6302b2050f9110000100615",
"61bb2419d254ff00430116e",
"642a01505c51d7000100022",
"57a25e827d2a68cd0304908",
"6393f9c9797c6c000100037",
"5ed55c7352621c004200641",
"63bcd3df920dcb00010002f",
"577ddcf76f210e1100349ff",
"64154749190d70000100178",
"58059bf57d2a68cd0309b90",
"62f7170bde34a30001000f0",
"649c34a56ce97f0001002b4"
],
"count": 204
}
user_ids
sorted by the date of a like. The first is the most recent one.
- With these ID's in place,
[POST] /users
is triggered — the main action for retrieving user profiles. Although the payload includesuser_ids
for "hidden profiles," the response is identical to open profiles (as on the "feed" screen).
In the example below, you can observe the first record from the response, which represents the most recent like (profile with the name "A", as shown on the screenshot above). We have everything: age
, interests
, desires
, about
, photos
and even associated
profile (since it is a couple).
{
"_id": "6302b2050f9110000100615",
"info": {
"desires": [
"Poly",
"Dates",
"Foreplay",
...
],
"interests": [
"Biking",
"Art",
"Science",
...
],
"about": "...",
"type": 0,
"name": "A",
"gender": 1,
"sexuality": 15,
"age": 30
},
"photos": [
{
"url": "...",
"nudity_score": 0.01
},
...
],
"location": {
"core": "new_york"
},
"public_profile_modified_at": "2023-07-21T17:56:12.884Z",
"modified_at": "2023-07-21T17:56:12.884Z",
"status": {
"membership_expires": "2024-07-11T21:31:53.665Z"
},
"deactivated": false,
"associated": {
"_id": "...",
"age": 34,
"deactivated": false,
"gender": 2,
"name": "...",
"photo": {
"url": "...",
"nudity_score": 0.01
},
"sexuality": 12,
"terminated": false
},
"layer_id": "...",
"last_seen": "2023-07-21T17:56:12.884Z",
"distance": 9.914694397168018,
"discover_distance": 12.357918363934957,
"blocked": false,
"ignore_distance": false,
"terminated": false
}
Photos are accessible publicly without authentication (all you need is the correct url) and blurring effect is applied on the client (no surprise). Exposed data is not used on the client but it is still there.
"hidden" profiles are not that hidden at the end.
At this point, I closed Charles and decided to create my own webapp (with blackjack and ... proxy in the middle).
It shows you the latest "hidden" profile listing all available photos and basic info. There are two buttons for liking and disliking. Once you press one of them, the next profile from the recent likes list is shown. If you go through all 12 profiles, you repeat the cycle with [GET] /curiosity
(to get again the latest likes) and [POST] /users
(to fetch users with the ID's from the previous request) until there are no more likes left.
SSL TSL XXL
You might know that in order to properly spy (→ read payload and headers) on HTTPS requests with your proxy, you must provide a root certificate for your remote device and trust it fully.
There are plenty of instructions how to generate a self-trusted SSL certificate: uno, dos, tres.
I used mkcert zero-config tool. Easy.
Once you have the certificate, you transfer it to your remote device (AirDrop or email), install and give permission to use it.
iPhone's "Certificate Trust Settings" screen
When this is done, connect your device to the same network as the machine which is running the proxy and point to the server itself.
iPhone's "WiFi proxy" screen
Proxy
I used NodeJS and picked mockttp. It is a powerful and easy-to-configure mock server with intuitive API for inspecting and intercepting HTTP requests.
- Creating the server instance with formerly created private
key
andcertificate
.
const optionsSSL = {
key: readFileSync(...),
cert: readFileSync(...)
}
const serverInstance = mockttp.getLocal({ https: optionsSSL })
- Specifying the rules on how to intercept requests.
Here the idea is to catch the first request to Feeld's API where session-token
header is not empty — it is used as an authentication record for other requests. Together with it, I store other essential headers — they will be reused for our future calls to Feeld's API.
let essentialHeaders = {
'session-token': '',
'user-id': '',
'device-id': '',
'user-agent': '',
'device-ids': '',
accept: '',
'accept-language': '',
'accept-encoding': '',
};
function extractEssentialHeaders(headers) {
return Object.keys(essentialHeaders).reduce((acc, headerKey) => {
return {
...acc,
[headerKey]: headers[headerKey],
};
}, {});
}
await server
.forAnyRequest()
.matching((req) => {
if (
req.url.match(new RegExp(`${config.baseUrl}/api/v5/.+`)) &&
req.headers['session-token'] !== undefined &&
req.headers['session-token'] !== null &&
req.headers['session-token'] !== 'null' &&
req.headers['session-token'] !== ''
// Ugly
) {
return true;
}
return false;
})
.once()
.thenPassThrough({
beforeRequest: (req) => {
essentialHeaders = extractEssentialHeaders(req.headers);
},
});
API
Having essentialHeaders
stored, I created a couple of routes for our future frontend with forGet
and forPost
builders.
Our server is a proxy and an API.
To cover our client needs, we need four endpoints (which respectively call the same Feeld API underneath):
[GET] /client/api/curiosity
[POST] /client/api/users
withid_list
as part of the body.[POST] /client/api/like
withuser_id
as part of the body.[POST] /client/api/dislike
withuser_id
as part of the body.
Here is the example for [GET] /client/api/curiosity
:
await server.forGet('/client/api/curiosity').thenCallback(async () => {
/*
`requestSpecificHeaders` includes:
['method', 'path', 'content-type']
url is pointing to Feeld's server
*/
const url = config.baseUrl + requestSpecificHeaders.path;
const headers = {
...requestSpecificHeaders,
...essentialHeaders,
};
// Simple validation to make sure non of the headers are empty
if (!areHeadersReady(headers)) {
console.error({ headers });
throw new Error(`Headers are not ready: ${JSON.stringify(headers)}`);
}
const fetchOptions = {
headers,
protocol: 'https',
method: requestSpecificHeaders.method,
};
const data = await fetch(url, fetchOptions);
const json = await data.json();
return {
statusCode: data.status,
json,
};
});
This backend allows us to see "hidden" profiles and like or dislike them in a Tinder-like manner (one after another).
UI
[GET] /ui
gives back a simple HTML file to render on the client.
Our server is a proxy, an API and a static file server.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Feeld Reveal</title>
</head>
<body>
<div id="app"></div>
<script src="https://cdn.tailwindcss.com"></script>
<script type="module" src="./public/client.js"></script>
</body>
</html>
There is a client.js
hooked in which is responsible for creating the UI and communicating with our /client/api
.
I do not post full source code of the server and client on purpose. It should be fairly easy to discover the missing parts and replicate the functionality.
If you struggle or have questions — just write me an email.
Play
- When the server is running, all we need to do is to open the Feeld app.
Right after, server console prints out that client-specific headers have been stored. This just confirms that one of the requests has been intercepted and captured with essentials.
iTerm console. Poor quality sorry
Now we can fetch Feeld's API on our own and reveal "BkPk" profile.
- Frontend. Rough and minimal.
Welcome screen
Once I press the Start
button, we will perform our first manual call to [GET] /client/api/curiosity
and then to [POST] /client/api/users
.
- Click!
The latest like from the "hidden" profiles list. Once you act on the shown profile, the next one from the list will be shown
The latest profile (who liked you) has been loaded. Displaying name
, about
and photos
.
Check it out, like or dislike.
- Recording of the device at the moment of the "like" button press.
Done.
The charm of using mobile app has disappeared quickly after I adopted custom UI on top of over-exposed data.
The mystery has disappeared. The "secret" was gone.
It confirms that sometimes, user experience is not only about what the client sees but rather about the data being served.
Notes
The payload for [POST] /discover/combined
request becomes heavy on the
"feed" screen. This endpoint fetches upcoming users once you move through the
feed and makes scroll infinite. Every new request contains previously loaded
user ID's as part of the payload. If I happen to scroll far enough, request
turn out so big that my iPhone freezes dramatically and UI lags.
User's images can be optimised in size.
I did inform Feeld about the article and shared it to the team before publishing.