Post

$2K API Access Control Bypass

After weeks of banging my head against a SaaS platform’s API, I finally cracked a gnarly broken access control bug that let me escalate from a nobody user to reading admin-only tenant configs. It was a brutal hunt—failed attempts, sketchy workarounds, and pure stubbornness got me there. Target’s staying anonymous (private program), but here’s the raw, unfiltered story for you to chew on.

The target was a SaaS app for multi-tenant cloud management—think teams sharing dashboards for cloud resources. Their bug bounty scope listed a REST API, and I figured access control was my best shot. Spoiler: I was right, but it wasn’t easy. The vuln let any authenticated user query admin-only tenant configuration data (like API keys and compliance settings) by abusing a hidden endpoint with a tricky ID manipulation. Took me forever to spot, but it was worth it.

I started with a trial account (role: basic_user, tenant ID: t-987654). The app’s frontend was locked down tight—no obvious IDORs in the UI. DevTools showed API calls to /v2/resources/ with my tenant ID, but nothing juicy. The API docs (buried in a help page) mentioned roles (basic_user, tenant_admin, global_admin), so I figured BAC was likely between tenants or roles.

First, I tried the obvious: swapping tenant_id in requests like /v2/resources/t-987654. Got 403 Forbidden every time unless it was my tenant. Smart move, I thought—they’re checking ownership. I sniffed around for other endpoints using ffuf and a wordlist tailored for SaaS APIs (internal, admin, config, etc.). Nada. Hours wasted.

Then I got desperate and proxied everything through Burp. One call caught my eye—a POST to /internal/tenants/t-987654/config when I updated my profile. It was weird; why was a user hitting an internal endpoint? The request looked like:

1
2
3
4
5
6
7
POST /internal/tenants/t-987654/config HTTP/1.1
Host: api.cloudapp.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiMTIzNDUiLCJyb2xlIjoiYmFzaWNfdXNlciIsInRlbmFudCI6InQtOTg3NjU0In0.signature
Content-Type: application/json

{"display_name": "My Tenant"}

Response was a boring 200 OK. I tried changing tenant_id to t-123456 (a random guess): http

1
2
3
4
5
6
POST /internal/tenants/t-123456/config HTTP/1.1
Host: api.cloudapp.com
Authorization: Bearer my_jwt_token
Content-Type: application/json

{"display_name": "Test"}

Got 403 Forbidden. Looked like they were validating tenant_id against my JWT’s tenant claim. I was stuck.

I wasn’t giving up. I started decoding JWTs to see what I could mess with. The token had:

1
2
3
4
5
6
7

{
  "user_id": "12345",
  "role": "basic_user",
  "tenant": "t-987654",
  "exp": 1746988800
}

No way to forge a tenant_admin role without cracking the signing key (HS256, no luck brute-forcing). But I noticed the /internal/tenants/ endpoint had a GET method in the API docs, marked “admin only.” I tried: bash

1
2
curl -H "Authorization: Bearer my_jwt_token" \
     https://api.cloudapp.com/internal/tenants/t-987654/config

Returned my tenant’s config:

1
2
3
4
5
6
7
8
9

{
  "tenant_id": "t-987654",
  "name": "My Tenant",
  "api_key": "sk_abc123xyz",
  "compliance": {
    "soc2_enabled": false
  }
}

Okay, that’s sensitive—an API key for my tenant. I tried another tenant_id (t-123456): bash

1
2
curl -H "Authorization: Bearer my_jwt_token" \
     https://api.cloudapp.com/internal/tenants/t-123456/config

Expected a 403. Instead, I got:

1
2
3
4
5
6
7
8
9
10

{
  "tenant_id": "t-123456",
  "name": "BigCorp Inc",
  "api_key": "sk_def456uvw",
  "compliance": {
    "soc2_enabled": true,
    "audit_log_retention": 90
  }
}

My jaw dropped. A basic_user could read any tenant’s config, including live API keys. But it was inconsistent—some IDs gave 404, others 403. I realized the endpoint only worked for tenants with global_admin-created configs (a quirk I stumbled on later).

The trick was realizing the /internal/ path bypassed the usual tenant_id checks for GET requests. POSTs were locked down, but GETs slipped through because of a logic flaw: the backend assumed any authenticated user could read configs if the tenant existed. No role validation for global_admin perms

  • Biggest win? Not quitting. This bug hid behind layers of “looks secure” nonsense. If I hadn’t rechecked the docs for the 10th time, I’d have missed it.
This post is licensed under CC BY 4.0 by the author.