BugForge — 2026.06.03

DiceForge: Authentication Bypass via Spoofable Client-IP Header

BugForge Authentication Bypass via Spoofable Client-IP Header easy

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:

  1. 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.
  2. GET /api/FUZZ surfaced /api/admin, which returns 400 {"error":"Incomplete path"}. The 400 (rather than the shell) signaled a real route expecting a sub-path segment.
  3. GET /api/admin/FUZZ surfaced /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.
  4. The 403 body is 21 bytes, giving a second clean size filter for the next stage: fuzzing the header that controls the gate.
  5. No flag-bearing route or conditional appears in the JavaScript bundle. Only POST /api/roll is 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


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.1 for 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

#access-control #header-spoofing #ip-allowlist #fuzzing #bugforge