larxius commited on
Commit
ec8e322
Β·
verified Β·
1 Parent(s): 20bc690

Update backend/scanners/mfa_bypass_scanner.py

Browse files
Files changed (1) hide show
  1. backend/scanners/mfa_bypass_scanner.py +356 -357
backend/scanners/mfa_bypass_scanner.py CHANGED
@@ -1,357 +1,356 @@
1
-
2
- """
3
- mfa_bypass_scanner.py β€” Multi-Factor Authentication Bypass Scanner
4
- ===================================================================
5
- Expert-grade rewrite (GAP-019 fix):
6
- 1. OTP brute-force feasibility (rate limiting check β€” tries 10 OTPs)
7
- 2. OTP validity window detection (how long does an OTP remain valid)
8
- 3. OTP reuse after consumption (submit same code twice)
9
- 4. Backup code entropy check (common patterns)
10
- 5. MFA skip via parameter manipulation (mfa_required=false)
11
- 6. Recovery flow bypass (does resetting password bypass MFA)
12
- 7. Response-based MFA state detection
13
- """
14
- import json, time, urllib.parse, re
15
- from scanners.base_scanner import BaseScanner
16
-
17
- # Common MFA/OTP endpoints to probe
18
- OTP_ENDPOINTS = [
19
- "/api/mfa/verify",
20
- "/api/2fa/verify",
21
- "/api/auth/otp",
22
- "/api/otp/verify",
23
- "/api/verify",
24
- "/auth/2fa",
25
- "/auth/mfa",
26
- "/login/otp",
27
- "/login/2fa",
28
- "/account/2fa/verify",
29
- "/users/mfa/confirm",
30
- "/security/2fa",
31
- ]
32
-
33
- # Parameters often used to bypass MFA
34
- BYPASS_PARAMS = [
35
- {"mfa_required": False, "skip_mfa": True},
36
- {"two_factor_skip": "1", "bypass_mfa": "true"},
37
- {"mfa": "bypass", "otp": "000000"},
38
- {"verify": "skip"},
39
- ]
40
-
41
- # Common weak/test OTP codes to check rate limiting
42
- TEST_OTPS = ["000000", "111111", "123456", "654321", "999999",
43
- "000001", "000002", "000003", "000004", "000005"]
44
-
45
- # MFA-related response patterns
46
- MFA_PRESENT_PATTERNS = [
47
- "two.factor", "2fa", "mfa", "otp", "verification code",
48
- "authenticator", "6-digit", "one-time", "passcode",
49
- ]
50
-
51
- MFA_SUCCESS_PATTERNS = [
52
- '"success":true', '"verified":true', '"authenticated":true',
53
- '"token":', '"access_token":', "welcome", "dashboard",
54
- ]
55
-
56
-
57
- class MfaBypassScanner(BaseScanner):
58
- SCANNER_NAME = "MFA Bypass Scanner"
59
- _SCANNER_KEY = "mfa_bypass"
60
-
61
- def __init__(self, scan_id, target, domain, **kwargs):
62
- super().__init__(scan_id, target, domain, **kwargs)
63
-
64
- def run(self) -> list:
65
- self.log("INFO", f"[MFABypass] Scanning {self.target} for MFA bypass vectors...")
66
- parsed = urllib.parse.urlparse(self.target)
67
- base = f"{parsed.scheme}://{parsed.netloc}"
68
-
69
- mfa_endpoints = self._discover_mfa_endpoints(base)
70
- if not mfa_endpoints:
71
- self.log("INFO", "[MFABypass] No MFA/OTP endpoints detected.")
72
- return self.vulns
73
-
74
- self.log("INFO", f"[MFABypass] Found {len(mfa_endpoints)} MFA endpoint(s): {mfa_endpoints}")
75
-
76
- for endpoint in mfa_endpoints[:3]:
77
- url = base + endpoint if not endpoint.startswith("http") else endpoint
78
- self._test_rate_limiting(url)
79
- self._test_otp_reuse(url)
80
- self._test_mfa_skip_params(url)
81
-
82
- # Check recovery/reset flow bypass
83
- self._test_recovery_bypass(base)
84
-
85
- # Additional MFA tests
86
- self._test_mfa_method_enumeration(base, mfa_endpoints)
87
- self._test_backup_code_brute_force(base)
88
-
89
- if not self.vulns:
90
- self.log("SUCCESS", "[MFABypass] No MFA bypass vulnerabilities detected.")
91
- return self.vulns
92
-
93
- # ── Endpoint discovery ────────────────────────────────────────────────
94
- def _discover_mfa_endpoints(self, base: str) -> list[str]:
95
- """Check which OTP endpoints exist (200 or 422/400 = accepts input)."""
96
- found = []
97
- for ep in OTP_ENDPOINTS:
98
- _, status = self._make_request(base + ep, "POST",
99
- json.dumps({"code": "000000"}).encode(),
100
- {"Content-Type": "application/json"})
101
- # 200, 400, 422 = endpoint exists; 404 = does not
102
- if status in (200, 201, 400, 401, 403, 422, 429):
103
- found.append(ep)
104
- return found
105
-
106
- # ── 1. Rate limiting / brute-force ────────────────────────────────────
107
- def _test_rate_limiting(self, url: str):
108
- """
109
- Submit 10 sequential wrong OTP codes.
110
- If none return 429 (Too Many Requests) or account lockout signals,
111
- MFA brute-force is feasible (10^6 OTPs in ~millions of requests).
112
- """
113
- self.log("INFO", f"[MFABypass] Testing OTP rate limiting on {url}...")
114
- got_blocked = False
115
-
116
- for i, otp in enumerate(TEST_OTPS):
117
- resp, status = self._make_request(
118
- url, "POST",
119
- json.dumps({"code": otp, "otp": otp, "token": otp}).encode(),
120
- {"Content-Type": "application/json"}
121
- )
122
- if status == 429:
123
- got_blocked = True
124
- self.log("SUCCESS", f"[MFABypass] Rate limiting active (429 on attempt {i+1}).")
125
- break
126
- if resp and any(p in resp.lower() for p in ["locked", "too many", "blocked", "suspended"]):
127
- got_blocked = True
128
- self.log("SUCCESS", f"[MFABypass] Account lockout triggered on attempt {i+1}.")
129
- break
130
- # Small delay to avoid hammering
131
- time.sleep(0.3)
132
-
133
- if not got_blocked:
134
- self.add_vuln(
135
- title=f"MFA OTP Brute-Force β€” No Rate Limiting at `{url}`",
136
- severity="High",
137
- category="Authentication",
138
- cvss_score=8.1,
139
- confidence="High",
140
- references=["https://cheatsheetseries.owasp.org/cheatsheets/Multifactor_Authentication_Cheat_Sheet.html"],
141
- description=(
142
- f"Submitted **{len(TEST_OTPS)} consecutive invalid OTP codes** to `{url}` "
143
- "without triggering rate limiting (HTTP 429) or account lockout.\n\n"
144
- "A 6-digit TOTP has 10^6 (1,000,000) possible values. Without rate limiting, "
145
- "an attacker can brute-force the OTP in minutes via automation, completely "
146
- "defeating MFA protection."
147
- ),
148
- remediation=(
149
- "1. Implement rate limiting: max 5 OTP attempts per 15 minutes per account.\n"
150
- "2. Lock the account after 10 failed MFA attempts.\n"
151
- "3. Return HTTP 429 with `Retry-After` header on rate limit.\n"
152
- "4. Notify the user of failed MFA attempts via email.\n"
153
- "5. Consider exponential backoff between allowed attempts."
154
- ),
155
- cwe_ids=["CWE-308"],
156
- owasp_category="A07:2021 – Identification and Authentication Failures",
157
- )
158
-
159
- # ── 2. OTP reuse ──────────────────────────────────────────────────────
160
- def _test_otp_reuse(self, url: str):
161
- """
162
- Submit the same OTP code twice β€” if the second attempt also 'succeeds'
163
- (or doesn't return 'already used'), the OTP is reusable.
164
- """
165
- self.log("INFO", f"[MFABypass] Testing OTP reuse on {url}...")
166
- test_otp = "123456"
167
- results = []
168
-
169
- for _ in range(2):
170
- resp, status = self._make_request(
171
- url, "POST",
172
- json.dumps({"code": test_otp, "otp": test_otp}).encode(),
173
- {"Content-Type": "application/json"}
174
- )
175
- results.append((status, resp or ""))
176
-
177
- # If second attempt doesn't explicitly say "code already used" or similar
178
- _, resp2 = results[1]
179
- if resp2 and not any(p in resp2.lower() for p in
180
- ["already used", "expired", "invalid", "used", "consumed"]):
181
- # Could indicate reuse is allowed (or endpoint just gives generic errors)
182
- self.log("INFO",
183
- "[MFABypass] OTP reuse check: second submission did not return 'already used' signal "
184
- "(manual verification recommended).")
185
- self.add_vuln(
186
- title=f"Possible OTP Reuse β€” No 'Already Used' Signal at `{url}`",
187
- severity="Medium",
188
- category="Authentication",
189
- cvss_score=6.5,
190
- confidence="Low",
191
- description=(
192
- f"Submitting the same OTP code (`{test_otp}`) twice to `{url}` "
193
- "did not produce an 'already used' or 'code consumed' response on "
194
- "the second attempt. This may indicate OTP codes can be reused, "
195
- "allowing an attacker who intercepts a valid OTP to replay it."
196
- ),
197
- remediation=(
198
- "1. Invalidate OTP codes immediately after first successful verification.\n"
199
- "2. Store consumed codes in a short-lived cache (TTL = OTP validity window).\n"
200
- "3. Return a specific error: `{\"error\": \"code_already_used\"}` on replay attempts."
201
- ),
202
- cwe_ids=["CWE-308"],
203
- owasp_category="A07:2021 – Identification and Authentication Failures",
204
- )
205
-
206
- # ── 3. MFA skip via parameter manipulation ────────────────────────────
207
- def _test_mfa_skip_params(self, url: str):
208
- """Inject MFA bypass parameters in the request body."""
209
- self.log("INFO", f"[MFABypass] Testing MFA parameter bypass on {url}...")
210
- for bypass_dict in BYPASS_PARAMS:
211
- payload = json.dumps({**bypass_dict, "code": "000000"}).encode()
212
- resp, status = self._make_request(
213
- url, "POST", payload, {"Content-Type": "application/json"}
214
- )
215
- if resp and any(p in resp.lower() for p in MFA_SUCCESS_PATTERNS):
216
- self.add_vuln(
217
- title=f"MFA Bypass via Parameter Manipulation at `{url}`",
218
- severity="Critical",
219
- category="Authentication",
220
- cvss_score=9.1,
221
- confidence="Confirmed",
222
- description=(
223
- f"MFA was bypassed by injecting `{bypass_dict}` into the request body. "
224
- "The server returned success signals despite an invalid OTP code."
225
- ),
226
- remediation=(
227
- "1. Never expose MFA control flags (`mfa_required`, `skip_mfa`) to the client.\n"
228
- "2. Enforce MFA server-side based on session state, not request parameters.\n"
229
- "3. Treat any extra/unknown parameters in MFA requests as suspicious."
230
- ),
231
- payload=json.dumps(bypass_dict),
232
- cwe_ids=["CWE-308"],
233
- owasp_category="A07:2021 – Identification and Authentication Failures",
234
- )
235
- return
236
-
237
- # ── 4. Recovery flow bypass ───────────────────────────────────────────
238
- def _test_recovery_bypass(self, base: str):
239
- """Check if password reset endpoint bypasses MFA."""
240
- reset_endpoints = [
241
- "/api/auth/reset-password",
242
- "/api/password/reset",
243
- "/password-reset",
244
- "/forgot-password",
245
- ]
246
- for ep in reset_endpoints:
247
- resp, status = self._make_request(
248
- base + ep, "POST",
249
- json.dumps({"email": "test@test.local"}).encode(),
250
- {"Content-Type": "application/json"}
251
- )
252
- if status in (200, 202):
253
- self.log("INFO",
254
- f"[MFABypass] Password reset endpoint exists: {ep} β€” "
255
- "manual verification needed: does reset bypass MFA?")
256
- self.add_vuln(
257
- title=f"Password Reset Endpoint Exists β€” MFA Bypass Risk ({ep})",
258
- severity="Low",
259
- category="Authentication",
260
- cvss_score=0.0,
261
- confidence="Low",
262
- description=(
263
- f"A password reset endpoint was found at `{base + ep}` (HTTP {status}). "
264
- "If the reset flow doesn't re-verify MFA after password change, "
265
- "an attacker with email access can reset the password and log in "
266
- "without completing MFA verification."
267
- ),
268
- remediation=(
269
- "1. Require MFA re-verification after password reset before granting full session access.\n"
270
- "2. Invalidate all existing sessions after password reset.\n"
271
- "3. Implement re-authentication for sensitive operations (NIST 800-63B Β§5.2.5)."
272
- ),
273
- cwe_ids=["CWE-308"],
274
- owasp_category="A07:2021 – Identification and Authentication Failures",
275
- )
276
- break
277
-
278
- # ── 5. MFA method enumeration ─────────────────────────────────────────
279
- def _test_mfa_method_enumeration(self, base: str, mfa_endpoints: list[str]):
280
- """Enumerate available MFA methods by probing different endpoints."""
281
- method_paths = [
282
- "/api/mfa/methods", "/api/2fa/methods", "/api/auth/mfa-methods",
283
- "/api/user/mfa", "/api/account/2fa", "/api/security/mfa",
284
- ]
285
- for path in method_paths:
286
- url = base + path
287
- resp, status = self._make_request(url)
288
- if resp and status == 200:
289
- try:
290
- methods = json.loads(resp)
291
- if isinstance(methods, dict) and any(k in methods for k in
292
- ["methods", "totp", "sms", "email", "backup_codes", "u2f", "webauthn"]):
293
- self.add_vuln(
294
- title="MFA Method Enumeration Possible",
295
- severity="Medium",
296
- category="Authentication",
297
- cvss_score=5.3,
298
- description=f"MFA configuration endpoint exposed at `{url}`. "
299
- f"Enumerates available authentication methods and allows "
300
- f"attackers to identify the weakest MFA method to target.",
301
- evidence=f"MFA methods available: {resp[:200]}",
302
- payload=url,
303
- request_details=f"GET {url}",
304
- response_details=f"HTTP {status}",
305
- confidence="Confirmed",
306
- remediation="1. Restrict access to MFA configuration endpoints.\n"
307
- "2. Require re-authentication before viewing MFA settings.\n"
308
- "3. Do not enumerate available methods for unauthenticated users.",
309
- cwe_ids=["CWE-308"],
310
- owasp_category="A07:2021 – Identification and Authentication Failures",
311
- )
312
- return
313
- except json.JSONDecodeError:
314
- continue
315
-
316
- # ── 6. Backup code brute force ────────────────────────────────────────
317
- def _test_backup_code_brute_force(self, base: str):
318
- """Test if backup codes can be brute-forced (typically 8-10 digit codes)."""
319
- backup_endpoints = [
320
- "/api/mfa/backup-codes", "/api/2fa/backup", "/api/auth/backup-code",
321
- "/api/mfa/verify-backup", "/api/auth/verify-backup-code",
322
- ]
323
- for ep in backup_endpoints:
324
- url = base + ep
325
- # Try a few guesses to see if rate limiting exists
326
- for code in ["00000000", "11111111", "12345678", "0000000000"]:
327
- resp, status = self._make_request(
328
- url, "POST",
329
- json.dumps({"code": code, "backup_code": code}).encode(),
330
- {"Content-Type": "application/json"}
331
- )
332
- if status == 429:
333
- self.log("SUCCESS", f"[MFABypass] Backup code rate limiting active at {url}")
334
- return
335
- if resp and status == 200:
336
- self.add_vuln(
337
- title="Backup Code Accepted β€” Potential Brute-Force Vector",
338
- severity="Critical",
339
- category="Authentication",
340
- cvss_score=9.0,
341
- description=f"Backup code endpoint at `{url}` accepted a common code "
342
- f"`{code}`. If backup codes are short or predictable, attackers "
343
- f"can brute-force them to bypass MFA entirely.",
344
- evidence=f"Backup code `{code}` accepted (status {status})",
345
- payload=f"code={code}",
346
- request_details=f"POST {url} with code={code}",
347
- response_details=f"HTTP {status}",
348
- confidence="Confirmed",
349
- remediation="1. Use cryptographically random backup codes (minimum 128 bits).\n"
350
- "2. Invalidate backup codes after first use.\n"
351
- "3. Implement rate limiting on backup code verification.\n"
352
- "4. Alert users when backup codes are used.",
353
- cwe_ids=["CWE-308"],
354
- owasp_category="A07:2021 – Identification and Authentication Failures",
355
- )
356
- return
357
- time.sleep(0.2)
 
1
+
2
+ """
3
+ mfa_bypass_scanner.py β€” Multi-Factor Authentication Bypass Scanner
4
+ ===================================================================
5
+ Expert-grade rewrite (GAP-019 fix):
6
+ 1. OTP brute-force feasibility (rate limiting check β€” tries 10 OTPs)
7
+ 2. OTP validity window detection (how long does an OTP remain valid)
8
+ 3. OTP reuse after consumption (submit same code twice)
9
+ 4. Backup code entropy check (common patterns)
10
+ 5. MFA skip via parameter manipulation (mfa_required=false)
11
+ 6. Recovery flow bypass (does resetting password bypass MFA)
12
+ 7. Response-based MFA state detection
13
+ """
14
+ import json, time, urllib.parse, re
15
+ from scanners.base_scanner import BaseScanner
16
+
17
+ # Common MFA/OTP endpoints to probe
18
+ OTP_ENDPOINTS = [
19
+ "/api/mfa/verify",
20
+ "/api/2fa/verify",
21
+ "/api/auth/otp",
22
+ "/api/otp/verify",
23
+ "/api/verify",
24
+ "/auth/2fa",
25
+ "/auth/mfa",
26
+ "/login/otp",
27
+ "/login/2fa",
28
+ "/account/2fa/verify",
29
+ "/users/mfa/confirm",
30
+ "/security/2fa",
31
+ ]
32
+
33
+ # Parameters often used to bypass MFA
34
+ BYPASS_PARAMS = [
35
+ {"mfa_required": False, "skip_mfa": True},
36
+ {"two_factor_skip": "1", "bypass_mfa": "true"},
37
+ {"mfa": "bypass", "otp": "000000"},
38
+ {"verify": "skip"},
39
+ ]
40
+
41
+ # Common weak/test OTP codes to check rate limiting
42
+ TEST_OTPS = ["000000", "111111", "123456", "654321", "999999",
43
+ "000001", "000002", "000003", "000004", "000005"]
44
+
45
+ # MFA-related response patterns
46
+ MFA_PRESENT_PATTERNS = [
47
+ "two.factor", "2fa", "mfa", "otp", "verification code",
48
+ "authenticator", "6-digit", "one-time", "passcode",
49
+ ]
50
+
51
+ MFA_SUCCESS_PATTERNS = [
52
+ '"success":true', '"verified":true', '"authenticated":true',
53
+ '"token":', '"access_token":', "welcome", "dashboard",
54
+ ]
55
+
56
+
57
+ class MfaBypassScanner(BaseScanner):
58
+ SCANNER_NAME = "MFA Bypass Scanner"
59
+ _SCANNER_KEY = "mfa_bypass"
60
+
61
+ def __init__(self, scan_id, target, domain, **kwargs):
62
+ super().__init__(scan_id, target, domain, **kwargs)
63
+
64
+ def run(self) -> list:
65
+ self.log("INFO", f"[MFABypass] Scanning {self.target} for MFA bypass vectors...")
66
+ parsed = urllib.parse.urlparse(self.target)
67
+ base = f"{parsed.scheme}://{parsed.netloc}"
68
+
69
+ mfa_endpoints = self._discover_mfa_endpoints(base)
70
+ if not mfa_endpoints:
71
+ self.log("INFO", "[MFABypass] No MFA/OTP endpoints detected.")
72
+ return self.vulns
73
+
74
+ self.log("INFO", f"[MFABypass] Found {len(mfa_endpoints)} MFA endpoint(s): {mfa_endpoints}")
75
+
76
+ for endpoint in mfa_endpoints[:3]:
77
+ url = base + endpoint if not endpoint.startswith("http") else endpoint
78
+ self._test_rate_limiting(url)
79
+ self._test_otp_reuse(url)
80
+ self._test_mfa_skip_params(url)
81
+
82
+ # Check recovery/reset flow bypass
83
+ self._test_recovery_bypass(base)
84
+
85
+ # Additional MFA tests
86
+ self._test_mfa_method_enumeration(base, mfa_endpoints)
87
+ self._test_backup_code_brute_force(base)
88
+
89
+ if not self.vulns:
90
+ self.log("SUCCESS", "[MFABypass] No MFA bypass vulnerabilities detected.")
91
+ return self.vulns
92
+
93
+ # ── Endpoint discovery ────────────────────────────────────────────────
94
+ def _discover_mfa_endpoints(self, base: str) -> list[str]:
95
+ """Check which OTP endpoints exist (200 or 422/400 = accepts input)."""
96
+ found = []
97
+ for ep in OTP_ENDPOINTS:
98
+ _, status = self._make_request(base + ep, "POST",
99
+ json.dumps({"code": "000000"}).encode(),
100
+ {"Content-Type": "application/json"})
101
+ # 200, 400, 422 = endpoint exists; 404 = does not
102
+ if status in (200, 201, 400, 401, 403, 422, 429):
103
+ found.append(ep)
104
+ return found
105
+
106
+ # ── 1. Rate limiting / brute-force ────────────────────────────────────
107
+ def _test_rate_limiting(self, url: str):
108
+ """
109
+ Submit 10 sequential wrong OTP codes.
110
+ If none return 429 (Too Many Requests) or account lockout signals,
111
+ MFA brute-force is feasible (10^6 OTPs in ~millions of requests).
112
+ """
113
+ self.log("INFO", f"[MFABypass] Testing OTP rate limiting on {url}...")
114
+ got_blocked = False
115
+
116
+ for i, otp in enumerate(TEST_OTPS):
117
+ resp, status = self._make_request(
118
+ url, "POST",
119
+ json.dumps({"code": otp, "otp": otp, "token": otp}).encode(),
120
+ {"Content-Type": "application/json"}
121
+ )
122
+ if status == 429:
123
+ got_blocked = True
124
+ self.log("SUCCESS", f"[MFABypass] Rate limiting active (429 on attempt {i+1}).")
125
+ break
126
+ if resp and any(p in resp.lower() for p in ["locked", "too many", "blocked", "suspended"]):
127
+ got_blocked = True
128
+ self.log("SUCCESS", f"[MFABypass] Account lockout triggered on attempt {i+1}.")
129
+ break
130
+ # No artificial sleep β€” network round-trips already throttle the rate
131
+
132
+ if not got_blocked:
133
+ self.add_vuln(
134
+ title=f"MFA OTP Brute-Force β€” No Rate Limiting at `{url}`",
135
+ severity="High",
136
+ category="Authentication",
137
+ cvss_score=8.1,
138
+ confidence="High",
139
+ references=["https://cheatsheetseries.owasp.org/cheatsheets/Multifactor_Authentication_Cheat_Sheet.html"],
140
+ description=(
141
+ f"Submitted **{len(TEST_OTPS)} consecutive invalid OTP codes** to `{url}` "
142
+ "without triggering rate limiting (HTTP 429) or account lockout.\n\n"
143
+ "A 6-digit TOTP has 10^6 (1,000,000) possible values. Without rate limiting, "
144
+ "an attacker can brute-force the OTP in minutes via automation, completely "
145
+ "defeating MFA protection."
146
+ ),
147
+ remediation=(
148
+ "1. Implement rate limiting: max 5 OTP attempts per 15 minutes per account.\n"
149
+ "2. Lock the account after 10 failed MFA attempts.\n"
150
+ "3. Return HTTP 429 with `Retry-After` header on rate limit.\n"
151
+ "4. Notify the user of failed MFA attempts via email.\n"
152
+ "5. Consider exponential backoff between allowed attempts."
153
+ ),
154
+ cwe_ids=["CWE-308"],
155
+ owasp_category="A07:2021 – Identification and Authentication Failures",
156
+ )
157
+
158
+ # ── 2. OTP reuse ──────────────────────────────────────────────────────
159
+ def _test_otp_reuse(self, url: str):
160
+ """
161
+ Submit the same OTP code twice β€” if the second attempt also 'succeeds'
162
+ (or doesn't return 'already used'), the OTP is reusable.
163
+ """
164
+ self.log("INFO", f"[MFABypass] Testing OTP reuse on {url}...")
165
+ test_otp = "123456"
166
+ results = []
167
+
168
+ for _ in range(2):
169
+ resp, status = self._make_request(
170
+ url, "POST",
171
+ json.dumps({"code": test_otp, "otp": test_otp}).encode(),
172
+ {"Content-Type": "application/json"}
173
+ )
174
+ results.append((status, resp or ""))
175
+
176
+ # If second attempt doesn't explicitly say "code already used" or similar
177
+ _, resp2 = results[1]
178
+ if resp2 and not any(p in resp2.lower() for p in
179
+ ["already used", "expired", "invalid", "used", "consumed"]):
180
+ # Could indicate reuse is allowed (or endpoint just gives generic errors)
181
+ self.log("INFO",
182
+ "[MFABypass] OTP reuse check: second submission did not return 'already used' signal "
183
+ "(manual verification recommended).")
184
+ self.add_vuln(
185
+ title=f"Possible OTP Reuse β€” No 'Already Used' Signal at `{url}`",
186
+ severity="Medium",
187
+ category="Authentication",
188
+ cvss_score=6.5,
189
+ confidence="Low",
190
+ description=(
191
+ f"Submitting the same OTP code (`{test_otp}`) twice to `{url}` "
192
+ "did not produce an 'already used' or 'code consumed' response on "
193
+ "the second attempt. This may indicate OTP codes can be reused, "
194
+ "allowing an attacker who intercepts a valid OTP to replay it."
195
+ ),
196
+ remediation=(
197
+ "1. Invalidate OTP codes immediately after first successful verification.\n"
198
+ "2. Store consumed codes in a short-lived cache (TTL = OTP validity window).\n"
199
+ "3. Return a specific error: `{\"error\": \"code_already_used\"}` on replay attempts."
200
+ ),
201
+ cwe_ids=["CWE-308"],
202
+ owasp_category="A07:2021 – Identification and Authentication Failures",
203
+ )
204
+
205
+ # ── 3. MFA skip via parameter manipulation ────────────────────────────
206
+ def _test_mfa_skip_params(self, url: str):
207
+ """Inject MFA bypass parameters in the request body."""
208
+ self.log("INFO", f"[MFABypass] Testing MFA parameter bypass on {url}...")
209
+ for bypass_dict in BYPASS_PARAMS:
210
+ payload = json.dumps({**bypass_dict, "code": "000000"}).encode()
211
+ resp, status = self._make_request(
212
+ url, "POST", payload, {"Content-Type": "application/json"}
213
+ )
214
+ if resp and any(p in resp.lower() for p in MFA_SUCCESS_PATTERNS):
215
+ self.add_vuln(
216
+ title=f"MFA Bypass via Parameter Manipulation at `{url}`",
217
+ severity="Critical",
218
+ category="Authentication",
219
+ cvss_score=9.1,
220
+ confidence="Confirmed",
221
+ description=(
222
+ f"MFA was bypassed by injecting `{bypass_dict}` into the request body. "
223
+ "The server returned success signals despite an invalid OTP code."
224
+ ),
225
+ remediation=(
226
+ "1. Never expose MFA control flags (`mfa_required`, `skip_mfa`) to the client.\n"
227
+ "2. Enforce MFA server-side based on session state, not request parameters.\n"
228
+ "3. Treat any extra/unknown parameters in MFA requests as suspicious."
229
+ ),
230
+ payload=json.dumps(bypass_dict),
231
+ cwe_ids=["CWE-308"],
232
+ owasp_category="A07:2021 – Identification and Authentication Failures",
233
+ )
234
+ return
235
+
236
+ # ── 4. Recovery flow bypass ───────────────────────────────────────────
237
+ def _test_recovery_bypass(self, base: str):
238
+ """Check if password reset endpoint bypasses MFA."""
239
+ reset_endpoints = [
240
+ "/api/auth/reset-password",
241
+ "/api/password/reset",
242
+ "/password-reset",
243
+ "/forgot-password",
244
+ ]
245
+ for ep in reset_endpoints:
246
+ resp, status = self._make_request(
247
+ base + ep, "POST",
248
+ json.dumps({"email": "test@test.local"}).encode(),
249
+ {"Content-Type": "application/json"}
250
+ )
251
+ if status in (200, 202):
252
+ self.log("INFO",
253
+ f"[MFABypass] Password reset endpoint exists: {ep} β€” "
254
+ "manual verification needed: does reset bypass MFA?")
255
+ self.add_vuln(
256
+ title=f"Password Reset Endpoint Exists β€” MFA Bypass Risk ({ep})",
257
+ severity="Low",
258
+ category="Authentication",
259
+ cvss_score=0.0,
260
+ confidence="Low",
261
+ description=(
262
+ f"A password reset endpoint was found at `{base + ep}` (HTTP {status}). "
263
+ "If the reset flow doesn't re-verify MFA after password change, "
264
+ "an attacker with email access can reset the password and log in "
265
+ "without completing MFA verification."
266
+ ),
267
+ remediation=(
268
+ "1. Require MFA re-verification after password reset before granting full session access.\n"
269
+ "2. Invalidate all existing sessions after password reset.\n"
270
+ "3. Implement re-authentication for sensitive operations (NIST 800-63B Β§5.2.5)."
271
+ ),
272
+ cwe_ids=["CWE-308"],
273
+ owasp_category="A07:2021 – Identification and Authentication Failures",
274
+ )
275
+ break
276
+
277
+ # ── 5. MFA method enumeration ─────────────────────────────────────────
278
+ def _test_mfa_method_enumeration(self, base: str, mfa_endpoints: list[str]):
279
+ """Enumerate available MFA methods by probing different endpoints."""
280
+ method_paths = [
281
+ "/api/mfa/methods", "/api/2fa/methods", "/api/auth/mfa-methods",
282
+ "/api/user/mfa", "/api/account/2fa", "/api/security/mfa",
283
+ ]
284
+ for path in method_paths:
285
+ url = base + path
286
+ resp, status = self._make_request(url)
287
+ if resp and status == 200:
288
+ try:
289
+ methods = json.loads(resp)
290
+ if isinstance(methods, dict) and any(k in methods for k in
291
+ ["methods", "totp", "sms", "email", "backup_codes", "u2f", "webauthn"]):
292
+ self.add_vuln(
293
+ title="MFA Method Enumeration Possible",
294
+ severity="Medium",
295
+ category="Authentication",
296
+ cvss_score=5.3,
297
+ description=f"MFA configuration endpoint exposed at `{url}`. "
298
+ f"Enumerates available authentication methods and allows "
299
+ f"attackers to identify the weakest MFA method to target.",
300
+ evidence=f"MFA methods available: {resp[:200]}",
301
+ payload=url,
302
+ request_details=f"GET {url}",
303
+ response_details=f"HTTP {status}",
304
+ confidence="Confirmed",
305
+ remediation="1. Restrict access to MFA configuration endpoints.\n"
306
+ "2. Require re-authentication before viewing MFA settings.\n"
307
+ "3. Do not enumerate available methods for unauthenticated users.",
308
+ cwe_ids=["CWE-308"],
309
+ owasp_category="A07:2021 – Identification and Authentication Failures",
310
+ )
311
+ return
312
+ except json.JSONDecodeError:
313
+ continue
314
+
315
+ # ── 6. Backup code brute force ────────────────────────────────────────
316
+ def _test_backup_code_brute_force(self, base: str):
317
+ """Test if backup codes can be brute-forced (typically 8-10 digit codes)."""
318
+ backup_endpoints = [
319
+ "/api/mfa/backup-codes", "/api/2fa/backup", "/api/auth/backup-code",
320
+ "/api/mfa/verify-backup", "/api/auth/verify-backup-code",
321
+ ]
322
+ for ep in backup_endpoints:
323
+ url = base + ep
324
+ # Try a few guesses to see if rate limiting exists
325
+ for code in ["00000000", "11111111", "12345678", "0000000000"]:
326
+ resp, status = self._make_request(
327
+ url, "POST",
328
+ json.dumps({"code": code, "backup_code": code}).encode(),
329
+ {"Content-Type": "application/json"}
330
+ )
331
+ if status == 429:
332
+ self.log("SUCCESS", f"[MFABypass] Backup code rate limiting active at {url}")
333
+ return
334
+ if resp and status == 200:
335
+ self.add_vuln(
336
+ title="Backup Code Accepted β€” Potential Brute-Force Vector",
337
+ severity="Critical",
338
+ category="Authentication",
339
+ cvss_score=9.0,
340
+ description=f"Backup code endpoint at `{url}` accepted a common code "
341
+ f"`{code}`. If backup codes are short or predictable, attackers "
342
+ f"can brute-force them to bypass MFA entirely.",
343
+ evidence=f"Backup code `{code}` accepted (status {status})",
344
+ payload=f"code={code}",
345
+ request_details=f"POST {url} with code={code}",
346
+ response_details=f"HTTP {status}",
347
+ confidence="Confirmed",
348
+ remediation="1. Use cryptographically random backup codes (minimum 128 bits).\n"
349
+ "2. Invalidate backup codes after first use.\n"
350
+ "3. Implement rate limiting on backup code verification.\n"
351
+ "4. Alert users when backup codes are used.",
352
+ cwe_ids=["CWE-308"],
353
+ owasp_category="A07:2021 – Identification and Authentication Failures",
354
+ )
355
+ return
356
+ # No sleep needed β€” network latency provides natural throttling