July 28, 2026
Two Fields, Zero Checks: Exploiting Broken Object Property Level Authorization on VulnBank
A hands-on walkthrough of OWASP API3: 2023 — how a missing allowlist turns an anonymous signup into full admin access with an $18,000…

By Muhammad Usman Faridi
6 min read
- 1 A hands-on walkthrough of OWASP API3: 2023 — how a missing allowlist turns an anonymous signup into full admin access with an $18,000 balance
- 2 What Is BOPLA?
- 3 Step One: Logging In, and Noticing Too Much Comes Back
- 4 Step Two: A Baseline Registration
- 5 Step Three: Does Authentication Even Matter Here?
A hands-on walkthrough of OWASP API3: 2023 — how a missing allowlist turns an anonymous signup into full admin access with an $18,000 balance
Some vulnerabilities take a chain of clever steps to reach. This one took a single JSON field.
This is a walkthrough of Broken Object Property Level Authorization (BOPLA) #3 on the OWASP API Security Top 10, demonstrated against VulnBank, an intentionally vulnerable banking application built for security training. By the end of it, a completely anonymous request, sent with no login and no prior account, results in a fully privileged admin account holding eighteen times its intended starting balance.
What Is BOPLA?
BOPLA isn't one bug; it's a category that bundles two related failures together, both centered on the same idea: an API failing to control access at the level of individual properties within an object, rather than the object as a whole.
Excessive Data Exposure: the server returns more fields in a response than the client has any legitimate reason to see. Internal IDs, permission flags, debug output data that was never meant to leave the backend, sitting in plain JSON.
Mass Assignment: the server accepts more fields in a request than it should, and blindly writes all of them to the underlying object. If an API expects username and password but doesn't restrict the request to only those fields, anything else a client chooses to add like: is_admin, balance, role gets written right alongside them.
Individually, either one is a problem. Together, as this walkthrough shows, they compound into full privilege escalation.
Step One: Logging In, and Noticing Too Much Comes Back
The first thing worth checking on any authentication flow is simple: does the response contain more than it should? A login endpoint's only real job is to verify credentials and hand back a session. Here's what VulnBank actually returned.
Buried in the response sits a debug_info object account_number, is_admin, an exact login_time, and an internal user_id,none of which the frontend needs to render a dashboard. This is the first half of BOPLA: the object being returned carries far more than the client-facing contract requires.
More importantly, this response did something else useful for testing: it told us the exact internal field name controlling admin status;is_admin. That single detail turns a vague hunch into a precise target.
Step Two: A Baseline Registration
Before tampering with anything, it's worth capturing a clean, untouched registration request — this becomes the control to compare everything else against.
A completely standard signup, just username and password comes back with is_admin: False and balance: 1000.0 in the response's debug data. That's the expected, intended state of a brand-new account. It also confirms the same excessive exposure problem exists here too: the response echoes back the raw request (including the plaintext password), the server's own User-Agent string, and an internal sequential user_id. None of this belongs in a registration response.
Step Three: Does Authentication Even Matter Here?
Before testing whether extra fields could be injected, it was worth checking whether a session was required at all for this endpoint to behave the way it does.
The cookie was stripped entirely before sending this request. Registration completed exactly the same as before, meaning /register never required a prior session in the first place. This matters: whatever comes next doesn't depend on being logged in, doesn't depend on prior access, and doesn't depend on any special privilege. It only depends on what fields get typed into the request body.
Step Four: Adding a Field That Was Never Offered
With the internal field name known (is_admin) and no authentication barrier in the way, the actual test was straightforward: register normally, but add one extra field the UI never exposes.
{
"username": "Max4",
"password": "M@ximum",
"is_admin": "true"
}{
"username": "Max4",
"password": "M@ximum",
"is_admin": "true"
}The response confirms it landed exactly as sent; "is_admin": true, listed directly inside fields_registered, meaning the backend didn't just receive that field, it persisted it to the new account. No form on the actual signup page ever presents an "admin" checkbox. This field only exists because the backend accepts whatever the client sends, with no allowlist restricting which properties a request is permitted to set.
Step Five: Confirming the Access Is Real
A field changing in a JSON response is one thing. Actual privileged access is another. Logging in as the newly created account confirmed the escalation was genuine, not cosmetic.
An Admin Panel entry now sits in the sidebar, a section that should only ever be reachable by legitimately provisioned administrators. This account had existed for seconds and never touched a real onboarding or approval process. The only thing separating it from a normal user was one string in a request body.
Step Six: If One Field Works, What Else Does?
Given that is_admin passed straight through with no validation, the natural next question was whether the same held true for other sensitive fields, specifically balance, which had shown up in the baseline response as a fixed default of 1000.0.
{
"username": "Max5",
"password": "M@ximum",
"is_admin": "true",
"balance": "18000"
}{
"username": "Max5",
"password": "M@ximum",
"is_admin": "true",
"balance": "18000"
}
The response leaves no ambiguity: "balance": 18000.0, "is_admin": True, both fields listed in fields_registered as accepted. This wasn't a second, separate vulnerability, it was the same missing allowlist, simply tested against a second field to confirm the scope of the problem. The backend isn't failing to protect one specific property; it isn't protecting any property at all.
Step Seven: Verified From the Account Itself
As with any finding worth presenting, the response body alone isn't the end of the story, logging into the resulting account and confirming the change from inside the application closes the loop.
$18,000.00, against an account that started existing roughly a minute earlier with a single API call. No transfer, no loan, no legitimate transaction of any kind. Just a value typed directly into a registration request that the backend never questioned.
Why This Isn't "Just a Feature"
It's worth heading off the obvious objection directly: could this be an intentional admin-provisioning or promotional-balance mechanism, rather than a bug?
No, for the same reasons that hold in any mass assignment case:
- There's no legitimate registration flow, UI element, or promotional code path that exposes
is_adminor arbitrarybalancevalues to a new signup - No approval step, verification, or review sits between "submit registration" and "become an administrator with $18,000"
- The fields in question aren't documented, discoverable through the UI, or ever rendered on the actual signup form, they were only reachable by directly editing a captured request
A designed feature has guardrails. This has none, because the fields were never meant to be client-controllable in the first place.
Root Cause
Every result above traces back to one missing check, repeated across two different properties: the backend takes the entire incoming JSON object and writes it to the new user record without restricting which keys are allowed to be present.
# Vulnerable pattern
user = User(**request.json)
db.save(user)# Vulnerable pattern
user = User(**request.json)
db.save(user)Whatever arrives in the request, expected or not, gets applied. There's no allowlist, no schema restricting the shape of an acceptable registration payload, nothing stopping a client from defining its own privilege level.
The Fix (Secure)
# Secure pattern
allowed = ['username', 'password']
data = {k: v for k, v in request.json.items() if k in allowed}
db.save(User(**data))# Secure pattern
allowed = ['username', 'password']
data = {k: v for k, v in request.json.items() if k in allowed}
db.save(User(**data))- Allowlist fields explicitly, per endpoint. Only bind the specific fields a given operation is supposed to accept, never the entire request body.
- Use separate schemas (DTOs) for input and output. A registration request DTO should have no
is_adminorbalancefields defined on it at all. - Strip debug output before shipping to production.
debug_info,debug_data, and raw request echoes have no place in a live response, they're development artifacts that leaked into production behavior here. - Never assume the frontend is the boundary. The actual signup page never exposed these fields — that didn't matter, because the backend was the real (and only) enforcement point, and it wasn't enforcing anything.
Not a New Lesson — Just a Recurring One
This exact failure pattern isn't unique to training labs. In 2012, a nearly identical bug in GitHub's SSH key upload form let a security researcher attach his own key to the Ruby on Rails organization's account by adding an unexpected user_id field to a request — resulting in real push access to one of the most widely used open-source repositories in the world. The mechanism was the same: a backend willing to accept and apply any field it was handed, with nothing in place to say no.
Thirteen years and a completely different application later, the same missing guardrail produces the same category of outcome: one field, sent by an attacker who was never authenticated to begin with, turning into complete unauthorized control.
Takeaway
BOPLA doesn't require sophisticated tooling or deep exploit chains. It requires noticing that a request only sends what a form shows you — and asking what happens if you send something else. In this case, the backend never checked. Two properties, is_admin and balance, were both fully controllable by anyone, at any time, with no session required.
Authorization has to be enforced at the level of every individual field an object exposes, not just at the level of "is this user logged in." A backend that trusts its entire input is a backend with no real boundary at all.