From 1142f6a8e1ef28e448b278abcfec088dac423ca1 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Tue, 12 May 2026 16:28:54 -0600 Subject: [PATCH] feat: support ticket-only exploit primitives for success detection **Added:** - Added `is_ticket_grant_vuln` function to identify primitives where Kerberos ticket saves indicate success - Added `result_has_ccache_evidence` function to detect saved ticket evidence in tool output - Introduced tests for both new functions to verify correct detection logic **Changed:** - Updated exploit success detection logic to allow ticket-only primitives (e.g., delegation, RBCD, S4U) to be marked successful based on saved ticket evidence, not just parser discoveries --- .../src/orchestrator/result_processing/mod.rs | 61 ++++++++++++++++++- .../orchestrator/result_processing/tests.rs | 58 ++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/ares-cli/src/orchestrator/result_processing/mod.rs b/ares-cli/src/orchestrator/result_processing/mod.rs index 2c2876f7..808a53a0 100644 --- a/ares-cli/src/orchestrator/result_processing/mod.rs +++ b/ares-cli/src/orchestrator/result_processing/mod.rs @@ -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"); @@ -409,6 +419,55 @@ fn parse_lockout_principal(line: &str) -> Option<(String, Option)> { /// "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 +/// `.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 .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) -> bool { + let Some(payload) = result.as_ref() else { + return false; + }; + let mut texts: Vec = 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) -> bool { let Some(payload) = result.as_ref() else { return false; diff --git a/ares-cli/src/orchestrator/result_processing/tests.rs b/ares-cli/src/orchestrator/result_processing/tests.rs index 9a2c37fb..49855826 100644 --- a/ares-cli/src/orchestrator/result_processing/tests.rs +++ b/ares-cli/src/orchestrator/result_processing/tests.rs @@ -1230,3 +1230,61 @@ 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 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!({})))); +}