Ottergram: Unauthenticated Path Traversal / Arbitrary File Read
Part 1: Pentest Report
Executive Summary
Ottergram is a React single-page application backed by an Express JSON API, using JWT bearer authentication. Posts carry an uploaded image, and the post viewer renders that image through a server-side read endpoint, GET /api/post/image?file=<path>, that takes the file path from a stored field. Testing confirmed that this read endpoint consumes the supplied path without traversal sanitization and requires no authentication, so any client can read files outside the intended uploads directory.
Testing confirmed 1 finding:
| ID | Title | Severity | CVSS | CWE | Endpoint |
|---|---|---|---|---|---|
| F1 | Unauthenticated arbitrary file read via path traversal | High | 7.5 | CWE-22 | GET /api/post/image |
The flag-bearing finding is F1. The image upload feature (POST /api/posts) only records the stored path and is not the defect; the separate read endpoint trusts that path on the way back out. A relative path with ../ segments escapes the uploads base directory and returns arbitrary files readable by the application process. The flag was recovered from disk by an unauthenticated request, with no account and no token.
Objective
This was a BugForge lab with a single planted vulnerability, hinted as “file inclusion on the post”. The objective was to locate and exploit it to read the flag off disk.
Scope / Initial Access
# Target Application
URL: https://lab-1782172213517-szoidu.labs-app.bugforge.io # ephemeral lab instance
# Auth details
Registration: open / self-serve via POST /api/register (returns a JWT)
Token: JWT HS256 bearer (operator session = user "haxor", id 4)
Note: the flag-bearing endpoint requires no authentication; the
token below was used only for general app navigation
Registration is open and returns a usable session token, but the finding does not depend on it. The exploit request carries no Authorization or Cookie header.
Reconnaissance: Reading the Bundle for the Image Sink
The application surface was mapped from the React production bundle (/static/js/main.e6d131e0.js), which is served without authentication and contains the API calls the front end makes. The hint (“file inclusion on the post”) pointed at a read endpoint that takes a file path, so the bundle was searched for how post images are referenced rather than fuzzing the post endpoints blind.
Observations that shaped the test plan:
- The post viewer builds the image URL as
"/api/post/image?file=".concat(r.image_url)and renders it as<img src=...>. The path argument is taken from a stored field, so it is attacker-controlled, and the read endpoint is the inclusion sink. - The upload endpoint (
POST /api/posts) only recordsimage_url; it does not read it back. Write and read are separate code paths, so the read path is worth testing for path trust on its own, independent of how the upload validates. - The endpoint string is present in the public bundle, which is served without authentication, so the read endpoint is recoverable without an account.
- An existing upload (
/uploads/otter1.png) is available as a known-good baseline for fixing the base directory depth before climbing with../.
Application Architecture
| Component | Detail |
|---|---|
| Backend | Express (X-Powered-By: Express), JSON API under /api |
| Frontend | React single-page application (Create React App), bundle /static/js/main.e6d131e0.js |
| Auth | JWT HS256 bearer; not required by the flag-bearing endpoint |
| Database | Not directly observable from the client |
API Surface
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
/api/register |
POST | No | Open self-serve; returns a JWT |
/api/login |
POST | No | Returns a JWT |
/api/post/image?file= |
GET | No | Reads a file off disk by the file param (F1) |
/api/posts |
GET | Yes | Feed; discloses the image_url field per post |
/api/posts |
POST | Yes | Create post; multipart image field; sets image_url |
/api/posts/:id/comments |
POST | Yes | |
/api/posts/:id/like |
POST/DELETE | Yes | |
/api/profile/:username |
GET | Yes | |
/api/profile |
PUT | Yes | Self profile edit |
/api/admin/* |
GET/PUT/DELETE | Yes (admin) | 403 for the non-admin operator token |
/uploads/<uuid>.(png\|jpg) |
GET | No | Static upload serving |
Attack Chain Visualization
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
│ Read public bundle │ │ Baseline read │ │ Climb with ../ │ │ No auth header │
│ image sink: │──▶│ ?file=/uploads/ │──▶│ ?file=../../app/ │──▶│ 200 text/plain │
│ /api/post/image? │ │ otter1.png → 200 │ │ flag.txt → 200 │ │ flag returned; │
│ file= │ │ fixes base depth │ │ depth-tuned read │ │ unauthenticated │
└─────────────────────┘ └─────────────────────┘ └─────────────────────┘ └─────────────────────┘
Findings
F1: Unauthenticated Arbitrary File Read via Path Traversal
Severity: High
CVSS v3.1: 7.5 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N)
CWE: CWE-22 (Improper Limitation of a Pathname to a Restricted Directory)
Endpoint: GET /api/post/image?file=<path>
Authentication required: No
Description
The endpoint reads a file from disk using the file query parameter and returns its raw bytes. The post viewer is designed to call it with the stored image_url, but the parameter is attacker-controlled and the handler applies no traversal sanitization. The observed behavior is consistent with joining the supplied value onto a base directory and reading it directly (the specific handler was inferred from the responses; the server source was not seen).
A relative path with ../ segments escapes the intended uploads base directory:
- A known-good upload path returns the file (
?file=/uploads/otter1.png→200 image/png), so the endpoint serves files and the base directory resolves the uploads path. - The flag path returns the file once enough
../segments are prepended to climb out of the base directory (?file=../../app/flag.txt→200 text/plain). - The request that read the flag carried no Authorization or Cookie header, so the endpoint requires no authentication.
The read is bounded only by the file-system permissions of the application process. Escalation beyond the flag (reading application source, environment files, or other secrets readable by the process) was identified as a next step in a real engagement but was not exercised here.
Impact
Unauthenticated arbitrary file read of any file readable by the application process.
Reproduction
Step 1: Confirm the read endpoint with a known-good baseline
Request an upload that is known to exist, to confirm the endpoint serves files and to fix the base directory depth before climbing.
GET /api/post/image?file=/uploads/otter1.png HTTP/1.1
Host: lab-1782172213517-szoidu.labs-app.bugforge.io
Response: 200 OK, Content-Type: image/png. The base directory resolves the uploads path.
Step 2: Tune the traversal depth
Increment ../ one level at a time and read the depth at which the target file resolves.
?file=/app/flag.txt → 404 File not found
?file=../app/flag.txt → 404 File not found
?file=../../app/flag.txt → 200 text/plain
The 404 to 200 transition as the depth increases confirms a real traversal rather than a reflected or ignored value: the base directory sits two levels below /app.
Step 3: Read the flag, unauthenticated
GET /api/post/image?file=../../app/flag.txt HTTP/1.1
Host: lab-1782172213517-szoidu.labs-app.bugforge.io
Response: 200 OK
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
X-Powered-By: Express
bug{yOEjSgVMQoHfbsdh0fQBWdIjHYbD88Cv}
The request carries no Authorization or Cookie header. Equivalent one-liner:
curl 'https://lab-1782172213517-szoidu.labs-app.bugforge.io/api/post/image?file=../../app/flag.txt'
Remediation
Fix 1: Resolve the path and confirm it stays inside the base directory
Do not read a client-supplied path directly. Resolve it against an allowlisted base directory and reject anything that escapes it. (The vulnerable shape below is inferred from behavior; the server source was not seen.)
// BEFORE (Vulnerable): client value joined onto the base and read directly
app.get('/api/post/image', (req, res) => {
const filePath = path.join(UPLOADS_DIR, req.query.file);
res.sendFile(filePath);
});
// AFTER (Secure): resolve, confirm containment, then serve
app.get('/api/post/image', (req, res) => {
const base = path.resolve(UPLOADS_DIR);
const resolved = path.resolve(base, req.query.file ?? '');
if (resolved !== base && !resolved.startsWith(base + path.sep)) {
return res.status(400).json({ error: 'Invalid path' });
}
res.sendFile(resolved);
});
Additional recommendations:
- Do not accept file-system paths from clients at all. Reference uploads by the opaque stored identifier (the UUID) and map it to a path on the server, so no path is reconstructed from user input.
- Serve uploads through a static handler rooted at the uploads directory rather than a parameterized read endpoint.
- Run the application as a low-privilege user and isolate the upload directory so a traversal cannot reach application source or system files.
OWASP Top 10 Coverage
- A01:2021 Broken Access Control: F1. Path traversal (CWE-22) is enumerated under this category. The endpoint serves files outside its intended directory to any client, with no authentication.
- A04:2021 Insecure Design: F1. The image read endpoint takes a file-system path from a client-controlled field by design, so the read side trusts a path the write side merely recorded.
Tools Used
| Tool | Purpose |
|---|---|
| Caido | Request capture and replay against the API |
| Firefox | Application interaction and bundle inspection |
| React bundle review | Recovering the image sink and endpoint from main.e6d131e0.js |
References
- CWE-22: Improper Limitation of a Pathname to a Restricted Directory (‘Path Traversal’): https://cwe.mitre.org/data/definitions/22.html
- OWASP Top 10 2021: A01 Broken Access Control: https://owasp.org/Top10/A01_2021-Broken_Access_Control/
- OWASP Top 10 2021: A04 Insecure Design: https://owasp.org/Top10/A04_2021-Insecure_Design/
- OWASP: Path Traversal: https://owasp.org/www-community/attacks/Path_Traversal
Part 2: Notes / Knowledge
Key Learnings
-
Establish each endpoint’s server-side authentication requirement independently of how the front-end treats it. When an endpoint is discovered through an authenticated view, re-fire it with no Authorization or Cookie header and tag it on two axes: does the front end attach a token, and does the server actually require one (anonymous request answered with
401/403versus200). The front end attaching a token is a client convention, not a server control, so enforcement has to be observed server-side. On this target the image read endpoint is reached only from a logged-in post view, but the request that read the flag carried no credential and still returned200. That single check is what moved the severity to High: an unauthenticated arbitrary read is materially worse than an authenticated one. -
A stored-and-served file feature has two independent sinks: the upload (write, sets a path) and the inclusion (read, serves a path); test the read sink for path trust on its own. When an app stores a file or path on upload and later serves it back through a generic read endpoint, locate the read endpoint (grep the bundle for
?file=/?path=and.concat(<stored_path_field>)) and test its path parameter for traversal directly. Upload validation and read-path validation are separate code, and the read side often trusts a path the write side merely recorded. HerePOST /api/postsonly setimage_url, while the viewer rendered<img src={"/api/post/image?file=".concat(r.image_url)}>, and that read endpoint had no traversal guard. Do not let “it is a file upload” stand in for testing the read sink; upload and inclusion are different bugs. -
To test path traversal with an unknown base directory depth, anchor on a known-good in-scope file first, then climb
../one level at a time. Request a file you know exists in scope (an existing upload) to confirm a200and see how the base path resolves, then increment the../count until the target resolves. The known-good baseline fixes the base directory depth so the climb is deterministic instead of guesswork, and the404to200transition as depth increases distinguishes a real traversal from a reflected or ignored value. On this target the ladder ran/uploads/otter1.png200 →/app/flag.txt404 →../app/flag.txt404 →../../app/flag.txt200, which both located the flag and proved the traversal was real.
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
File upload abuse (POST /api/posts multipart) |
Accepted, only sets image_url |
The upload merely records the stored path; the defect is on the read side. Upload-vs-inclusion was the conceptual mix-up worth naming |
Absolute target path (?file=/app/flag.txt) |
404 File not found |
The supplied value is resolved under the base directory, not from the file-system root, so a path that looks absolute still resolves inside the base |
Single-level traversal (?file=../app/flag.txt) |
404 File not found |
The base directory sits two levels below /app; one ../ is not enough to climb out |
Admin endpoints (/api/admin/*) with the operator token |
403 |
Role-gated for the non-admin token; out of scope for this rotation’s planted vulnerability |
Tags: #path-traversal #lfi #file-inclusion #arbitrary-file-read #unauthenticated #react #express #bugforge
Document Version: 1.0
Last Updated: 2026-06-23