Add files via upload

This commit is contained in:
Joas A Santos authored and GitHub committed 2026-02-11 10:47:33 -03:00
1 parent e32573a950
commit 30acd5afc7
52 files changed
+22492 -706

No files matched your search

@@ -0,0 +1,532 @@
"""
NeuroSploit v3 - Advanced Injection Vulnerability Testers
Testers for LDAP, XPath, GraphQL, CRLF, Header, Email, EL, Log, HTML, CSV, and ORM injection.
"""
import re
from typing import Tuple, Dict, Optional
from backend.core.vuln_engine.testers.base_tester import BaseTester
class LdapInjectionTester(BaseTester):
"""Tester for LDAP Injection vulnerabilities"""
def __init__(self):
super().__init__()
self.name = "ldap_injection"
self.error_patterns = [
r"javax\.naming\.NamingException",
r"LDAPException",
r"ldap_search\(\)",
r"ldap_bind\(\)",
r"Invalid DN syntax",
r"Bad search filter",
r"DSA is unavailable",
r"LDAP error code \d+",
r"cn=.*,\s*ou=.*,\s*dc=",
r"objectClass=",
r"No such object",
r"invalid attribute description",
]
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for LDAP injection indicators"""
# Check for LDAP error messages
for pattern in self.error_patterns:
match = re.search(pattern, response_body, re.IGNORECASE)
if match:
return True, 0.8, f"LDAP error detected: {match.group(0)[:100]}"
# Wildcard injection - check if directory listing returned
if "*" in payload:
# Multiple DN entries suggest directory enumeration
dn_count = len(re.findall(r"dn:\s+\S+", response_body, re.IGNORECASE))
if dn_count > 1:
return True, 0.85, f"LDAP wildcard returned {dn_count} directory entries"
# Filter manipulation - check for unexpected data volume
if ")(|" in payload or "*)(objectClass" in payload:
if response_status == 200 and len(response_body) > 5000:
return True, 0.6, "LDAP filter manipulation may have returned extra data"
return False, 0.0, None
class XpathInjectionTester(BaseTester):
"""Tester for XPath Injection vulnerabilities"""
def __init__(self):
super().__init__()
self.name = "xpath_injection"
self.error_patterns = [
r"XPathException",
r"Invalid XPath",
r"xpath syntax error",
r"javax\.xml\.xpath",
r"XPathEvalError",
r"xmlXPathEval:",
r"XPATH syntax error",
r"DOMXPath",
r"SimpleXMLElement::xpath\(\)",
r"lxml\.etree\.XPathEvalError",
]
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for XPath injection indicators"""
# XPath error messages
for pattern in self.error_patterns:
match = re.search(pattern, response_body, re.IGNORECASE)
if match:
return True, 0.85, f"XPath error detected: {match.group(0)[:100]}"
# Boolean-based XPath injection - true condition returning data
if ("' or '1'='1" in payload or "or 1=1" in payload):
if response_status == 200:
# Check for XML-like data in response
xml_tags = re.findall(r"<[a-zA-Z][^>]*>", response_body)
if len(xml_tags) > 5:
return True, 0.65, "XPath boolean injection may have returned XML data"
# Check for exposed XML node data
if "' | //" in payload or "extractvalue(" in payload.lower():
if re.search(r"<\?xml\s+version=", response_body):
return True, 0.7, "XML document exposed via XPath injection"
return False, 0.0, None
class GraphqlInjectionTester(BaseTester):
"""Tester for GraphQL Injection / Introspection vulnerabilities"""
def __init__(self):
super().__init__()
self.name = "graphql_injection"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for GraphQL introspection and injection indicators"""
body_lower = response_body.lower()
# Introspection query response - schema exposure
if "__schema" in payload or "__type" in payload or "introspection" in payload.lower():
if '"__schema"' in response_body or '"__type"' in response_body:
return True, 0.9, "GraphQL introspection enabled - schema exposed"
if '"types"' in response_body and '"queryType"' in response_body:
return True, 0.9, "GraphQL schema types exposed via introspection"
# GraphQL error messages revealing structure
graphql_errors = [
r'"errors"\s*:\s*\[',
r"Cannot query field",
r"Unknown argument",
r"Field .* not found in type",
r"Syntax Error.*GraphQL",
r"GraphQL error",
]
for pattern in graphql_errors:
if re.search(pattern, response_body, re.IGNORECASE):
# Error messages can reveal field/type names
if re.search(r'"message"\s*:\s*".*(?:field|type|argument)', response_body, re.IGNORECASE):
return True, 0.7, "GraphQL error reveals schema information"
# Mutation that returned success unexpectedly
if "mutation" in payload.lower() and response_status == 200:
if '"data"' in response_body and '"errors"' not in response_body:
return True, 0.5, "GraphQL mutation succeeded - verify authorization"
return False, 0.0, None
class CrlfInjectionTester(BaseTester):
"""Tester for CRLF Injection vulnerabilities"""
def __init__(self):
super().__init__()
self.name = "crlf_injection"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for CRLF injection - injected headers appearing in response"""
# Check if our injected header appeared in response headers
headers_lower = {k.lower(): v for k, v in response_headers.items()}
if "x-test" in headers_lower:
return True, 0.95, f"CRLF injection confirmed: X-Test header injected with value '{headers_lower['x-test']}'"
if "x-injected" in headers_lower:
return True, 0.95, f"CRLF injection confirmed: X-Injected header present"
# Check for Set-Cookie injection
if "set-cookie" in headers_lower and "neurosploit" in str(headers_lower.get("set-cookie", "")).lower():
return True, 0.9, "CRLF injection: injected Set-Cookie header detected"
# Check if payload characters are reflected unencoded in Location header
if "location" in headers_lower:
location = headers_lower["location"]
if "\r\n" in location or "%0d%0a" in location.lower():
return True, 0.8, "CRLF characters in Location header"
return False, 0.0, None
class HeaderInjectionTester(BaseTester):
"""Tester for Host Header Injection vulnerabilities"""
def __init__(self):
super().__init__()
self.name = "header_injection"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for Host header reflected in response URLs"""
body_lower = response_body.lower()
# Check if injected host value appears in response links
evil_markers = ["evil.com", "attacker.com", "neurosploit.test"]
for marker in evil_markers:
if marker in payload.lower():
if marker in body_lower:
# Check if it appears in URLs, links, or redirects
url_pattern = rf'(?:href|src|action|url|link|redirect)\s*[=:]\s*["\']?[^"\']*{re.escape(marker)}'
if re.search(url_pattern, response_body, re.IGNORECASE):
return True, 0.9, f"Host header injected into response URL: {marker}"
return True, 0.7, f"Injected host value '{marker}' reflected in response"
# Password reset poisoning check
if "password" in body_lower and "reset" in body_lower:
headers_lower = {k.lower(): v for k, v in response_headers.items()}
if response_status in [200, 302]:
return True, 0.5, "Password reset response may use Host header for link generation"
return False, 0.0, None
class EmailInjectionTester(BaseTester):
"""Tester for Email Header Injection vulnerabilities"""
def __init__(self):
super().__init__()
self.name = "email_injection"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for email header injection success indicators"""
body_lower = response_body.lower()
# Check for CC/BCC injection indicators
if any(h in payload.lower() for h in ["cc:", "bcc:", "\r\nto:", "%0acc:", "%0abcc:"]):
# Successful email send with injected headers
if response_status == 200:
success_indicators = [
"email sent", "message sent", "mail sent",
"successfully sent", "email delivered", "sent successfully",
]
for indicator in success_indicators:
if indicator in body_lower:
return True, 0.75, f"Email injection: '{indicator}' after CC/BCC injection attempt"
# Check for multiple recipient confirmation
if re.search(r"(?:sent to|delivered to|recipients?)\s*:?\s*\d+", response_body, re.IGNORECASE):
return True, 0.7, "Email sent to multiple recipients after injection"
# SMTP error leak
smtp_errors = [r"SMTP error", r"550 \d+", r"relay access denied", r"mail\(\).*failed"]
for pattern in smtp_errors:
if re.search(pattern, response_body, re.IGNORECASE):
return True, 0.6, "SMTP error revealed - email injection attempted"
return False, 0.0, None
class ELInjectionTester(BaseTester):
"""Tester for Expression Language (EL) Injection vulnerabilities"""
def __init__(self):
super().__init__()
self.name = "el_injection"
self.math_results = {
"${7*7}": "49",
"#{7*7}": "49",
"${3*11}": "33",
"#{3*11}": "33",
}
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for EL injection - expression evaluation in response"""
# Check mathematical expression results
for expr, result in self.math_results.items():
if expr in payload and result in response_body:
# Make sure the result isn't just the expression echoed
if expr not in response_body:
return True, 0.95, f"EL injection confirmed: {expr} evaluated to {result}"
# Java class names exposed via EL
java_indicators = [
r"java\.lang\.\w+",
r"java\.io\.File",
r"Runtime\.getRuntime",
r"ProcessBuilder",
r"javax\.\w+\.\w+",
r"org\.apache\.\w+",
r"getClass\(\)\.forName",
]
for pattern in java_indicators:
if re.search(pattern, response_body):
return True, 0.8, f"Java class exposure via EL injection: {pattern}"
# Spring EL specific
if "T(java.lang" in payload:
if re.search(r"class\s+\w+|java\.\w+", response_body):
return True, 0.7, "Spring EL injection indicator - Java class reference in response"
return False, 0.0, None
class LogInjectionTester(BaseTester):
"""Tester for Log Injection / Log4Shell / JNDI Injection vulnerabilities"""
def __init__(self):
super().__init__()
self.name = "log_injection"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for log injection and JNDI callback indicators"""
# JNDI/Log4Shell detection via context callback
if "${jndi:" in payload.lower():
# Check context for callback confirmation
if context.get("callback_received"):
return True, 0.95, "JNDI injection confirmed via callback"
# Check for Log4j error in response
log4j_indicators = [
r"log4j", r"Log4jException", r"JNDI lookup",
r"javax\.naming", r"InitialContext",
]
for pattern in log4j_indicators:
if re.search(pattern, response_body, re.IGNORECASE):
return True, 0.7, f"Log4j/JNDI indicator in response: {pattern}"
# Newline injection in log-like responses
if "\n" in payload or "%0a" in payload.lower() or "\\n" in payload:
# Check if response includes log-format lines with our injected content
log_patterns = [
r"\[\d{4}-\d{2}-\d{2}.*\].*neurosploit",
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}.*neurosploit",
r"(?:INFO|WARN|ERROR|DEBUG)\s+.*neurosploit",
]
for pattern in log_patterns:
if re.search(pattern, response_body, re.IGNORECASE):
return True, 0.7, "Log injection: injected content appears in log-format output"
# Generic log forging check
if "neurosploit" in payload and response_status == 200:
if re.search(r"(?:log|audit|event).*neurosploit", response_body, re.IGNORECASE):
return True, 0.5, "Injected marker appears in log/audit output"
return False, 0.0, None
class HtmlInjectionTester(BaseTester):
"""Tester for HTML Injection vulnerabilities"""
def __init__(self):
super().__init__()
self.name = "html_injection"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for rendered HTML tags in response"""
if response_status >= 400:
return False, 0.0, None
# Check for injected HTML tags rendered in response
html_tests = [
(r"<b>neurosploit</b>", "Bold tag rendered"),
(r"<i>neurosploit</i>", "Italic tag rendered"),
(r"<u>neurosploit</u>", "Underline tag rendered"),
(r'<a\s+href=["\']?[^"\']*["\']?>neurosploit</a>', "Anchor tag rendered"),
(r'<img\s+src=["\']?[^"\']*["\']?', "Image tag rendered"),
(r'<form\s+[^>]*action=', "Form tag rendered"),
(r'<iframe\s+', "IFrame tag rendered"),
(r'<marquee>', "Marquee tag rendered"),
(r'<h1>neurosploit</h1>', "H1 tag rendered"),
(r'<div\s+style=', "Styled div rendered"),
]
for pattern, description in html_tests:
if re.search(pattern, response_body, re.IGNORECASE):
# Verify it wasn't already there (check if payload was actually injected)
if any(tag in payload.lower() for tag in ["<b>", "<i>", "<u>", "<a ", "<img", "<form", "<iframe", "<marquee", "<h1>", "<div"]):
return True, 0.8, f"HTML injection: {description}"
# Check for payload reflection without encoding
if "<" in payload and ">" in payload:
# Find the injected tag in response
tag_match = re.search(r"<(\w+)[^>]*>", payload)
if tag_match:
tag_name = tag_match.group(1)
if f"<{tag_name}" in response_body and f"&lt;{tag_name}" not in response_body:
return True, 0.75, f"HTML tag <{tag_name}> reflected without encoding"
return False, 0.0, None
class CsvInjectionTester(BaseTester):
"""Tester for CSV Injection (Formula Injection) vulnerabilities"""
def __init__(self):
super().__init__()
self.name = "csv_injection"
self.formula_chars = ["=", "+", "-", "@", "\t", "\r"]
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for formula characters preserved in CSV export responses"""
headers_lower = {k.lower(): v.lower() for k, v in response_headers.items()}
content_type = headers_lower.get("content-type", "")
# Check if response is CSV or spreadsheet
is_csv = "text/csv" in content_type or "spreadsheet" in content_type
is_export = "content-disposition" in headers_lower and any(
ext in headers_lower.get("content-disposition", "")
for ext in [".csv", ".xls", ".xlsx"]
)
if is_csv or is_export:
# Check if formula characters are preserved without escaping
for char in self.formula_chars:
if char in payload and payload in response_body:
return True, 0.8, f"CSV injection: formula character '{char}' preserved in export"
# Check for specific formula patterns
formula_patterns = [
r'[=+\-@].*(?:HYPERLINK|IMPORTXML|IMPORTDATA|cmd|powershell)',
r'=\w+\(.*\)',
]
for pattern in formula_patterns:
if re.search(pattern, response_body):
return True, 0.7, "CSV injection: formula pattern found in export data"
# Non-CSV response but payload was stored
if response_status in [200, 201] and any(c in payload for c in self.formula_chars[:4]):
if payload in response_body:
return True, 0.4, "Formula characters accepted and stored - verify CSV export"
return False, 0.0, None
class OrmInjectionTester(BaseTester):
"""Tester for ORM Injection vulnerabilities"""
def __init__(self):
super().__init__()
self.name = "orm_injection"
self.error_patterns = [
r"Hibernate.*Exception",
r"javax\.persistence",
r"org\.hibernate",
r"ActiveRecord::.*Error",
r"Sequelize.*Error",
r"SQLAlchemy.*Error",
r"Doctrine.*Exception",
r"TypeORM.*Error",
r"Prisma.*Error",
r"EntityFramework.*Exception",
r"LINQ.*Exception",
r"django\.db.*Error",
r"peewee\.\w+Error",
]
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for ORM injection indicators"""
# ORM error messages
for pattern in self.error_patterns:
match = re.search(pattern, response_body, re.IGNORECASE)
if match:
return True, 0.8, f"ORM error detected: {match.group(0)[:100]}"
# Filter manipulation - unexpected data returned
if any(op in payload for op in ["__gt", "__lt", "__ne", "$ne", "$gt", ">=", "!="]):
if response_status == 200:
# Check context for baseline comparison
if "baseline_length" in context:
diff = abs(len(response_body) - context["baseline_length"])
if diff > 500:
return True, 0.6, f"ORM filter manipulation: response size differs by {diff} bytes"
# Check for data volume suggesting bypassed filters
if "__all" in payload or "objects.all" in payload:
if response_status == 200 and len(response_body) > 10000:
return True, 0.5, "ORM injection may have bypassed query filters - large data returned"
return False, 0.0, None
+223
View File
@@ -122,3 +122,226 @@ class SessionFixationTester(BaseTester):
return True, 0.6, "Session ID exposed in URL"
return False, 0.0, None
class WeakPasswordTester(BaseTester):
"""Tester for Weak Password acceptance"""
def __init__(self):
super().__init__()
self.name = "weak_password"
self.weak_passwords = [
"123456", "password", "12345678", "qwerty", "abc123",
"111111", "123123", "admin", "letmein", "welcome",
"1234", "1", "a"
]
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for successful login/registration with weak passwords"""
body_lower = response_body.lower()
# Check if payload contains a weak password
payload_has_weak = any(wp in payload for wp in self.weak_passwords)
if not payload_has_weak:
return False, 0.0, None
# Check for successful auth with weak password
if response_status in [200, 201, 302]:
success_indicators = [
r'"(?:access_)?token"\s*:', r'"session"\s*:',
r"(?:login|registration|signup)\s+successful",
r'"authenticated"\s*:\s*true', r'"success"\s*:\s*true',
r"welcome", r"dashboard", r"logged\s*in",
]
for pattern in success_indicators:
if re.search(pattern, response_body, re.IGNORECASE):
matched_pw = next((wp for wp in self.weak_passwords if wp in payload), "unknown")
return True, 0.85, f"Weak password accepted: '{matched_pw}' allowed for authentication"
# Redirect to authenticated area
location = response_headers.get("Location", "")
if response_status == 302 and any(x in location.lower() for x in ["dashboard", "home", "profile", "account"]):
matched_pw = next((wp for wp in self.weak_passwords if wp in payload), "unknown")
return True, 0.8, f"Weak password accepted: Redirect to authenticated area with '{matched_pw}'"
return False, 0.0, None
class DefaultCredentialsTester(BaseTester):
"""Tester for Default Credentials acceptance"""
def __init__(self):
super().__init__()
self.name = "default_credentials"
self.default_creds = [
("admin", "admin"), ("admin", "password"), ("admin", "admin123"),
("root", "root"), ("root", "toor"), ("root", "password"),
("administrator", "administrator"), ("admin", "1234"),
("test", "test"), ("guest", "guest"), ("user", "user"),
("admin", "changeme"), ("admin", "default"),
]
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for successful login with default credentials"""
body_lower = response_body.lower()
# Check if payload matches default creds
payload_lower = payload.lower()
matched_cred = None
for username, password in self.default_creds:
if username in payload_lower and password in payload_lower:
matched_cred = f"{username}/{password}"
break
if not matched_cred:
return False, 0.0, None
# Check for successful login
if response_status in [200, 201, 302]:
auth_success = [
r'"(?:access_)?token"\s*:', r'"session"\s*:',
r"(?:login|auth)\s+successful", r'"success"\s*:\s*true',
r'"authenticated"\s*:\s*true', r"welcome",
r"dashboard", r"admin\s*panel",
]
for pattern in auth_success:
if re.search(pattern, response_body, re.IGNORECASE):
return True, 0.9, f"Default credentials accepted: {matched_cred}"
# Redirect to admin/dashboard
location = response_headers.get("Location", "")
if response_status == 302 and any(x in location.lower() for x in ["dashboard", "admin", "home", "panel"]):
return True, 0.85, f"Default credentials accepted: {matched_cred} (redirect to {location})"
return False, 0.0, None
class TwoFactorBypassTester(BaseTester):
"""Tester for Two-Factor Authentication Bypass"""
def __init__(self):
super().__init__()
self.name = "two_factor_bypass"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for authenticated access without completing 2FA"""
body_lower = response_body.lower()
# Check if we reached authenticated content without 2FA
if response_status == 200:
# Authenticated area indicators
auth_area_patterns = [
r"dashboard", r"my\s*account", r"profile",
r"settings", r"admin\s*panel", r'"user"\s*:\s*\{',
r'"email"\s*:\s*"[^"]+"', r'"role"\s*:',
]
# 2FA page indicators (we should NOT see these if bypassed)
twofa_page_patterns = [
r"(?:enter|verify)\s+(?:your\s+)?(?:otp|code|token|2fa)",
r"two.?factor", r"verification\s+code",
r"authenticator", r"sms\s+code",
]
has_auth_content = any(re.search(p, response_body, re.IGNORECASE) for p in auth_area_patterns)
is_twofa_page = any(re.search(p, response_body, re.IGNORECASE) for p in twofa_page_patterns)
if has_auth_content and not is_twofa_page:
# Check if payload suggests 2FA bypass attempt
bypass_indicators = [
"2fa", "otp", "mfa", "verify", "code",
"step2", "second", "challenge",
]
if any(bi in payload.lower() for bi in bypass_indicators):
return True, 0.85, "2FA bypass: Authenticated area accessed without completing 2FA"
# Direct navigation bypass
if context.get("skip_2fa") or context.get("direct_access"):
return True, 0.9, "2FA bypass: Direct navigation to authenticated page bypassed 2FA"
# Redirect skipping 2FA step
if response_status in [301, 302]:
location = response_headers.get("Location", "").lower()
if any(x in location for x in ["dashboard", "home", "account"]):
if "verify" not in location and "2fa" not in location and "otp" not in location:
return True, 0.7, "2FA bypass: Redirect to authenticated area skipping verification"
return False, 0.0, None
class OauthMisconfigTester(BaseTester):
"""Tester for OAuth Misconfiguration"""
def __init__(self):
super().__init__()
self.name = "oauth_misconfig"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for OAuth misconfiguration (open redirect, token leakage)"""
# Check for open redirect in OAuth flow
if response_status in [301, 302, 303, 307]:
location = response_headers.get("Location", "")
# External redirect in OAuth callback
if "redirect_uri" in payload.lower() or "callback" in payload.lower():
# Check if redirecting to attacker-controlled domain
evil_domains = ["evil.com", "attacker.com", "malicious.com"]
if any(domain in location for domain in evil_domains):
return True, 0.9, f"OAuth misconfig: Open redirect in OAuth flow to {location}"
# Check if arbitrary redirect_uri accepted
if payload in location:
return True, 0.85, "OAuth misconfig: Arbitrary redirect_uri accepted"
# Check for token in URL parameters (should be in fragment or POST)
if response_status in [200, 302]:
location = response_headers.get("Location", "")
# Token in query string instead of fragment
token_in_url = re.search(
r'[?&](?:access_token|token|code)=([A-Za-z0-9._-]+)',
location
)
if token_in_url:
return True, 0.8, "OAuth misconfig: Token/code exposed in URL query parameters"
# Token in response body URL
token_in_body = re.search(
r'(?:redirect|callback|return)["\']?\s*[:=]\s*["\']?https?://[^"\'>\s]*[?&]access_token=',
response_body, re.IGNORECASE
)
if token_in_body:
return True, 0.75, "OAuth misconfig: Access token in redirect URL"
# Check for missing state parameter (CSRF in OAuth)
if "state=" not in response_body and "state=" not in response_headers.get("Location", ""):
if re.search(r"(?:authorize|oauth|auth)\?", response_body, re.IGNORECASE):
return True, 0.6, "OAuth misconfig: Missing state parameter (CSRF risk)"
return False, 0.0, None
@@ -128,3 +128,166 @@ class PrivilegeEscalationTester(BaseTester):
return True, 0.7, f"Privilege escalation: Admin functionality '{func}' accessible"
return False, 0.0, None
class BflaTester(BaseTester):
"""Tester for Broken Function Level Authorization (BFLA)"""
def __init__(self):
super().__init__()
self.name = "bfla"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for admin functionality accessible to regular user"""
if response_status == 200:
# Admin-specific data patterns
admin_data_patterns = [
r'"users"\s*:\s*\[',
r'"all_users"\s*:',
r'"admin_settings"\s*:',
r'"system_config"\s*:',
r'"audit_log"\s*:',
r'"role"\s*:\s*"admin"',
r'"permissions"\s*:\s*\[',
r'"api_keys"\s*:\s*\[',
]
for pattern in admin_data_patterns:
if re.search(pattern, response_body, re.IGNORECASE):
# Check if this was an admin endpoint accessed as regular user
admin_url_indicators = [
"/admin", "/manage", "/users", "/settings",
"/config", "/system", "/audit", "/logs",
]
if any(ind in payload.lower() for ind in admin_url_indicators):
return True, 0.85, f"BFLA: Admin data returned for non-admin request"
# Admin page content
admin_content = [
"user management", "system configuration", "admin dashboard",
"manage users", "all accounts", "server status",
"delete user", "create admin",
]
body_lower = response_body.lower()
for content in admin_content:
if content in body_lower:
return True, 0.8, f"BFLA: Admin functionality '{content}' accessible to regular user"
# Admin endpoint should return 403 for non-admin
if response_status not in [401, 403] and context.get("is_admin_endpoint"):
if response_status == 200:
return True, 0.7, "BFLA: Admin endpoint returned 200 instead of 403"
return False, 0.0, None
class MassAssignmentTester(BaseTester):
"""Tester for Mass Assignment vulnerability"""
def __init__(self):
super().__init__()
self.name = "mass_assignment"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for extra parameters being accepted and modifying model state"""
if response_status in [200, 201]:
# Check if privileged fields were accepted
privileged_fields = [
(r'"(?:is_)?admin"\s*:\s*true', "admin flag"),
(r'"role"\s*:\s*"admin"', "admin role"),
(r'"role"\s*:\s*"superuser"', "superuser role"),
(r'"verified"\s*:\s*true', "verified status"),
(r'"is_staff"\s*:\s*true', "staff flag"),
(r'"is_superuser"\s*:\s*true', "superuser flag"),
(r'"balance"\s*:\s*\d{4,}', "balance modification"),
(r'"credits"\s*:\s*\d{3,}', "credits modification"),
(r'"discount"\s*:\s*\d+', "discount field"),
(r'"price"\s*:\s*0', "price zeroed"),
]
# Check if payload attempted mass assignment
mass_assign_indicators = [
"admin", "role", "is_staff", "is_superuser",
"verified", "balance", "credits", "price", "discount",
]
payload_has_mass_assign = any(ind in payload.lower() for ind in mass_assign_indicators)
if payload_has_mass_assign:
for pattern, field_name in privileged_fields:
if re.search(pattern, response_body, re.IGNORECASE):
return True, 0.85, f"Mass assignment: Privileged field '{field_name}' accepted and reflected"
# Check if response changed compared to baseline
if context.get("baseline_body"):
baseline = context["baseline_body"]
if response_body != baseline and len(response_body) > len(baseline):
return True, 0.6, "Mass assignment: Response differs from baseline after extra parameters"
return False, 0.0, None
class ForcedBrowsingTester(BaseTester):
"""Tester for Forced Browsing (direct access to restricted URLs)"""
def __init__(self):
super().__init__()
self.name = "forced_browsing"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for direct access to restricted URLs without proper authorization"""
# Should get 401/403/302 for restricted content, not 200
if response_status == 200:
# Sensitive content indicators
sensitive_patterns = [
(r"(?:admin|management)\s+(?:panel|dashboard|console)", "admin panel"),
(r'"(?:password|secret|api_key|token)"\s*:\s*"[^"]+"', "sensitive data"),
(r"(?:backup|dump|export)\s+(?:file|data|database)", "backup files"),
(r"phpinfo\(\)", "PHP info page"),
(r"(?:configuration|config)\s+(?:file|settings)", "configuration page"),
(r"(?:internal|private)\s+(?:api|endpoint|documentation)", "internal docs"),
(r"(?:debug|diagnostic)\s+(?:info|page|console)", "debug page"),
(r"(?:user|customer)\s+(?:list|database|records)", "user records"),
]
for pattern, desc in sensitive_patterns:
if re.search(pattern, response_body, re.IGNORECASE):
return True, 0.8, f"Forced browsing: Restricted content accessible - {desc}"
# Check for restricted URL patterns in payload
restricted_paths = [
"/admin", "/backup", "/config", "/internal",
"/debug", "/private", "/management", "/phpinfo",
"/.git", "/.env", "/wp-admin", "/server-status",
]
if any(path in payload.lower() for path in restricted_paths):
if len(response_body) > 200:
return True, 0.7, "Forced browsing: Restricted URL returned content (200 OK)"
# Check that redirect isn't to the same restricted page (false positive)
if response_status in [301, 302]:
location = response_headers.get("Location", "").lower()
if "login" in location or "signin" in location or "auth" in location:
return False, 0.0, None # Properly redirecting to login
return False, 0.0, None
@@ -148,3 +148,283 @@ class OpenRedirectTester(BaseTester):
return True, 0.7, "Open redirect via JavaScript"
return False, 0.0, None
class DomClobberingTester(BaseTester):
"""Tester for DOM Clobbering vulnerability"""
def __init__(self):
super().__init__()
self.name = "dom_clobbering"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for HTML injection that could override JS variables via DOM clobbering"""
if response_status == 200:
# Check if injected HTML with id/name attributes is reflected
clobber_patterns = [
r'<(?:a|form|img|input|iframe|embed|object)\s+[^>]*(?:id|name)\s*=\s*["\']?(?:' + re.escape(payload.split("=")[0] if "=" in payload else payload) + r')',
r'<a\s+[^>]*id=["\'][^"\']+["\'][^>]*href=["\']',
r'<form\s+[^>]*name=["\'][^"\']+["\']',
]
for pattern in clobber_patterns:
if re.search(pattern, response_body, re.IGNORECASE):
return True, 0.8, "DOM Clobbering: Injected HTML with id/name attribute reflected"
# Check for common clobberable global variables
clobber_targets = [
r'<[^>]+id=["\'](?:location|document|window|self|top|frames|opener|parent)["\']',
r'<[^>]+name=["\'](?:location|document|window|self|top|frames|opener|parent)["\']',
]
for pattern in clobber_targets:
if re.search(pattern, response_body, re.IGNORECASE):
return True, 0.85, "DOM Clobbering: HTML element with JS global variable name injected"
return False, 0.0, None
class PostMessageVulnTester(BaseTester):
"""Tester for postMessage vulnerability (missing origin check)"""
def __init__(self):
super().__init__()
self.name = "postmessage_vuln"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for addEventListener('message') without origin validation"""
if response_status == 200:
# Find message event listeners
message_listener = re.search(
r'addEventListener\s*\(\s*["\']message["\']',
response_body
)
if message_listener:
# Check if origin is NOT validated nearby
# Get surrounding context (500 chars after listener)
listener_pos = message_listener.start()
handler_block = response_body[listener_pos:listener_pos + 500]
origin_checks = [
r'\.origin\s*[!=]==?\s*["\']',
r'event\.origin',
r'e\.origin',
r'msg\.origin',
r'origin\s*===',
]
has_origin_check = any(re.search(p, handler_block) for p in origin_checks)
if not has_origin_check:
return True, 0.85, "postMessage vulnerability: Message listener without origin validation"
else:
# Origin check exists but might be weak
if re.search(r'\.origin\s*[!=]==?\s*["\']["\']', handler_block):
return True, 0.7, "postMessage vulnerability: Origin check appears to be empty string"
# Check for postMessage with wildcard origin
wildcard_post = re.search(
r'\.postMessage\s*\([^)]+,\s*["\']\*["\']',
response_body
)
if wildcard_post:
return True, 0.75, "postMessage vulnerability: postMessage with wildcard '*' target origin"
return False, 0.0, None
class WebsocketHijackTester(BaseTester):
"""Tester for WebSocket Cross-Origin Hijacking"""
def __init__(self):
super().__init__()
self.name = "websocket_hijack"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for WebSocket connections accepting cross-origin requests"""
# WebSocket upgrade accepted (101 Switching Protocols)
if response_status == 101:
# Check if Origin header was sent and accepted
upgrade = response_headers.get("Upgrade", "").lower()
if upgrade == "websocket":
return True, 0.8, "WebSocket hijack: Cross-origin WebSocket upgrade accepted"
# Check for WebSocket endpoint in response without origin validation
if response_status == 200:
ws_patterns = [
r'new\s+WebSocket\s*\(\s*["\']wss?://',
r'ws://[^"\'>\s]+',
r'wss://[^"\'>\s]+',
]
for pattern in ws_patterns:
if re.search(pattern, response_body):
# Check for lack of CORS-like origin checking
if "origin" not in response_body.lower() or context.get("cross_origin_accepted"):
return True, 0.7, "WebSocket hijack: WebSocket endpoint found without apparent origin validation"
# 403 on WebSocket with wrong origin is good (not vulnerable)
if response_status == 403:
return False, 0.0, None
return False, 0.0, None
class PrototypePollutionTester(BaseTester):
"""Tester for JavaScript Prototype Pollution"""
def __init__(self):
super().__init__()
self.name = "prototype_pollution"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for __proto__ pollution indicators"""
if response_status == 200:
# Check if __proto__ payload was processed
proto_indicators = [
r'__proto__',
r'constructor\.prototype',
r'Object\.prototype',
]
# Payload should contain proto pollution attempt
is_proto_payload = any(
ind in payload for ind in ["__proto__", "constructor", "prototype"]
)
if is_proto_payload:
# Check for pollution effect in response
pollution_effects = [
r'"__proto__"\s*:\s*\{',
r'"polluted"\s*:\s*true',
r'"isAdmin"\s*:\s*true',
r'"__proto__":\s*\{[^}]*\}',
]
for pattern in pollution_effects:
if re.search(pattern, response_body, re.IGNORECASE):
return True, 0.85, "Prototype pollution: __proto__ property accepted and reflected"
# Check if server processed the prototype chain modification
if response_status == 200 and "__proto__" in response_body:
return True, 0.7, "Prototype pollution: __proto__ present in server response"
# Check for error indicating proto processing
if re.search(r"(?:cannot|unable to).*(?:merge|assign|extend).*proto", response_body, re.IGNORECASE):
return True, 0.6, "Prototype pollution: Server attempted to process __proto__"
return False, 0.0, None
class CssInjectionTester(BaseTester):
"""Tester for CSS Injection vulnerability"""
def __init__(self):
super().__init__()
self.name = "css_injection"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for CSS code rendered in style context"""
if response_status == 200:
# Check if CSS payload is reflected in style context
css_contexts = [
# Inside <style> tags
r'<style[^>]*>(?:[^<]*?)' + re.escape(payload)[:30].replace("\\", "\\\\"),
# Inside style attribute
r'style\s*=\s*["\'][^"\']*' + re.escape(payload)[:30].replace("\\", "\\\\"),
]
for pattern in css_contexts:
try:
if re.search(pattern, response_body, re.IGNORECASE | re.DOTALL):
return True, 0.85, "CSS injection: Payload reflected in style context"
except re.error:
continue
# Check for common CSS injection payloads reflected
css_attack_patterns = [
r'expression\s*\(',
r'url\s*\(\s*["\']?javascript:',
r'@import\s+["\']?https?://',
r'background:\s*url\s*\(\s*["\']?https?://[^"\')\s]*attacker',
r'behavior:\s*url\s*\(',
r'-moz-binding:\s*url\s*\(',
]
for pattern in css_attack_patterns:
if re.search(pattern, response_body, re.IGNORECASE):
return True, 0.8, "CSS injection: Dangerous CSS property reflected"
return False, 0.0, None
class TabnabbingTester(BaseTester):
"""Tester for Reverse Tabnabbing vulnerability"""
def __init__(self):
super().__init__()
self.name = "tabnabbing"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for target=_blank links without rel=noopener"""
if response_status == 200:
# Find all target=_blank links
blank_links = re.finditer(
r'<a\s+[^>]*target\s*=\s*["\']_blank["\'][^>]*>',
response_body,
re.IGNORECASE
)
vulnerable_count = 0
for match in blank_links:
link_tag = match.group(0)
# Check for rel=noopener or rel=noreferrer
has_protection = re.search(
r'rel\s*=\s*["\'][^"\']*(?:noopener|noreferrer)[^"\']*["\']',
link_tag,
re.IGNORECASE
)
if not has_protection:
vulnerable_count += 1
if vulnerable_count > 0:
confidence = min(0.5 + vulnerable_count * 0.1, 0.8)
return True, confidence, f"Tabnabbing: {vulnerable_count} target=_blank link(s) without rel=noopener/noreferrer"
return False, 0.0, None
@@ -0,0 +1,405 @@
"""
NeuroSploit v3 - Cloud & Supply Chain Vulnerability Testers
Testers for S3 misconfiguration, cloud metadata, subdomain takeover,
vulnerable dependencies, container escape, and serverless misconfiguration.
"""
import re
from typing import Tuple, Dict, Optional
from backend.core.vuln_engine.testers.base_tester import BaseTester
class S3BucketMisconfigTester(BaseTester):
"""Tester for S3 Bucket Misconfiguration vulnerabilities"""
def __init__(self):
super().__init__()
self.name = "s3_bucket_misconfig"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for S3 bucket listing or misconfiguration"""
# Bucket listing XML response
if re.search(r"<ListBucketResult\s", response_body):
return True, 0.95, "S3 bucket listing enabled - bucket contents exposed"
# Bucket listing with objects
if "<Contents>" in response_body and "<Key>" in response_body:
keys = re.findall(r"<Key>([^<]+)</Key>", response_body)
if keys:
return True, 0.95, f"S3 bucket listing: {len(keys)} objects exposed (e.g., {keys[0][:50]})"
# NoSuchBucket - potential subdomain takeover
if "NoSuchBucket" in response_body:
bucket_match = re.search(r"<BucketName>([^<]+)</BucketName>", response_body)
bucket_name = bucket_match.group(1) if bucket_match else "unknown"
return True, 0.8, f"S3 bucket '{bucket_name}' does not exist - potential takeover"
# AccessDenied with bucket info
if "AccessDenied" in response_body and "s3.amazonaws.com" in response_body:
return True, 0.5, "S3 bucket exists but access denied - bucket enumerated"
# Public write/upload success
if response_status in [200, 204] and context.get("is_upload_test"):
headers_lower = {k.lower(): v for k, v in response_headers.items()}
if "x-amz-request-id" in headers_lower:
return True, 0.9, "S3 bucket allows public write access"
# Bucket policy exposed
if '"Statement"' in response_body and '"Effect"' in response_body:
if '"s3:' in response_body:
return True, 0.85, "S3 bucket policy exposed"
return False, 0.0, None
class CloudMetadataExposureTester(BaseTester):
"""Tester for Cloud Metadata Exposure vulnerabilities (SSRF to metadata)"""
def __init__(self):
super().__init__()
self.name = "cloud_metadata_exposure"
self.metadata_indicators = {
# AWS
"aws": [
(r"ami-[0-9a-f]{8,17}", "AWS AMI ID"),
(r"i-[0-9a-f]{8,17}", "AWS Instance ID"),
(r"arn:aws:[a-z0-9-]+:[a-z0-9-]*:\d{12}:", "AWS ARN"),
(r"AKIA[0-9A-Z]{16}", "AWS Access Key"),
(r"169\.254\.169\.254", "AWS metadata endpoint"),
(r"\"(?:AccessKeyId|SecretAccessKey|Token)\"", "AWS IAM credentials"),
(r"ec2\.internal", "AWS internal hostname"),
(r"\"accountId\"\s*:\s*\"\d{12}\"", "AWS Account ID"),
],
# GCP
"gcp": [
(r"projects/\d+/", "GCP Project reference"),
(r"metadata\.google\.internal", "GCP metadata endpoint"),
(r"\"access_token\"\s*:\s*\"ya29\.", "GCP OAuth token"),
(r"compute\.googleapis\.com", "GCP Compute API"),
(r"serviceAccounts/[^/]+/token", "GCP service account token"),
],
# Azure
"azure": [
(r"metadata\.azure\.com", "Azure metadata endpoint"),
(r"(?:subscriptionId|resourceGroupName)\"\s*:\s*\"", "Azure resource info"),
(r"\.blob\.core\.windows\.net", "Azure Blob Storage"),
(r"\.vault\.azure\.net", "Azure Key Vault"),
(r"\"access_token\"\s*:\s*\"eyJ", "Azure JWT token"),
],
}
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for cloud metadata in response"""
findings = []
for provider, patterns in self.metadata_indicators.items():
for pattern, description in patterns:
if re.search(pattern, response_body):
findings.append(f"{provider.upper()}: {description}")
if findings:
# IAM credentials or tokens are critical
critical = any(k in f for f in findings for k in ["credentials", "Access Key", "token", "Token"])
confidence = 0.95 if critical else 0.8
return True, confidence, f"Cloud metadata exposure: {', '.join(findings[:3])}"
# Check for metadata endpoint access (SSRF to cloud metadata)
metadata_urls = ["169.254.169.254", "metadata.google.internal",
"metadata.azure.com", "100.100.100.200"]
for url in metadata_urls:
if url in payload and response_status == 200 and len(response_body) > 50:
return True, 0.85, f"Cloud metadata accessible via SSRF ({url})"
return False, 0.0, None
class SubdomainTakeoverTester(BaseTester):
"""Tester for Subdomain Takeover vulnerabilities"""
def __init__(self):
super().__init__()
self.name = "subdomain_takeover"
self.takeover_fingerprints = [
# AWS S3
(r"NoSuchBucket", "S3 bucket - NoSuchBucket"),
(r"The specified bucket does not exist", "S3 bucket does not exist"),
# GitHub Pages
(r"There isn't a GitHub Pages site here", "GitHub Pages - unclaimed"),
(r"For root URLs.*GitHub Pages", "GitHub Pages not configured"),
# Heroku
(r"No such app", "Heroku - app not found"),
(r"herokucdn\.com/error-pages/no-such-app", "Heroku - no such app"),
# Shopify
(r"Sorry, this shop is currently unavailable", "Shopify - shop unavailable"),
# Tumblr
(r"There's nothing here\.", "Tumblr - unclaimed"),
(r"Whatever you were looking for doesn't currently exist", "Tumblr not found"),
# WordPress.com
(r"Do you want to register.*wordpress\.com", "WordPress.com - unclaimed"),
# Azure
(r"404 Web Site not found", "Azure - web app not found"),
# Fastly
(r"Fastly error: unknown domain", "Fastly - unknown domain"),
# Pantheon
(r"404 error unknown site", "Pantheon - unknown site"),
# Zendesk
(r"Help Center Closed", "Zendesk - closed"),
# Unbounce
(r"The requested URL was not found on this server.*unbounce", "Unbounce - not found"),
# Surge.sh
(r"project not found", "Surge.sh - not found"),
# Fly.io
(r"404.*fly\.io", "Fly.io - not found"),
]
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for cloud provider error pages indicating takeover opportunity"""
for pattern, description in self.takeover_fingerprints:
if re.search(pattern, response_body, re.IGNORECASE):
return True, 0.9, f"Subdomain takeover: {description}"
# CNAME pointing to unclaimed resource
if context.get("cname_target"):
cname = context["cname_target"]
unclaimed_domains = [
".s3.amazonaws.com", ".herokuapp.com", ".github.io",
".azurewebsites.net", ".cloudfront.net", ".fastly.net",
".ghost.io", ".myshopify.com", ".surge.sh",
]
for domain in unclaimed_domains:
if cname.endswith(domain) and response_status in [404, 0]:
return True, 0.85, f"Subdomain takeover: CNAME to {cname} returns {response_status}"
# NXDOMAIN with CNAME
if context.get("dns_nxdomain") and context.get("has_cname"):
return True, 0.8, "Subdomain takeover: CNAME exists but target domain is NXDOMAIN"
return False, 0.0, None
class VulnerableDependencyTester(BaseTester):
"""Tester for Vulnerable Dependency detection"""
def __init__(self):
super().__init__()
self.name = "vulnerable_dependency"
self.vulnerable_libs = [
# JavaScript
(r"jquery[/.-](?:1\.\d+|2\.\d+|3\.[0-4]\.\d+)", "jQuery < 3.5.0 (XSS)"),
(r"angular[/.-]1\.[0-5]\.\d+", "AngularJS < 1.6 (sandbox escape)"),
(r"lodash[/.-](?:[0-3]\.\d+|4\.(?:1[0-6]|[0-9])\.\d+)", "Lodash < 4.17.21 (prototype pollution)"),
(r"bootstrap[/.-](?:[1-3]\.\d+|4\.[0-3]\.\d+)", "Bootstrap < 4.3.1 (XSS)"),
(r"moment[/.-](?:[01]\.\d+|2\.(?:[0-9]|1[0-8])\.\d+)", "Moment.js < 2.19.3 (ReDoS)"),
(r"handlebars[/.-](?:[0-3]\.\d+|4\.[0-6]\.\d+)", "Handlebars < 4.7.7 (prototype pollution)"),
# Python
(r"Django[/=](?:1\.\d+|2\.[01]\.\d+|3\.0\.\d+)", "Django < 3.1 (multiple CVEs)"),
(r"Flask[/=](?:0\.\d+|1\.[01]\.\d+)", "Flask < 2.0 (known issues)"),
(r"requests[/=]2\.(?:[0-9]|1\d|2[0-4])\.\d+", "Requests < 2.25 (CVE-2023-32681)"),
# Java
(r"log4j[/-]2\.(?:[0-9]|1[0-4])\.\d+", "Log4j < 2.15 (Log4Shell CVE-2021-44228)"),
(r"spring-core[/-](?:[1-4]\.\d+|5\.[0-2]\.\d+)", "Spring < 5.3 (Spring4Shell)"),
(r"jackson-databind[/-]2\.(?:[0-8]|9\.[0-9])\.\d*", "Jackson < 2.9.10 (deserialization)"),
# PHP
(r"laravel/framework[/:]\s*v?(?:[1-7]\.\d+|8\.[0-7]\d)", "Laravel < 8.80 (known issues)"),
]
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for known vulnerable library version strings"""
findings = []
# Check response body (JS files, package.json, error pages)
for pattern, description in self.vulnerable_libs:
match = re.search(pattern, response_body, re.IGNORECASE)
if match:
findings.append(f"{match.group(0)} - {description}")
# Check headers for version info
headers_str = "\n".join(f"{k}: {v}" for k, v in response_headers.items())
for pattern, description in self.vulnerable_libs:
match = re.search(pattern, headers_str, re.IGNORECASE)
if match and description not in str(findings):
findings.append(f"{match.group(0)} - {description}")
if findings:
# Log4Shell and Spring4Shell are critical
critical = any(k in f for f in findings for k in ["Log4Shell", "Spring4Shell", "deserialization"])
confidence = 0.9 if critical else 0.75
return True, confidence, f"Vulnerable dependency: {'; '.join(findings[:3])}"
return False, 0.0, None
class ContainerEscapeTester(BaseTester):
"""Tester for Container Escape / Container Misconfiguration vulnerabilities"""
def __init__(self):
super().__init__()
self.name = "container_escape"
self.container_indicators = [
# Docker
(r"\.dockerenv", "Docker environment file accessible"),
(r"docker\.sock", "Docker socket exposed"),
(r"/var/run/docker\.sock", "Docker socket path"),
# Cgroup
(r"docker[/-][0-9a-f]{12,64}", "Docker container cgroup"),
(r"/proc/self/cgroup.*docker", "Docker cgroup detected"),
(r"/proc/self/cgroup.*kubepods", "Kubernetes pod cgroup"),
# Kubernetes
(r"KUBERNETES_SERVICE_HOST", "Kubernetes service host env"),
(r"KUBERNETES_PORT", "Kubernetes port env"),
(r"/var/run/secrets/kubernetes\.io", "Kubernetes secrets path"),
(r"serviceaccount/token", "Kubernetes service account token"),
(r"kube-system", "Kubernetes system namespace"),
# Container runtime
(r"containerd", "containerd runtime"),
(r"runc", "runc runtime"),
# Process namespace
(r"process\s+1\b.*(?:init|systemd|tini|dumb-init)", "Container init process"),
]
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for Docker/container indicators in response"""
findings = []
for pattern, description in self.container_indicators:
if re.search(pattern, response_body, re.IGNORECASE):
findings.append(description)
if findings:
# Docker socket or K8s secrets are critical
critical = any(k in f for f in findings for k in ["socket", "secrets", "token"])
confidence = 0.9 if critical else 0.7
return True, confidence, f"Container exposure: {', '.join(findings[:3])}"
# Privileged container detection
if context.get("is_capability_check"):
# Check for extra capabilities
cap_patterns = [
r"cap_sys_admin", r"cap_sys_ptrace", r"cap_net_admin",
r"cap_dac_override", r"cap_sys_rawio",
]
for pattern in cap_patterns:
if re.search(pattern, response_body, re.IGNORECASE):
return True, 0.8, f"Privileged container: {pattern} capability detected"
# Mount namespace check
if re.search(r"/dev/(?:sda|xvda|nvme)\d*\s", response_body):
return True, 0.6, "Host block devices visible from container"
return False, 0.0, None
class ServerlessMisconfigTester(BaseTester):
"""Tester for Serverless Misconfiguration vulnerabilities"""
def __init__(self):
super().__init__()
self.name = "serverless_misconfig"
self.env_patterns = [
# AWS Lambda
(r"AWS_LAMBDA_FUNCTION_NAME\s*[=:]\s*(\S+)", "Lambda function name"),
(r"AWS_SECRET_ACCESS_KEY\s*[=:]\s*(\S+)", "Lambda AWS secret key"),
(r"AWS_SESSION_TOKEN\s*[=:]\s*(\S+)", "Lambda session token"),
(r"AWS_LAMBDA_LOG_GROUP_NAME", "Lambda log group"),
(r"_HANDLER\s*[=:]\s*(\S+)", "Lambda handler path"),
(r"LAMBDA_TASK_ROOT\s*[=:]\s*(\S+)", "Lambda task root"),
(r"AWS_EXECUTION_ENV\s*[=:]\s*(\S+)", "Lambda execution environment"),
# Google Cloud Functions
(r"FUNCTION_NAME\s*[=:]\s*(\S+)", "Cloud Function name"),
(r"GCLOUD_PROJECT\s*[=:]\s*(\S+)", "GCP project ID"),
(r"GOOGLE_CLOUD_PROJECT\s*[=:]\s*(\S+)", "GCP project"),
(r"GCP_PROJECT\s*[=:]\s*(\S+)", "GCP project"),
(r"FUNCTION_REGION\s*[=:]\s*(\S+)", "Cloud Function region"),
# Azure Functions
(r"FUNCTIONS_WORKER_RUNTIME\s*[=:]\s*(\S+)", "Azure Function runtime"),
(r"AzureWebJobsStorage\s*[=:]\s*(\S+)", "Azure storage connection string"),
(r"WEBSITE_SITE_NAME\s*[=:]\s*(\S+)", "Azure site name"),
]
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for serverless environment variable exposure"""
findings = []
for pattern, description in self.env_patterns:
match = re.search(pattern, response_body)
if match:
value = match.group(1) if match.lastindex else ""
# Redact sensitive values
if "key" in description.lower() or "token" in description.lower() or "secret" in description.lower():
display = f"{description} (REDACTED)"
else:
display = f"{description}: {value[:30]}"
findings.append(display)
if findings:
# Credentials are critical
critical = any(k in f for f in findings for k in ["secret", "token", "REDACTED"])
confidence = 0.95 if critical else 0.75
return True, confidence, f"Serverless misconfiguration: {', '.join(findings[:3])}"
# Function source code exposure
if context.get("is_source_request"):
source_indicators = [
r"exports\.handler\s*=", r"def lambda_handler\(",
r"def main\(req:", r"module\.exports",
r"def hello_http\(request\):",
]
for pattern in source_indicators:
if re.search(pattern, response_body):
return True, 0.8, "Serverless misconfiguration: function source code exposed"
# Invocation error details
error_patterns = [
r"\"errorType\"\s*:\s*\"(\w+)\"",
r"\"stackTrace\"\s*:\s*\[",
r"Runtime\.HandlerNotFound",
r"\"errorMessage\"\s*:\s*\".*(?:import|require|module)",
]
for pattern in error_patterns:
match = re.search(pattern, response_body)
if match:
return True, 0.7, f"Serverless misconfiguration: detailed error exposed ({match.group(0)[:60]})"
return False, 0.0, None
@@ -0,0 +1,388 @@
"""
NeuroSploit v3 - Data Exposure Vulnerability Testers
Testers for sensitive data exposure, information disclosure, API key exposure,
source code disclosure, backup file exposure, and version disclosure.
"""
import re
from typing import Tuple, Dict, Optional
from backend.core.vuln_engine.testers.base_tester import BaseTester
class SensitiveDataExposureTester(BaseTester):
"""Tester for Sensitive Data Exposure (PII leakage)"""
def __init__(self):
super().__init__()
self.name = "sensitive_data_exposure"
self.pii_patterns = [
# SSN (US)
(r"\b\d{3}-\d{2}-\d{4}\b", "SSN pattern"),
# Credit card numbers (Visa, MC, Amex, Discover)
(r"\b4\d{3}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b", "Visa card number"),
(r"\b5[1-5]\d{2}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b", "MasterCard number"),
(r"\b3[47]\d{2}[\s-]?\d{6}[\s-]?\d{5}\b", "Amex card number"),
(r"\b6(?:011|5\d{2})[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b", "Discover card number"),
# Email addresses in bulk (10+ suggests a data leak)
(r"[\w.+-]+@[\w-]+\.[\w.-]+", "email address"),
# Phone numbers (US format)
(r"\b\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}\b", "phone number"),
# Passport numbers
(r"\b[A-Z]\d{8}\b", "passport number pattern"),
# Private keys
(r"-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----", "private key"),
# Password hashes
(r"\$2[aby]?\$\d{1,2}\$[./A-Za-z0-9]{53}", "bcrypt hash"),
(r"\b[a-f0-9]{32}\b", "MD5 hash"),
]
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for PII patterns in response"""
if response_status >= 400:
return False, 0.0, None
findings = []
for pattern, description in self.pii_patterns:
matches = re.findall(pattern, response_body)
if matches:
# Private keys and password hashes are always significant
if "private key" in description:
return True, 0.95, f"Sensitive data exposure: {description} found in response"
if "bcrypt hash" in description:
return True, 0.9, f"Sensitive data exposure: {description} found in response"
# For patterns like emails, phone numbers - check for bulk exposure
if description in ["email address", "phone number"]:
if len(matches) >= 5:
findings.append(f"{len(matches)} {description}s")
elif description == "MD5 hash":
if len(matches) >= 3:
findings.append(f"{len(matches)} {description}es")
else:
findings.append(f"{description} ({matches[0][:20]}...)")
if findings:
confidence = min(0.9, 0.6 + 0.1 * len(findings))
return True, confidence, f"Sensitive data exposure: {', '.join(findings[:3])}"
return False, 0.0, None
class InformationDisclosureTester(BaseTester):
"""Tester for Information Disclosure vulnerabilities"""
def __init__(self):
super().__init__()
self.name = "information_disclosure"
self.disclosure_patterns = [
# Server version headers
(r"Server:\s*(.+)", "header", "Server version"),
(r"X-Powered-By:\s*(.+)", "header", "Technology stack"),
(r"X-AspNet-Version:\s*(.+)", "header", "ASP.NET version"),
(r"X-AspNetMvc-Version:\s*(.+)", "header", "ASP.NET MVC version"),
]
self.body_patterns = [
# Path disclosure
(r"(?:/var/www|/home/\w+|/srv/|/opt/\w+|C:\\inetpub|C:\\Users\\\w+)[/\\]\S+", "Internal path"),
# Stack traces
(r"Traceback \(most recent call last\)", "Python stack trace"),
(r"at \w+\.\w+\([\w.]+:\d+\)", "Java stack trace"),
(r"(?:Fatal error|Warning|Notice):\s+.*\sin\s+/\S+\s+on line \d+", "PHP error with path"),
(r"Microsoft \.NET Framework Version:\d+", ".NET framework version"),
# Database info
(r"(?:MySQL|PostgreSQL|Oracle|MSSQL)\s+\d+\.\d+", "Database version"),
# Debug info
(r"(?:DEBUG|TRACE)\s*=\s*(?:true|True|1)", "Debug mode enabled"),
(r"(?:SECRET_KEY|DB_PASSWORD|API_SECRET)\s*[=:]\s*\S+", "Secret in debug output"),
# Internal IPs
(r"\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b", "Internal IP address"),
]
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for information disclosure in headers and body"""
findings = []
# Check headers
headers_str = "\n".join(f"{k}: {v}" for k, v in response_headers.items())
for pattern, location, description in self.disclosure_patterns:
match = re.search(pattern, headers_str, re.IGNORECASE)
if match:
findings.append(f"{description}: {match.group(1)[:50]}")
# Check body
for pattern, description in self.body_patterns:
match = re.search(pattern, response_body, re.IGNORECASE)
if match:
findings.append(f"{description}: {match.group(0)[:80]}")
if findings:
# Stack traces and secrets are higher severity
high_severity = any("stack trace" in f.lower() or "secret" in f.lower() or "debug" in f.lower() for f in findings)
confidence = 0.85 if high_severity else 0.7
return True, confidence, f"Information disclosure: {'; '.join(findings[:3])}"
return False, 0.0, None
class ApiKeyExposureTester(BaseTester):
"""Tester for API Key Exposure vulnerabilities"""
def __init__(self):
super().__init__()
self.name = "api_key_exposure"
self.key_patterns = [
# AWS
(r"AKIA[0-9A-Z]{16}", "AWS Access Key"),
(r"(?:aws_secret_access_key|AWS_SECRET_ACCESS_KEY)\s*[=:]\s*[A-Za-z0-9/+=]{40}", "AWS Secret Key"),
# Google
(r"AIza[0-9A-Za-z\-_]{35}", "Google API Key"),
(r"ya29\.[0-9A-Za-z\-_]+", "Google OAuth Token"),
# Stripe
(r"sk_live_[0-9a-zA-Z]{24,}", "Stripe Secret Key"),
(r"pk_live_[0-9a-zA-Z]{24,}", "Stripe Publishable Key"),
(r"rk_live_[0-9a-zA-Z]{24,}", "Stripe Restricted Key"),
# GitHub
(r"gh[pousr]_[A-Za-z0-9_]{36,}", "GitHub Token"),
# Slack
(r"xox[baprs]-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]*", "Slack Token"),
# Twilio
(r"SK[0-9a-fA-F]{32}", "Twilio API Key"),
# SendGrid
(r"SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}", "SendGrid API Key"),
# Heroku
(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", "Heroku API Key / UUID"),
# Generic patterns
(r"(?:api[_-]?key|apikey|api[_-]?secret)\s*[=:]\s*['\"]?([A-Za-z0-9_\-]{20,})['\"]?", "Generic API Key"),
(r"(?:access[_-]?token|auth[_-]?token)\s*[=:]\s*['\"]?([A-Za-z0-9_\-.]{20,})['\"]?", "Access Token"),
# Bearer tokens in JS
(r"['\"]Bearer\s+[A-Za-z0-9_\-\.]{20,}['\"]", "Hardcoded Bearer Token"),
]
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for API key patterns in response"""
findings = []
for pattern, description in self.key_patterns:
matches = re.findall(pattern, response_body)
if matches:
# Skip UUIDs unless in specific context (too many false positives)
if "UUID" in description:
continue
# Redact the actual key in evidence
sample = matches[0] if isinstance(matches[0], str) else matches[0]
redacted = sample[:8] + "..." + sample[-4:] if len(sample) > 12 else sample[:4] + "..."
findings.append(f"{description} ({redacted})")
if findings:
# AWS/Stripe secret keys are critical
critical = any(k in f for f in findings for k in ["AWS Secret", "Stripe Secret", "Secret Key"])
confidence = 0.95 if critical else 0.85
return True, confidence, f"API key exposure: {', '.join(findings[:3])}"
return False, 0.0, None
class SourceCodeDisclosureTester(BaseTester):
"""Tester for Source Code Disclosure vulnerabilities"""
def __init__(self):
super().__init__()
self.name = "source_code_disclosure"
self.source_indicators = [
# Git
(r"\[core\]\s*\n\s*repositoryformatversion", "Git config exposed"),
(r"\[remote \"origin\"\]", "Git config with remote"),
(r"ref: refs/heads/", "Git HEAD reference exposed"),
# Source maps
(r"\"version\"\s*:\s*3,\s*\"sources\"", "JavaScript source map"),
(r"//[#@]\s*sourceMappingURL=", "Source map reference"),
# PHP source
(r"<\?php\s", "PHP source code"),
(r"<\?=", "PHP short tag source"),
# Python source
(r"^(?:import |from \w+ import |def \w+\(|class \w+)", "Python source code"),
# Java/JSP
(r"<%@?\s*page\s+", "JSP source code"),
(r"package\s+\w+\.\w+;", "Java package declaration"),
# Environment files
(r"(?:DB_PASSWORD|SECRET_KEY|DATABASE_URL)\s*=\s*\S+", "Environment file content"),
# Composer/package files with private repos
(r"\"require\".*\"private/", "Private package reference"),
]
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for source code indicators in response"""
if response_status >= 400:
return False, 0.0, None
for pattern, description in self.source_indicators:
match = re.search(pattern, response_body, re.MULTILINE)
if match:
# Git config is high confidence
if "Git" in description:
return True, 0.95, f"Source code disclosure: {description}"
# Environment file is critical
if "Environment" in description:
return True, 0.95, f"Source code disclosure: {description}"
# Source maps lower confidence (often intentional)
if "source map" in description.lower():
return True, 0.6, f"Source code disclosure: {description}"
return True, 0.8, f"Source code disclosure: {description}"
# Check for common source file access patterns
source_paths = [".git/config", ".env", ".htaccess", "web.config",
"wp-config.php", "config.php", "settings.py"]
for path in source_paths:
if path in payload and response_status == 200 and len(response_body) > 50:
return True, 0.7, f"Source code disclosure: {path} accessible"
return False, 0.0, None
class BackupFileExposureTester(BaseTester):
"""Tester for Backup File Exposure vulnerabilities"""
def __init__(self):
super().__init__()
self.name = "backup_file_exposure"
self.file_signatures = [
# SQL dumps
(r"-- MySQL dump \d+", "MySQL database dump"),
(r"-- PostgreSQL database dump", "PostgreSQL database dump"),
(r"CREATE TABLE\s+[`\"]\w+[`\"]", "SQL DDL statements"),
(r"INSERT INTO\s+[`\"]\w+[`\"]", "SQL data dump"),
# Archive signatures (in text responses)
(r"PK\x03\x04", "ZIP archive"),
# Tar
(r"ustar\s", "TAR archive"),
# Config backups
(r"<\?xml.*<configuration>", "XML configuration backup"),
(r"server\s*\{[^}]*listen\s+\d+", "Nginx config backup"),
(r"<VirtualHost\s+", "Apache config backup"),
]
self.backup_extensions = [
".bak", ".backup", ".old", ".orig", ".save",
".swp", ".swo", ".tmp", ".temp", ".copy",
"~", ".sql", ".tar.gz", ".zip", ".dump",
]
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for backup file content in response"""
if response_status >= 400:
return False, 0.0, None
# Check file signatures
for pattern, description in self.file_signatures:
if re.search(pattern, response_body, re.IGNORECASE):
return True, 0.9, f"Backup file exposure: {description} detected"
# Check if backup extension in request returned content
for ext in self.backup_extensions:
if ext in payload and response_status == 200 and len(response_body) > 100:
headers_lower = {k.lower(): v for k, v in response_headers.items()}
content_type = headers_lower.get("content-type", "").lower()
# Non-HTML responses to backup file requests are suspicious
if "text/html" not in content_type:
return True, 0.75, f"Backup file exposure: {ext} file served ({content_type})"
# HTML response but contains code-like content
if re.search(r"(?:function |class |import |require\(|define\()", response_body):
return True, 0.7, f"Backup file exposure: {ext} file contains source code"
return False, 0.0, None
class VersionDisclosureTester(BaseTester):
"""Tester for Version Disclosure mapping to known CVEs"""
def __init__(self):
super().__init__()
self.name = "version_disclosure"
# Software versions with known critical CVEs
self.vulnerable_versions = {
r"Apache/2\.4\.49\b": "CVE-2021-41773 (path traversal)",
r"Apache/2\.4\.50\b": "CVE-2021-42013 (path traversal bypass)",
r"nginx/1\.(?:[0-9]|1[0-7])\.\d+": "Potential nginx < 1.18 vulnerabilities",
r"PHP/(?:5\.\d|7\.[0-3])\.\d+": "Outdated PHP version with known CVEs",
r"OpenSSL/1\.0\.\d": "OpenSSL 1.0.x - multiple known CVEs",
r"jQuery/(?:1\.\d|2\.\d|3\.[0-4])\.\d+": "jQuery < 3.5 - XSS via htmlPrefilter",
r"WordPress/(?:[1-4]\.\d|5\.[0-7])": "Outdated WordPress version",
r"Drupal/(?:[1-7]\.\d|8\.[0-5])": "Outdated Drupal version",
r"Rails/(?:[1-4]\.\d|5\.[01])": "Outdated Rails version",
r"Spring Framework/(?:[1-4]\.\d|5\.[0-2])": "Outdated Spring version",
r"Express/(?:[1-3]\.\d|4\.(?:1[0-6]))": "Outdated Express.js version",
r"Django/(?:1\.\d|2\.[01]|3\.0)": "Outdated Django version",
r"Log4j.(?:2\.(?:0|1[0-4])\.\d)": "CVE-2021-44228 (Log4Shell)",
r"Tomcat/(?:[1-8]\.\d|9\.[0-3]\d\.\d)": "Potentially outdated Tomcat",
r"IIS/(?:[1-9]\.0|10\.0)": "IIS version disclosure",
}
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for version strings mapping to known CVEs"""
# Combine headers and body for scanning
headers_str = "\n".join(f"{k}: {v}" for k, v in response_headers.items())
full_text = headers_str + "\n" + response_body
findings = []
for pattern, cve_info in self.vulnerable_versions.items():
match = re.search(pattern, full_text, re.IGNORECASE)
if match:
version_str = match.group(0)
findings.append(f"{version_str} - {cve_info}")
if findings:
# Known CVEs are high confidence
has_cve = any("CVE-" in f for f in findings)
confidence = 0.9 if has_cve else 0.7
return True, confidence, f"Version disclosure: {'; '.join(findings[:3])}"
# Generic version disclosure in Server header
headers_lower = {k.lower(): v for k, v in response_headers.items()}
server = headers_lower.get("server", "")
if re.search(r"/\d+\.\d+", server):
return True, 0.5, f"Version disclosure in Server header: {server}"
return False, 0.0, None
@@ -201,3 +201,156 @@ class FileUploadTester(BaseTester):
return True, 0.8, "Executable file path returned - possible RCE"
return False, 0.0, None
class ArbitraryFileReadTester(BaseTester):
"""Tester for Arbitrary File Read vulnerabilities"""
def __init__(self):
super().__init__()
self.name = "arbitrary_file_read"
self.sensitive_file_patterns = {
# /etc/passwd format
r"root:.*:0:0:": "/etc/passwd",
r"daemon:.*:\d+:\d+:": "/etc/passwd",
r"nobody:.*:\d+:\d+:": "/etc/passwd",
# .env file patterns
r"(?:DB_PASSWORD|DATABASE_URL|SECRET_KEY|API_KEY|APP_SECRET)\s*=": ".env file",
r"(?:AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY)\s*=": ".env file (AWS credentials)",
# SSH key headers
r"-----BEGIN (?:RSA |DSA |EC |OPENSSH )?PRIVATE KEY-----": "SSH/TLS private key",
r"-----BEGIN CERTIFICATE-----": "TLS certificate",
# Shadow file
r"root:\$[0-9a-z]+\$": "/etc/shadow",
# Config files
r"<\?php.*\$db": "PHP config with DB credentials",
}
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for sensitive file contents in response"""
for pattern, file_desc in self.sensitive_file_patterns.items():
if re.search(pattern, response_body, re.IGNORECASE):
return True, 0.95, f"Arbitrary file read confirmed: {file_desc} content detected"
# Check for base64-encoded sensitive content
base64_pattern = re.findall(r'[A-Za-z0-9+/]{40,}={0,2}', response_body)
for b64_match in base64_pattern[:5]: # Check first 5 matches
try:
import base64
decoded = base64.b64decode(b64_match).decode('utf-8', errors='ignore')
if re.search(r"root:.*:0:0:", decoded) or re.search(r"-----BEGIN.*PRIVATE KEY-----", decoded):
return True, 0.9, "Arbitrary file read: Base64-encoded sensitive file content"
except Exception:
pass
return False, 0.0, None
class ArbitraryFileDeleteTester(BaseTester):
"""Tester for Arbitrary File Delete vulnerabilities"""
def __init__(self):
super().__init__()
self.name = "arbitrary_file_delete"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for successful file deletion indicators"""
body_lower = response_body.lower()
# Check for explicit deletion success messages
delete_success_patterns = [
r"file\s+(?:has been\s+)?(?:deleted|removed)\s+successfully",
r"(?:deleted|removed)\s+successfully",
r'"success"\s*:\s*true.*(?:delet|remov)',
r'"status"\s*:\s*"(?:deleted|removed)"',
r'"message"\s*:\s*".*(?:deleted|removed).*"',
]
for pattern in delete_success_patterns:
if re.search(pattern, response_body, re.IGNORECASE):
return True, 0.85, "Arbitrary file delete: Deletion success confirmed in response"
# Check for 200/204 on DELETE request with traversal path
traversal_indicators = ["../", "..\\", "%2e%2e", "..%2f", "..%5c"]
has_traversal = any(t in payload.lower() for t in traversal_indicators)
if has_traversal:
if response_status == 204:
return True, 0.8, "Arbitrary file delete: 204 No Content after path traversal delete"
if response_status == 200:
return True, 0.7, "Arbitrary file delete: 200 OK after path traversal delete request"
# Check for file-not-found on subsequent access (context-based)
if context.get("follow_up_status") == 404:
return True, 0.85, "Arbitrary file delete: File not found after deletion request"
return False, 0.0, None
class ZipSlipTester(BaseTester):
"""Tester for Zip Slip (path traversal in archive extraction)"""
def __init__(self):
super().__init__()
self.name = "zip_slip"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for path traversal in archive extraction"""
body_lower = response_body.lower()
# Check for traversal path acceptance in response
traversal_patterns = [
r"\.\./\.\./\.\./",
r"\.\.\\\.\.\\\.\.\\",
r"%2e%2e%2f",
r"%2e%2e/",
]
for pattern in traversal_patterns:
if re.search(pattern, response_body, re.IGNORECASE):
# Traversal path echoed in response
if response_status in [200, 201]:
return True, 0.8, "Zip Slip: Path traversal sequence accepted in archive extraction"
# Check for successful extraction with traversal payload
if response_status in [200, 201]:
extraction_success = [
r"extract(?:ed|ion)\s+(?:successful|complete)",
r"(?:file|archive)\s+(?:uploaded|processed)\s+successfully",
r'"extracted"\s*:\s*true',
r'"files"\s*:\s*\[.*\.\./.*\]',
]
for pattern in extraction_success:
if re.search(pattern, response_body, re.IGNORECASE):
if any(t in payload for t in ["../", "..\\", "%2e%2e"]):
return True, 0.85, "Zip Slip: Archive with traversal paths extracted successfully"
# Check for file written outside expected directory
overwrite_indicators = [
r"(?:overwr(?:ote|itten)|replaced)\s+.*(?:/etc/|/var/|/tmp/|C:\\)",
r"(?:created|wrote)\s+.*\.\./",
]
for pattern in overwrite_indicators:
if re.search(pattern, response_body, re.IGNORECASE):
return True, 0.9, "Zip Slip: File written outside extraction directory"
return False, 0.0, None
@@ -150,3 +150,360 @@ class HTTPMethodsTester(BaseTester):
return True, 0.6, f"{payload} method accepted"
return False, 0.0, None
class DirectoryListingTester(BaseTester):
"""Tester for Directory Listing exposure"""
def __init__(self):
super().__init__()
self.name = "directory_listing"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for directory listing patterns"""
if response_status == 200:
listing_patterns = [
r"<title>Index of\s*/",
r"Index of\s*/",
r"<h1>Index of",
r"Directory listing for\s*/",
r"<title>Directory listing",
r'<a\s+href="\.\./">\.\./</a>',
r"Parent Directory</a>",
r'\[DIR\]',
r'\[TXT\]',
r"<pre>.*<a href=",
]
for pattern in listing_patterns:
if re.search(pattern, response_body, re.IGNORECASE):
return True, 0.9, "Directory listing: Server directory contents exposed"
# Check for Apache/Nginx-specific listing
if re.search(r'<address>Apache/[\d.]+ .* Server at', response_body, re.IGNORECASE):
if "Index of" in response_body:
return True, 0.95, "Directory listing: Apache directory listing enabled"
return False, 0.0, None
class DebugModeTester(BaseTester):
"""Tester for Debug Mode/Page exposure"""
def __init__(self):
super().__init__()
self.name = "debug_mode"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for debug pages, stack traces with source paths"""
# Werkzeug/Flask debugger
werkzeug_patterns = [
r"Werkzeug\s+Debugger",
r"werkzeug\.debug",
r"<div class=\"debugger\">",
r"The debugger caught an exception",
r"__debugger__",
]
for pattern in werkzeug_patterns:
if re.search(pattern, response_body, re.IGNORECASE):
return True, 0.95, "Debug mode: Werkzeug interactive debugger exposed (RCE risk)"
# Laravel debug
laravel_patterns = [
r"Whoops!.*Laravel",
r"Ignition\s",
r"vendor/laravel",
r"Laravel.*Exception",
r"app/Http/Controllers",
]
for pattern in laravel_patterns:
if re.search(pattern, response_body, re.IGNORECASE):
return True, 0.9, "Debug mode: Laravel debug page exposed"
# Django debug
django_patterns = [
r"You\'re seeing this error because you have <code>DEBUG = True</code>",
r"Django Version:",
r"Traceback.*django",
r"INSTALLED_APPS",
r"settings\.py",
]
for pattern in django_patterns:
if re.search(pattern, response_body, re.IGNORECASE):
return True, 0.9, "Debug mode: Django debug page exposed"
# Generic stack traces with source paths
stack_trace_patterns = [
r"(?:File|at)\s+[\"']?(?:/[a-z]+/|C:\\)[^\s\"']+\.(?:py|php|rb|js|java|go)\b",
r"Traceback \(most recent call last\)",
r"Stack trace:.*(?:\.php|\.py|\.rb|\.java)",
r"(?:Error|Exception)\s+in\s+(?:/[a-z]+/|C:\\)[^\s]+:\d+",
]
for pattern in stack_trace_patterns:
if re.search(pattern, response_body, re.IGNORECASE | re.DOTALL):
return True, 0.8, "Debug mode: Stack trace with source file paths exposed"
# ASP.NET detailed errors
if re.search(r"Server Error in '/' Application", response_body):
return True, 0.85, "Debug mode: ASP.NET detailed error page exposed"
return False, 0.0, None
class ExposedAdminPanelTester(BaseTester):
"""Tester for Publicly Accessible Admin Panel"""
def __init__(self):
super().__init__()
self.name = "exposed_admin_panel"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for admin login pages accessible publicly"""
if response_status == 200:
admin_panel_patterns = [
(r"(?:admin|administrator)\s+(?:login|panel|dashboard|console)", "admin login page"),
(r"<title>[^<]*(?:admin|dashboard|control\s*panel|cms)[^<]*</title>", "admin title"),
(r"wp-login\.php", "WordPress login"),
(r"wp-admin", "WordPress admin"),
(r"/admin/login", "admin login endpoint"),
(r"phpmyadmin", "phpMyAdmin"),
(r"adminer\.php", "Adminer"),
(r"cPanel", "cPanel"),
(r"Webmin", "Webmin"),
(r"Plesk", "Plesk"),
(r"joomla.*administrator", "Joomla admin"),
(r"drupal.*user/login", "Drupal admin login"),
]
for pattern, panel_name in admin_panel_patterns:
if re.search(pattern, response_body, re.IGNORECASE):
return True, 0.75, f"Exposed admin panel: {panel_name} accessible publicly"
return False, 0.0, None
class ExposedApiDocsTester(BaseTester):
"""Tester for Publicly Accessible API Documentation"""
def __init__(self):
super().__init__()
self.name = "exposed_api_docs"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for Swagger/OpenAPI documentation pages"""
if response_status == 200:
api_docs_patterns = [
(r"swagger-ui", "Swagger UI"),
(r'"swagger"\s*:\s*"[0-9.]+"', "Swagger spec"),
(r'"openapi"\s*:\s*"[0-9.]+"', "OpenAPI spec"),
(r"swagger-ui-bundle\.js", "Swagger UI bundle"),
(r"<title>Swagger UI</title>", "Swagger UI page"),
(r"redoc", "ReDoc API docs"),
(r"api-docs", "API documentation"),
(r"graphiql", "GraphiQL interface"),
(r"GraphQL Playground", "GraphQL Playground"),
(r'"paths"\s*:\s*\{', "OpenAPI paths object"),
(r'"info"\s*:\s*\{.*"title"\s*:', "OpenAPI info object"),
]
for pattern, doc_type in api_docs_patterns:
if re.search(pattern, response_body, re.IGNORECASE):
return True, 0.8, f"Exposed API docs: {doc_type} publicly accessible"
# Check content type for JSON API specs
content_type = response_headers.get("Content-Type", "")
if "json" in content_type.lower():
if re.search(r'"paths"\s*:\s*\{.*"(?:get|post|put|delete)"', response_body, re.DOTALL):
return True, 0.85, "Exposed API docs: OpenAPI/Swagger JSON specification exposed"
return False, 0.0, None
class InsecureCookieFlagsTester(BaseTester):
"""Tester for Missing Secure/HttpOnly/SameSite Cookie Flags"""
def __init__(self):
super().__init__()
self.name = "insecure_cookie_flags"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check Set-Cookie headers for missing security flags"""
# Collect all Set-Cookie headers
set_cookie_values = []
for key, value in response_headers.items():
if key.lower() == "set-cookie":
if isinstance(value, list):
set_cookie_values.extend(value)
else:
set_cookie_values.append(value)
if not set_cookie_values:
return False, 0.0, None
issues = []
for cookie in set_cookie_values:
cookie_lower = cookie.lower()
cookie_name = cookie.split("=")[0].strip()
# Session cookies are more critical
is_session = any(
s in cookie_name.lower()
for s in ["session", "sess", "sid", "token", "auth", "jwt", "csrf"]
)
missing_flags = []
if "secure" not in cookie_lower:
missing_flags.append("Secure")
if "httponly" not in cookie_lower:
missing_flags.append("HttpOnly")
if "samesite" not in cookie_lower:
missing_flags.append("SameSite")
if missing_flags:
severity = "session cookie" if is_session else "cookie"
issues.append(f"{cookie_name} ({severity}): missing {', '.join(missing_flags)}")
if issues:
confidence = 0.8 if any("session cookie" in i for i in issues) else 0.6
return True, confidence, f"Insecure cookie flags: {'; '.join(issues[:3])}"
return False, 0.0, None
class HttpSmugglingTester(BaseTester):
"""Tester for HTTP Request Smuggling (CL/TE discrepancy)"""
def __init__(self):
super().__init__()
self.name = "http_smuggling"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for CL/TE discrepancy indicators"""
# Check for request smuggling indicators
smuggling_indicators = [
# Different response than expected
(r"400 Bad Request.*(?:Content-Length|Transfer-Encoding)", "CL/TE parsing error"),
(r"(?:invalid|malformed)\s+(?:chunk|transfer.encoding)", "chunked encoding error"),
]
for pattern, desc in smuggling_indicators:
if re.search(pattern, response_body, re.IGNORECASE | re.DOTALL):
return True, 0.7, f"HTTP smuggling indicator: {desc}"
# Check for dual Transfer-Encoding handling
te_header = response_headers.get("Transfer-Encoding", "")
cl_header = response_headers.get("Content-Length", "")
if te_header and cl_header:
return True, 0.75, "HTTP smuggling: Both Transfer-Encoding and Content-Length in response"
# Check for timeout-based detection (context)
if context.get("response_time_ms", 0) > 10000:
if "transfer-encoding" in payload.lower() or "content-length" in payload.lower():
return True, 0.6, "HTTP smuggling: Abnormal response delay with CL/TE payload"
# Check for response desync indicators
if response_status == 0 or context.get("connection_reset"):
return True, 0.65, "HTTP smuggling: Connection reset/timeout with smuggling payload"
return False, 0.0, None
class CachePoisoningTester(BaseTester):
"""Tester for Web Cache Poisoning"""
def __init__(self):
super().__init__()
self.name = "cache_poisoning"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for cached response with injected unkeyed input"""
if response_status == 200:
# Check for cache headers indicating response was cached
cache_indicators = {
"X-Cache": response_headers.get("X-Cache", ""),
"CF-Cache-Status": response_headers.get("CF-Cache-Status", ""),
"Age": response_headers.get("Age", ""),
"X-Varnish": response_headers.get("X-Varnish", ""),
}
is_cached = False
for header, value in cache_indicators.items():
if value:
if any(hit in value.upper() for hit in ["HIT", "STALE"]):
is_cached = True
break
if header == "Age" and int(value or 0) > 0:
is_cached = True
break
# Check if our unkeyed input is reflected in the cached response
if is_cached or response_headers.get("Cache-Control", ""):
# Common unkeyed headers that might be reflected
unkeyed_indicators = [
r"X-Forwarded-Host", r"X-Forwarded-Scheme",
r"X-Original-URL", r"X-Rewrite-URL",
]
if payload in response_body:
if is_cached:
return True, 0.9, "Cache poisoning: Injected unkeyed input reflected in cached response"
else:
return True, 0.7, "Cache poisoning: Unkeyed input reflected - verify caching"
# Check for Vary header missing expected values
vary = response_headers.get("Vary", "")
cache_control = response_headers.get("Cache-Control", "")
if "no-store" not in cache_control and "private" not in cache_control:
if payload in response_body:
return True, 0.6, "Cache poisoning potential: Input reflected in cacheable response"
return False, 0.0, None
+92 -21
View File
@@ -35,20 +35,15 @@ class XSSReflectedTester(BaseTester):
# Check if payload is reflected
if payload in response_body:
# Check if it's in a dangerous context
dangerous_patterns = [
rf'<script[^>]*>{re.escape(payload)}',
rf'on\w+\s*=\s*["\']?{re.escape(payload)}',
rf'javascript:\s*{re.escape(payload)}',
rf'<[^>]+{re.escape(payload)}[^>]*>',
]
for pattern in dangerous_patterns:
if re.search(pattern, response_body, re.IGNORECASE):
return True, 0.9, f"XSS payload reflected in dangerous context: {pattern}"
# Payload reflected but possibly encoded
return True, 0.7, "XSS payload reflected in response"
# Use context-aware analysis to determine execution position
from backend.core.xss_context_analyzer import analyze_xss_execution_context
ctx = analyze_xss_execution_context(response_body, payload)
if ctx["executable"]:
return True, 0.95, f"XSS payload in auto-executing context: {ctx['detail']}"
elif ctx["interactive"]:
return True, 0.85, f"XSS payload in interactive context: {ctx['detail']}"
# Reflected but not in executable position
return True, 0.5, f"XSS payload reflected but {ctx['context']}: {ctx['detail']}"
# Check for partial reflection (script tags, etc.)
for marker in self.markers:
@@ -59,11 +54,21 @@ class XSSReflectedTester(BaseTester):
class XSSStoredTester(BaseTester):
"""Tester for Stored XSS vulnerabilities"""
"""Tester for Stored XSS vulnerabilities.
Supports two-phase verification:
Phase 1: analyze_response() - Check if submission succeeded (data stored)
Phase 2: analyze_display_response() - Check if payload executes on display page
"""
def __init__(self):
super().__init__()
self.name = "xss_stored"
self.storage_indicators = [
"success", "created", "saved", "posted", "submitted",
"thank", "comment", "added", "published", "updated",
"your comment", "your post", "your message",
]
def analyze_response(
self,
@@ -73,12 +78,78 @@ class XSSStoredTester(BaseTester):
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for stored XSS - requires subsequent request verification"""
# For stored XSS, we need to check if data was stored
# This is a simplified check - full implementation would verify on retrieval
if response_status in [200, 201, 302]:
if "success" in response_body.lower() or "created" in response_body.lower():
return True, 0.5, "Data possibly stored - verify retrieval for stored XSS"
"""Phase 1: Check if payload was likely stored.
Returns confidence 0.3-0.5 for storage-only confirmation.
Full confirmation requires Phase 2 (analyze_display_response).
"""
body_lower = response_body.lower()
# Redirect after POST is a common form submission pattern
if response_status in [301, 302, 303]:
return True, 0.4, "Redirect after submission - payload likely stored"
if response_status in [200, 201]:
# Check for storage success indicators
for indicator in self.storage_indicators:
if indicator in body_lower:
return True, 0.4, f"Storage indicator found: '{indicator}'"
# Check if payload is reflected in the same response (immediate display)
if payload in response_body:
dangerous = [
"<script", "onerror=", "onload=", "onclick=", "onfocus=",
"onmouseover=", "<svg", "<img", "<iframe", "javascript:"
]
payload_lower = payload.lower()
for ctx in dangerous:
if ctx in payload_lower:
return True, 0.8, f"Stored XSS: payload reflected in dangerous context ({ctx})"
return True, 0.6, "Payload reflected in submission response"
# POST returning 200 often means submission accepted
if response_status == 200 and context.get("method") == "POST":
return True, 0.3, "POST returned 200 - submission possibly accepted"
return False, 0.0, None
def analyze_display_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Phase 2: Verify payload executes on the display page.
Called after navigating to the page where stored content is rendered.
"""
if response_status >= 400:
return False, 0.0, None
# Check if payload exists unescaped in display page
if payload in response_body:
# Use context-aware analysis to determine execution position
from backend.core.xss_context_analyzer import analyze_xss_execution_context
ctx = analyze_xss_execution_context(response_body, payload)
if ctx["executable"]:
return True, 0.95, f"Stored XSS confirmed: {ctx['detail']}"
elif ctx["interactive"]:
return True, 0.90, f"Stored XSS (interaction required): {ctx['detail']}"
# Payload present but not executable
return True, 0.5, f"Stored payload on display page but {ctx['context']}: {ctx['detail']}"
# Check for core execution markers even if full payload is modified
core_markers = [
"alert(1)", "alert(document.domain)", "onerror=alert",
"onload=alert", "onfocus=alert", "ontoggle=alert",
]
body_lower = response_body.lower()
for marker in core_markers:
if marker in payload.lower() and marker in body_lower:
return True, 0.85, f"Stored XSS: execution marker '{marker}' found on display page"
return False, 0.0, None
+457
View File
@@ -0,0 +1,457 @@
"""
NeuroSploit v3 - Logic and Protocol Vulnerability Testers
Testers for race conditions, business logic, rate limiting, parameter pollution,
type juggling, timing attacks, host header injection, HTTP smuggling, cache poisoning.
"""
import re
from typing import Tuple, Dict, Optional
from backend.core.vuln_engine.testers.base_tester import BaseTester
class RaceConditionTester(BaseTester):
"""Tester for Race Condition vulnerabilities"""
def __init__(self):
super().__init__()
self.name = "race_condition"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for duplicate operation success indicators"""
body_lower = response_body.lower()
# Multiple success responses from concurrent requests
if context.get("concurrent_successes", 0) > 1:
return True, 0.85, f"Race condition: {context['concurrent_successes']} concurrent requests succeeded"
# Double-spend / duplicate operation indicators
duplicate_indicators = [
"already processed", "duplicate", "already exists",
"already applied", "already redeemed",
]
# If we got a success despite expected duplicate check
if response_status in [200, 201]:
success_words = ["success", "created", "processed", "applied", "completed", "confirmed"]
if any(w in body_lower for w in success_words):
if context.get("request_count", 0) > 1:
return True, 0.7, "Race condition: operation succeeded multiple times"
# Check for resource count discrepancy
if "balance" in body_lower or "quantity" in body_lower or "count" in body_lower:
numbers = re.findall(r'"(?:balance|quantity|count|amount)"\s*:\s*(-?\d+\.?\d*)', response_body)
if numbers and context.get("expected_value") is not None:
try:
actual = float(numbers[0])
expected = float(context["expected_value"])
if actual != expected:
return True, 0.75, f"Race condition: value mismatch (expected {expected}, got {actual})"
except (ValueError, IndexError):
pass
return False, 0.0, None
class BusinessLogicTester(BaseTester):
"""Tester for Business Logic vulnerabilities"""
def __init__(self):
super().__init__()
self.name = "business_logic"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for business logic bypass indicators"""
body_lower = response_body.lower()
# Negative value acceptance
if re.search(r"-\d+", payload):
if response_status == 200:
if any(w in body_lower for w in ["success", "accepted", "processed", "approved"]):
return True, 0.8, "Business logic: negative value accepted"
# Check for negative pricing
if re.search(r'"(?:total|price|amount)"\s*:\s*-\d+', response_body):
return True, 0.9, "Business logic: negative price/amount in response"
# Zero-value bypass
if payload.strip() in ["0", "0.00", "0.0"]:
if response_status == 200 and "success" in body_lower:
return True, 0.75, "Business logic: zero value accepted for transaction"
# Workflow step skip
if context.get("skipped_step"):
if response_status == 200:
return True, 0.7, f"Business logic: step '{context['skipped_step']}' was skippable"
# Discount/coupon abuse
if "coupon" in payload.lower() or "discount" in payload.lower():
if re.search(r'"discount"\s*:\s*(?:100|[1-9]\d{2,})', response_body):
return True, 0.8, "Business logic: excessive discount applied"
# Role/privilege escalation via parameter
if any(w in payload.lower() for w in ["admin", "role=admin", "is_admin=true", "privilege"]):
if response_status == 200 and "admin" in body_lower:
return True, 0.6, "Business logic: privilege escalation parameter accepted"
return False, 0.0, None
class RateLimitBypassTester(BaseTester):
"""Tester for Rate Limit Bypass vulnerabilities"""
def __init__(self):
super().__init__()
self.name = "rate_limit_bypass"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for continued success after many requests (bypass)"""
headers_lower = {k.lower(): v for k, v in response_headers.items()}
# After many requests, still getting 200
request_count = context.get("request_count", 0)
if request_count > 50 and response_status == 200:
# Check rate limit headers
remaining = headers_lower.get("x-ratelimit-remaining",
headers_lower.get("x-rate-limit-remaining",
headers_lower.get("ratelimit-remaining")))
if remaining is not None:
try:
if int(remaining) > 0:
return True, 0.6, f"Rate limit not enforced after {request_count} requests (remaining: {remaining})"
except ValueError:
pass
else:
# No rate limit headers at all
return True, 0.7, f"No rate limiting detected after {request_count} requests"
# Rate limit bypass via header manipulation
bypass_headers = ["x-forwarded-for", "x-real-ip", "x-originating-ip", "x-client-ip"]
if any(h in payload.lower() for h in bypass_headers):
if response_status == 200 and context.get("was_rate_limited"):
return True, 0.85, "Rate limit bypassed via IP spoofing header"
# Check if 429 was expected but got 200
if context.get("expected_429") and response_status == 200:
return True, 0.8, "Expected 429 (rate limited) but received 200"
return False, 0.0, None
class ParameterPollutionTester(BaseTester):
"""Tester for HTTP Parameter Pollution vulnerabilities"""
def __init__(self):
super().__init__()
self.name = "parameter_pollution"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for different behavior with duplicate parameters"""
# Compare response with baseline (single param)
if "baseline_body" in context and "baseline_status" in context:
baseline_len = len(context["baseline_body"])
current_len = len(response_body)
diff = abs(current_len - baseline_len)
# Significant response difference
if diff > 200:
return True, 0.7, f"Parameter pollution: response differs by {diff} bytes from baseline"
# Status code difference
if response_status != context["baseline_status"]:
return True, 0.75, f"Parameter pollution: status changed from {context['baseline_status']} to {response_status}"
# Check if attacker-controlled value was used
if "neurosploit" in payload and "neurosploit" in response_body:
if context.get("original_value") and context["original_value"] not in response_body:
return True, 0.8, "Parameter pollution: attacker value used instead of original"
# WAF bypass via duplicate params
if context.get("waf_blocked_original") and response_status == 200:
return True, 0.8, "Parameter pollution: WAF bypass - blocked payload succeeded with duplicate params"
return False, 0.0, None
class TypeJugglingTester(BaseTester):
"""Tester for Type Juggling / Type Coercion vulnerabilities"""
def __init__(self):
super().__init__()
self.name = "type_juggling"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for auth bypass with type coercion"""
body_lower = response_body.lower()
# Type juggling payloads
juggling_values = ["0", "true", "false", "null", "[]", "{}", "0e123", "0e999"]
if payload.strip() in juggling_values or payload.strip().startswith("0e"):
# Auth bypass
if response_status == 200:
auth_success = [
"authenticated", "logged in", "welcome", "dashboard",
"token", "session", "success",
]
for indicator in auth_success:
if indicator in body_lower:
return True, 0.8, f"Type juggling: auth bypass with '{payload.strip()}' - '{indicator}' in response"
# JWT/token accepted
if "jwt" in body_lower or "bearer" in body_lower:
if response_status == 200:
return True, 0.7, f"Type juggling: token accepted with value '{payload.strip()}'"
# Magic hash comparison bypass (0e strings)
if re.match(r"0e\d+", payload.strip()):
if response_status == 200 and any(w in body_lower for w in ["match", "equal", "valid", "correct"]):
return True, 0.85, f"Type juggling: magic hash bypass with '{payload.strip()}'"
# Array vs string comparison
if payload.strip() in ["[]", "Array"]:
if response_status == 200 and "success" in body_lower:
return True, 0.7, "Type juggling: array comparison bypass"
return False, 0.0, None
class TimingAttackTester(BaseTester):
"""Tester for Timing Attack vulnerabilities"""
def __init__(self):
super().__init__()
self.name = "timing_attack"
def check_timeout_vulnerability(self, vuln_type: str) -> bool:
"""Timing attacks are detected via response time differences"""
return True
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check response time differences for timing side channels"""
# Primary detection is via response timing (handled by engine)
response_time = context.get("response_time_ms", 0)
baseline_time = context.get("baseline_time_ms", 0)
if response_time > 0 and baseline_time > 0:
diff = response_time - baseline_time
# Significant timing difference (> 100ms)
if diff > 100:
return True, 0.7, f"Timing attack: {diff}ms difference (baseline: {baseline_time}ms, actual: {response_time}ms)"
# Very significant (> 500ms)
if diff > 500:
return True, 0.9, f"Timing attack: {diff}ms difference strongly indicates character-by-character comparison"
# Check for timing via multiple measurements
if "timing_samples" in context:
samples = context["timing_samples"]
if len(samples) >= 2:
max_diff = max(samples) - min(samples)
if max_diff > 200:
return True, 0.65, f"Timing attack: {max_diff}ms variance across samples"
return False, 0.0, None
class HostHeaderInjectionTester(BaseTester):
"""Tester for Host Header Injection vulnerabilities"""
def __init__(self):
super().__init__()
self.name = "host_header_injection"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for Host value reflected in response links/URLs"""
body_lower = response_body.lower()
# Injected host appearing in response
evil_hosts = ["evil.com", "attacker.com", "neurosploit.test", "canary.host"]
for host in evil_hosts:
if host in payload.lower() and host in body_lower:
# High confidence if in URL context
url_contexts = [
rf'https?://{re.escape(host)}',
rf'href\s*=\s*["\'][^"\']*{re.escape(host)}',
rf'action\s*=\s*["\'][^"\']*{re.escape(host)}',
rf'redirect.*{re.escape(host)}',
]
for pattern in url_contexts:
if re.search(pattern, response_body, re.IGNORECASE):
return True, 0.9, f"Host header injection: '{host}' reflected in URL context"
return True, 0.7, f"Host header injection: '{host}' reflected in response body"
# X-Forwarded-Host injection
if "x-forwarded-host" in payload.lower():
headers_lower = {k.lower(): v for k, v in response_headers.items()}
location = headers_lower.get("location", "")
if any(h in location.lower() for h in evil_hosts):
return True, 0.9, "Host header injection: X-Forwarded-Host reflected in redirect"
# Password reset link poisoning
if context.get("is_password_reset") and response_status == 200:
for host in evil_hosts:
if host in body_lower:
return True, 0.95, f"Host header injection in password reset: link points to '{host}'"
return False, 0.0, None
class HttpSmugglingTester(BaseTester):
"""Tester for HTTP Request Smuggling vulnerabilities"""
def __init__(self):
super().__init__()
self.name = "http_smuggling"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for HTTP smuggling indicators"""
headers_lower = {k.lower(): v for k, v in response_headers.items()}
# Response splitting - two HTTP responses in one
if re.search(r"HTTP/\d\.\d\s+\d{3}", response_body):
return True, 0.85, "HTTP smuggling: embedded HTTP response in body (response splitting)"
# Conflicting Content-Length and Transfer-Encoding
has_cl = "content-length" in headers_lower
has_te = "transfer-encoding" in headers_lower
if has_cl and has_te:
return True, 0.7, "HTTP smuggling: both Content-Length and Transfer-Encoding present"
# CL.TE or TE.CL desync indicators
if context.get("desync_detected"):
return True, 0.9, "HTTP smuggling: request desync confirmed"
# Unexpected response to smuggled second request
if "smuggle_marker" in context:
marker = context["smuggle_marker"]
if marker in response_body:
return True, 0.85, f"HTTP smuggling: smuggled request marker '{marker}' in response"
# Different status than expected (frontend vs backend disagreement)
if context.get("expected_status") and response_status != context["expected_status"]:
if response_status in [400, 403] and context["expected_status"] == 200:
return True, 0.5, f"HTTP smuggling: status mismatch (expected {context['expected_status']}, got {response_status})"
# Timeout on second request (queued/poisoned)
if context.get("second_request_timeout"):
return True, 0.7, "HTTP smuggling: second request timed out (possible queue poisoning)"
return False, 0.0, None
def check_timeout_vulnerability(self, vuln_type: str) -> bool:
"""Smuggling can cause timeouts on subsequent requests"""
return True
class CachePoisoningTester(BaseTester):
"""Tester for Web Cache Poisoning vulnerabilities"""
def __init__(self):
super().__init__()
self.name = "cache_poisoning"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for cache poisoning - injected content served from cache"""
headers_lower = {k.lower(): v for k, v in response_headers.items()}
# Check for cache hit with injected content
cache_hit = False
cache_headers = ["x-cache", "cf-cache-status", "x-varnish", "x-drupal-cache",
"x-proxy-cache", "x-cdn-cache"]
for header in cache_headers:
value = headers_lower.get(header, "").lower()
if "hit" in value:
cache_hit = True
break
# Age header indicates cached response
age = headers_lower.get("age")
if age and age != "0":
cache_hit = True
if cache_hit:
# Check if our injected content is in the cached response
injection_markers = ["neurosploit", "xss", "evil.com", "attacker"]
for marker in injection_markers:
if marker in payload.lower() and marker in response_body.lower():
return True, 0.9, f"Cache poisoning: injected content '{marker}' served from cache"
# Unkeyed header reflected in response (potential cache poison vector)
unkeyed_headers = ["x-forwarded-host", "x-forwarded-scheme", "x-original-url",
"x-rewrite-url", "x-forwarded-prefix"]
for header in unkeyed_headers:
if header in payload.lower():
# Check if the value appears in response
for marker in ["evil.com", "neurosploit", "attacker"]:
if marker in payload.lower() and marker in response_body.lower():
cache_status = "cached" if cache_hit else "uncached"
confidence = 0.85 if cache_hit else 0.5
return True, confidence, f"Cache poisoning: unkeyed header '{header}' reflected ({cache_status})"
# Cache deception check
if context.get("is_cache_deception_test"):
if cache_hit and ("token" in response_body.lower() or "session" in response_body.lower()):
return True, 0.8, "Cache deception: sensitive data cached via path confusion"
return False, 0.0, None
@@ -97,3 +97,115 @@ class CSRFTester(BaseTester):
return True, 0.7, "No CSRF token found in form - possible CSRF"
return False, 0.0, None
class GraphqlIntrospectionTester(BaseTester):
"""Tester for GraphQL Introspection exposure"""
def __init__(self):
super().__init__()
self.name = "graphql_introspection"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for __schema data in response indicating introspection is enabled"""
if response_status == 200:
# Direct __schema indicators
schema_patterns = [
r'"__schema"\s*:\s*\{',
r'"__type"\s*:\s*\{',
r'"queryType"\s*:\s*\{',
r'"mutationType"\s*:\s*\{',
r'"subscriptionType"\s*:',
r'"types"\s*:\s*\[.*"name"\s*:\s*"__',
r'"directives"\s*:\s*\[.*"name"\s*:',
]
for pattern in schema_patterns:
if re.search(pattern, response_body, re.IGNORECASE | re.DOTALL):
return True, 0.9, "GraphQL introspection: Full schema exposed via __schema query"
# Check for type listings
type_listing_patterns = [
r'"kind"\s*:\s*"(?:OBJECT|SCALAR|ENUM|INPUT_OBJECT|INTERFACE|UNION)"',
r'"fields"\s*:\s*\[.*"name"\s*:.*"type"\s*:',
r'"inputFields"\s*:\s*\[',
r'"enumValues"\s*:\s*\[',
]
type_match_count = sum(
1 for p in type_listing_patterns
if re.search(p, response_body, re.IGNORECASE | re.DOTALL)
)
if type_match_count >= 2:
return True, 0.85, "GraphQL introspection: Type schema data exposed"
# Check for field suggestions (partial introspection)
if re.search(r'"(?:message|errors)".*"Did you mean.*"', response_body):
return True, 0.6, "GraphQL introspection: Field suggestions leak schema information"
return False, 0.0, None
class GraphqlDosTester(BaseTester):
"""Tester for GraphQL Denial of Service via deeply nested queries"""
def __init__(self):
super().__init__()
self.name = "graphql_dos"
def analyze_response(
self,
payload: str,
response_status: int,
response_headers: Dict,
response_body: str,
context: Dict
) -> Tuple[bool, float, Optional[str]]:
"""Check for slow response with deeply nested queries indicating DoS potential"""
# Check response time for nested query DoS
response_time_ms = context.get("response_time_ms", 0)
# Nested query indicators in payload
nesting_indicators = [
payload.count("{") > 5,
"__typename" in payload and payload.count("__typename") > 3,
"fragment" in payload.lower() and "..." in payload,
]
is_nested_payload = any(nesting_indicators)
if is_nested_payload:
# Very slow response indicates resource exhaustion
if response_time_ms > 10000: # > 10 seconds
return True, 0.85, f"GraphQL DoS: Deeply nested query caused {response_time_ms}ms response time"
if response_time_ms > 5000: # > 5 seconds
return True, 0.7, f"GraphQL DoS: Nested query caused slow response ({response_time_ms}ms)"
# Check for timeout/error responses
if response_status in [408, 504, 502]:
if is_nested_payload:
return True, 0.8, "GraphQL DoS: Server timeout on deeply nested query"
# Check for resource limit errors (server has some protection but confirms issue)
resource_errors = [
r"query.*(?:too complex|too deep|exceeds.*(?:depth|complexity))",
r"max.*(?:depth|complexity).*(?:exceeded|reached)",
r"(?:depth|complexity)\s+limit",
r"query.*(?:cost|weight).*exceeded",
]
for pattern in resource_errors:
if re.search(pattern, response_body, re.IGNORECASE):
return True, 0.5, "GraphQL DoS: Depth/complexity limits exist but confirm nested queries are processed"
# Server error on complex query
if response_status == 500 and is_nested_payload:
return True, 0.65, "GraphQL DoS: Server error on deeply nested query"
return False, 0.0, None