From a39beacc9eb487159a838ddffa015c44d8c88660 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Fri, 15 May 2026 09:34:27 -0600 Subject: [PATCH] feat: normalize ntlm hash values in loot json output **Added:** - Added `report_hash_value` function to output only bare NT hash for NTLM `LM:NT` pairs, ensuring compatibility with external scoreboards expecting strict 32-hex NT hashes - Added tests for `report_hash_value` to cover NTLM pairs, bare NT, Kerberos blobs, and non-NTLM hashes **Changed:** - Updated loot JSON output to use `report_hash_value` for NTLM hashes, stripping LM part when appropriate --- ares-cli/src/ops/loot/format/json.rs | 4 +- ares-cli/src/ops/loot/format/report_filter.rs | 58 +++++++++++++++++-- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/ares-cli/src/ops/loot/format/json.rs b/ares-cli/src/ops/loot/format/json.rs index 70e26635..7694d3c0 100644 --- a/ares-cli/src/ops/loot/format/json.rs +++ b/ares-cli/src/ops/loot/format/json.rs @@ -4,7 +4,7 @@ use ares_core::models::SharedRedTeamState; use super::display::build_domain_achievements; use super::hosts::dedup_hosts; -use super::report_filter::{is_reportable_credential, is_reportable_hash}; +use super::report_filter::{is_reportable_credential, is_reportable_hash, report_hash_value}; use crate::dedup::{dedup_credentials, dedup_hashes, dedup_users}; pub(super) fn print_loot_json( @@ -161,7 +161,7 @@ pub(super) fn print_loot_json( "username": h.username, "domain": h.domain, "hash_type": h.hash_type, - "hash_value": h.hash_value, + "hash_value": report_hash_value(&h.hash_type, &h.hash_value), "source": h.source, })).collect::>(), "shares": state.all_shares.iter().map(|s| serde_json::json!({ diff --git a/ares-cli/src/ops/loot/format/report_filter.rs b/ares-cli/src/ops/loot/format/report_filter.rs index f0b28286..67b33c59 100644 --- a/ares-cli/src/ops/loot/format/report_filter.rs +++ b/ares-cli/src/ops/loot/format/report_filter.rs @@ -12,10 +12,6 @@ use ares_core::models::{Credential, Hash}; -/// Built-in / system accounts that aren't credit-worthy AD-user findings. -/// -/// `krbtgt` is included because it's consumed internally by Golden Ticket -/// detection rather than tracked as a cred objective. const NOISE_USERNAMES: &[&str] = &[ "krbtgt", "guest", @@ -66,6 +62,29 @@ pub(super) fn is_reportable_credential(c: &Credential) -> bool { true } +/// Normalize an NTLM `hash_value` to the bare 32-char NT hex for report +/// output. Secretsdump and other extractors store NTLM hashes as the full +/// `LM:NT` pair (e.g. `aad3b435...:8c6d9454...`); external scoreboards parse +/// the report with a strict 32-hex regex and reject the colon form. Internal +/// callers (golden-ticket forging, impacket recovery) still see the original +/// `LM:NT` value from state — this only rewrites the serialized output. +pub(super) fn report_hash_value(hash_type: &str, hash_value: &str) -> String { + if !hash_type.eq_ignore_ascii_case("ntlm") { + return hash_value.to_string(); + } + match hash_value.split_once(':') { + Some((lm, nt)) + if lm.len() == 32 + && nt.len() == 32 + && lm.bytes().all(|b| b.is_ascii_hexdigit()) + && nt.bytes().all(|b| b.is_ascii_hexdigit()) => + { + nt.to_string() + } + _ => hash_value.to_string(), + } +} + /// True if a hash should be surfaced in the loot JSON output. /// /// Hashes whose `cracked_password` is set are dropped because the cracked @@ -200,4 +219,35 @@ mod tests { assert!(!is_reportable_credential(&cred("", "contoso.local"))); assert!(!is_reportable_hash(&hash("", "contoso.local", None))); } + + #[test] + fn report_hash_value_strips_lm_from_ntlm_pair() { + let nt = "8c6d94541dbc90f085e86828428d2cbf"; + let lm_nt = format!("aad3b435b51404eeaad3b435b51404ee:{nt}"); + assert_eq!(report_hash_value("NTLM", &lm_nt), nt); + assert_eq!(report_hash_value("ntlm", &lm_nt), nt); + } + + #[test] + fn report_hash_value_leaves_bare_nt_alone() { + let nt = "8c6d94541dbc90f085e86828428d2cbf"; + assert_eq!(report_hash_value("NTLM", nt), nt); + } + + #[test] + fn report_hash_value_leaves_kerberos_blobs_alone() { + // Kerberoast TGS blob: contains colons but isn't an LM:NT pair. + let tgs = "$krb5tgs$23$*sql_svc$fabrikam.local$cifs/sql01*$abc:def"; + assert_eq!(report_hash_value("kerberoast", tgs), tgs); + // AS-REP blob. + let asrep = "$krb5asrep$23$alice@contoso.local:abcd1234"; + assert_eq!(report_hash_value("asrep", asrep), asrep); + } + + #[test] + fn report_hash_value_leaves_non_ntlm_alone() { + // AES key looks like hex but isn't NTLM — must not be touched. + let aes = "aad3b435b51404eeaad3b435b51404ee:8c6d94541dbc90f085e86828428d2cbf"; + assert_eq!(report_hash_value("aes256", aes), aes); + } }