Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions ares-cli/src/orchestrator/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,18 @@ async fn run_inner() -> Result<()> {
"Configuration loaded"
);

// Install the operation scope so `ares_tools::dispatch` rejects single-IP
// tool invocations against hosts the operator never authorized. Empty
// target_ips → unrestricted (legacy/test launches that didn't pass IPs).
let scope = ares_tools::scope::OperationScope::new(config.target_ips.clone());
ares_tools::scope::init_scope(scope);
if !config.target_ips.is_empty() {
info!(
target_ips = %config.target_ips.join(","),
"Installed operation scope — out-of-scope single-IP tool calls will be rejected"
);
}

let queue = TaskQueue::connect(&config.redis_url, &config.nats_url)
.await
.context("Failed to connect to Redis/NATS")?;
Expand Down
12 changes: 12 additions & 0 deletions ares-cli/src/worker/tool_executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,18 @@ pub async fn run_tool_exec_loop(
status_tx: tokio::sync::watch::Sender<WorkerStatus>,
shutdown: Arc<tokio::sync::Notify>,
) -> anyhow::Result<()> {
// Install the operation scope from ARES_OPERATION_ID so out-of-scope
// single-IP tool calls get rejected before any subprocess runs. The worker
// doesn't otherwise parse target_ips out of the env JSON; this is the
// only path that needs them.
let scope = ares_tools::scope::install_from_env();
if !scope.is_unrestricted() {
info!(
target_ips = %scope.target_ips().join(","),
"Worker installed operation scope — out-of-scope single-IP tool calls will be rejected"
);
}

let subject = nats::tool_exec_subject(&config.worker_role);
let queue_group = format!("ares-tools-{}", config.worker_role);

Expand Down
7 changes: 6 additions & 1 deletion ares-tools/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ pub mod lateral;
pub mod parsers;
pub mod privesc;
pub mod recon;
pub mod scope;

use anyhow::Result;
use serde_json::Value;
Expand Down Expand Up @@ -63,8 +64,12 @@ impl ToolOutput {

/// Dispatch a tool call by name, executing the corresponding CLI command.
///
/// Returns the tool output or an error if the tool is unknown or execution fails.
/// Returns the tool output or an error if the tool is unknown or execution
/// fails. Calls whose `target` / `target_ip` is a literal IP outside the
/// configured operation scope are rejected before any subprocess runs (see
/// [`scope::validate_in_scope`]).
pub async fn dispatch(tool_name: &str, arguments: &Value) -> Result<ToolOutput> {
scope::validate_in_scope(tool_name, arguments)?;
match tool_name {
// ── Reconnaissance ──────────────────────────────────────────
"nmap_scan" => recon::nmap_scan(arguments).await,
Expand Down
65 changes: 61 additions & 4 deletions ares-tools/src/parsers/nmap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,29 @@

use serde_json::{json, Value};

/// Collapse adjacent duplicate labels in an FQDN (`host.host.suffix` →
/// `host.suffix`). Self-named-workgroup hosts (typical of stock Windows
/// installs that aren't domain-joined) report their reverse-DNS as
/// `name.name.workgroup`, which then propagates into recon output and host
/// records as a malformed FQDN. Real AD names don't repeat the leading label,
/// so collapsing is safe in practice.
fn dedup_adjacent_labels(fqdn: &str) -> String {
let labels: Vec<&str> = fqdn.split('.').collect();
if labels.len() < 3 {
return fqdn.to_string();
}
let mut out: Vec<&str> = Vec::with_capacity(labels.len());
for label in labels {
if let Some(prev) = out.last() {
if prev.eq_ignore_ascii_case(label) {
continue;
}
}
out.push(label);
}
out.join(".")
}

pub fn parse_nmap_output(output: &str, params: &Value) -> Vec<Value> {
let target_ip = params
.get("target")
Expand Down Expand Up @@ -32,7 +55,7 @@ pub fn parse_nmap_output(output: &str, params: &Value) -> Vec<Value> {

let rest = line.trim_start_matches("Nmap scan report for").trim();
if let Some(paren_start) = rest.find('(') {
hostname = rest[..paren_start].trim().to_string();
hostname = dedup_adjacent_labels(rest[..paren_start].trim());
current_ip = rest[paren_start + 1..]
.trim_end_matches(')')
.trim()
Expand Down Expand Up @@ -88,7 +111,7 @@ pub fn parse_nmap_output(output: &str, params: &Value) -> Vec<Value> {
if let Some(rest) = trimmed.strip_prefix(prefix) {
let fqdn = rest.trim().to_lowercase();
if fqdn.contains('.') && !fqdn.contains(' ') {
hostname = fqdn;
hostname = dedup_adjacent_labels(&fqdn);
break;
}
}
Expand All @@ -105,7 +128,7 @@ pub fn parse_nmap_output(output: &str, params: &Value) -> Vec<Value> {
.trim()
.to_lowercase();
if cn.contains('.') && !cn.contains(' ') {
hostname = cn;
hostname = dedup_adjacent_labels(&cn);
}
}
}
Expand All @@ -121,7 +144,7 @@ pub fn parse_nmap_output(output: &str, params: &Value) -> Vec<Value> {
.trim()
.to_lowercase();
if dns.contains('.') && !dns.contains(' ') {
hostname = dns;
hostname = dedup_adjacent_labels(&dns);
}
}
}
Expand Down Expand Up @@ -428,4 +451,38 @@ PORT STATE SERVICE VERSION
let role_strs: Vec<&str> = roles.iter().filter_map(|v| v.as_str()).collect();
assert!(role_strs.contains(&"winrm"));
}

#[test]
fn dedup_adjacent_labels_collapses_doubled_first_label() {
// Self-named-workgroup hosts (stock Windows installs not domain-joined)
// report reverse-DNS as `host.host.workgroup`. Collapse the doubled
// label so the recorded FQDN is `host.workgroup`.
assert_eq!(
dedup_adjacent_labels("dc01.dc01.contoso.local"),
"dc01.contoso.local"
);
assert_eq!(
dedup_adjacent_labels("dc01.contoso.local"),
"dc01.contoso.local"
);
assert_eq!(dedup_adjacent_labels("contoso.local"), "contoso.local");
// Case-insensitive match — preserves the case of the first occurrence
assert_eq!(
dedup_adjacent_labels("DC01.dc01.contoso.local"),
"DC01.contoso.local"
);
}

#[test]
fn parse_nmap_dedupes_doubled_reverse_dns() {
// Reverse-DNS for a Win2003-style self-named workgroup host comes back
// as `host.host.workgroup`. The recorded hostname must be collapsed.
let output = "\
Nmap scan report for dc01.dc01.contoso.local (192.168.58.10)
445/tcp open microsoft-ds";
let params = json!({"target": "192.168.58.10"});
let hosts = parse_nmap_output(output, &params);
assert_eq!(hosts.len(), 1);
assert_eq!(hosts[0]["hostname"], "dc01.contoso.local");
}
}
138 changes: 111 additions & 27 deletions ares-tools/src/parsers/secrets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,20 @@ pub fn parse_secretsdump(output: &str, params: &Value) -> (Vec<Value>, Vec<Value
for line in output.lines() {
let line = line.trim();

// Section markers — secretsdump emits these as informational lines
// Section markers — secretsdump and nxc emit these informational lines
// before each block. Recognize them so we can tell SAM rows from NTDS
// rows when the row itself has no `DOMAIN\` prefix.
// rows when the row itself has no `DOMAIN\` prefix. Match liberally:
// impacket says "Dumping local SAM", nxc says "Dumping SAM hashes",
// both should land us in LocalSam.
if line.starts_with('[') {
if line.contains("Dumping local SAM") {
let lower = line.to_ascii_lowercase();
if lower.contains("dumping local sam") || lower.contains("dumping sam") {
section = DumpSection::LocalSam;
} else if line.contains("Dumping Domain Credentials")
|| line.contains("Dumping cached domain")
|| line.contains("NTDS")
|| line.contains("Searching for pekList")
} else if lower.contains("dumping domain credentials")
|| lower.contains("dumping cached domain")
|| lower.contains("ntds")
|| lower.contains("searching for peklist")
|| lower.contains("reading and decrypting hashes from")
{
section = DumpSection::Domain;
}
Expand All @@ -56,7 +60,17 @@ pub fn parse_secretsdump(output: &str, params: &Value) -> (Vec<Value>, Vec<Value
if parts.len() >= 4 {
let raw_user = parts[0];
let rid = parts.get(1).copied().unwrap_or("");
let (user_domain, username) = if raw_user.contains('\\') {
let (user_domain, username) = if section == DumpSection::LocalSam {
// In the local SAM section, any `\` prefix is the host's
// own computer name (or workgroup), never an AD realm.
// Strip it and leave the domain empty — otherwise a
// standalone host whose computer name happens to share its
// first label with `target_domain` (e.g. WIN-XXXX with a
// self-named WIN-XXXX.WGRP.LOCAL workgroup) gets attributed
// to that workgroup as if it were an AD domain.
let user = raw_user.split_once('\\').map_or(raw_user, |(_, u)| u);
(String::new(), user.to_string())
} else if raw_user.contains('\\') {
let split: Vec<&str> = raw_user.splitn(2, '\\').collect();
let netbios = split[0];
// Resolve NetBIOS domain prefix to FQDN using target_domain.
Expand Down Expand Up @@ -97,31 +111,41 @@ pub fn parse_secretsdump(output: &str, params: &Value) -> (Vec<Value>, Vec<Value

/// Decide whether an unprefixed dump row is a local SAM account.
///
/// Two signals: (1) the dump section we're currently parsing and (2) the
/// well-known RID/name pairs that are always machine-local
/// Three signals, in order: (1) the dump section we're currently parsing,
/// (2) the well-known RID/name pairs that are always machine-local
/// (Administrator/500, Guest/501, DefaultAccount/503, WDAGUtilityAccount/504,
/// plus secretsdump's pseudo-rows like `$MACHINE.ACC` and `_SC_*` service
/// secrets emitted in the LSA section). Note that `krbtgt` is NOT in this
/// list: krbtgt is always an AD account, never local.
/// plus secretsdump's LSA pseudo-rows like `$MACHINE.ACC` and `_SC_*`), and
/// (3) the safe default for `Unknown` section: treat as local SAM unless the
/// user is `krbtgt` (always AD). NTDS dumps reliably emit pekList/NTDS markers
/// before the rows, so an unmarked dump is almost certainly a SAM dump from
/// `secretsdump @host` or `nxc smb --sam`. Defaulting unmarked custom RIDs to
/// `target_domain` (the prior behavior) silently mis-attributes local-only
/// users like `ansible`/`devops`/etc. to the operator's AD scope.
fn is_local_sam_account(raw_user: &str, rid: &str, section: DumpSection) -> bool {
if section == DumpSection::LocalSam {
return true;
}
let name = raw_user.to_ascii_lowercase();
// RID-based: 500/501/503/504 are well-known built-ins. Don't include 502
// (krbtgt) — it's a domain account that happens to share a fixed RID.
if matches!(rid, "500" | "501" | "503" | "504") {
let name = raw_user.to_ascii_lowercase();
if matches!(
if matches!(rid, "500" | "501" | "503" | "504")
&& matches!(
name.as_str(),
"administrator" | "guest" | "defaultaccount" | "wdagutilityaccount"
) {
return true;
}
)
{
return true;
}
// LSA pseudo-rows from `[*] Dumping LSA Secrets` — `$MACHINE.ACC`, etc.
if raw_user.starts_with('$') || raw_user.starts_with("_SC_") || raw_user.starts_with("NL$") {
return true;
}
// Safe default for unmarked dumps: treat as local SAM. krbtgt and machine
// accounts (`ENDS_WITH$`) are never local — let those fall through to the
// target_domain branch.
if section == DumpSection::Unknown && name != "krbtgt" && !raw_user.ends_with('$') {
return true;
}
false
}

Expand Down Expand Up @@ -270,23 +294,83 @@ WIN-XYZ$:1001:aad3b435b51404eeaad3b435b51404ee:1234567890abcdef1234567890abcdef:
}

#[test]
fn parse_secretsdump_well_known_sam_in_unknown_section() {
// No section marker before the rows — fall back to the well-known
// RID/name signal. Administrator/500 and DefaultAccount/503 are
// always local; svc_custom/1001 stays attributed to target_domain.
fn parse_secretsdump_unknown_section_defaults_to_local_sam() {
// No section marker before the rows — safe default is local SAM
// attribution (empty domain). NTDS dumps reliably emit pekList/NTDS
// markers; an unmarked dump is almost always a SAM dump from
// `secretsdump @host` or `nxc smb --sam`. Custom RIDs like 1001 must
// not silently inherit `target_domain` — that's how Ansible-provisioned
// local users (e.g. on standalone hosts) leak into AD scope.
let output = "\
Administrator:500:aad3b435b51404eeaad3b435b51404ee:e19ccf75ee54e06b06a5907af13cef42:::
DefaultAccount:503:aad3b435b51404eeaad3b435b51404ee:abcdef1234567890abcdef1234567890:::
svc_custom:1001:aad3b435b51404eeaad3b435b51404ee:1234567890abcdef1234567890abcdef:::";
ansible:1001:aad3b435b51404eeaad3b435b51404ee:1234567890abcdef1234567890abcdef:::";
let params = json!({"target_domain": "contoso.local"});
let (hashes, _) = parse_secretsdump(output, &params);
assert_eq!(hashes.len(), 3);
for h in &hashes {
assert_eq!(
h["domain"], "",
"{} should not inherit target_domain",
h["username"]
);
}
}

#[test]
fn parse_secretsdump_nxc_style_sam_marker() {
// nxc/netexec emits `[*] Dumping SAM hashes` (no "local") before rows.
// The parser must recognize this variant and still treat the section
// as LocalSam — otherwise unmarked custom users fall through to
// target_domain attribution.
let output = "\
[*] Dumping SAM hashes
Administrator:500:aad3b435b51404eeaad3b435b51404ee:e19ccf75ee54e06b06a5907af13cef42:::
ansible:1001:aad3b435b51404eeaad3b435b51404ee:abcdef1234567890abcdef1234567890:::";
let params = json!({"target_domain": "contoso.local"});
let (hashes, _) = parse_secretsdump(output, &params);
assert_eq!(hashes.len(), 2);
assert_eq!(hashes[0]["username"], "Administrator");
assert_eq!(hashes[0]["domain"], "");
assert_eq!(hashes[1]["username"], "DefaultAccount");
assert_eq!(hashes[1]["username"], "ansible");
assert_eq!(hashes[1]["domain"], "");
assert_eq!(hashes[2]["username"], "svc_custom");
assert_eq!(hashes[2]["domain"], "contoso.local");
}

#[test]
fn parse_secretsdump_local_sam_strips_computer_name_prefix() {
// Standalone host with self-named workgroup dumps rows like
// `WIN-ABCDEFGHIJK\ansible:1001:...`. The prefix is the host's own
// computer name, NOT an AD NetBIOS realm — even when the operator's
// `target_domain` happens to be `win-abcdefghijk.wgrp.local` (which
// would otherwise pass the first-label match in
// `resolve_netbios_to_fqdn`). In LocalSam section, the prefix is
// always stripped and the domain is left empty.
let output = "\
[*] Dumping local SAM hashes (uid:rid:lmhash:nthash)
WIN-ABCDEFGHIJK\\Administrator:500:aad3b435b51404eeaad3b435b51404ee:e19ccf75ee54e06b06a5907af13cef42:::
WIN-ABCDEFGHIJK\\ansible:1001:aad3b435b51404eeaad3b435b51404ee:abcdef1234567890abcdef1234567890:::";
let params = json!({"target_domain": "win-abcdefghijk.wgrp.local"});
let (hashes, _) = parse_secretsdump(output, &params);
assert_eq!(hashes.len(), 2);
for h in &hashes {
assert_eq!(h["domain"], "");
}
assert_eq!(hashes[0]["username"], "Administrator");
assert_eq!(hashes[1]["username"], "ansible");
}

#[test]
fn parse_secretsdump_machine_account_unmarked_keeps_target_domain() {
// Machine accounts (ending in `$`) are AD-only, never local SAM.
// Even with no section marker, they must inherit target_domain so a
// partial NTDS dump doesn't lose its computer-account hashes.
let output =
"WIN-XYZ$:1001:aad3b435b51404eeaad3b435b51404ee:1234567890abcdef1234567890abcdef:::";
let params = json!({"target_domain": "contoso.local"});
let (hashes, _) = parse_secretsdump(output, &params);
assert_eq!(hashes.len(), 1);
assert_eq!(hashes[0]["username"], "WIN-XYZ$");
assert_eq!(hashes[0]["domain"], "contoso.local");
}

#[test]
Expand Down
Loading
Loading