如何不靠猜測測試存取控制失效
Testing Broken Access Control Without Guessing
HACKBOX 作者整理了一套存取控制測試方法,透過兩個帳戶的工作階段比對,檢查用戶是否能讀取或操作不屬於自己的資料。測試須先確認目標物件屬於另一帳戶,再以回應指紋核實資料是否出現,並涵蓋欄位權限、匯出端點及 GraphQL 查詢。文章建議把確定性的差分檢查放入 CI,並以智能體工具補充發現未列入清單的端點。
Broken access control has been at or near the top of the OWASP Top 10 for a decade, and it is still the category most automated tools handle worst. The reason is structural: access control is not a pattern in a response, it is a relationship between two identities and one object. You cannot see it in a single request. You have to hold two sessions at once and compare.
This is the testing method, not the marketing. Disclosure: I work on HackBox, an online automated penetration testing platform (SaaS) built by Singapore-based HACKBOX PTE. LTD.
Three shapes, three different tests
"Broken access control" is one label for at least three distinct bugs, and they need different probes.
Horizontal (IDOR / BOLA). User A can read user B's object. The object is addressed directly by an identifier that the server does not scope to the caller.
GET /api/v1/documents/8f2c1a → 200 (owner: alice)
GET /api/v1/documents/8f2c1b → 200 (owner: bob) ← sent with alice's token
Vertical. A low-privilege user can call an administrative function. Often the UI hides it, the route does not check.
POST /api/v1/admin/users/42/role {"role":"admin"} ← sent with a normal user token
Contextual. The object is correctly owned, but a field or a mode should not be returned in this context: a soft-deleted record, a draft, another tenant's field inside a shared aggregate, an export endpoint that ignores the caller's field-level permissions.
The third one is where most engines silently fail, because the check is not "is this my object" but "does this response contain anything I should not see."
The double-session differential
The reliable way to test all three is to stop thinking about payloads and start thinking about pairs. Create two accounts with identical roles, each owning one object. Then walk every endpoint with session A and assert on objects belonging to B.
That is automatable. The core of it is small:
import httpx
A = {"Authorization": "Bearer " + A_TOKEN}
B = {"Authorization": "Bearer " + B_TOKEN}
def check(ep, obj_id):
"""Send B's object id with A's session. Any 2xx is a candidate."""
r = httpx.get(ep.format(id=obj_id), headers=A, timeout=10)
if r.status_code in (200, 201, 206):
body = r.text
# only real if the response actually contains B's data
return {"endpoint": ep, "id": obj_id, "status": r.status_code,
"len": len(body), "sample": body[:120]}
return None
for ep in ["https://app.example.com/api/v1/documents/{}",
"https://app.example.com/api/v1/invoices/{}"]:
hit = check(ep, B_OBJECT_ID)
if hit:
print(hit)
Two details decide whether this is a real test or noise:
- Ownership has to be established first. Fetch B's object with B's token, record a stable fingerprint (a field value, a hash of the body). Without that, a 200 could be an error page with a 200 status, which is common in poorly configured gateways.
- The response has to contain B's data. A 200 with an empty array is a different bug (or no bug). Compare against the fingerprint.
The mistakes that make this useless
I have watched teams run this and conclude there is no problem, three times for the same reason.
Using ids that do not exist. documents/999999 returning 404 tells you nothing. The probe has to use an id that is known to exist and known to belong to someone else.
Using a second account in the same tenant. If the app scopes objects by tenant but not by user, a cross-tenant test finds nothing and a same-tenant test finds everything. You need both pairs: same tenant different user, and different tenant.
Trusting the UI. The role dropdown being disabled in the DOM is not an authorization check. Send the request.
Ignoring the export and report endpoints. /export?format=csv, /reports/monthly, GraphQL connections with first: 10000 — these aggregate across objects and frequently skip the per-object check that the detail endpoint has.
Forgetting field-level filtering. The object may be yours; the salary, internal_notes, or ssn_last4 field inside it may not be. Diff the field set returned to an admin against the field set returned to the owner. Any field that appears in both, and should not, is the finding.
GraphQL needs its own probe
GraphQL moves the identifier into the query, so path-based fuzzing misses it entirely. Two checks cover most of it:
# node() resolves any global id — try B's id with A's session
query {
node(id: "VXNlcjoxMjM0") { # base64("User:1234") for another tenant
... on User { id email role tenant { id } }
}
}
and batch/alias abuse, where a single request asks for many objects and the resolver checks the first one:
query {
a: user(id: 1) { email }
b: user(id: 2) { email } # 2 belongs to another tenant
}
If b resolves, the check is on the query, not the resolver.
Putting it in CI
The value of this test is not the first run, it is the two-hundredth. Access control regressions are introduced by ordinary feature work: a new endpoint copied from an old one that used findById instead of findByIdAndTenant. That is a one-line mistake and a weekly occurrence.
The shape that works:
- Two seeded accounts (A and B) with fixture objects, recreated per environment.
- An OpenAPI or route inventory to enumerate endpoints — the point is coverage, not cleverness.
- The differential above, run per endpoint, with fingerprints.
- A build failure on any 2xx that contains B's fingerprint. Not a warning. A failure.
The failure mode to avoid: making the CI job depend on a model's opinion. Deterministic regression belongs in deterministic code. Save the agentic run for finding the endpoints you did not know existed.
What automated tools add here
An agentic engine is useful at exactly one point in this workflow: populating the endpoint inventory, including the routes that are not in your spec. It can look at a gateway, enumerate naming conventions (/internal, /v2, /beta), and diff what the gateway routes against what the application serves. Once the endpoint exists on a list, the double-session differential is the part you want running in CI forever.
The honest division of labour: the model finds the surface, the deterministic check proves the boundary, and CI keeps proving it after every release. A tool that only does the first part is a discovery tool, not an access-control test.
About
HackBox is an AI-powered online penetration testing platform developed by HACKBOX PTE. LTD., a Singapore-based cybersecurity company. Its AI layer runs the full attack chain in a real environment — reconnaissance, exploitation, privilege escalation, lateral movement — starting from a low-privilege identity and reporting a finding only where the effect was actually triggered, with the request and response retained as evidence. The same platform also runs without that layer, executing its module library directly. Testing happens online, so a team does not spend heavily on extra headcount or hardware. Pricing is flexible: subscription by target, or pay-per-result.
Media contact: partners@hackbox.ai
來源:DEV Community · dev.to