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
61 changes: 60 additions & 1 deletion ares-cli/src/orchestrator/result_processing/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,9 +215,19 @@ pub async fn process_completed_task(
// evidence (discoveries from real tool stdout) corroborates the
// exploit. The text heuristic catches obvious lies; the parser
// check catches silent fabrication.
// Default evidence gate (parser-extracted discoveries). For
// ticket-only exploit chains (constrained/unconstrained
// delegation, RBCD) `getST` writes a `.ccache` to disk and
// emits a "Saving ticket in …" line — neither produces a
// credential/hash/host the regex parsers can attach to
// `discoveries`. Treat the ticket save as the success signal
// for those vuln types so the scoreboard credits the
// primitive on getST exit-0.
let has_ticket_evidence =
is_ticket_grant_vuln(&vuln_id) && result_has_ccache_evidence(&result.result);
let actually_succeeded = result.success
&& !result_text_indicates_failure(&result.result)
&& result_has_parser_evidence(&result.result);
&& (result_has_parser_evidence(&result.result) || has_ticket_evidence);

if actually_succeeded {
info!(vuln_id = %vuln_id, task_id = %task_id, "Marking vulnerability as exploited");
Expand Down Expand Up @@ -554,6 +564,55 @@ fn parse_lockout_principal(line: &str) -> Option<(String, Option<String>)> {
/// "Parser-extracted" means populated by ares-tools parsers running on real
/// tool stdout — never LLM-fabricated. Used to ground state writes (e.g.
/// `mark_exploited`) against actual evidence.
/// True when `vuln_id` belongs to a primitive whose success is a saved
/// Kerberos ticket rather than a structured discovery. `getST` /
/// `impacket-ticketer` for these flows emit a "Saving ticket in
/// `<principal>.ccache`" line and return exit-0 — no credential/hash/host
/// the regex parsers can attach to `discoveries`. Used alongside
/// `result_has_ccache_evidence` so the scoreboard credits the primitive
/// on a clean getST run.
fn is_ticket_grant_vuln(vuln_id: &str) -> bool {
let v = vuln_id.to_lowercase();
v.starts_with("constrained_delegation_")
|| v.starts_with("unconstrained_delegation_")
|| v.starts_with("rbcd_")
|| v.starts_with("s4u_")
}

/// True when the result's raw tool output indicates a Kerberos ticket was
/// successfully saved to disk. Recognises impacket's canonical line
/// (`Saving ticket in <principal>.ccache`) and bare `.ccache` filenames in
/// output blobs. Conservative — requires either the explicit "Saving
/// ticket" preamble or a `.ccache` token to avoid crediting tasks that
/// merely *reference* a ticket path in commentary.
fn result_has_ccache_evidence(result: &Option<Value>) -> bool {
let Some(payload) = result.as_ref() else {
return false;
};
let mut texts: Vec<String> = Vec::new();
for key in &["tool_output", "output", "summary"] {
if let Some(s) = payload.get(*key).and_then(|v| v.as_str()) {
texts.push(s.to_string());
}
}
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 text in texts {
let lower = text.to_lowercase();
if lower.contains("saving ticket in") && lower.contains(".ccache") {
return true;
}
}
false
}

fn result_has_parser_evidence(result: &Option<Value>) -> bool {
let Some(payload) = result.as_ref() else {
return false;
Expand Down
58 changes: 58 additions & 0 deletions ares-cli/src/orchestrator/result_processing/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1231,6 +1231,64 @@ fn extract_locked_users_rejects_llm_narrative_tokens() {
assert!(locked.is_empty(), "got false positives: {locked:?}");
}

#[test]
fn is_ticket_grant_vuln_recognizes_delegation_prefixes() {
use super::is_ticket_grant_vuln;
assert!(is_ticket_grant_vuln("constrained_delegation_alice"));
assert!(is_ticket_grant_vuln("UNCONSTRAINED_DELEGATION_WEB01$"));
assert!(is_ticket_grant_vuln("rbcd_dc01_target"));
assert!(is_ticket_grant_vuln("s4u_admin_at_contoso"));
}

#[test]
fn is_ticket_grant_vuln_rejects_non_ticket_primitives() {
use super::is_ticket_grant_vuln;
assert!(!is_ticket_grant_vuln("kerberoast_svc_sql"));
assert!(!is_ticket_grant_vuln("adcs_esc1_192.168.58.50"));
assert!(!is_ticket_grant_vuln("mssql_impersonation_192.168.58.51"));
assert!(!is_ticket_grant_vuln(""));
}

#[test]
fn ccache_evidence_detects_saving_ticket_line() {
use super::result_has_ccache_evidence;
let payload = json!({
"output": "[*] Impersonating Administrator\n\
[*] Requesting S4U2self\n\
[*] Requesting S4U2Proxy\n\
[*] Saving ticket in Administrator@cifs_dc01@CONTOSO.LOCAL.ccache"
});
assert!(result_has_ccache_evidence(&Some(payload)));
}

#[test]
fn ccache_evidence_detects_in_tool_outputs_array() {
use super::result_has_ccache_evidence;
let payload = json!({
"tool_outputs": [
{"output": "[*] Saving ticket in alice@CIFS.ccache"}
]
});
assert!(result_has_ccache_evidence(&Some(payload)));
}

#[test]
fn ccache_evidence_rejects_bare_mention() {
use super::result_has_ccache_evidence;
// LLM commentary that mentions a ticket path but doesn't prove a save.
let payload = json!({
"summary": "S4U2Proxy returned an error before saving the .ccache"
});
assert!(!result_has_ccache_evidence(&Some(payload)));
}

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

#[test]
fn is_gmsa_principal_matches_trailing_dollar_with_gmsa_name() {
use super::is_gmsa_principal;
Expand Down
Loading