BugForge — 2026.05.16

Shady Oaks Financial: Broken Access Control on Admin Route Group

BugForge Broken Access Control easy

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

  1. 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.
  2. 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.
  3. The bundle references five admin endpoints: GET /api/admin/flag, GET /api/admin/users, GET /api/admin/transactions, GET /api/admin/stats, and PUT /api/admin/stocks/:id/trend. All share the same /api/admin/ prefix, suggesting a single route group.
  4. The JWT payload includes a role claim. Registration always sets role: "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.
  5. 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:

  1. GET /api/admin/flag returns the flag in a {"flag": "..."} body to any registered user.
  2. GET /api/admin/users returns 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

#broken-access-control #idor #admin-route-group #jwt #bugforge #webapp #cwe-285 #cwe-862