diff --git a/ares-llm/src/tool_registry/privesc/adcs.rs b/ares-llm/src/tool_registry/privesc/adcs.rs index 5b53e517..615f3e01 100644 --- a/ares-llm/src/tool_registry/privesc/adcs.rs +++ b/ares-llm/src/tool_registry/privesc/adcs.rs @@ -131,8 +131,10 @@ pub fn definitions() -> Vec { name: "certipy_shadow".into(), description: "Exploit Shadow Credentials by adding a Key Credential to a target \ account's msDS-KeyCredentialLink attribute via Certipy, then authenticating \ - with the resulting certificate. Provide either `password` or `hashes` for \ - authentication." + with the resulting certificate. You MUST provide exactly one of `password` \ + OR `hashes` — never pass an empty string for the unused field; omit it \ + entirely. If the orchestrator handed you a plaintext password, pass \ + `password` and DO NOT include `hashes` at all." .into(), input_schema: json!({ "type": "object", @@ -147,11 +149,11 @@ pub fn definitions() -> Vec { }, "password": { "type": "string", - "description": "Password for authentication. Optional if `hashes` is provided." + "description": "Plaintext password for the source account. Use this when the orchestrator provides a `password` field — do NOT also pass `hashes`." }, "hashes": { "type": "string", - "description": "NTLM hash for pass-the-hash (format: 'lmhash:nthash' or just ':nthash'). Use instead of password." + "description": "NTLM hash for pass-the-hash (format: 'lmhash:nthash' or ':nthash'). Use ONLY when the orchestrator provides a `hash` / `nt_hash` field and NO password. Omit this field entirely — do not pass an empty string — when using `password`." }, "dc_ip": { "type": "string", diff --git a/ares-tools/src/privesc/adcs.rs b/ares-tools/src/privesc/adcs.rs index 300b94f9..ac473a0f 100644 --- a/ares-tools/src/privesc/adcs.rs +++ b/ares-tools/src/privesc/adcs.rs @@ -142,7 +142,11 @@ pub async fn certipy_shadow(args: &Value) -> Result { let domain = required_str(args, "domain")?; let target = required_str(args, "target")?; let dc_ip = required_str(args, "dc_ip")?; - let hashes = optional_str(args, "hashes"); + // Treat an empty-string `hashes` as missing so the password fallback + // fires. The LLM agent has been observed passing `hashes=""` when only + // a password is available — without this guard the `-hashes ''` flag + // is forwarded to certipy and certipy rejects the empty value. + let hashes = optional_str(args, "hashes").filter(|s| !s.is_empty()); let user_at_domain = format!("{username}@{domain}"); @@ -1077,6 +1081,44 @@ mod tests { assert_eq!(user_at_domain, "admin@contoso.local"); } + #[test] + fn certipy_shadow_empty_hashes_falls_back_to_password() { + // The LLM has been observed sending `hashes=""` when only a password + // is available — without the empty-string filter, certipy receives + // `-hashes ''` and bails with "invalid hash format". The filter at + // the top of `certipy_shadow` must treat empty hashes as missing so + // the password branch runs. + let args = json!({ + "username": "alice", + "domain": "contoso.local", + "password": "P@ssw0rd!", + "hashes": "", + "target": "Administrator", + "dc_ip": "192.168.58.10" + }); + // Mirror the same filter used in `certipy_shadow` itself. + let hashes = optional_str(&args, "hashes").filter(|s| !s.is_empty()); + assert!( + hashes.is_none(), + "empty hashes should be treated as missing" + ); + // password fallback must still resolve. + assert!(required_str(&args, "password").is_ok()); + } + + #[test] + fn certipy_shadow_present_hashes_used() { + let args = json!({ + "username": "alice", + "domain": "contoso.local", + "hashes": "aad3b435b51404eeaad3b435b51404ee:8846f7eaee8fb117ad06bdd830b7586c", + "target": "Administrator", + "dc_ip": "192.168.58.10" + }); + let hashes = optional_str(&args, "hashes").filter(|s| !s.is_empty()); + assert!(hashes.is_some()); + } + // --- certipy_template_esc4 --- #[test]