Skip to content

About

No description, website, or topics provided.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Latest commit

 

History

2 Commits

Folders and files

Repository files navigation

A web application with 16 levels of 2FA authentication vulnerabilities, designed for hands-on security training using Burp Suite and manual exploitation.


🚀 Quick Start

Prerequisites

  • Docker & Docker Compose
  • Burp Suite (Community or Pro)
  • A browser configured to proxy through Burp (127.0.0.1:8080)

Launch the Lab

git clone <repo>
cd 2fa-bypass-mastery-lab
docker-compose up --build

Open: http://localhost:3000

Default Credentials

Account Email Password
User user@lab.com password
Victim victim@lab.com password
Attacker attacker@lab.com password
Admin admin@lab.com password

📐 Architecture

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

Key API Endpoints

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 ⚠️ L16 GET bypass
POST /api/2fa/disable Disable 2FA (no CSRF)
GET /api/user/dashboard User dashboard
GET /api/admin/panel Admin panel

🎯 Level Guide


Level 1 — Response Manipulation

Difficulty: Beginner
Endpoint: POST /api/2fa/level1/verify

What's Broken

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.

Exploitation Steps

  1. Login as user@lab.com and trigger OTP send
  2. Configure Burp Suite as your proxy
  3. Submit any OTP code and intercept the response in Burp Proxy
  4. In the Intercept tab → change "success":false → "success":true
  5. Forward the modified response
  6. The frontend redirects you to the dashboard

What Actually Went Wrong

// ⚠️ 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
}

Correct Implementation

// ✅ 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) => { ... });

Best Practices

  • 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

Level 2 — Direct Endpoint Bypass

Difficulty: Beginner
Endpoint: GET /api/2fa/level2/dashboard

What's Broken

After a successful login, the session is marked preAuth: true. The /dashboard endpoint only checks if a session exists — not whether 2FA was completed.

Exploitation Steps

  1. Login as user@lab.com (receive requires2FA: true)
  2. Skip the 2FA step entirely
  3. Directly GET /api/2fa/level2/dashboard — access granted!
  4. Or navigate to the dashboard URL without ever visiting the 2FA page

What Actually Went Wrong

// ⚠️ 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 });
});

Correct Implementation

// ✅ 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);

Level 3 — OTP Leaked in HTTP Response

Difficulty: Beginner
Endpoint: POST /api/2fa/level3/send

What's Broken

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.

Exploitation Steps

  1. Intercept the request to /api/2fa/level3/send with Burp Suite
  2. Read the response body: {"success": true, "email": "...", "code": "492183"}
  3. Use that code in the verify step

What Actually Went Wrong

// ⚠️ VULNERABLE: OTP included in response!
res.json({ success: true, email, code, message: 'OTP sent to email' });

Correct Implementation

// ✅ 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 response

Level 4 — Client-Side OTP Logic

Difficulty: Beginner
Files: /otp-level4.js (publicly accessible)

What's Broken

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.

Exploitation Steps

  1. Open the level 4 page
  2. Open DevTools → Sources → otp-level4.js
  3. Find const LEVEL4_SECRET_OTP = "736291"
  4. Or run in console: checkOTP("736291") → true
  5. The JS then sends clientValidated: true to the server, which trusts it
  6. Alternatively: directly call the API with clientValidated: true

What Actually Went Wrong

// ⚠️ 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 }); }

Correct Implementation

// ✅ 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
  }
});

Level 5 — OTP Reuse

Difficulty: Beginner
Endpoint: POST /api/2fa/level5/verify

What's Broken

After a successful OTP verification, the code is not marked as used in the database. It can be submitted again indefinitely.

Exploitation Steps

  1. Request an OTP (check server logs for the code)
  2. Verify once — note the code
  3. Verify again with the same code — still works!
  4. In a real scenario: intercept a legitimate user's OTP and replay it later

What Actually Went Wrong

// ⚠️ 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 });
}

Correct Implementation

// ✅ 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;

Level 6 — Brute Force (No Rate Limit)

Difficulty: Intermediate
Endpoint: POST /api/2fa/level6/verify

What's Broken

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.

Exploitation Steps

  1. Start an OTP send request for your target
  2. In Burp Suite: send verify request to Intruder
  3. Set position on the code parameter
  4. Payload type: Numbers — from 000000 to 999999, step 1
  5. Max threads: 20+
  6. 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).

Correct Implementation

// ✅ 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)

Level 7 — OTP Not Bound to User

Difficulty: Intermediate
Endpoint: POST /api/2fa/level7/verify

What's Broken

The server validates that some active OTP with the given code exists — but doesn't verify it belongs to the account being authenticated.

Exploitation Steps

  1. Login as attacker@lab.com
  2. Request your OTP for level 7 — get/intercept your own code
  3. Now try to login as victim@lab.com
  4. When prompted for OTP, submit your attacker OTP code
  5. Access granted to victim's account!

What Actually Went Wrong

// ⚠️ 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!
);

Correct Implementation

// ✅ 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]
);

Level 8 — Session Permission Sharing

Difficulty: Intermediate
Endpoint: POST /api/2fa/level8/verify

What's Broken

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.

Exploitation Steps

  1. Fully login and pass 2FA as attacker@lab.com
  2. Now the session has twoFaPassed: true
  3. Modify the userId in subsequent requests (or swap session cookies)
  4. The server sees twoFaPassed === true and grants access

Correct Implementation

// ✅ 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 2FA

Level 9 — Null / Empty Value Bypass

Difficulty: Intermediate
Endpoint: POST /api/2fa/level9/verify

What's Broken

The server uses a truthy check (if (code)) instead of strict equality. Sending any non-empty, non-zero value as the code grants access.

Exploitation Steps

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)

What Actually Went Wrong

// ⚠️ VULNERABLE: Truthy check — any non-empty value passes
if (code) {
  return res.json({ success: true, flag }); // no actual comparison!
}

Correct Implementation

// ✅ 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' });
}

Level 10 — X-Forwarded-For IP Bypass

Difficulty: Intermediate
Endpoint: POST /api/2fa/level10/verify

What's Broken

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.

Exploitation Steps

  1. Intercept any request to /api/2fa/level10/verify in Burp Suite
  2. Add header: X-Forwarded-For: 127.0.0.1
  3. Forward — access granted!

What Actually Went Wrong

// ⚠️ VULNERABLE: Client can set X-Forwarded-For to anything
if (req.headers['x-forwarded-for'] === '127.0.0.1') {
  bypass = true;
}

Correct Implementation

// ✅ 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 headers

Level 11 — CSRF: Disable 2FA

Difficulty: Advanced
Endpoint: POST /api/2fa/disable

What's Broken

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.

Exploitation Steps

  1. Login as the victim (user@lab.com)
  2. In a separate tab, visit /attacker/csrf
  3. Click "Launch CSRF Attack"
  4. The attacker page submits a form to /api/2fa/disable — victim's 2FA is now off!

What Actually Went Wrong

// ⚠️ VULNERABLE: No CSRF token required
router.post('/disable', async (req, res) => {
  await db.execute('UPDATE users SET is_2fa_enabled = 0 WHERE id = ?', [userId]);
});

Correct Implementation

// ✅ 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 disabled

Level 12 — Clickjacking: Disable 2FA

Difficulty: Advanced
Attack Page: /attacker/clickjack

What's Broken

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.

Exploitation Steps

  1. Login as user@lab.com
  2. Visit /attacker/clickjack
  3. The page embeds the lab in a transparent iframe
  4. The "Win a Prize!" button overlays the hidden "Disable 2FA" button
  5. Victim clicks — 2FA is disabled

Correct Implementation

// ✅ 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();
});

Level 13 — Password Change Silently Disables 2FA

Difficulty: Advanced
Endpoint: POST /api/2fa/level13/password-change

What's Broken

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.

Exploitation Steps

  1. Login as user@lab.com
  2. POST to /api/2fa/level13/password-change with any new password
  3. 2FA is now disabled — subsequent logins skip the OTP step

Correct Implementation

// ✅ 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 sessions

Level 14 — Sessions Not Invalidated When 2FA Enabled

Difficulty: Advanced
Endpoint: GET /api/2fa/level14/dashboard

What's Broken

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.

Exploitation Steps

  1. Login as user@lab.com and copy your session cookie
  2. Use /api/2fa/level14/enable-2fa to enable 2FA on the account
  3. Now use the OLD session cookie directly to access /api/2fa/level14/dashboard
  4. Access granted — 2FA was "enabled" but your old session bypasses it

Correct Implementation

// ✅ 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
});

Level 15 — Backup Codes: No Rate Limit + Never Invalidated

Difficulty: Advanced
Endpoint: POST /api/2fa/level15/backup

What's Broken

  1. No rate limit on backup code attempts
  2. Backup codes are not invalidated after use — can be reused
  3. The codes are predictable/sequential (BKUP-001, BKUP-002...)

Exploitation Steps

  1. Know the target email (user@lab.com)
  2. The backup codes are BKUP-001, BKUP-002, BKUP-003
  3. Try each — no rate limit, so rapid enumeration works
  4. BKUP-001 succeeds — and can be used again and again

Correct Implementation

// ✅ 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 used

Level 16 — HTTP Method Bypass (GET vs POST)

Difficulty: Expert
Endpoint: GET /api/2fa/level16/verify?code=anything

What's Broken

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.

Exploitation Steps

  1. Normal flow uses POST /api/2fa/level16/verify
  2. Instead, send: GET /api/2fa/level16/verify?code=1 (any value)
  3. The GET handler logs it and immediately grants access — no real check!

Correct Implementation

// ✅ 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 mutations

🔐 Admin Panel

The admin panel at /admin requires:

  1. Logged in as admin@lab.com
  2. 2FA completed (use Level 16 GET bypass or Level 10 XFF bypass to get twoFaPassed=true)
  3. is_admin: true in session

Once inside, /api/admin/panel returns all users and captured flags.


🛠️ Difficulty Progression

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

🧰 Recommended Tools

  • Burp Suite — proxy, intruder, repeater
  • Browser DevTools — sources, network, console
  • curl / httpie — quick request crafting
  • Postman — API testing
  • Python requests — custom brute-force scripts

📋 Quick Cheat Sheet

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

⚖️ Legal & Ethical Notice

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.

About

No description, website, or topics provided.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages