Galaxy Dash: Broken Access Control via Writable Avatar Field
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:
GET /api/invoices/:idandGET /api/bookings/:idboth take a small integer id, suggesting sequential server-side records and a candidate for broken object-level access control.- Invoice JSON includes a
file_pathfield pointing at a server filesystem path (/app/invoices/GD-2026-0000NN.txt), implying a separate route serves the hard-copy invoice file. PUT /api/organization/avataraccepts anavatar_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.Access-Control-Allow-Origin: *is set on the API, and the JWT carries noexpclaim.
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:
-
GET /api/invoices/:idperforms no organization ownership check. Our org-4 token retrieves organization 1’s and organization 2’s invoice JSON, each of which includes afile_pathpointing at the invoice’s hard-copy file on the server. -
GET /api/files/:filenameauthorizes the request against a caller-writable field. The route serves a file only when the requested path matches the caller’s own organizationavatar_url. That value is set by the caller throughPUT /api/organization/avatar, which accepts an arbitrary path without validation. Settingavatar_urlto 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_urlon 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
expclaim 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/:idreturns records the caller does not own, andGET /api/files/:filenameauthorizes 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_urlviaPUT /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 returned400. 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