Summary

By Sid — Cybersecurity Engineer & Pentester Disclosure note: This was found during an authorized, client-commissioned penetration test. The target platform, endpoint domain, employee names, and all captured values below have been anonymized/fictionalized — none of the identifiers, credentials, or personal data shown are real. Reported to the client, remediation done/confirmed as of 08/24/26. TL;DR During a gray-box pentest of a corporate SaaS platform (“TargetApp”), an internal HR/personnel endpoint accepted a plain sequential integer — idPersonnal — as the only identifier for which employee record to return, with no check that the authenticated caller had any relationship to that record. Walking the ID up or down returned full personnel records for other employees: name, department, supervisor, RFC-equivalent tax ID, phone, email, and role — with no rate limiting and no additional authorization step. This is API1:2023 – Broken Object Level Authorization (BOLA) from the OWASP API Security Top 10, and it turned a single authenticated low-privilege session into a full employee directory dump. Get Sid’s stories in your inbox Join Medium for free to get updates from this writer. Severity: Critical (CVSS 3.1: 9.8 ) Class: API1:2023 — Broken Object Level Authorization (OWASP API Security Top 10) Background: what is BOLA? BOLA is the API-specific cousin of the classic IDOR: an endpoint uses a client-supplied identifier to fetch an object, and the server trusts that identifier without verifying the caller actually owns or is entitled to that specific object. OWASP describes it plainly — object-level authorization is an access control mechanism that should be enforced every time an object is accessed via an identifier, and its absence is one of the most common and highest-impact flaws in API design, because unlike a UI-level access-control gap, an API endpoint often has no equivalent “you can’t click that button” visual cue hiding the problem. Recon: where the endpoint came from The account used for testing was a standard authenticated user with no administrative role. While mapping the platform’s API traffic in Burp Suite during normal navigation of the HR/personnel module, a request stood out: POST /Personnal/SelectPersonnal HTTP/1.1 Host: api.targetapp.example.com The request body carried an idPersonnal field alongside a handful of filter options: {“option”:6,“idPersonnal”:5741,“noEmployee”:"",“idLocation”:0,“idCenter”:0,“password”:""} 5741 — a short, sequential-looking integer — was the value the frontend had filled in automatically for the logged-in user’s own record. That’s the tell to look for in almost every BOLA case: an ID that looks small, incremental, and guessable, sitting in a field the client fully controls. Exploitation POST /Personnal/SelectPersonnal HTTP/1.1 Host: api.targetapp.example.com User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0 Accept: application/json, text/plain, / Content-Type: application/json Authorization: Bearer eyJhbGciOiJIUzI1NiIs… Content-Length: 89 {“option”:6,“idPersonnal”:5743,“noEmployee”:"",“idLocation”:0,“idCenter”:0,“password”:""} Response: 200 OK , with a full personnel record — for a different employee than the one logged in: [ { “idPersonnal”: 5743, “idPersonnal_Boss”: 5740, “noEmployee”: “115398”, “idCenter”: 2927, “center”: “CORPORATE”, “name”: “ALEX”, “lastName”: “MORGAN”, “motherLastName”: “REYES”, “completeName”: “ALEX MORGAN REYES”, “supervisor”: “Jordan K.”, “idDepartment”: 4837, “department”: “INFORMATION TECHNOLOGY”, “idState”: 0, “state”: "" } ] No error, no authorization check, no indication that the server even noticed the request was for a different idPersonnal than the one tied to the authenticated session. Repeating the same request with idPersonnal: 5745 and other nearby values returned yet more employee records — each one a different person, with zero relationship to the account making the request. Because the identifier is a small sequential integer, the entire employee directory is trivially enumerable by scripting a loop over the ID range — no brute-forcing of anything, just counting. A compounding issue The same response object also included an email and a password field alongside every other attribute — meaning this BOLA didn’t just leak PII, it leaked account credentials in plaintext for every employee record it could enumerate. That’s a separate root cause (credentials should never be stored or transmitted unhashed in the first place), but it meant the practical impact of this BOLA was not “read access to a directory” — it was “read access to a directory that also hands you working login credentials for everyone in it.” Impact

  • Confidentiality: full enumeration of the organization’s personnel data — names, departments, supervisors, government tax IDs, phone numbers, and (due to the compounding issue above) plaintext passwords — for every employee in the system, not just the tester’s own account.
  • Integrity / further compromise: the harvested plaintext credentials were independently confirmed to be valid, directly enabling account takeover for other employees, including accounts with elevated/administrative roles.
  • Scale: no rate limiting was observed on the endpoint, meaning the entire directory was extractable via a simple automated loop — this is not a “one victim” bug, it’s a mass-enumeration bug. Root cause The endpoint’s handler used idPersonnal purely as a database lookup key, with the query logic effectively reading as “return the record matching this ID” rather than “return this record if the authenticated user is entitled to see it.” There was no server-side check binding the requested idPersonnal to the identity or role encoded in the session’s bearer token — object-level authorization was never enforced, only object-level retrieval. Remediation
  • Enforce object-level authorization on every request that includes an identifier. Before returning any record, the server must verify that the authenticated user either owns that idPersonnal or holds an explicit, role-based grant to view records outside their own (e.g., HR/admin roles).

Pseudo-code

def select_personnal(request): requested_id = request.json.get(“idPersonnal”) caller = get_authenticated_user(request) if requested_id != caller.idPersonnal and not caller.has_role(“hr_admin”): raise ForbiddenError() record = db.get_personnal(requested_id) return serialize_for_role(record, caller.role) # never return password/email to non-privileged roles Conclusion BOLA earns its top spot on the OWASP API Top 10 for a simple reason: APIs hand attackers exactly the kind of interface — structured, predictable, identifier-driven — that makes this class of bug trivial to find and devastating to exploit once found. Here, a single unguarded integer parameter was the only thing standing between one authenticated session and an entire organization’s personnel directory, credentials included. Object-level authorization has to be checked on every object access, every time — never inferred from the fact that a request “looks” like it came from a legitimate, logged-in user.

By Sid

Original Article