From 8f6e7ec63c1bb7f86aff628fe82b7780133e543a Mon Sep 17 00:00:00 2001 From: Michael Douglas Date: Thu, 29 Oct 2026 16:46:25 -0300 Subject: [PATCH 1/6] feat(scanner): WAF evasion engine, bump v1.5.0 WafState + WafEvasion with 5 bypass strategies (DOUBLE_ENCODE, CASE_MUTATION, UNICODE_NORMALIZE, CHARSET_EBCDIC, HPP). ScanRunner Phase 1.5 auto-probes effective bypass after WAF detection. HeadersModule marks shared WafState on 403 + WAF signature. SQLi, XSS, LFI, SSTI, CmdInjection, XXE modules integrated with evasionPayload() for transparent WAF bypass during scans. 26 WAF tests (18 unit + 8 integration with mock WAF server). --- README.md | 17 +- build.gradle | 2 +- .../effront/anarcry/scanner/ScanContext.java | 14 +- .../anarcry/scanner/ScanOrchestrator.java | 3 +- .../effront/anarcry/scanner/ScanRunner.java | 20 +- .../scanner/crypto/headers/HeadersModule.java | 1 + .../anarcry/scanner/util/WafEvasion.java | 166 ++++++++ .../anarcry/scanner/util/WafState.java | 34 ++ .../web/cmdinject/CommandInjectionModule.java | 7 +- .../anarcry/scanner/web/lfi/LfiModule.java | 11 +- .../scanner/web/sqli/SqlInjectionModule.java | 5 +- .../anarcry/scanner/web/ssti/SstiModule.java | 5 +- .../anarcry/scanner/web/xss/XssModule.java | 3 +- .../anarcry/scanner/web/xxe/XxeModule.java | 16 +- .../anarcry/harness/Sprint18E2ETest.java | 3 +- .../anarcry/harness/WafHarnessTest.java | 365 ++++++++++++++++++ .../anarcry/scanner/util/WafEvasionTest.java | 263 +++++++++++++ 17 files changed, 910 insertions(+), 25 deletions(-) create mode 100644 src/main/java/gg/effront/anarcry/scanner/util/WafEvasion.java create mode 100644 src/main/java/gg/effront/anarcry/scanner/util/WafState.java create mode 100644 src/test/java/gg/effront/anarcry/harness/WafHarnessTest.java create mode 100644 src/test/java/gg/effront/anarcry/scanner/util/WafEvasionTest.java 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/build.gradle b/build.gradle index 2d442df..4c464be 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 { 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..8b9bd6f 100644 --- a/src/main/java/gg/effront/anarcry/scanner/ScanRunner.java +++ b/src/main/java/gg/effront/anarcry/scanner/ScanRunner.java @@ -2,6 +2,8 @@ import gg.effront.anarcry.model.Finding; import gg.effront.anarcry.model.RequestRecord; +import gg.effront.anarcry.scanner.util.HttpClientFactory; +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; @@ -118,7 +120,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 +244,22 @@ private void runScan(ScanContext origCtx) { eventCb.accept(new ScanEvent(ScanEvent.Type.MODULE_DONE, enricher.id(), moduleIndex, moduleCount)); } + // Phase 1.5: WAF detection probe + if (ctx.wafState().isWafDetected()) { + safeLog.accept( + "[*] Phase 1.5: WAF detectado (" + ctx.wafState().wafProduct() + ") -- probing bypass strategies"); + try { + WafEvasion.Strategy effective = WafEvasion.probeStrategies(ctx, HttpClientFactory.http2(), + ctx.targetUrl(), "WafProbe", safeLog); + if (effective != null) { + ctx.wafState().markEffectiveStrategy(effective); + safeLog.accept("[+] WAF bypass: usando " + effective + " para modulos de payload"); + } + } 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..a760771 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 @@ -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/util/WafEvasion.java b/src/main/java/gg/effront/anarcry/scanner/util/WafEvasion.java new file mode 100644 index 0000000..3f1a898 --- /dev/null +++ b/src/main/java/gg/effront/anarcry/scanner/util/WafEvasion.java @@ -0,0 +1,166 @@ +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.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) { + String first = URLEncoder.encode(payload, StandardCharsets.UTF_8); + return first.replace("%", "%25"); + } + + 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) : ""; + } + + // -- 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)) { + log.accept("[+] WAF bypass: estrategia " + s + " efetiva (HTTP " + resp.statusCode() + ")"); + return s; + } + } catch (Exception ignored) { + } + } + 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..80ba19a --- /dev/null +++ b/src/main/java/gg/effront/anarcry/scanner/util/WafState.java @@ -0,0 +1,34 @@ +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<>(); + + 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(); + } +} 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..c90561d 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; @@ -130,7 +131,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"); @@ -313,7 +314,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 +383,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/sqli/SqlInjectionModule.java b/src/main/java/gg/effront/anarcry/scanner/web/sqli/SqlInjectionModule.java index 34b8ebf..9ea1ca9 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; @@ -103,7 +104,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)) @@ -155,7 +156,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/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/WafHarnessTest.java b/src/test/java/gg/effront/anarcry/harness/WafHarnessTest.java new file mode 100644 index 0000000..618e3fd --- /dev/null +++ b/src/test/java/gg/effront/anarcry/harness/WafHarnessTest.java @@ -0,0 +1,365 @@ +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(""); + HttpRequest req = HttpRequest.newBuilder().uri(URI.create("http://127.0.0.1:" + port + "/?q=" + encoded)) + .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"); + + HttpRequest evadedReq = HttpRequest.newBuilder() + .uri(URI.create("http://127.0.0.1:" + port + "/?q=" + evaded)).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..c2ce5ef --- /dev/null +++ b/src/test/java/gg/effront/anarcry/scanner/util/WafEvasionTest.java @@ -0,0 +1,263 @@ +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.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"); + // Should use DOUBLE_ENCODE strategy + assertTrue(result.contains("%25"), "DOUBLE_ENCODE deve produzir %25 no resultado, got: " + result); + } +} From 2094063c5ff6538601ffa14eb82421ea66855da5 Mon Sep 17 00:00:00 2001 From: Michael Douglas Date: Thu, 29 Oct 2026 16:47:35 -0300 Subject: [PATCH 2/6] chore: bump anarchrypto launcher to v1.5.0 --- anarchrypto | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/anarchrypto b/anarchrypto index a8343b6..7f4b15a 100755 --- a/anarchrypto +++ b/anarchrypto @@ -1,5 +1,5 @@ #!/bin/bash -JAR="$(dirname "$(realpath "$0")")/build/libs/anarcry-1.4.2.jar" +JAR="$(dirname "$(realpath "$0")")/build/libs/anarcry-1.5.0.jar" SELF="$(realpath "$0")" if [ ! -f "$JAR" ]; then @@ -21,7 +21,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 v1.5.0" \ bash "$SELF" "$@" fi printf '\e[8;54;220t' From fffac1796488d0bae247fea20dffe00ce899e71d Mon Sep 17 00:00:00 2001 From: Michael Douglas Date: Thu, 29 Oct 2026 16:52:51 -0300 Subject: [PATCH 3/6] refactor: centralize version in build.gradle via Version class Version.java reads version.properties (expanded by Gradle processResources) and exposes get(), label(), userAgent(). Replaces all hardcoded version strings in AnarCryGui, AnarCryTui, HeadersModule, HSTSModule, RepeaterView and the anarchrypto launcher. --- anarchrypto | 12 ++++-- build.gradle | 6 +++ src/main/java/gg/effront/anarcry/Version.java | 37 +++++++++++++++++++ .../gg/effront/anarcry/gui/AnarCryGui.java | 6 ++- .../anarcry/gui/views/RepeaterView.java | 2 +- .../scanner/crypto/headers/HeadersModule.java | 2 +- .../scanner/crypto/hsts/HSTSModule.java | 4 +- .../gg/effront/anarcry/tui/AnarCryTui.java | 4 +- src/main/resources/version.properties | 1 + 9 files changed, 63 insertions(+), 11 deletions(-) create mode 100644 src/main/java/gg/effront/anarcry/Version.java create mode 100644 src/main/resources/version.properties diff --git a/anarchrypto b/anarchrypto index 7f4b15a..875bbab 100755 --- a/anarchrypto +++ b/anarchrypto @@ -1,9 +1,13 @@ #!/bin/bash -JAR="$(dirname "$(realpath "$0")")/build/libs/anarcry-1.5.0.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.5.0" \ + --title "AnarCry v${VERSION}" \ bash "$SELF" "$@" fi printf '\e[8;54;220t' diff --git a/build.gradle b/build.gradle index 4c464be..f3575d0 100644 --- a/build.gradle +++ b/build.gradle @@ -39,6 +39,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/scanner/crypto/headers/HeadersModule.java b/src/main/java/gg/effront/anarcry/scanner/crypto/headers/HeadersModule.java index a760771..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; 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..010dfd9 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; 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} From baaa05e146e71ecf9b04a9c401478ca2775af58b Mon Sep 17 00:00:00 2001 From: Michael Douglas Date: Fri, 30 Oct 2026 15:25:39 -0300 Subject: [PATCH 4/6] fix: Codacy findings, BrowserTransport phase 2 skip, report grouping - Fix doubleEncode triple-encoding bug: return single-encoded so caller's URLEncoder.encode completes the double on wire - Skip HPP/EBCDIC as effective strategy in probeStrategies (not GET-compatible) - Add JS challenge detection (isJsChallenge, detectChallengeType) - Skip time-based blind phase in CmdInjection and SQLi when BrowserTransport is active (latency makes timing unreliable) - Group report Details section by module+title to collapse duplicate findings that differ only by endpoint - PQC positive findings (TLS 1.3/X25519MLKEM768 supported) now correctly report as INFO instead of LOW - Add BrowserTransport, ChallengeSolver, StealthHttpClient - Add CommandInjectionModuleTest (9 tests), SQLi BT tests (2), WafEvasion JS challenge tests (19), doubleEncode wire-form test --- build.gradle | 3 +- .../effront/anarcry/report/ReportEngine.java | 39 ++- .../effront/anarcry/scanner/ScanRunner.java | 100 ++++++- .../scanner/crypto/hsts/HSTSModule.java | 8 + .../anarcry/scanner/crypto/pqc/PqcModule.java | 6 +- .../scanner/util/BrowserTransport.java | 169 +++++++++++ .../anarcry/scanner/util/ChallengeSolver.java | 122 ++++++++ .../gg/effront/anarcry/scanner/util/Http.java | 21 +- .../scanner/util/HttpClientFactory.java | 22 ++ .../scanner/util/StealthHttpClient.java | 228 +++++++++++++++ .../anarcry/scanner/util/WafEvasion.java | 42 ++- .../anarcry/scanner/util/WafState.java | 58 ++++ .../web/cmdinject/CommandInjectionModule.java | 14 +- .../scanner/web/oauth/OAuthModule.java | 28 +- .../scanner/web/sqli/SqlInjectionModule.java | 7 +- .../harness/CommandInjectionModuleTest.java | 264 ++++++++++++++++++ .../harness/SqlInjectionModuleTest.java | 76 +++++ .../anarcry/harness/WafHarnessTest.java | 12 +- .../anarcry/scanner/util/WafEvasionTest.java | 200 ++++++++++++- 19 files changed, 1365 insertions(+), 54 deletions(-) create mode 100644 src/main/java/gg/effront/anarcry/scanner/util/BrowserTransport.java create mode 100644 src/main/java/gg/effront/anarcry/scanner/util/ChallengeSolver.java create mode 100644 src/main/java/gg/effront/anarcry/scanner/util/StealthHttpClient.java create mode 100644 src/test/java/gg/effront/anarcry/harness/CommandInjectionModuleTest.java diff --git a/build.gradle b/build.gradle index f3575d0..069b25d 100644 --- a/build.gradle +++ b/build.gradle @@ -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) 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/ScanRunner.java b/src/main/java/gg/effront/anarcry/scanner/ScanRunner.java index 8b9bd6f..a63c732 100644 --- a/src/main/java/gg/effront/anarcry/scanner/ScanRunner.java +++ b/src/main/java/gg/effront/anarcry/scanner/ScanRunner.java @@ -2,7 +2,9 @@ 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; @@ -96,6 +98,7 @@ public void run(ScanContext ctx) { } catch (Exception e) { logCb.accept("[!] Scan falhou: " + e.getMessage()); } finally { + ctx.wafState().closeBrowserTransport(); onScanFinish.run(); } }); @@ -244,19 +247,96 @@ private void runScan(ScanContext origCtx) { eventCb.accept(new ScanEvent(ScanEvent.Type.MODULE_DONE, enricher.id(), moduleIndex, moduleCount)); } - // Phase 1.5: WAF detection probe - if (ctx.wafState().isWafDetected()) { - safeLog.accept( - "[*] Phase 1.5: WAF detectado (" + ctx.wafState().wafProduct() + ") -- probing bypass strategies"); + // Phase 1.5: WAF detection + bypass + if (!ctx.wafState().isWafDetected()) { + safeLog.accept("[*] Phase 1.5: WAF detection probe em " + ctx.targetUrl()); try { - WafEvasion.Strategy effective = WafEvasion.probeStrategies(ctx, HttpClientFactory.http2(), - ctx.targetUrl(), "WafProbe", safeLog); - if (effective != null) { - ctx.wafState().markEffectiveStrategy(effective); - safeLog.accept("[+] WAF bypass: usando " + effective + " para modulos de payload"); + 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("[!] WAF probe falhou: " + e.getMessage()); + 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()); + } } } 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 010dfd9..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 @@ -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..9ac9aff --- /dev/null +++ b/src/main/java/gg/effront/anarcry/scanner/util/BrowserTransport.java @@ -0,0 +1,169 @@ +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; + + 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 index 3f1a898..b3f5925 100644 --- a/src/main/java/gg/effront/anarcry/scanner/util/WafEvasion.java +++ b/src/main/java/gg/effront/anarcry/scanner/util/WafEvasion.java @@ -11,6 +11,7 @@ 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; @@ -36,8 +37,7 @@ private WafEvasion() { // -- Strategy implementations (GET-compatible: transform payload string) -- public static String doubleEncode(String payload) { - String first = URLEncoder.encode(payload, StandardCharsets.UTF_8); - return first.replace("%", "%25"); + return URLEncoder.encode(payload, StandardCharsets.UTF_8); } public static String mutateCase(String payload) { @@ -130,6 +130,36 @@ public static String detectWafProduct(String 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, @@ -154,10 +184,16 @@ public static Strategy probeStrategies(ScanContext ctx, HttpClient hc, String ta 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 ignored) { + } catch (Exception e) { + log.accept("[-] WAF probe " + s + " falhou: " + e.getMessage()); } } log.accept("[-] WAF bypass: nenhuma estrategia efetiva"); diff --git a/src/main/java/gg/effront/anarcry/scanner/util/WafState.java b/src/main/java/gg/effront/anarcry/scanner/util/WafState.java index 80ba19a..1bda554 100644 --- a/src/main/java/gg/effront/anarcry/scanner/util/WafState.java +++ b/src/main/java/gg/effront/anarcry/scanner/util/WafState.java @@ -8,6 +8,11 @@ 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); @@ -31,4 +36,57 @@ public void markEffectiveStrategy(WafEvasion.Strategy 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 c90561d..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 @@ -95,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<>(); @@ -178,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); @@ -190,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) { 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 9ea1ca9..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 @@ -77,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<>(); @@ -143,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) { 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..9d3355a --- /dev/null +++ b/src/test/java/gg/effront/anarcry/harness/CommandInjectionModuleTest.java @@ -0,0 +1,264 @@ +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.lang.reflect.Constructor; +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() throws Exception { + Constructor ctor = BrowserTransport.class.getDeclaredConstructors()[0]; + ctor.setAccessible(true); + return (BrowserTransport) ctor.newInstance(null, null, null, "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/SqlInjectionModuleTest.java b/src/test/java/gg/effront/anarcry/harness/SqlInjectionModuleTest.java index afc7bc0..11bc3dc 100644 --- a/src/test/java/gg/effront/anarcry/harness/SqlInjectionModuleTest.java +++ b/src/test/java/gg/effront/anarcry/harness/SqlInjectionModuleTest.java @@ -4,10 +4,13 @@ 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.lang.reflect.Constructor; import java.net.InetSocketAddress; +import java.util.ArrayList; import java.util.List; import java.util.Set; @@ -159,4 +162,77 @@ 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() throws Exception { + Constructor ctor = BrowserTransport.class.getDeclaredConstructors()[0]; + ctor.setAccessible(true); + return (BrowserTransport) ctor.newInstance(null, null, null, "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 index 618e3fd..e38ebbe 100644 --- a/src/test/java/gg/effront/anarcry/harness/WafHarnessTest.java +++ b/src/test/java/gg/effront/anarcry/harness/WafHarnessTest.java @@ -116,9 +116,11 @@ void wafPassesDoubleEncodedPayload() throws Exception { int port = server.getAddress().getPort(); try { HttpClient hc = HttpClientFactory.http2(); - String encoded = WafEvasion.doubleEncode(""); - HttpRequest req = HttpRequest.newBuilder().uri(URI.create("http://127.0.0.1:" + port + "/?q=" + encoded)) - .GET().build(); + // doubleEncode returns single-encoded; caller's URLEncode completes the double + String transformed = WafEvasion.doubleEncode(""); + 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()); @@ -247,8 +249,10 @@ void evasionPayloadIntegration() throws Exception { 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=" + evaded)).GET().build(); + .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()); diff --git a/src/test/java/gg/effront/anarcry/scanner/util/WafEvasionTest.java b/src/test/java/gg/effront/anarcry/scanner/util/WafEvasionTest.java index c2ce5ef..280672a 100644 --- a/src/test/java/gg/effront/anarcry/scanner/util/WafEvasionTest.java +++ b/src/test/java/gg/effront/anarcry/scanner/util/WafEvasionTest.java @@ -5,6 +5,9 @@ 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; @@ -25,10 +28,10 @@ class WafEvasionTest { @Order(1) void doubleEncodeTransforms() { String result = WafEvasion.doubleEncode(""; String result = WafEvasion.evasionPayload(ctx, payload); assertNotEquals(payload, result, "Com WAF + strategy definida, payload deve ser transformado"); - // Should use DOUBLE_ENCODE strategy - assertTrue(result.contains("%25"), "DOUBLE_ENCODE deve produzir %25 no resultado, got: " + result); + // 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()); } } From 18704134fd108286f12f8dc3b524c1c15fe9f0e5 Mon Sep 17 00:00:00 2001 From: Michael Douglas Date: Fri, 30 Oct 2026 15:58:42 -0300 Subject: [PATCH 5/6] fix: replace setAccessible reflection with public stub factory Add BrowserTransport.stub(targetHost) factory method for tests, replacing reflection-based constructor access flagged by Codacy. --- .../gg/effront/anarcry/scanner/util/BrowserTransport.java | 4 ++++ .../anarcry/harness/CommandInjectionModuleTest.java | 7 ++----- .../gg/effront/anarcry/harness/SqlInjectionModuleTest.java | 7 ++----- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/main/java/gg/effront/anarcry/scanner/util/BrowserTransport.java b/src/main/java/gg/effront/anarcry/scanner/util/BrowserTransport.java index 9ac9aff..560e7a2 100644 --- a/src/main/java/gg/effront/anarcry/scanner/util/BrowserTransport.java +++ b/src/main/java/gg/effront/anarcry/scanner/util/BrowserTransport.java @@ -47,6 +47,10 @@ public final class BrowserTransport implements AutoCloseable { 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; diff --git a/src/test/java/gg/effront/anarcry/harness/CommandInjectionModuleTest.java b/src/test/java/gg/effront/anarcry/harness/CommandInjectionModuleTest.java index 9d3355a..290a8ec 100644 --- a/src/test/java/gg/effront/anarcry/harness/CommandInjectionModuleTest.java +++ b/src/test/java/gg/effront/anarcry/harness/CommandInjectionModuleTest.java @@ -9,7 +9,6 @@ import gg.effront.anarcry.scanner.util.BrowserTransport; import gg.effront.anarcry.scanner.web.cmdinject.CommandInjectionModule; -import java.lang.reflect.Constructor; import java.net.InetSocketAddress; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; @@ -26,10 +25,8 @@ @TestMethodOrder(MethodOrderer.OrderAnnotation.class) class CommandInjectionModuleTest { - private static BrowserTransport stubBrowserTransport() throws Exception { - Constructor ctor = BrowserTransport.class.getDeclaredConstructors()[0]; - ctor.setAccessible(true); - return (BrowserTransport) ctor.newInstance(null, null, null, "stub.local"); + private static BrowserTransport stubBrowserTransport() { + return BrowserTransport.stub("stub.local"); } private static HttpServer vulnerableServer() throws Exception { diff --git a/src/test/java/gg/effront/anarcry/harness/SqlInjectionModuleTest.java b/src/test/java/gg/effront/anarcry/harness/SqlInjectionModuleTest.java index 11bc3dc..21843ce 100644 --- a/src/test/java/gg/effront/anarcry/harness/SqlInjectionModuleTest.java +++ b/src/test/java/gg/effront/anarcry/harness/SqlInjectionModuleTest.java @@ -8,7 +8,6 @@ import gg.effront.anarcry.scanner.web.sqli.SqlInjectionModule; import org.junit.jupiter.api.*; -import java.lang.reflect.Constructor; import java.net.InetSocketAddress; import java.util.ArrayList; import java.util.List; @@ -167,10 +166,8 @@ void sqliCweRegistryEntry() { // BrowserTransport interaction: Phase 2 skip // ------------------------------------------------- - private static BrowserTransport stubBrowserTransport() throws Exception { - Constructor ctor = BrowserTransport.class.getDeclaredConstructors()[0]; - ctor.setAccessible(true); - return (BrowserTransport) ctor.newInstance(null, null, null, "stub.local"); + private static BrowserTransport stubBrowserTransport() { + return BrowserTransport.stub("stub.local"); } @Test From 814067ff6fe0ca43bd1137c52f99f155550ca1f6 Mon Sep 17 00:00:00 2001 From: Michael Douglas Date: Fri, 30 Oct 2026 18:18:17 -0300 Subject: [PATCH 6/6] chore: retrigger Codacy analysis