BugForge — 2026.06.05

Galaxy Dash: Broken Access Control via Writable Avatar Field

BugForge Broken Access Control medium

Part 1: Pentest Report

Executive Summary

Galaxy Dash is an intergalactic-delivery web application: a React single-page front end backed by an Express API, using HS256 JWT bearer tokens for authentication. Any visitor can self-register and immediately becomes org_admin of a new organization. Testing found that the file-serving route GET /api/files/:filename authorizes a request by comparing the requested path against the caller’s own organization avatar_url, a value the caller fully controls through PUT /api/organization/avatar. By pointing the avatar at another organization’s confidential invoice file, an attacker grants themselves read access to it.

Testing confirmed 1 finding:

ID Title Severity CVSS CWE Endpoint
F1 Cross-tenant invoice disclosure via writable avatar_url authorization grant High 6.5 CWE-639 GET /api/files/:filename

The finding allows any registered organization to read any other organization’s confidential invoice, including its financial detail and an embedded authentication token, given the invoice number. Invoice numbers are sequential and predictable (GD-2026-0000NN) and are themselves readable through an object-reference flaw on GET /api/invoices/:id. The engagement flag was recovered from the CONFIDENTIAL footer of organization 1’s invoice.


Objective

Assess the Galaxy Dash application for access-control and tenant-isolation defects, and recover the engagement flag.


Scope / Initial Access

# Target Application
URL: https://lab-1780667968664-w8vh1o.labs-app.bugforge.io

# Auth details
POST /api/register -> returns a JWT and a user object with role=org_admin,
                      a fresh organizationId, and an all-true permissions map.
JWT: HS256 bearer. Payload {id, username, organizationId, iat}. No exp claim.
Our identity: user id=5, org_admin of organizationId=4.

Registration is open and self-service. Each new account is the administrator of its own organization, which establishes a clean tenant boundary to test against: anything our org-4 token can read that belongs to organization 1, 2, or 3 is a cross-tenant disclosure.


Reconnaissance: Mapping the API From the SPA Bundle

The front end is a Create React App build (main.6c630545.js). Reading the bundle and exercising the API surface produced the endpoint map below. The observations that shaped the test plan:

  1. GET /api/invoices/:id and GET /api/bookings/:id both take a small integer id, suggesting sequential server-side records and a candidate for broken object-level access control.
  2. Invoice JSON includes a file_path field pointing at a server filesystem path (/app/invoices/GD-2026-0000NN.txt), implying a separate route serves the hard-copy invoice file.
  3. PUT /api/organization/avatar accepts an avatar_url, and the profile object stores that value verbatim. The bundle contains no client code that reads or renders the avatar back from a serve route, leaving the storage field with no visible consumer.
  4. Access-Control-Allow-Origin: * is set on the API, and the JWT carries no exp claim.

Observation 2 (a stored file path) and observation 3 (a writable field with no visible reader) together motivated searching for the route that serves invoice files and testing how it decides what a caller is allowed to read.


Application Architecture

Component Detail
Backend Express (X-Powered-By: Express)
Frontend React SPA, Create React App build (main.6c630545.js)
Auth JWT HS256 bearer; payload {id, username, organizationId, iat}, no exp
Tenancy Per-organization; each registered account is org_admin of its own org

API Surface

Endpoint Method Auth Notes
/api/register POST No Returns token + org_admin user, fresh org
/api/login POST No Standard login
/api/verify-token GET Yes Resolves identity from token
/api/invoices/:id GET Yes No org ownership check (chain link)
/api/organization/avatar PUT Yes Stores arbitrary avatar_url, unvalidated
/api/files/:filename GET Yes Serves file if path matches caller’s avatar_url
/api/bookings/:id GET Yes Same id pattern (not tested cross-org)
/api/bookings POST Yes Accepts client total_price (not tested)
/api/organization PUT Yes Profile update (not tested for mass assignment)

Known Organizations

Organization ID Source
1 Invoice 1 owner (flag-bearing)
2 Invoice 2 owner
4 Our registered org

Attack Chain Visualization

┌──────────────────────────┐     ┌──────────────────────────┐     ┌──────────────────────────┐
│ GET /api/invoices/1      │     │ PUT /api/organization/   │     │ GET /api/files/          │
│ (our org-4 JWT)          │     │ avatar                   │     │ GD-2026-000001.txt       │
│ no org check; leaks      │ ──▶ │ set avatar_url to org 1  │ ──▶ │ path matches avatar_url; │
│ file_path of org 1's     │     │ invoice path (no         │     │ serves plaintext invoice │
│ invoice                  │     │ validation)              │     │ -> CONFIDENTIAL footer   │
└──────────────────────────┘     └──────────────────────────┘     └──────────────────────────┘
                                                                                F1

Findings

F1: Cross-tenant invoice disclosure via writable avatar_url authorization grant

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-639 (Authorization Bypass Through User-Controlled Key), CWE-285 (Improper Authorization) Endpoint: GET /api/files/:filename Authentication required: Yes

Description

The defect is a chain of two compounding access-control failures:

  1. GET /api/invoices/:id performs no organization ownership check. Our org-4 token retrieves organization 1’s and organization 2’s invoice JSON, each of which includes a file_path pointing at the invoice’s hard-copy file on the server.

  2. GET /api/files/:filename authorizes the request against a caller-writable field. The route serves a file only when the requested path matches the caller’s own organization avatar_url. That value is set by the caller through PUT /api/organization/avatar, which accepts an arbitrary path without validation. Setting avatar_url to a victim organization’s invoice file path causes the route to serve that file.

Because the authorization decision is gated on a value the attacker controls, the attacker self-grants read access to any invoice file whose path is known. Invoice paths are sequential (GD-2026-0000NN.txt) and discoverable through the object-reference flaw in defect 1.

Impact

Cross-tenant read of any organization’s confidential invoice, including its financial detail and an embedded authentication token.

Reproduction

Step 1: Read another organization’s invoice JSON to obtain its file path

GET /api/invoices/1 HTTP/1.1
Host: lab-1780667968664-w8vh1o.labs-app.bugforge.io
Authorization: Bearer <our org-4 JWT>

Response: 200 OK with organization 1’s invoice JSON, including "file_path": "/app/invoices/GD-2026-000001.txt". Our token is org 4, so this is a cross-tenant read.

Step 2: Point our organization avatar at the victim invoice path

PUT /api/organization/avatar HTTP/1.1
Host: lab-1780667968664-w8vh1o.labs-app.bugforge.io
Authorization: Bearer <our org-4 JWT>
Content-Type: application/json

{"avatar_url":"/app/invoices/GD-2026-000001.txt"}

Response: 200 OK. The arbitrary filesystem path is accepted and stored as our organization’s avatar_url.

Step 3: Request the invoice file

GET /api/files/GD-2026-000001.txt HTTP/1.1
Host: lab-1780667968664-w8vh1o.labs-app.bugforge.io
Authorization: Bearer <our org-4 JWT>

Response: 200 OK, Content-Type: text/plain, body is the plaintext invoice. The CONFIDENTIAL footer contains the flag.

Isolating the cause (one-variable control): with avatar_url set to the invoice 1 path, GET /api/files/GD-2026-000001.txt returns 200 (served) while GET /api/files/GD-2026-000002.txt returns 403 {"error":"Access denied"}. Repointing avatar_url to the invoice 2 path flips the results: invoice 2 serves and invoice 1 returns 403. Access tracks the caller-controlled avatar_url and nothing else.

Remediation

The file-serving route must authorize on a server-side ownership relationship, not on a value the caller can write.

Fix 1: Authorize /api/files against record ownership, not avatar_url

// BEFORE (Vulnerable): access gated on a caller-writable field
app.get('/api/files/:filename', auth, (req, res) => {
  const requested = '/app/invoices/' + req.params.filename;
  if (requested === req.org.avatar_url) {      // caller controls avatar_url
    return res.sendFile(requested);
  }
  return res.status(403).json({ error: 'Access denied' });
});

// AFTER (Secure): resolve the invoice and confirm it belongs to the caller's org
app.get('/api/files/:filename', auth, async (req, res) => {
  const invoice = await db.invoices.findOne({ filename: req.params.filename });
  if (!invoice || invoice.organizationId !== req.user.organizationId) {
    return res.status(403).json({ error: 'Access denied' });
  }
  return res.sendFile(invoice.file_path);       // server-resolved path
});

Fix 2: Scope GET /api/invoices/:id to the caller’s organization

// BEFORE (Vulnerable): any id is returned regardless of owner
const invoice = await db.invoices.findById(req.params.id);
return res.json(invoice);

// AFTER (Secure): filter by the caller's organization
const invoice = await db.invoices.findOne({
  id: req.params.id,
  organizationId: req.user.organizationId,
});
if (!invoice) return res.status(404).json({ error: 'Not found' });
return res.json(invoice);

Additional recommendations:

  • Validate avatar_url on write: restrict it to an allowed avatar store or CDN host, never an arbitrary server filesystem path.
  • Do not let any user-writable profile field participate in an authorization decision.
  • Add an exp claim to issued JWTs so leaked tokens expire.

OWASP Top 10 Coverage

  • A01:2021 Broken Access Control: Both links of the chain are access-control failures. GET /api/invoices/:id returns records the caller does not own, and GET /api/files/:filename authorizes against a caller-writable field instead of a server-side ownership check.

Tools Used

Tool Purpose
Burp Suite Issuing and replaying API requests, isolating the avatar_url control
Browser dev tools Reading the React bundle to map the API surface

References

  • CWE-639: Authorization Bypass Through User-Controlled Key: https://cwe.mitre.org/data/definitions/639.html
  • CWE-285: Improper Authorization: https://cwe.mitre.org/data/definitions/285.html
  • OWASP A01:2021 Broken Access Control: https://owasp.org/Top10/A01_2021-Broken_Access_Control/

Part 2: Notes / Knowledge

Key Learnings

  • A route that authorizes by matching the request against a user-writable field is self-grantable. When a serve route decides access by comparing the requested resource to a value the caller can set (here avatar_url via PUT /api/organization/avatar), the caller points that field at any resource and the gate opens. This is distinct from classic IDOR, where you enumerate an object reference, and from path traversal, where the path here was sanitized and returned 400. The probe is one variable: set the writable field to the target path, request it, then flip the field to a second target and confirm access flips with it. If it tracks the field, the field is the authorization decision. Realism caveat: the class (an attacker-writable reference treated as proof of ownership) is real and reusable, but the wiring in this lab, an avatar field gating invoice-file reads, is artificial. No real avatar flow grants a filesystem read because you named a path; it copies the blob to an avatar store or CDN. Carry the class forward, not the specific plumbing.

  • When a writable field stores a path or URL but has no consumer in the client bundle, guess the conventional serve-route name rather than only field-prefixed names. The avatar value was stored but never read back anywhere in the front end, so the route that consumed it was not derivable from source. The route that served the file was /api/files/<invoice#>.txt, a convention bet on the standard shape of a file-serving endpoint, not something the bundle referenced. When a stored value has no visible reader, the consumer often lives at a conventionally named route the front end never calls.


Failed Approaches

Approach Result Why It Failed
Path traversal on /api/files (..%2f..%2fetc/passwd) 400, empty body Traversal is sanitized; the route is filename-scoped, not arbitrary file read
Direct URL fetch of /app/invoices/*.txt SPA catchall /app/invoices/... is a server filesystem path, not a web route
GET /api/organization/avatar as a serve route SPA catchall No such handler; the avatar is consumed only by /api/files
JWT alg=none / weak-secret crack Not pursued Token already grants a registered org; flag reachable without forging

Observed but not verified (flag already captured): POST /api/bookings accepts client total_price and calculated_risk_percent (possible price tampering); GET /api/bookings/:id shares the invoices id pattern and likely the same missing org check; PUT /api/organization is a mass-assignment candidate. None were tested.


Tags: #broken-access-control #idor #authorization-bypass #multi-tenant #bugforge Document Version: 1.0 Last Updated: 2026-06-05

#broken-access-control #idor #authorization-bypass #multi-tenant #bugforge