
Executive Summary
A multi-tenant document vault exposed a single JSON-RPC endpoint for every permission operation on folders and files. One of its methods, addObjectAccess, accepted a parameter named user_group_id that was supposed to carry exactly one numeric user identifier — the account being granted Read, Write, or FullControl on a folder. The server enforced a tenant-ownership check on that identifier, and the check worked: supplying the ID of a user belonging to a different organisation produced a clean Access Denied. The authorization control existed, it was server-side, and it was reached on every request.
It was still bypassable, because two components of the backend disagreed about what the parameter was. The authorization layer treated user_group_id as a scalar and validated a single value out of it. The code that actually wrote the ACL treated the same string as a comma-delimited list and iterated over every element. Submitting two IDs joined by a comma — the victim’s first, a legitimate same-tenant ID last — satisfied the validator while handing the writer two entries to persist. The result was both an integrity failure (an arbitrary external account added to a folder ACL, across a tenant boundary) and a confidentiality failure: a follow-up call to the companion read method, getObjectAcl, returned the victim’s full name, email address, internal user ID and tenant ID. This post walks the finding end to end, explains why parser differentials like this survive code review, and gives a concrete test matrix and remediation pattern for the class.
The Target: A Multi-Tenant Document Vault
The application is a document vault built around folders and files, with access control lists attached to those objects. A folder owner can open a “Permissions” panel and grant another user one of three permission levels — Read, Write, or FullControl. Because the platform is multi-tenant, the intended rule is straightforward and entirely conventional: you may only grant permissions to accounts inside your own tenant. Tenant isolation is the product’s primary security boundary, and the Permissions panel is one of the few places in the UI where a user hands the server an identifier that refers to somebody else.
That last property is what makes the feature interesting to an attacker. Most identifiers a client sends refer to objects the client already owns; a permission-grant parameter, by design, refers to a third party. Any endpoint whose whole purpose is to accept a reference to another principal deserves the same scrutiny as an authentication flow, because it is the point where the application must decide which other principals you are allowed to name.
The Endpoint
Every action in the Permissions panel is funnelled through one JSON-RPC-style endpoint. Rather than REST paths per resource, the application posts a method name and a payload to a single PHP entry point:
POST /rpc/api.php
Content-Type: application/json
{
"method": "addObjectAccess",
"data": [{
"data": [{
"user_group_id": 1012,
"name": "firstName lastName",
...
}],
"id": "..."
}],
"type": "rpc",
"tid": 55
}
Request shape for the permission-grant call, as documented in the original article.
The field that matters is user_group_id: it tells the backend which principal to add to the object’s ACL. The surrounding name field is cosmetic — client-supplied display text — and id identifies the folder being modified. The single-endpoint RPC design is worth noting on its own. When dozens of privileged operations share one route, per-route authorization middleware becomes impossible; every method has to enforce its own checks internally, and the consistency of those checks becomes a function of developer discipline rather than framework structure. That is fertile ground for exactly the kind of gap described below.
First Attempt: The Ownership Check Holds
The first and most obvious test on any permission-grant endpoint is whether it will accept a principal you have no business naming. Substituting the identifier of a user in an unrelated tenant:
"user_group_id": "victim_id"
The server answered Access Denied. This is the correct behaviour, and it is also the point where a large share of testers close the tab and move on. The response proves there is a server-side ownership check that resolves the supplied ID and confirms the target account is visible to, or owned by, the requesting tenant. The control is real and it is enforced.
What the response does not prove is that the value the check inspected is the same value the rest of the request handler consumed. A rejection tells you a validator ran; it says nothing about the completeness of the mapping between what was validated and what was executed. That distinction is the entire bug.
The Bypass: One Comma
The next step was not a wordlist but a probe of the parameter’s grammar: feed user_group_id a series of special characters and watch for any change in server behaviour. Most produced identical errors. The comma did not — the response differed, which is the signal that the value is being parsed rather than simply compared. A parameter that reacts to a delimiter is a parameter that some component is splitting.
From there the payload writes itself. Instead of one identifier, send two, joined by a comma — the victim’s ID first, followed by an ID that legitimately belongs to the attacker’s own tenant:
"user_group_id": "victim_id,tenant_user_id"
The bypass payload, as published in the original article.
This was accepted. Ordering is load-bearing: the ownership check evaluates only the last element of the list, so the trailing same-tenant ID is what the validator sees and approves. The ACL writer, meanwhile, splits the string and processes both elements, granting the victim access alongside the legitimate user. One comma converts a correctly-implemented authorization check into a rubber stamp.

Confirming the Leak
A 200 OK on a write is weak evidence. To demonstrate real impact you have to read the state back through a second, independent code path — which in this application is the companion method getObjectAcl:
{
"method": "getObjectAcl",
"data": [{ "id": "FOLDER_ID" }],
"tid": 589
}
The folder’s ACL now contained a complete entry for an account with no relationship to the attacker’s tenant whatsoever:
{
"id": "512",
"name": "V...",
"first_name": "V...",
"last_name": "...",
"email": "v...@othercompany-example.com",
"tenant_id": "184",
"group_type": "Provider",
"user_group_id": "512"
}
Redacted ACL entry returned for the cross-tenant victim, as published in the original article.
The response hands back full name, email address, internal user ID and the victim’s tenant_id — the last of which is arguably the most useful field to an attacker, because it converts an opaque user ID into a mapping between individuals and the organisations they work for. The write primitive has become a read primitive: any user who holds FullControl on a single folder in their own tenant can, for any guessed or enumerated user ID, force that account into an ACL and then read its profile back. Sequential or otherwise predictable identifiers turn that into a bulk directory-harvesting loop against the entire platform.
Root Cause: A Parser Differential Between Authorization and Persistence
It is worth being precise about what failed here, because the naive reading — “they forgot an authorization check” — is wrong, and the wrong diagnosis leads to the wrong fix. The check was present, server-side, and correct in isolation. What failed is the assumption that the validator and the executor were looking at the same object.
The application contains two implicit contracts for one field:
- Authorization layer:
user_group_idis a scalar. Resolve it, compare its tenant to mine, allow or deny. - Persistence layer:
user_group_idis a delimited list. Split it, iterate, write one ACL row per element.
Security decisions are made over a strictly narrower input than the one that reaches the side effect. This is the same structural defect that underlies HTTP request smuggling (front-end and back-end disagree on where a request ends), SSRF filter bypasses (validator and HTTP client parse the URL differently), and mass-assignment bugs (the model binds more fields than the whitelist inspected). The delimiter is incidental; the differential is the vulnerability. Any time an authorization decision and its corresponding effect are computed from separately-parsed views of the same input, the effect can be made to exceed the decision.
Two design choices made this easy to miss in review. First, the field is weakly typed on the wire — the legitimate client sends the integer 1012, but the handler evidently accepts and stringifies whatever arrives, so "1012,44" passes through the JSON layer untouched. Second, the “validate here, act there” separation means a reviewer reading the authorization function sees correct code, and a reviewer reading the ACL writer sees correct code. Only reading both together, and asking whether they parse identically, exposes the gap.
Why the Ordering Matters
The victim ID has to come first and the attacker-owned ID last. That asymmetry is a fingerprint of how the validation was written, and recognising it is useful during testing because it tells you which of several possible implementations you are up against. Plausible mechanisms that produce “last element wins” behaviour include:
- A loop that assigns the resolved account to a single variable on each iteration, then performs one check after the loop ends — leaving only the final value examined.
- A lookup helper that splits the string, iterates, and returns or overwrites a result, so the last match survives into the authorization comparison.
- A database query such as
... WHERE id IN (list) LIMIT 1with an ordering that surfaces the trailing entry, checked once against the caller’s tenant. - A cast or “take the last token” normalisation applied before validation but not before the write.
The practical consequence for testers: always try both orderings. A “first element wins” implementation is equally common, and a payload of victim,mine that fails may succeed instantly as mine,victim. Testing only one direction and concluding the parameter is safe is a false negative you will never see reported.
Impact Assessment
The finding breaks tenant isolation in both directions — it writes across the boundary and reads across it.
| Dimension | Effect | Notes |
|---|---|---|
| Confidentiality | Disclosure of PII — full name, email address, internal user ID and tenant ID — for users in unrelated tenants | Repeatable against any guessed or enumerated user ID; predictable IDs make it bulk-harvestable |
| Integrity | Arbitrary external accounts can be added to a folder ACL, bypassing tenant isolation | Grant level follows the request, up to FullControl |
| Prerequisites | Any authenticated account holding FullControl on at least one folder in its own tenant | A default, low-privilege position — not an admin-only path |
| Boundary crossed | Tenant isolation — the platform’s primary security boundary | Affects every tenant on the deployment, not just the attacker’s |
Classification-wise this sits across CWE-639 (Authorization Bypass Through User-Controlled Key), CWE-863 (Incorrect Authorization) and CWE-138 (Improper Neutralization of Special Elements) for the delimiter handling, and maps to OWASP API Security Top 10 API1:2023 Broken Object Level Authorization with a secondary reading of API3:2023 Broken Object Property Level Authorization. Under CVSS v3.1 a defensible vector is AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N — scope is changed because the impact lands on tenants other than the attacker’s — giving roughly 8.5 High. Reporters should argue the scope change explicitly; it is the difference between a medium and a high payout, and triage teams routinely miss it on multi-tenant findings.
Generalising the Technique: A Test Matrix
The transferable lesson is that a rejected single value is the start of the test, not the end. Once you know a validator exists, the goal shifts to finding an encoding of the same parameter that the validator and the executor read differently. The following payload families are worth running against any ID-bearing parameter that returns an authorization error:
| Family | Payload | Server behaviour it targets |
|---|---|---|
| Comma list | victim_id,my_id and my_id,victim_id | Validator checks one element; writer splits and iterates |
| Alternate delimiters | victim_id;my_id, victim_id|my_id, victim_id my_id, newline-separated | Same differential with a different split character |
| JSON array | "user_group_id": ["victim_id"] | Validator coerces the array to a string; writer indexes into it |
| Nested object | {"id": ["a","b"]} | Type-confusion in the resolver |
| Duplicate keys | Two user_group_id keys in one JSON object | Parsers disagree on first-wins vs last-wins |
| Duplicate parameters | user_group_id=mine&user_group_id=victim (HTTP parameter pollution) | Framework-level ambiguity between layers |
| Whitespace / padding | victim_id , victim_id, victim_id%00 | Trim applied in one layer only |
| Type juggling | "512" vs 512 vs 512.0 vs "512abc" | Loose comparison in PHP-style backends |
| Wildcards | *, %, 0, -1, null | Values that short-circuit the lookup entirely |
Two practical notes on running this. First, watch response differences, not just successes — a changed error string, a different status code, or a shift in response time on one delimiter and not another is the tell that something downstream is parsing your input. That is precisely how the comma was found here. Second, always verify through a second code path. Confirm every apparent write by reading the state back with a different method, as the getObjectAcl call did; otherwise you risk reporting an accepted request that silently no-ops.
Where to look for these parameters: permission and sharing dialogs, “invite user” and “add member” flows, bulk-action endpoints (which are natively list-shaped and therefore prime candidates), assignment fields on tickets and tasks, notification recipient lists, and any export or report generator that takes a set of object IDs.
Key Takeaways
- A rejection is a discovery, not a dead end.
Access Deniedproves a validator exists and tells you where to aim; it says nothing about whether the validator and the executor agree on the input. - The bug class is the parser differential, not the comma. Any gap between the value authorization inspects and the value the side effect consumes is exploitable, whatever character opens it.
- Order the elements both ways. “Last element wins” and “first element wins” are equally plausible implementations; testing one direction only produces false negatives.
- Probe the grammar before the values. Special characters that change server behaviour reveal parsing; that signal is worth more than a long list of candidate IDs.
- A write primitive is often a read primitive. Forcing a record into a structure you can read back turns an ACL modification into a PII disclosure — a materially higher-severity finding.
- Single-endpoint RPC designs concentrate risk. When every privileged operation shares one route, no framework middleware can enforce authorization uniformly.
- Argue the scope change on multi-tenant findings. Impact landing on tenants other than your own is a CVSS scope change and materially raises severity.
Defensive Recommendations
- Parse once, at the edge. Decode the request into a typed structure before any business logic runs, and pass that object — never the raw string — to both the authorization check and the persistence layer. Eliminating the second parse eliminates the differential.
- Enforce strict types on identifiers. If
user_group_idis an integer, reject anything that is not an integer at the schema boundary rather than coercing it. A JSON Schema with"type": "integer", or a strict cast that fails closed, stops this entire payload family. - Validate every element of a collection, not a representative one. If the field legitimately accepts multiple IDs, change its declared type to an array and authorize each entry independently, failing the whole request if any single element fails.
- Authorize as late and as close to the effect as possible. Perform the tenant-ownership check inside the same function that writes the ACL row, on the same variable it is about to persist, so the two cannot drift apart.
- Scope every lookup by tenant in the query itself. Resolve principals with
WHERE id = ? AND tenant_id = ?rather than resolving globally and comparing afterwards. Tenant scoping belongs in the data-access layer, not in a caller that can be bypassed. - Filter outbound PII by relationship.
getObjectAclshould return name, email andtenant_idonly for principals the caller is entitled to see, so a bad write cannot be escalated into a profile read. - Use unpredictable identifiers. UUIDv4 in place of sequential integers does not fix the authorization defect, but it removes the enumeration that turns a single leak into bulk harvesting.
- Rate-limit and quota permission grants. Legitimate users add collaborators occasionally; hundreds of
addObjectAccesscalls per minute from one account is an attack signature, not a workflow. - Add regression tests for delimiter payloads. Any parameter that takes an ID deserves a test asserting that
"valid,invalid"and"invalid,valid"are both rejected outright.
Detection
For defenders working an existing deployment rather than a code fix, this class leaves usable traces. Non-numeric content in a field that should only ever hold digits is a high-fidelity signal with almost no legitimate baseline — alert on any user_group_id that fails a strict integer match. Cross-tenant ACL entries are similarly cheap to hunt for: a periodic reconciliation query that flags every ACL row whose principal’s tenant_id differs from the owning object’s will surface both live exploitation and any historical grants that predate the fix. Finally, watch for the read-back pattern — an addObjectAccess call followed closely by getObjectAcl against the same folder, repeated across many distinct target IDs, is the enumeration loop described above rather than a user managing collaborators.
Conclusion
This finding is a good reminder that the presence of an authorization check says very little about its coverage. The vault’s ownership validation was server-side, mandatory, and correct for the input it examined — it simply examined a different input than the one that reached the database. That gap cost nothing to exploit: a single comma, one extra identifier, and the right ordering. For testers, the lesson is to treat Access Denied as a map rather than a wall, and to spend the next few minutes probing how the parameter is parsed instead of which values it accepts. For builders, the lesson is narrower and harder: make sure the value you authorize is, byte for byte, the value you act on.
Original text: “IDOR via Comma-Injection: How Concatenating Two IDs Leaked Cross-Tenant PII” by s0ufm3l at Medium, 9 August 2026.


