BugForge — 2026.06.23

Ottergram: Unauthenticated Path Traversal / Arbitrary File Read

BugForge Path Traversal / File Inclusion easy

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:

  1. 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.
  2. The upload endpoint (POST /api/posts) only records image_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.
  3. The endpoint string is present in the public bundle, which is served without authentication, so the read endpoint is recoverable without an account.
  4. 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:

  1. A known-good upload path returns the file (?file=/uploads/otter1.png200 image/png), so the endpoint serves files and the base directory resolves the uploads path.
  2. The flag path returns the file once enough ../ segments are prepended to climb out of the base directory (?file=../../app/flag.txt200 text/plain).
  3. 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/403 versus 200). 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 returned 200. 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. Here POST /api/posts only set image_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 a 200 and 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 the 404 to 200 transition as depth increases distinguishes a real traversal from a reflected or ignored value. On this target the ladder ran /uploads/otter1.png 200 → /app/flag.txt 404 → ../app/flag.txt 404 → ../../app/flag.txt 200, 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

#path-traversal #lfi #file-inclusion #arbitrary-file-read #unauthenticated #express #react #bugforge