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
7 changes: 4 additions & 3 deletions ares-cli/src/orchestrator/automation/mssql_exploitation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,15 +186,16 @@ 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",
"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 = '<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., whoami /priv, reg query for autologon)",
"Check for SeImpersonatePrivilege for potato escalation",
"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)",
Expand Down
128 changes: 128 additions & 0 deletions ares-cli/src/orchestrator/result_processing/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,12 +316,140 @@ pub async fn process_completed_task(
}
}

// SeImpersonate primitive detection. When a task's output captures a
// `whoami /priv` (or equivalent) showing SeImpersonatePrivilege held
// (and enabled), we have everything needed to escalate to SYSTEM via
// PrintSpoofer / GodPotato. Surface this as `seimpersonate_<host>` and
// mark exploited so the scoreboard credits the primitive. The follow-on
// potato dispatch is left for the existing privesc agent (already wired
// with godpotato / printspoofer tools) to consume opportunistically.
if result_has_seimpersonate_signal(&result.result) {
let host_label =
derive_seimpersonate_host_label(dispatcher, task_target_ip.as_deref()).await;
let vuln_id = format!("seimpersonate_{}", host_label);
let mut details = std::collections::HashMap::new();
details.insert("host".into(), Value::String(host_label.clone()));
if let Some(ref ip) = task_target_ip {
details.insert("target_ip".into(), Value::String(ip.clone()));
}
details.insert(
"note".into(),
Value::String(
"SeImpersonatePrivilege observed enabled — \
escalation path via PrintSpoofer / GodPotato to SYSTEM."
.into(),
),
);
let vuln = ares_core::models::VulnerabilityInfo {
vuln_id: vuln_id.clone(),
vuln_type: "seimpersonate".to_string(),
target: task_target_ip.clone().unwrap_or_else(|| host_label.clone()),
discovered_by: "result_processing".to_string(),
discovered_at: chrono::Utc::now(),
details,
recommended_agent: "privesc".to_string(),
priority: 2,
};
let _ = dispatcher
.state
.publish_vulnerability(&dispatcher.queue, vuln)
.await;
if let Err(e) = dispatcher
.state
.mark_exploited(&dispatcher.queue, &vuln_id)
.await
{
warn!(
err = %e,
vuln_id = %vuln_id,
"Failed to mark seimpersonate primitive exploited"
);
} else {
info!(
vuln_id = %vuln_id,
host = %host_label,
task_id = %task_id,
"SeImpersonate primitive observed in task output — exploit token emitted"
);
}
}

dispatcher.credential_access_notify.notify_waiters();
dispatcher.delegation_notify.notify_waiters();

let _ = dispatcher.notify_state_update().await;
}

/// Resolve a host label for a `seimpersonate_<label>` vuln_id. Prefers the
/// host's `hostname` (e.g. `web01`) when known so the scoreboard token is
/// stable across runs, falls back to the IP. Hostname is lowercased and the
/// AD suffix stripped (`web01.contoso.local` → `web01`) so two runs that
/// see the same machine produce the same token.
async fn derive_seimpersonate_host_label(
dispatcher: &Arc<Dispatcher>,
target_ip: Option<&str>,
) -> String {
if let Some(ip) = target_ip {
let state = dispatcher.state.read().await;
if let Some(host) = state.hosts.iter().find(|h| h.ip == ip) {
if !host.hostname.is_empty() {
let lower = host.hostname.to_lowercase();
return lower
.split_once('.')
.map(|(short, _)| short.to_string())
.unwrap_or(lower);
}
}
return ip.replace('.', "_");
}
"unknown".to_string()
}

/// Returns `true` when any text payload on the result contains a recognised
/// SeImpersonate signal. Conservative — only matches `SeImpersonatePrivilege`
/// alongside an `Enabled` token (the format `whoami /priv` uses). This avoids
/// false positives from output that merely *mentions* the privilege name
/// (e.g. recon plans or LLM commentary).
fn result_has_seimpersonate_signal(result: &Option<Value>) -> bool {
let Some(payload) = result else {
return false;
};

let mut texts: Vec<String> = Vec::new();
if let Some(arr) = payload.get("tool_outputs").and_then(|v| v.as_array()) {
for item in arr {
if let Some(s) = item.as_str() {
texts.push(s.to_string());
} else if let Some(s) = item.get("output").and_then(|v| v.as_str()) {
texts.push(s.to_string());
}
}
}
for key in &["summary", "output", "tool_output"] {
if let Some(s) = payload.get(*key).and_then(|v| v.as_str()) {
texts.push(s.to_string());
}
}

for text in texts {
for line in text.lines() {
let lower = line.to_lowercase();
if !lower.contains("seimpersonateprivilege") {
continue;
}
// `whoami /priv` table rows look like:
// SeImpersonatePrivilege Impersonate a client after authentication Enabled
// We require an `enabled` (case-insensitive) token on the same
// line. `Disabled` rows are also reported by whoami but are not
// exploitable.
if lower.contains("enabled") && !lower.contains("disabled") {
return true;
}
}
}
false
}

/// Extract `(username, optional domain)` pairs from a tool result that
/// reported a per-user lockout. Looks at `tool_outputs`, `output`,
/// `tool_output`, and `summary` fields for netexec-style lines such as:
Expand Down
63 changes: 63 additions & 0 deletions ares-cli/src/orchestrator/result_processing/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1230,3 +1230,66 @@ fn extract_locked_users_rejects_llm_narrative_tokens() {
let locked = extract_locked_usernames_from_result(&Some(payload));
assert!(locked.is_empty(), "got false positives: {locked:?}");
}

#[test]
fn seimpersonate_signal_detects_enabled_in_whoami_priv_output() {
use super::result_has_seimpersonate_signal;
// Real-world `whoami /priv` row format from a service account context.
let payload = json!({
"output": "PRIVILEGES INFORMATION\n\
----------------------\n\
Privilege Name Description State\n\
============================= =========================================== ========\n\
SeAssignPrimaryTokenPrivilege Replace a process level token Disabled\n\
SeImpersonatePrivilege Impersonate a client after authentication Enabled\n\
SeIncreaseQuotaPrivilege Adjust memory quotas for a process Disabled"
});
assert!(result_has_seimpersonate_signal(&Some(payload)));
}

#[test]
fn seimpersonate_signal_ignores_disabled_priv() {
use super::result_has_seimpersonate_signal;
let payload = json!({
"output": "SeImpersonatePrivilege Impersonate a client after authentication Disabled"
});
assert!(!result_has_seimpersonate_signal(&Some(payload)));
}

#[test]
fn seimpersonate_signal_ignores_bare_mention_without_state() {
use super::result_has_seimpersonate_signal;
// LLM commentary that names the privilege but doesn't prove it's held.
let payload = json!({
"summary": "Plan: check for SeImpersonatePrivilege if we get xp_cmdshell working"
});
assert!(!result_has_seimpersonate_signal(&Some(payload)));
}

#[test]
fn seimpersonate_signal_detects_in_tool_outputs_array() {
use super::result_has_seimpersonate_signal;
let payload = json!({
"tool_outputs": [
{"output": "whoami output:\nSeImpersonatePrivilege Impersonate a client Enabled"}
]
});
assert!(result_has_seimpersonate_signal(&Some(payload)));
}

#[test]
fn seimpersonate_signal_empty_payload() {
use super::result_has_seimpersonate_signal;
assert!(!result_has_seimpersonate_signal(&None));
assert!(!result_has_seimpersonate_signal(&Some(json!({}))));
}

#[test]
fn seimpersonate_signal_case_insensitive() {
use super::result_has_seimpersonate_signal;
// Some shells/agents may upper- or lower-case the row.
let payload = json!({
"output": "seimpersonateprivilege description text ENABLED"
});
assert!(result_has_seimpersonate_signal(&Some(payload)));
}
Loading