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).

feeldiphoneapp,"like"screen

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.

  1. 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.

  1. With these ID's in place, [POST] /users is triggered — the main action for retrieving user profiles. Although the payload includes user_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"certificatetrustsettings"screen

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"wifiproxy"screen

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.

  1. Creating the server instance with formerly created private key and certificate.
const optionsSSL = {
  key: readFileSync(...),
  cert: readFileSync(...)
}

const serverInstance = mockttp.getLocal({ https: optionsSSL })
  1. 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 with id_list as part of the body.
  • [POST] /client/api/like with user_id as part of the body.
  • [POST] /client/api/dislike with user_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

  1. 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.

itermconsole.poorqualitysorry

iTerm console. Poor quality sorry

Now we can fetch Feeld's API on our own and reveal "BkPk" profile.

  1. Frontend. Rough and minimal.
welcomescreen

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.

  1. Click!
thelatestlikefromthe"hidden"profileslist.onceyouactontheshownprofile,thenextonefromthe<ahref="#likes"class="text-gray-400">list</a>willbeshown

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.

  1. 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.