DiceForge: Authentication Bypass via Spoofable Client-IP Header
Part 1: Pentest Report
Executive Summary
DiceForge is a minimal dice-roller built as a React single-page application on an Express backend, with no user accounts, login, cookies, or tokens anywhere in the app. The only access control in the entire application sits on a single administrative endpoint, GET /api/admin/config, which is gated by an IP allowlist. That allowlist reads a client-supplied header (X-Client-IP) and permits loopback only. Because the header is fully attacker-controlled, the gate is defeated by sending X-Client-IP: 127.0.0.1, which returns the configuration object including the secret.
Testing confirmed 1 finding:
| ID | Title | Severity | CVSS | CWE | Endpoint |
|---|---|---|---|---|---|
| F1 | Authentication bypass / secret disclosure via spoofable X-Client-IP header |
High | 7.5 | CWE-290, CWE-348 | GET /api/admin/config |
The endpoint is invisible from the application itself: nothing in the UI or the JavaScript bundle references it. It was reached by fuzzing three positions in sequence: the URL path, the sub-path, and finally the header name, which isolated the one header the backend trusts as the true client address.
Objective
Find the flag, delivered as the apiSecret value, in a minimal dice-roller application. The lab is rated easy but deliberately obscure: there is zero in-app indication of the vulnerability or the endpoint that carries the flag.
Scope / Initial Access
# Target Application
URL: https://lab-1780078176173-fof6ea.labs-app.bugforge.io
# Auth details
No authentication of any kind. No login, registration, cookies, or
tokens. The application is fully anonymous. The only access control
present is the IP allowlist on GET /api/admin/config.
The application is anonymous end to end. There is no privilege boundary to cross and no account to register; every request is unauthenticated. This makes the lone allowlisted endpoint the entire access-control surface.
Reconnaissance: Fuzzing Three Positions
The application exposes no link to the target endpoint, so the surface had to be mapped by enumeration. The SPA serves a catch-all route: any unmatched path returns a 200 text/html response of exactly 824 bytes (the app shell), including GET requests against POST-only API routes. Filtering on that 824-byte size made path fuzzing tractable. Routing is case-insensitive.
Observations that shaped the test plan:
- The SPA catch-all is a fixed 824-byte shell. Any non-existent path returns it, so a size filter (
-fs 824) cleanly separates real routes from the shell during path fuzzing. GET /api/FUZZsurfaced/api/admin, which returns 400{"error":"Incomplete path"}. The 400 (rather than the shell) signaled a real route expecting a sub-path segment.GET /api/admin/FUZZsurfaced/api/admin/config, which returns 403{"error":"Forbidden"}. An endpoint that exists but refuses the request points to an access-control gate rather than a missing route.- The 403 body is 21 bytes, giving a second clean size filter for the next stage: fuzzing the header that controls the gate.
- No flag-bearing route or conditional appears in the JavaScript bundle. Only
POST /api/rollis referenced by the frontend, confirming the target endpoint is reachable only by direct enumeration, not by reading the client code.
Application Architecture
| Component | Detail |
|---|---|
| Backend | Express (X-Powered-By: Express), JSON APIs |
| Frontend | React single-page app (CRA build: /static/js/main.d383d43b.js) |
| Auth | None; fully anonymous; only access control is the IP allowlist on GET /api/admin/config |
| CORS | Access-Control-Allow-Origin: * |
API Surface
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
/api/roll |
POST | None | Rolls dice; body {"dice":[{"type":"d100","count":1}]}. Count capped at 20 client-side only. |
/api/dice |
GET | None | Static die catalog (d4 to d100). |
/api/stats |
GET | None | Roll history / aggregates. No authentication (minor info leak). |
/api/admin |
GET | None | Returns 400 Incomplete path; route is /api/admin/:section. |
/api/admin/config |
GET | IP allowlist | Target. 403 Forbidden unless the request appears to originate from loopback. |
Attack Chain Visualization
┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐
│ Fuzz URL path │ │ Fuzz sub-path │ │ /api/admin/config │ │ Fuzz header NAME, │
│ GET /api/FUZZ │──▶│ GET /api/admin/ │──▶│ exists but 403 │──▶│ value=127.0.0.1 │
│ (filter 824B shell)│ │ FUZZ → /config │ │ Forbidden (IP gate)│ │ X-Client-IP wins │
└────────────────────┘ └────────────────────┘ └────────────────────┘ └─────────┬──────────┘
│
▼
┌─────────────────────────────┐
│ 200 OK: apiSecret returned │
│ bug{TtKsml9rGs92gF5OGuU6...} │
└─────────────────────────────┘
Findings
F1: Authentication bypass / secret disclosure via spoofable X-Client-IP header
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-290 (Authentication Bypass by Spoofing), CWE-348 (Use of Less Trusted Source)
Endpoint: GET /api/admin/config
Authentication required: No
Description
GET /api/admin/config is access-controlled by an IP allowlist. The allowlist reads a client-supplied header, X-Client-IP, treats its value as the true client address, and permits only loopback (127.0.0.1). Because the header is supplied by the requester, an unauthenticated caller can set it to 127.0.0.1 and satisfy the allowlist. The endpoint then returns the application configuration object, which includes apiSecret.
The value (not merely the presence) of the header is what is checked: a non-loopback value such as 8.8.8.8 is still rejected with 403.
Impact
Unauthenticated disclosure of the application secret and administrative configuration.
Reproduction
Step 1: Confirm the gate rejects an ordinary request
GET /api/admin/config HTTP/1.1
Host: lab-1780078176173-fof6ea.labs-app.bugforge.io
Response: 403 {"error":"Forbidden"} (21 bytes). The endpoint exists but refuses the request.
Step 2: Send the spoofed loopback header
GET /api/admin/config HTTP/1.1
Host: lab-1780078176173-fof6ea.labs-app.bugforge.io
X-Client-IP: 127.0.0.1
Response: 200 OK
{
"appName": "DiceForge",
"version": "1.0.0",
"apiSecret": "bug{TtKsml9rGs92gF5OGuU6j4DCbSGTw5Gf}",
"maxDicePerRoll": 20,
"maxDiceTypes": 7,
"rateLimit": 100
}
The allowlist is satisfied and the secret is returned.
Step 3: Control test with a non-loopback value
GET /api/admin/config HTTP/1.1
Host: lab-1780078176173-fof6ea.labs-app.bugforge.io
X-Client-IP: 8.8.8.8
Response: 403 {"error":"Forbidden"}. Confirms the allowlist checks the header value, not just its presence.
One-line proof of concept:
curl -s -H "X-Client-IP: 127.0.0.1" \
https://lab-1780078176173-fof6ea.labs-app.bugforge.io/api/admin/config
Remediation
The defect is trusting a client-supplied header as the source IP. The client address must come from the connection, not from a request header.
Fix 1: Derive the client IP from the trusted proxy chain, not an inbound header
// BEFORE (Vulnerable)
// Reads an attacker-controlled header as the client address.
const clientIp = req.headers['x-client-ip'];
if (clientIp !== '127.0.0.1') {
return res.status(403).json({ error: 'Forbidden' });
}
// AFTER (Secure)
// Configure Express to trust only known proxies, then read the
// framework-derived address (socket address, or the proxy chain
// only when a trusted proxy set it).
app.set('trust proxy', ['127.0.0.1', '10.0.0.0/8']); // known proxies only
// ...
const clientIp = req.ip; // resolved against the trusted proxy config
if (clientIp !== '127.0.0.1') {
return res.status(403).json({ error: 'Forbidden' });
}
Additional recommendations:
- Do not gate sensitive endpoints on client IP at all where a stronger control is available. An IP allowlist is a network control; pair it with authentication and authorization for an administrative configuration endpoint.
- Never read
X-Client-IP,X-Forwarded-For,X-Real-IP, or similar headers as authoritative unless they were set by a proxy you control and the request cannot reach the app without passing through that proxy. - Do not return secrets (
apiSecret) in a configuration response. Keep secrets server-side and out of any client-reachable payload.
OWASP Top 10 Coverage
- A01:2021 Broken Access Control: The single access-control gate in the application is defeated by a spoofed request header, granting an unauthenticated caller access to an administrative endpoint.
- A07:2021 Identification and Authentication Failures: Client identity (source IP) is established from an attacker-controlled header, so the “loopback only” restriction provides no real assurance of who is calling.
Tools Used
| Tool | Purpose |
|---|---|
| ffuf | Path, sub-path, and header-name fuzzing (filtering the 824-byte shell and 21-byte 403) |
| curl | Single-variable isolation and proof of concept |
References
- CWE-290: Authentication Bypass by Spoofing
- CWE-348: Use of Less Trusted Source
- OWASP A01:2021 Broken Access Control
- Express “trust proxy” documentation
Part 2: Notes / Knowledge
Key Learnings
-
When a bypass class’s first probe fails, run the whole batch before pivoting out of the class. Source-IP and header-keyed gates are a finite, enumerable family:
X-Forwarded-For,X-Real-IP,X-Client-IP,True-Client-IP,CF-Connecting-IP, and a handful more. On this target, the first probe (X-Forwarded-For) failed and the temptation was to pivot to a different technique (path-normalization), but the answer was the next header in the same family. The full list costs seconds to exhaust; leaving the class early to chase an unrelated technique can step right over a live answer one line down. Exhaust the wordlist before changing tactics. -
The header name is a fuzz position, not just the header value. A gate keyed on a request header (an IP allowlist, a privilege flag, a URL-override) is beaten by enumerating which header the backend trusts, not by crafting a clever value. Pin a known-good value (here,
127.0.0.1for the loopback allowlist) and fuzz the header name against a wordlist of source-IP and override headers; the survivor that flips 403 to 200 is the trusted header. The win condition is purely “which header,” and that is a finite, fuzzable set, so header-keyed gates fall to a wordlist rather than to ingenuity.
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
X-Forwarded-For: 127.0.0.1 |
403 | Backend does not read XFF as the client address; it trusts X-Client-IP specifically. |
X-Real-IP: 127.0.0.1 |
403 | Same; not the header the gate reads. |
Host: localhost |
403 | Gate keys on a client-IP header, not the Host header. |
Path-normalization on /api/admin/config (//, /./, %2e, %2f, ..;/, ;, trailing slash) |
403 | The gate is not a path-prefix match that normalization can sidestep. |
Direct guesses of named flag routes (/api/flag, /secret, /debug, /env, /config) |
824-byte shell | Those routes do not exist; the SPA catch-all returns the app shell. |
| Reading the JS bundle for a flag route or conditional | Only /api/roll referenced |
No second API route or flag conditional is present in the client code. |
Tags: #access-control #header-spoofing #ip-allowlist #fuzzing #bugforge
Document Version: 1.1
Last Updated: 2026-06-03