Gift List: OTP Recipient Manipulation → Admin Access
Overview
- Platform: BugForge
- Vulnerability: OTP Recipient Manipulation (Authentication Bypass) — admin login send-code endpoint accepts arbitrary username, routing the OTP to any user’s message inbox
- Key Technique: Changing the hidden
usernameparameter on /admin-login/send-code to redirect the admin OTP to our own inbox, then submitting it to authenticate as administrator - Result: Full admin access and flag capture
Objective
Gain admin access to the BugForge “Gift List” application and capture the flag. The application is a gift list manager where users create wish lists, add items, and share lists via public links. Admin access is gated behind an OTP flow.
Initial Access
# Target Application
URL: https://lab-1775772686893-mxgmk6.labs-app.bugforge.io
# Auth details
POST /register with username, password, confirmPassword (form-urlencoded)
JWT cookie "token" (HS256, HttpOnly, SameSite=Lax)
Registered as: haxor (id:2, role: user)
Key Findings
- OTP Recipient Manipulation — Authentication Bypass (CWE-287: Improper Authentication) — The POST /admin-login/send-code endpoint accepts a
usernameparameter that determines which user’s inbox receives the OTP code. The HTML form pre-fills this as a hidden input with valueadministrator, but an attacker can change it to any registered username. The OTP verification endpoint (/admin-login POST) validates the code independently of who received it — if the code is correct, the requester gets an admin JWT. This completely bypasses the admin authentication flow.
Attack Chain Visualization
┌──────────────┐ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Register │────▶│ Recon: Map auth │────▶│ Manipulate │────▶│ Read OTP from │
│ (haxor) │ │ surface, find │ │ send-code POST │ │ /messages, │
│ Get JWT │ │ /admin-login │ │ username=haxor │ │ submit at │
│ │ │ with hidden │ │ (redirect OTP │ │ /admin-login │
│ │ │ username field │ │ to our inbox) │ │ → admin JWT │
└──────────────┘ └──────────────────┘ └──────────────────┘ └──────────────────┘
Application Architecture
| Component | Detail |
|---|---|
| Backend | Express (Node.js) |
| Frontend | EJS templates (server-side rendering) |
| Auth | JWT HS256 — HttpOnly cookie “token”, SameSite=Lax |
| CSS | Bootstrap 5.3.2 (CDN) |
| Forms | Form-urlencoded POST bodies throughout |
Endpoint Surface
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
| /register | GET/POST | No | username, password, confirmPassword |
| /login | GET/POST | No | username, password → JWT cookie |
| /admin-login | GET | No | Separate admin login page |
| /admin-login/send-code | POST | No | username param controls OTP recipient |
| /admin-login | POST | No | code param — verifies OTP, grants admin JWT |
| /dashboard | GET | Yes | User’s gift lists |
| /lists | POST | Yes | Create new list |
| /list/:id | GET | Yes | View list items |
| /lists/:id/items/add | POST | Yes | Add item (name, store_url, priority, notes) |
| /delete/:list_id/:item_id | POST | Yes | Delete item |
| /lists/:id/share | POST | Yes | Generate share link |
| /lists/:id/delete | POST | Yes | Delete list |
| /share/:uuid | GET | No | Public shared list view |
| /share/:uuid/items/:id/bought | POST | No | Mark item as bought |
| /messages | GET | Yes | User’s message inbox |
| /logout | GET | Yes | Clear JWT cookie |
Known Users
| Username | ID | Role |
|---|---|---|
| administrator | 1 | admin |
| haxor | 2 | user (us) |
Exploitation Path
Step 1: Register and Authenticate
POST /register HTTP/1.1
Content-Type: application/x-www-form-urlencoded
username=haxor&password=password&confirmPassword=password
Response: 302 → /login. After login, JWT cookie set with payload {"id":2,"username":"haxor","role":"user","iat":...,"exp":...}.
Step 2: Recon — Identify Admin Login Flow
Navigating to /admin-login reveals a two-step OTP authentication flow:
- Send code — a form with a hidden input
username=administratorand a “Send Code” button. Submits POST to /admin-login/send-code. - Verify code — after sending, a code input field appears. Submits POST to /admin-login with the OTP code.
The server response on send: “Code sent to administrator inbox” — implying the OTP is delivered as an in-app message, not via email or SMS.
Key observation: the username field is a hidden HTML input, not enforced server-side. The client supplies which user should receive the OTP.
Step 3: Redirect OTP to Our Inbox
Modified the send-code request to route the OTP to our user:
POST /admin-login/send-code HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Cookie: token=<our-jwt>
username=haxor
Response: 302 → /admin-login?sent=true (same behavior as the legitimate flow — no indication that the recipient was changed).
Step 4: Retrieve OTP from Messages
Navigated to GET /messages. The OTP code YlrjSdctafxy appeared in our inbox as a new message. The server generated the code and delivered it to whichever user was specified in the username parameter — no validation that the username matches “administrator”.
Step 5: Submit OTP for Admin Access
POST /admin-login HTTP/1.1
Content-Type: application/x-www-form-urlencoded
code=YlrjSdctafxy
Response: 302 → /administrator. The server issued a new JWT cookie with admin privileges. Flag displayed on the admin dashboard.
Flag / Objective Achieved
| Vector | Flag |
|---|---|
| OTP recipient manipulation on /admin-login/send-code → admin JWT | bug{7r6xYgmvDuImv8o5y3eOoJzIiN0YweX5} |
Key Learnings
-
Hidden form fields are not access controls. The
username=administratorhidden input creates the illusion that only the admin account can receive the OTP, but the server blindly trusts whatever value the client sends. Hidden fields are a UI convenience, not a security boundary. -
OTP delivery and OTP verification must be coupled. The server generates a code, sends it to user X, then accepts the code from anyone. The verification step checks “is this the right code?” but not “did we send it to the person submitting it?” This decoupling is the root cause — the system has authentication (code check) but no authorization (recipient check).
-
Rate limiting doesn’t matter when you have the code. The application rate-limits send-code (5/window) and verify (10/window), which would slow brute force. But recipient manipulation bypasses brute force entirely — you get the exact code delivered to your own inbox.
-
In-app messaging as OTP delivery is an unusual trust model. Using the application’s own messaging system for OTP delivery means any user who can read their messages can potentially receive misdirected OTPs. External channels (SMS, email) at least require the attacker to compromise a separate system.
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
| Mass assignment on /register (role=admin) | 200 OK, role unchanged in JWT | Server-side registration only picks username/password, ignores extra fields |
Tools Used
| Tool | Purpose |
|---|---|
| Caido | HTTP proxy — request interception, parameter manipulation |
| Browser DevTools | HTML inspection, hidden field discovery |
Remediation
1. OTP Recipient Manipulation — Authentication Bypass (CVSS: 9.8 — Critical)
Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
Issue: The POST /admin-login/send-code endpoint accepts a client-supplied username parameter that controls which user’s inbox receives the OTP code. An attacker changes this from administrator to their own username, receives the OTP in their messages, and submits it to gain admin access. No authentication is required — the send-code endpoint is public.
CWE Reference: CWE-287 — Improper Authentication
Fix:
// BEFORE (Vulnerable)
router.post('/admin-login/send-code', rateLimit, async (req, res) => {
const { username } = req.body;
const code = generateOTP();
await sendMessageToUser(username, `Your admin code: ${code}`);
// Store code for verification
await storeOTP(code);
res.redirect('/admin-login?sent=true');
});
// AFTER (Secure)
router.post('/admin-login/send-code', rateLimit, async (req, res) => {
// Hardcode the recipient — never accept it from the client
const adminUsername = 'administrator';
const code = generateOTP();
await sendMessageToUser(adminUsername, `Your admin code: ${code}`);
await storeOTP(code);
res.redirect('/admin-login?sent=true');
});
Additional recommendations:
- Remove the
usernameparameter from the send-code endpoint entirely — the recipient should be determined server-side, not by client input. - Bind the OTP to the admin user session: on verification, check that the submitter is the same user who was intended to receive the code.
- Use an external OTP delivery channel (email, SMS, authenticator app) rather than in-app messages, so OTP delivery requires access to a separate system the attacker doesn’t control.
- Consider time-limited OTP codes (e.g., 5-minute expiry) to reduce the window for interception.
- Log OTP send events with the target username to detect manipulation attempts.
OWASP Top 10 Coverage
- A07:2021 — Identification and Authentication Failures: The core vulnerability. The admin authentication flow trusts client-supplied input to determine OTP delivery, allowing an attacker to redirect the OTP to their own account and authenticate as admin.
- A04:2021 — Insecure Design: The separation between OTP generation/delivery and OTP verification without binding the two to the intended recipient is a design-level flaw. The system was designed with the assumption that the hidden form field would always contain “administrator.”
- A01:2021 — Broken Access Control: The send-code endpoint performs no authorization check — any user (or unauthenticated visitor) can trigger OTP generation and control where it’s delivered.
References
- CWE-287: Improper Authentication
- CWE-304: Missing Critical Step in Authentication
- OWASP Testing Guide: OTP Implementation
- OWASP Top 10:2021 — Identification and Authentication Failures
Tags: #otp-bypass #auth-bypass #parameter-manipulation #bugforge #webapp
Document Version: 1.0
Last Updated: 2026-04-09