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
10 changes: 6 additions & 4 deletions ares-llm/src/tool_registry/privesc/adcs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,10 @@ pub fn definitions() -> Vec<ToolDefinition> {
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",
Expand All @@ -147,11 +149,11 @@ pub fn definitions() -> Vec<ToolDefinition> {
},
"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",
Expand Down
44 changes: 43 additions & 1 deletion ares-tools/src/privesc/adcs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,11 @@ pub async fn certipy_shadow(args: &Value) -> Result<ToolOutput> {
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}");

Expand Down Expand Up @@ -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]
Expand Down
Loading