A web application with 16 levels of 2FA authentication vulnerabilities, designed for hands-on security training using Burp Suite and manual exploitation.
- Docker & Docker Compose
- Burp Suite (Community or Pro)
- A browser configured to proxy through Burp (127.0.0.1:8080)
git clone <repo>
cd 2fa-bypass-mastery-lab
docker-compose up --buildOpen: http://localhost:3000
| Account | Password | |
|---|---|---|
| User | user@lab.com | password |
| Victim | victim@lab.com | password |
| Attacker | attacker@lab.com | password |
| Admin | admin@lab.com | password |
2fa-bypass-mastery-lab/
├── docker-compose.yml
├── docker/
│ └── init.sql # DB schema + seed data
└── backend/
├── server.js # Express app entry point
├── package.json
├── Dockerfile
├── db/
│ └── connection.js # MySQL pool
├── routes/
│ ├── auth.js # Register / Login / Logout
│ ├── twofa.js # All 16 level endpoints
│ ├── admin.js # Admin panel API
│ ├── user.js # User dashboard / settings
│ └── pages.js # EJS page routes
├── views/
│ ├── index.ejs # Home page (level grid)
│ ├── level.ejs # Universal level page
│ ├── login.ejs
│ ├── register.ejs
│ ├── dashboard.ejs
│ ├── admin.ejs
│ ├── settings.ejs
│ ├── attacker-csrf.ejs # Level 11 attack page
│ └── attacker-clickjack.ejs # Level 12 attack page
└── static/
├── styles.css
└── otp-level4.js # ⚠️ Level 4: hardcoded OTP
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/auth/login | Login (returns 2FA flag) |
| POST | /api/auth/register | Register new user |
| POST | /api/2fa/level{N}/send | Send OTP for level N |
| POST | /api/2fa/level{N}/verify | Verify OTP for level N |
| GET | /api/2fa/level16/verify | |
| POST | /api/2fa/disable | Disable 2FA (no CSRF) |
| GET | /api/user/dashboard | User dashboard |
| GET | /api/admin/panel | Admin panel |
Difficulty: Beginner
Endpoint: POST /api/2fa/level1/verify
The server always responds with {"success": false} regardless of whether the
OTP is correct. The frontend JavaScript uses this value to decide whether to
redirect to the dashboard. An attacker who intercepts the response and changes
false → true gains access.
- Login as
user@lab.comand trigger OTP send - Configure Burp Suite as your proxy
- Submit any OTP code and intercept the response in Burp Proxy
- In the Intercept tab → change
"success":false→"success":true - Forward the modified response
- The frontend redirects you to the dashboard
// ⚠️ VULNERABLE: Server always returns false
res.json({ success: false, message: 'Verification complete' });
// ⚠️ VULNERABLE: Client trusts the response value for access control
if (response.success === true) {
window.location = '/dashboard'; // access granted based on client-side check
}// ✅ Server ONLY grants access if OTP is valid — never returns "success:true" incorrectly
// ✅ Access control enforced server-side with session middleware
// ✅ Frontend redirect triggered by successful session state, not response value
router.get('/dashboard', requireAuth, requireTwoFA, (req, res) => { ... });- Never make security decisions client-side
- Server must enforce access control with session middleware
- Frontend redirects should confirm session state, not JSON values
- Use server-side redirects (302) rather than client-side JS redirects
Difficulty: Beginner
Endpoint: GET /api/2fa/level2/dashboard
After a successful login, the session is marked preAuth: true. The /dashboard
endpoint only checks if a session exists — not whether 2FA was completed.
- Login as
user@lab.com(receiverequires2FA: true) - Skip the 2FA step entirely
- Directly
GET /api/2fa/level2/dashboard— access granted! - Or navigate to the dashboard URL without ever visiting the 2FA page
// ⚠️ VULNERABLE: Only checks for session, not 2FA completion
router.get('/dashboard', async (req, res) => {
if (!req.session.userId) return res.status(401).json({error: 'Login required'});
// ⚠️ MISSING: if (!req.session.twoFaPassed) return 403
res.json({ success: true, flag });
});// ✅ Middleware chain: authenticated AND 2FA completed
function requireTwoFA(req, res, next) {
if (!req.session.twoFaPassed) return res.status(403).json({error: '2FA required'});
next();
}
router.get('/dashboard', requireAuth, requireTwoFA, handler);Difficulty: Beginner
Endpoint: POST /api/2fa/level3/send
When requesting an OTP, the server includes the actual code in the JSON response body. Anyone who can see the HTTP traffic (proxy, network sniffing) gets the code directly.
- Intercept the request to
/api/2fa/level3/sendwith Burp Suite - Read the response body:
{"success": true, "email": "...", "code": "492183"} - Use that code in the verify step
// ⚠️ VULNERABLE: OTP included in response!
res.json({ success: true, email, code, message: 'OTP sent to email' });// ✅ Store OTP server-side, only send via out-of-band channel (email/SMS)
await sendEmail(user.email, `Your OTP: ${code}`);
res.json({ success: true, message: 'OTP sent to your registered email' });
// Never include the code in the HTTP responseDifficulty: Beginner
Files: /otp-level4.js (publicly accessible)
The OTP validation logic — including the hardcoded secret — lives in a JavaScript file served to the client. Anyone can open DevTools and read the code.
- Open the level 4 page
- Open DevTools → Sources →
otp-level4.js - Find
const LEVEL4_SECRET_OTP = "736291" - Or run in console:
checkOTP("736291")→true - The JS then sends
clientValidated: trueto the server, which trusts it - Alternatively: directly call the API with
clientValidated: true
// ⚠️ VULNERABLE: Hardcoded OTP in public JS
const LEVEL4_SECRET_OTP = "736291";
function checkOTP(input) { return input === LEVEL4_SECRET_OTP; }
// ⚠️ VULNERABLE: Server blindly trusts client's claim
if (clientValidated === true) { return res.json({ success: true, flag }); }// ✅ ALL validation on server — client never receives or handles OTP secret
router.post('/verify', async (req, res) => {
const { code } = req.body;
const stored = await getOTPFromDB(req.session.userId);
if (stored && stored.code === code && !stored.used && !isExpired(stored)) {
// grant access
}
});Difficulty: Beginner
Endpoint: POST /api/2fa/level5/verify
After a successful OTP verification, the code is not marked as used in the database. It can be submitted again indefinitely.
- Request an OTP (check server logs for the code)
- Verify once — note the code
- Verify again with the same code — still works!
- In a real scenario: intercept a legitimate user's OTP and replay it later
// ⚠️ VULNERABLE: Missing used=1 update after successful verification
if (otpRows[0] && otpRows[0].code === code) {
// await db.execute('UPDATE otp_codes SET used=1 WHERE id=?', [...]); ← MISSING
return res.json({ success: true, flag });
}// ✅ Invalidate OTP immediately on first use
await db.execute('UPDATE otp_codes SET used = 1 WHERE id = ?', [otpRows[0].id]);
// ✅ Also add expiry: check created_at + 5 minutes
if (Date.now() - new Date(otp.created_at).getTime() > 5 * 60 * 1000) return 401;Difficulty: Intermediate
Endpoint: POST /api/2fa/level6/verify
The OTP verify endpoint has no rate limiting, lockout, or delay. An attacker can try all 1,000,000 possible 6-digit codes at full speed.
- Start an OTP send request for your target
- In Burp Suite: send verify request to Intruder
- Set position on the
codeparameter - Payload type: Numbers — from 000000 to 999999, step 1
- Max threads: 20+
- Start attack — watch for HTTP 200 with
success: true
Expected time: 6-digit space = 1,000,000 codes. At 100 req/s ≈ 2.8 hours worst case, but expected success is at ~500,000 (halfway through on average).
// ✅ Rate limiting: use express-rate-limit
const rateLimit = require('express-rate-limit');
const otpLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts max
message: { error: 'Too many attempts. Try again in 15 minutes.' }
});
router.post('/verify', otpLimiter, handler);
// ✅ Account lockout after repeated failures
// ✅ Progressive delays (1s, 2s, 4s...)
// ✅ OTP expiry (5 minutes)
// ✅ Longer OTPs or TOTP (time-based, harder to brute-force in window)Difficulty: Intermediate
Endpoint: POST /api/2fa/level7/verify
The server validates that some active OTP with the given code exists — but doesn't verify it belongs to the account being authenticated.
- Login as
attacker@lab.com - Request your OTP for level 7 — get/intercept your own code
- Now try to login as
victim@lab.com - When prompted for OTP, submit your attacker OTP code
- Access granted to victim's account!
// ⚠️ VULNERABLE: Matches any OTP, not tied to the target user
const [otpRows] = await db.execute(
'SELECT * FROM otp_codes WHERE code = ? AND level = 7 AND used = 0',
[code] // ← no user_id check!
);// ✅ Always query by BOTH user_id AND code
const [otpRows] = await db.execute(
'SELECT * FROM otp_codes WHERE user_id = ? AND code = ? AND level = ? AND used = 0',
[req.session.userId, code, level]
);Difficulty: Intermediate
Endpoint: POST /api/2fa/level8/verify
The twoFaPassed flag in the session is shared globally for the session without
being tied to a specific userId. An attacker who authenticates fully (with 2FA)
on their own account and then changes the userId in session/request can access
other accounts.
- Fully login and pass 2FA as
attacker@lab.com - Now the session has
twoFaPassed: true - Modify the userId in subsequent requests (or swap session cookies)
- The server sees
twoFaPassed === trueand grants access
// ✅ Bind 2FA status to a specific user in the session
req.session[`2fa_${userId}_passed`] = true;
// ✅ Or store in DB with session ID + user ID combination
// ✅ Always re-verify: session userId must match the user who completed 2FADifficulty: Intermediate
Endpoint: POST /api/2fa/level9/verify
The server uses a truthy check (if (code)) instead of strict equality.
Sending any non-empty, non-zero value as the code grants access.
Try any of these as the code value:
"1"← truthy string"true"← truthy string"abcdef"← truthy string" "← space is truthy[]← (in some serialization contexts)
Does NOT bypass: null, undefined, 0, "", false (all falsy)
// ⚠️ VULNERABLE: Truthy check — any non-empty value passes
if (code) {
return res.json({ success: true, flag }); // no actual comparison!
}// ✅ Strict validation: type check + length check + constant-time comparison
if (typeof code !== 'string' || code.length !== 6 || !/^\d{6}$/.test(code)) {
return res.status(400).json({ error: 'Invalid OTP format' });
}
if (!crypto.timingSafeEqual(Buffer.from(code), Buffer.from(expectedCode))) {
return res.status(401).json({ error: 'Invalid OTP' });
}Difficulty: Intermediate
Endpoint: POST /api/2fa/level10/verify
The server checks the X-Forwarded-For header and bypasses 2FA if it sees
127.0.0.1. Since this is a client-controlled header, any attacker can spoof it.
- Intercept any request to
/api/2fa/level10/verifyin Burp Suite - Add header:
X-Forwarded-For: 127.0.0.1 - Forward — access granted!
// ⚠️ VULNERABLE: Client can set X-Forwarded-For to anything
if (req.headers['x-forwarded-for'] === '127.0.0.1') {
bypass = true;
}// ✅ Never trust X-Forwarded-For for security decisions
// ✅ If behind a trusted reverse proxy, configure it explicitly:
app.set('trust proxy', 1); // Only trust the first proxy in chain
// ✅ Never implement IP-based 2FA bypasses — use proper authentication
// ✅ If IP allowlisting needed, verify via server-to-server, not headersDifficulty: Advanced
Endpoint: POST /api/2fa/disable
The endpoint to disable 2FA accepts any POST request without a CSRF token. A malicious external site can submit a form to this endpoint, and the victim's browser will automatically include their session cookie.
- Login as the victim (
user@lab.com) - In a separate tab, visit
/attacker/csrf - Click "Launch CSRF Attack"
- The attacker page submits a form to
/api/2fa/disable— victim's 2FA is now off!
// ⚠️ VULNERABLE: No CSRF token required
router.post('/disable', async (req, res) => {
await db.execute('UPDATE users SET is_2fa_enabled = 0 WHERE id = ?', [userId]);
});// ✅ Use CSRF tokens (e.g., csurf middleware)
const csrf = require('csurf');
router.post('/disable', csrf(), requireTwoFA, handler);
// ✅ Set SameSite=Strict on session cookies
cookie: { sameSite: 'strict' }
// ✅ Check Origin/Referer headers
// ✅ Require re-authentication (password) to disable 2FA
// ✅ Send notification email when 2FA is disabledDifficulty: Advanced
Attack Page: /attacker/clickjack
The application doesn't set X-Frame-Options or Content-Security-Policy: frame-ancestors.
Any external site can embed pages in a transparent iframe, tricking users into clicking
buttons they can't see.
- Login as
user@lab.com - Visit
/attacker/clickjack - The page embeds the lab in a transparent iframe
- The "Win a Prize!" button overlays the hidden "Disable 2FA" button
- Victim clicks — 2FA is disabled
// ✅ Set X-Frame-Options on every response
app.use((req, res, next) => {
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
next();
});Difficulty: Advanced
Endpoint: POST /api/2fa/level13/password-change
The password change handler contains a hidden side effect: it sets
is_2fa_enabled = 0. No warning, no confirmation — 2FA is silently disabled.
An attacker who gains temporary write access (account takeover via password reset
flow, etc.) can use this to disable 2FA permanently.
- Login as
user@lab.com - POST to
/api/2fa/level13/password-changewith any new password - 2FA is now disabled — subsequent logins skip the OTP step
// ✅ Never disable 2FA as a side effect of other operations
await db.execute('UPDATE users SET password = ? WHERE id = ?', [hash, userId]);
// Don't touch is_2fa_enabled or otp_secret
// ✅ Password change should REQUIRE 2FA confirmation:
// 1. User must enter current password
// 2. User must also enter current 2FA code
// ✅ After password change, send security notification email
// ✅ Optionally: invalidate all other sessionsDifficulty: Advanced
Endpoint: GET /api/2fa/level14/dashboard
When a user enables 2FA, their existing active sessions are not invalidated. An attacker who stole a session cookie before 2FA was enabled can still use it.
- Login as
user@lab.comand copy your session cookie - Use
/api/2fa/level14/enable-2fato enable 2FA on the account - Now use the OLD session cookie directly to access
/api/2fa/level14/dashboard - Access granted — 2FA was "enabled" but your old session bypasses it
// ✅ On security-sensitive changes, invalidate ALL sessions except current:
router.post('/enable-2fa', async (req, res) => {
await db.execute('UPDATE users SET is_2fa_enabled = 1 WHERE id = ?', [userId]);
// Regenerate current session
req.session.regenerate((err) => {
req.session.userId = userId;
req.session.twoFaPassed = true; // current session is authenticated
// All other session IDs are now invalid
});
// ✅ Better: store session ID in DB, invalidate all others for this user
});Difficulty: Advanced
Endpoint: POST /api/2fa/level15/backup
- No rate limit on backup code attempts
- Backup codes are not invalidated after use — can be reused
- The codes are predictable/sequential (
BKUP-001,BKUP-002...)
- Know the target email (
user@lab.com) - The backup codes are
BKUP-001,BKUP-002,BKUP-003 - Try each — no rate limit, so rapid enumeration works
BKUP-001succeeds — and can be used again and again
// ✅ Rate limit backup code attempts (max 3 per hour)
// ✅ Invalidate code after use:
const idx = codes.indexOf(backupCode);
codes.splice(idx, 1);
await db.execute('UPDATE users SET backup_codes = ? WHERE id = ?',
[JSON.stringify(codes), userId]);
// ✅ Use cryptographically random codes (never sequential):
const crypto = require('crypto');
const codes = Array.from({length: 10}, () => crypto.randomBytes(4).toString('hex').toUpperCase());
// Generates: "A3F2B1C9", "D7E4F0A1", etc.
// ✅ Store hashed backup codes (bcrypt), not plaintext
// ✅ Limit to 10 backup codes total; require new setup after all usedDifficulty: Expert
Endpoint: GET /api/2fa/level16/verify?code=anything
The server has a POST /verify handler with real OTP validation, and a
GET /verify handler that skips all validation. The developer likely added
the GET route for testing and forgot to remove it.
- Normal flow uses
POST /api/2fa/level16/verify - Instead, send:
GET /api/2fa/level16/verify?code=1(any value) - The GET handler logs it and immediately grants access — no real check!
// ✅ Only expose POST endpoints for state-changing operations
// ✅ Never leave debug/test routes in production
// ✅ Explicit method restriction:
router.post('/verify', validateOTP);
// No GET handler for verify
// ✅ Use middleware to reject unexpected methods:
app.use('/api/2fa/verify', (req, res, next) => {
if (req.method !== 'POST') return res.status(405).json({error: 'Method Not Allowed'});
next();
});
// ✅ Security review: grep codebase for .get() routes that perform mutationsThe admin panel at /admin requires:
- Logged in as
admin@lab.com - 2FA completed (use Level 16 GET bypass or Level 10 XFF bypass to get
twoFaPassed=true) is_admin: truein session
Once inside, /api/admin/panel returns all users and captured flags.
| Levels | Theme | Skills Required |
|---|---|---|
| 1–5 | Logic & information | Burp Proxy, DevTools, basic HTTP |
| 6–10 | HTTP manipulation | Burp Intruder, header injection |
| 11–13 | Classic web attacks | CSRF, Clickjacking, side-effects |
| 14–16 | Design & architecture | Session analysis, method bypass |
- Burp Suite — proxy, intruder, repeater
- Browser DevTools — sources, network, console
- curl / httpie — quick request crafting
- Postman — API testing
- Python requests — custom brute-force scripts
| Level | TL;DR Attack |
|---|---|
| 1 | Burp → intercept response → change false to true |
| 2 | Skip 2FA, go directly to dashboard URL |
| 3 | Read the code field in the send response |
| 4 | View /otp-level4.js source → use hardcoded OTP |
| 5 | Submit the same OTP code twice |
| 6 | Burp Intruder → payload 000000–999999 |
| 7 | Get OTP for your account → use it for victim's |
| 8 | Pass 2FA on one account → reuse session on another |
| 9 | Send any truthy value as code (e.g. "x") |
| 10 | Add header: X-Forwarded-For: 127.0.0.1 |
| 11 | Visit /attacker/csrf while logged in |
| 12 | Visit /attacker/clickjack while logged in |
| 13 | POST to password-change → 2FA auto-disabled |
| 14 | Save cookie → enable 2FA → use old cookie |
| 15 | Try BKUP-001, BKUP-002 with no rate limit |
| 16 | GET /api/2fa/level16/verify?code=1 |
This application is provided strictly for:
- Authorized security training
- CTF practice
- Personal learning in isolated lab environments
Do not use these techniques against systems you do not own or have explicit written permission to test. Unauthorized access is illegal.