The developer has been given responsible disclosure and I have been informed that steps are being taken to address the security concerns.
I have minimal experience with exploitation or security research- some techniques employed in this write-up are almost certainly suboptimal. This is not a tutorial, do not treat it as such. Sensitive information has been censored as best as possible.
Earlier in the year I attended a hackathon organized by my university's IEEE branch. My team and I managed to hack together a pretty decent submission, and for our efforts, we were rewarded with second place. Among the submissions, a one Mentora piqued my interest. Mentora marketed itself as an "AI tutor", seeking to gamify quizzes and provide personalized lesson plans using, you guessed it, large language models.
Interested, I began to explore the "How we built it" section of the Devpost. React Native, Supabase, and OpenAI integration. Yup! This reeks of vibe coder slop. Unfortunately, Mentora never seemed to see the light of day. Luckily, our hero didn't stop there, and this time he's breaking into the social media market. Enter: Pandu.
Much like Mentora, Pandu is a pretty generic take on an existing genre but with more AI slop. Disappointingly, there is yet to be an Android release of Pandu, and given that I don't own any Apple devices, investigating this might be a little rough.
Apparently, Apple's IPA
format is encrypted, meaning that blindly pulling files from a server and expecting to be able to read them isn't going to work. Luckily, someone has managed to automate this process of decrypting these bundles and has even been so kind as to expose the tooling behind a Telegram bot.
After acquiring the decrypted IPA, I simply suffixed the file with .zip
and extracted it:
The archive yielded a Payload/
folder, with the following structure:
The main file of interest here is the main.jsbundle
, which appears to be a JavaScript bundle compiled with Hermes. After a quick pass with hermes-dec, I was left with a mostly readable blob of pseudocode:
I began my search by looking for very low-hanging fruit. Surely an app published on the App Store wouldn't expose an OpenAI key, right?
r11 = 'sk-proj-*************************************************';
WHAT? There. Is. No. Way.
|
Secrets in plain sight—
Exposed keys invite misuse,
Guard doors, don’t paint signs.
So it turns out that all the fancy LLM integration is handled client-side, with the users' device issuing the OpenAI API queries and forwarding the output to the "backend" (if you can even call it that, I'll get to this later).
Now that we know that LLM queries are handled client-side, it shouldn't be too difficult to pull out the system prompt.
An LLM prompt isn't exactly "valuable information," and "prompt engineering" is most definitely not a skill- Nonetheless I'm expecting this to be quite funny.
"You are a Gen Z App, You are Pandu,you are helping a user spark conversations with a new user, you are not cringe and you are not too forward, be human-like. You generate 1 short, trendy, and fun conversation starter. It should be under 100 characters and should not be unfinished. It should be tailored to the user's vibe or profile info. Keep it casual or playful and really gen z use slangs and emojis. No Quotation mark"
I wonder what prompted him to tell the model not to be cringe.
If you aren't familiar, Supabase is a Backend-as-a-Service, providing CRUD-accessible endpoints, a database, storage buckets, authentication, and even real-time communication. The upside of something like this is that you don't need to understand the complexities that come with backend development in order to build something that "just works". However, you'll later come to see that this is also a hidden downside, as some people shouldn't be permitted within 10 feet of a backend.
Taking the place of a standard database, Supabase expects the client to talk directly to the database- now, you might be thinking to yourself: "isn't that a horrible idea?" and to your question, I'll reply "yes." The assumption is that the person in charge of configuring Supabase will correctly dish out permissions such that end-users cannot simply read/write to arbitrary relations, but as you'll soon see, this is not always the case.
With the decompiled pseudocode in hand, it was as simple as grepping for a few keywords:
;
Now that we have the Supabase information, it's time to go hunting for relations that might be useful. After some investigation, it appears that all Supabase queries follow roughly the same pattern in the pseudocode:
r6 = r4.; // supabase.from('user_wallet')...
r4 = r6.;
r13 = 'user_wallet';
...
With this in mind, we can write some cheeky Python that'll help us filter through this checks notes 1.5M LoC blob.
I know that this could be accomplished with a regex, but I'm stubborn and refuse to change
: =
=
continue
continue
= or
=
=
And let's run it!
These relation names are... interesting to say the least.
Let's poke around and see what we have access to, a quick select
from the profiles
relation doesn't seem like a terrible idea ;).
;
const supabaseUrl = 'https://XXXXXXXXXXXX.supabase.co';
const supabaseAnonKey = 'eyJh.XXXXXXXX.XXXXXXXXX';
const supabase = ;
const result = await supabase
.
.
.
Well, this is odd. I'm assuming this is a blank account that was created during testing? Either way, we now have a list of fields which is exactly what I was looking for.
let brokenProfiles = await supabase
.
.
.
let nonBrokenProfiles = await supabase
.
.
.
let suckers = await supabase
.
.
.
;
;
;
broken profiles: 1286
non-broken profiles: 9427
suckers: 146
Weird. I'm not quite sure what this indicates, but those numbers are certainly interesting.
(Can you believe that nearly 10k people were dumb enough to sign up for this app?)
I've gone ahead and had my girlfriend create an account on her iPhone, this way I can explore a little more without having to worry about stepping on anyone's toes.
let result = await supabase
.
.
.
There are a few things here that are noteworthy, but let's investigate the expo_push_token
field. It appears that any time a notification is to be sent out, it is issued by the client, with the following steps being taken:
profiles
relation for recipients' Expo push tokenr10 = r6;
r8 = global;
r6 = r8.;
r9 = r6.;
r7 = '';
r6 = ' won the Flag Game! 🏆🏳️';
r12 = r9.;
r9 = r8.;
r7 = ;
r6 = 'POST';
r7 = r6;
r6 = ;
r10 = 'application/json';
r6 = r10;
r7 = r6;
r11 = r8.;
r10 = r11.;
r6 = ;
r15 = r15.;
r6 = r15;
r6 = r12;
r12 = ;
r14 = _closure2_slot3;
r12 = r14;
r12 = r13;
r6 = r12;
r6 = r10.;
r7 = r6;
r6 = 'https://exp.host/--/api/v2/push/send';
r6 = r9.;
Under no circumstances should these Expo tokens be exposed to clients, however, the very architecture of this application relies on that being the case. With that in mind, I'm quite sure that it's possible to issue arbitrary push notifications to any user. According to the official Expo Docs all we need to do is issue the following POST request:
I wonder if it worked...
The main feature that sets Pandu apart from other social media platforms is its integration of games. Despite being poorly executed and far from original, there seems to be a group of people competing for spot #1 on the in-game leaderboard. I don't quite like Pandu, but I definitely am competitive by nature- let's see what we can do.
The rankings seem to be calculated client-side by simply querying the user_progression
relation for the top 20 users sorted by total win count.
r1 = ;
r1 = r13;
r19 = _closure1_slot5;
r20 = r19.;
r19 = r20.;
r21 = 'user_progression';
r22 = r19.;
r20 = r22.;
r19 = 'id, username, nickname, wins, level, profiles!inner (avatar_url,age)';
r24 = r20.;
r22 = r24.;
r19 = ;
r19 = r3;
r20 = 'wins';
r24 = r22.;
r22 = r24.;
r19 = 20;
r19 = r22.;
r1 = r19;
r1 = r7.;
;
let leaderboard = await supabase
.
.
.
.;
Presumably, all we should have to do is create a profile and propagate those aforementioned fields such that we satisfy the query. Additionally, we need to set the user_id
field to satisfy the foreign key relationship between user_progression
and profiles
.
First, let's create the account:
const auth = await supabase..;
const userId = auth...;
And now we can propagate the fields of the relations:
await supabase
.
.;
await supabase
.
.;
And voilà! :)
Moving on to something a bit more serious, the privacy implications of using software built by someone whose productive output is directly tied to the uptime of Cursor is absolutely horrendous. Despite his apparent lack of competence, it appears that he has (miraculously) managed to implement user chat sessions in a pretty solid way. By piggybacking off of Supabase's authentication and StreamChat's real-time communication API, he has completely avoided doing any heavy lifting himself. Smart.
With this in mind, we should look for the tiny portion of this stack that he is responsible for creating. Before a chat session is initialized, a "chat request" is first sent out to a user, after which they can choose to either accept or reject the invitation. Checking the table of relations we mined earlier, the chat_requests
relation seems like it might be relevant. Let's investigate.
await supabase
.
.
.;
Yup! Our suspicion is confirmed. Every single chat request is public! This is truly a nightmare. Even worse, I seemingly have read/write access to this table, meaning it's feasible to send chat requests with arbitrary messages on behalf of other users.
Okay, I think it's time we stop beating around the bush. I'm sure if you've been an attentive reader you will have noticed the user_locations
relation from earlier. The in-app use case of this appears to be matching users up with others who are geographically close to them. While I understand the justification for a feature like this, the execution here is dangerously flawed. With a single select
I am able to pull the live geographic location of any user on the app. You heard me right. This is not the future of social media, it's a sexual predator's wet dream.
await supabase
.
.
.
If you need even more compelling evidence that this app is downright dangerous, take a quick look at this histogram of profile count by age:
Nearly a thousand children under the age of 18 with their live location, photo, and age being beamed up to a database that's left wide open. Criminal.
Christian is the "mastermind" behind this technological shitshow and its parent company, Lunexis. As you've already seen, this man needs no introduction.
At first, I was wondering how he managed to even publish something like this, but I'm starting to think that Apple just got tired of rejecting it over and over.
"Vibe coding" isn't just a cheap shortcut, it's reckless and dangerous. Christian's incompetence is jeopardizing the privacy of hundreds of people, all while he lines his pockets. What he is doing is illegal, perverse, and downright disgusting.
Think I'm exaggerating? I was planning on doing some math to estimate his MRR, but it looks like he's already gone ahead and bragged about it on Twitter.
Earlier in this write-up I managed to identify 146 active subscribers, assuming this figure is accurate and his revenue-per-subscription has stayed constant, that leaves us with an estimated MRR just north of $2,500. He is making serious money and has absolutely no clue what he's doing!
Calling this platform harmful is not an understatement. I am urging you to stop supporting this creator, report the app immediately, and get friends and loved ones off of this app as swiftly as possible.