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
67 changes: 41 additions & 26 deletions ares-cli/src/orchestrator/result_processing/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,15 @@ pub use discovery_polling::discovery_poller;
use std::sync::Arc;

use anyhow::Result;
use redis::aio::ConnectionLike;
use serde_json::Value;
use tracing::{debug, info, warn};

use crate::orchestrator::dispatcher::Dispatcher;
use crate::orchestrator::output_extraction;
use crate::orchestrator::results::CompletedTask;
use crate::orchestrator::state::SharedState;
use crate::orchestrator::task_queue::TaskQueueCore;
use crate::orchestrator::throttling::Throttler;

use self::admin_checks::{
Expand Down Expand Up @@ -771,6 +774,42 @@ fn gmsa_exploit_token(username: &str) -> String {
format!("gmsa_{}", username.trim_end_matches('$').to_lowercase())
}

/// gMSA managed-password recovery side-effect: when secretsdump returns a
/// Group Managed Service Account hash (account ends with `$` and name
/// contains "gmsa"), credit the gMSA primitive even though we never went
/// through `auto_gmsa_extraction`. Without this, gMSA hashes captured
/// incidentally via DCSync never emit a `gmsa_*` token to the exploited
/// set and the scoreboard understates progress.
///
/// No-op for non-gMSA usernames. Errors from `mark_exploited` are logged
/// but not propagated — credit emission is best-effort and shouldn't
/// fail the surrounding hash-publish flow.
async fn emit_gmsa_exploit_token_if_gmsa<C>(
state: &SharedState,
queue: &TaskQueueCore<C>,
username: &str,
) where
C: ConnectionLike + Clone + Send + Sync + 'static,
{
if !is_gmsa_principal(username) {
return;
}
let vuln_id = gmsa_exploit_token(username);
if let Err(e) = state.mark_exploited(queue, &vuln_id).await {
warn!(
err = %e,
vuln_id = %vuln_id,
"Failed to mark gMSA hash as exploited"
);
} else {
info!(
vuln_id = %vuln_id,
account = %username,
"gMSA hash captured via secretsdump — emitted exploit token"
);
}
}

fn parse_lockout_principal(line: &str) -> Option<(String, Option<String>)> {
let marker_pos = LOCKOUT_PATTERNS.iter().filter_map(|p| line.find(p)).min()?;
let prefix = &line[..marker_pos];
Expand Down Expand Up @@ -1408,32 +1447,8 @@ async fn extract_discoveries(
)
.await;

// gMSA managed-password recovery side-effect: when secretsdump
// returns a Group Managed Service Account hash (account ends
// with `$` and name contains "gmsa"), credit the gMSA primitive
// even though we never went through `auto_gmsa_extraction`.
// Without this, gMSA hashes captured incidentally via DCSync
// never emit a `gmsa_*` token to the exploited set.
if is_gmsa_principal(&username) {
let vuln_id = gmsa_exploit_token(&username);
if let Err(e) = dispatcher
.state
.mark_exploited(&dispatcher.queue, &vuln_id)
.await
{
warn!(
err = %e,
vuln_id = %vuln_id,
"Failed to mark gMSA hash as exploited"
);
} else {
info!(
vuln_id = %vuln_id,
account = %username,
"gMSA hash captured via secretsdump — emitted exploit token"
);
}
}
emit_gmsa_exploit_token_if_gmsa(&dispatcher.state, &dispatcher.queue, &username)
.await;
}
Ok(false) => {}
Err(e) => warn!(err = %e, "Failed to publish hash"),
Expand Down
48 changes: 48 additions & 0 deletions ares-cli/src/orchestrator/result_processing/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1331,6 +1331,54 @@ fn gmsa_exploit_token_converges_with_enumeration_format() {
assert_eq!(gmsa_exploit_token("gmsaDragon$"), "gmsa_gmsadragon");
}

mod emit_gmsa_exploit_token {
use super::super::emit_gmsa_exploit_token_if_gmsa;
use crate::orchestrator::state::SharedState;
use crate::orchestrator::task_queue::TaskQueueCore;
use ares_core::state::mock_redis::MockRedisConnection;

fn mock_queue() -> TaskQueueCore<MockRedisConnection> {
TaskQueueCore::from_connection(MockRedisConnection::new())
}

#[tokio::test]
async fn marks_exploited_for_gmsa_principal() {
let state = SharedState::new("op-1".to_string());
let q = mock_queue();
emit_gmsa_exploit_token_if_gmsa(&state, &q, "gmsaDragon$").await;
let s = state.read().await;
assert!(s.exploited_vulnerabilities.contains("gmsa_gmsadragon"));
}

#[tokio::test]
async fn no_op_for_plain_machine_account() {
// DC01$ ends with `$` but is not a gMSA — no token should be emitted.
let state = SharedState::new("op-1".to_string());
let q = mock_queue();
emit_gmsa_exploit_token_if_gmsa(&state, &q, "DC01$").await;
let s = state.read().await;
assert!(s.exploited_vulnerabilities.is_empty());
}

#[tokio::test]
async fn no_op_for_regular_user() {
let state = SharedState::new("op-1".to_string());
let q = mock_queue();
emit_gmsa_exploit_token_if_gmsa(&state, &q, "alice").await;
let s = state.read().await;
assert!(s.exploited_vulnerabilities.is_empty());
}

#[tokio::test]
async fn token_normalized_lowercase_for_mixed_case_input() {
let state = SharedState::new("op-1".to_string());
let q = mock_queue();
emit_gmsa_exploit_token_if_gmsa(&state, &q, "GMSA_WEB$").await;
let s = state.read().await;
assert!(s.exploited_vulnerabilities.contains("gmsa_gmsa_web"));
}
}

#[test]
fn seimpersonate_signal_detects_enabled_in_whoami_priv_output() {
use super::result_has_seimpersonate_signal;
Expand Down
Loading