#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum BindVerdict {
Success,
InvalidCredentials,
AccountState(&'static str),
StrongerAuthRequired,
Unreachable,
Other,
}
pub(crate) fn classify_bind(err: &str) -> BindVerdict {
let e = err.to_ascii_lowercase();
let has = |needle: &str| e.contains(needle);
if has("data 52e") {
return BindVerdict::InvalidCredentials;
}
for (code, msg) in [
("data 525", "user not found"),
("data 530", "logon-hours restriction"),
("data 531", "workstation restriction"),
("data 532", "password expired"),
("data 533", "account disabled"),
("data 701", "account expired"),
("data 773", "must change password"),
("data 775", "account locked out"),
] {
if has(code) {
return BindVerdict::AccountState(msg);
}
}
if has("80090346")
|| has("strongerauth")
|| has("stronger auth")
|| has("confidentiality")
|| has("channel binding")
|| (has("signing") && (has("ldap") || has("simple_bind") || has("simple bind")))
|| has("result code: 8")
|| has("rc=8")
|| has("rc: 8,")
|| has("(strongerauthrequired)")
{
return BindVerdict::StrongerAuthRequired;
}
if has("result code: 49")
|| has("rc: 49,")
|| has("rc=49")
|| has("(invalidcredentials)")
|| has("supplied credential is invalid")
{
return BindVerdict::InvalidCredentials;
}
if has("connection refused")
|| has("timed out")
|| has("timeout")
|| has("os error 10061") || has("os error 111") || has("no route")
|| has("unreachable")
{
return BindVerdict::Unreachable;
}
if e.is_empty() {
return BindVerdict::Success;
}
BindVerdict::Other
}
pub(crate) fn bind_fix(v: &BindVerdict) -> &'static str {
match v {
BindVerdict::Success => "bind OK",
BindVerdict::InvalidCredentials => {
"check --user (try DOMAIN\\user or a UPN) and --password; 52e is bad \
credentials, not (by itself) channel binding"
}
BindVerdict::AccountState(s) => s,
BindVerdict::StrongerAuthRequired => {
"DC enforces LDAP signing / channel binding — use ldaps:// (636) or a \
GSSAPI-signed bind (--gssapi), not plaintext 389"
}
BindVerdict::Unreachable => {
"host/port unreachable — check the --url host, firewall, and that the DC is up"
}
BindVerdict::Other => {
"unrecognized bind error — re-run with -vv to log the raw LDAP wire result \
(`ldap-bind wire result` line), then file an issue with that line"
}
}
}
fn kdc_code(e: &str, code: u16) -> bool {
let needle = format!("kdc error {code}");
let mut start = 0;
while let Some(pos) = e[start..].find(&needle) {
let abs = start + pos;
let end = abs + needle.len();
let after = e.as_bytes().get(end).copied();
if !after.is_some_and(|b| b.is_ascii_digit()) {
return true;
}
start = end;
}
false
}
pub(crate) fn fix_hint(err: &str) -> Option<&'static str> {
let e = err.to_ascii_lowercase();
let has = |n: &str| e.contains(n);
match classify_bind(err) {
BindVerdict::InvalidCredentials => return Some(bind_fix(&BindVerdict::InvalidCredentials)),
BindVerdict::AccountState(s) => return Some(s),
BindVerdict::StrongerAuthRequired => {
return Some(bind_fix(&BindVerdict::StrongerAuthRequired))
}
_ => {}
}
if has("skew") || has("krb_ap_err_skew") || has("clock") {
return Some("Kerberos clock skew — sync the host clock to the DC (ntpdate/w32tm); AD tolerates ~5 min");
}
if has("preauth") || has("kdc_err_preauth") {
return Some("Kerberos pre-auth required/failed — check the password/hash and that the account exists");
}
if has("kdc_err_c_principal_unknown") || has("client not found in kerberos database") {
return Some(
"principal unknown to the KDC — check the username and realm (case-sensitive REALM)",
);
}
if has("kdc_err_s_principal_unknown") || has("server not found in kerberos database") {
return Some("SPN unknown to the KDC — check the target service principal name");
}
if has("status_access_denied") {
return Some("SMB access denied — the account lacks rights on the target/share; try a privileged user");
}
if has("status_logon_failure") {
return Some("SMB logon failed — check --user/--password/--domain (NETBIOS or FQDN)");
}
if has("status_pipe_not_available") || has("status_object_name_not_found") {
return Some("named pipe/RPC endpoint not available — the service may be stopped or the opnum blocked");
}
if (has("failed to lookup") && has("address"))
|| has("name or service not known")
|| has("no such host")
{
return Some("DNS resolution failed — use the DC IP for --url/--host, or point resolv.conf at the AD DNS");
}
if kdc_code(&e, 68) {
return Some("KDC error 68 (KDC_ERR_WRONG_REALM) — wrong realm; the referral in the reply names the correct realm to retry against");
}
if kdc_code(&e, 25) {
return Some("KDC error 25 (KDC_ERR_PREAUTH_REQUIRED) — the account requires pre-auth (normal); this stops AS-REP roasting but the account is valid");
}
if kdc_code(&e, 24) {
return Some("KDC error 24 (KDC_ERR_PREAUTH_FAILED) — the supplied password/hash is WRONG for the account, but the account itself exists");
}
if kdc_code(&e, 23) {
return Some("KDC error 23 (KDC_ERR_KEY_EXPIRED) — the account's password expired; reset it before roasting");
}
if kdc_code(&e, 18) {
return Some("KDC error 18 (KDC_ERR_CLIENT_REVOKED) — the account is disabled/locked; check with `enum krb-users` or SAMR");
}
if kdc_code(&e, 14) {
return Some("KDC error 14 (KDC_ERR_ETYPE_NOSUPP) — the account's etypes don't include AES; enable AES on the account, or accept RC4 downgrade with --rc4");
}
if kdc_code(&e, 7) {
return Some("KDC error 7 (KDC_ERR_S_PRINCIPAL_UNKNOWN) — the target SPN is not in AD; check --spn / --target-spn (needs an FQDN, not an IP)");
}
if kdc_code(&e, 6) {
return Some("KDC error 6 (KDC_ERR_C_PRINCIPAL_UNKNOWN) — user not found in the realm; check --user spelling and case-sensitive --realm");
}
if has("denied by policy module") || has("template that is not supported") {
return Some(
"AD CS refused the CSR — the CA has not (re-)published this template. On the DC: \
`certutil -SetCATemplates +<name>` then `Restart-Service CertSvc`",
);
}
if has("error_code 66") || has("kdc_err_certificate_mismatch") {
return Some(
"PKINIT blocked by KB5014754 strong cert mapping — the cert issued fine but the KDC \
refuses to map it to an account without a SID/SecurityIdentifier extension. Try a \
pre-KB5014754 DC or add the SID mapping via certipy-style OID 1.3.6.1.4.1.311.25.2",
);
}
if has("refusing to send an authenticated ldap simple_bind over plaintext") {
return Some(
"LDAP requires an integrity-checked bind — use `ldaps://…:636 --insecure` for a \
lab cert, `--gssapi` for SASL-sealed LDAP/389, or `--allow-plaintext-ldap` if \
the DC really has no LDAPS cert (cleartext credentials on the wire)",
);
}
if has("connection reset by peer")
|| has("os error 104") || has("os error 10054") || has("wsaeconnreset")
{
return Some(
"TCP reset — the port is OPEN but the peer killed the flow. For ldaps://: \
the DC's Schannel policy rejected the handshake (try `--tls-native` for \
OpenSSL/Schannel ciphers, or check the DC's SCH_USE_STRONG_CRYPTO and \
ciphersuite policy). For ldap://: the DC is enforcing LDAP signing.",
);
}
let tls_context = has("verify") || has("self-signed") || has("unknown ca") || has("chain");
if (has("tls") || has("certificate")) && tls_context {
return Some("TLS verification failed — pass --insecure for a lab self-signed DC cert, or trust the CA");
}
if has("connection refused")
|| has("timed out")
|| has("timeout")
|| has("os error 10061") || has("os error 111") || has("no route")
|| has("unreachable")
{
return Some(
"host/port unreachable — check the target host, firewall, and that the DC role is up",
);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn data_52e_is_invalid_credentials_not_cbt() {
assert_eq!(
classify_bind("80090308: LdapErr: DSID-0C09..., comment: AcceptSecurityContext error, data 52e, v4563"),
BindVerdict::InvalidCredentials
);
}
#[test]
fn data_80090346_is_stronger_auth() {
assert_eq!(
classify_bind("comment: AcceptSecurityContext error, data 80090346, v4563"),
BindVerdict::StrongerAuthRequired
);
}
#[test]
fn result_code_8_is_stronger_auth() {
assert_eq!(
classify_bind("ldap result code: 8 (strongerAuthRequired)"),
BindVerdict::StrongerAuthRequired
);
}
#[test]
fn disabled_account_is_account_state() {
assert_eq!(
classify_bind("AcceptSecurityContext error, data 533, v4563"),
BindVerdict::AccountState("account disabled")
);
}
#[test]
fn bare_rc49_is_invalid_credentials_ux_b() {
assert_eq!(
classify_bind("LdapResult { rc: 49, matched: \"\", text: \"\", refs: [] }"),
BindVerdict::InvalidCredentials
);
}
#[test]
fn strongerauthrequired_name_only_is_stronger_auth_ux_b() {
assert_eq!(
classify_bind("LDAP bind failed: (strongerAuthRequired)"),
BindVerdict::StrongerAuthRequired
);
}
#[test]
fn supplied_credential_is_invalid_ux_b() {
assert_eq!(
classify_bind(
"bind failed as `administrator@testlab.local` — The supplied credential is invalid"
),
BindVerdict::InvalidCredentials
);
}
#[test]
fn connection_refused_is_unreachable() {
assert_eq!(
classify_bind("Connection refused (os error 111)"),
BindVerdict::Unreachable
);
}
#[test]
fn fix_hint_names_the_fix_for_top_classes() {
assert!(fix_hint("data 52e").unwrap().contains("DOMAIN\\user"));
assert!(fix_hint("data 533").unwrap().contains("disabled"));
assert!(fix_hint("data 80090346")
.unwrap()
.contains("channel binding"));
assert!(fix_hint("KRB_AP_ERR_SKEW: clock skew too great")
.unwrap()
.contains("clock"));
assert!(fix_hint("KDC_ERR_PREAUTH_FAILED")
.unwrap()
.contains("pre-auth"));
assert!(fix_hint("STATUS_ACCESS_DENIED")
.unwrap()
.contains("access denied"));
assert!(fix_hint("STATUS_LOGON_FAILURE").unwrap().contains("logon"));
assert!(fix_hint("failed to lookup address information")
.unwrap()
.contains("DNS"));
assert!(fix_hint("self-signed certificate in chain")
.unwrap()
.contains("--insecure"));
assert!(fix_hint("Connection refused (os error 111)")
.unwrap()
.contains("unreachable"));
}
#[test]
fn fix_hint_is_none_for_unrecognized() {
assert!(fix_hint("some totally unrelated internal error xyz").is_none());
assert!(fix_hint("").is_none());
}
#[test]
fn adcs_policy_refusal_does_not_misclassify_as_tls() {
let h = fix_hint(
"Denied by Policy Module 0x80094800, The request was for a certificate template \
that is not supported by the Active Directory Certificate Services policy: ESC1Vuln",
)
.unwrap();
assert!(h.contains("AD CS"), "got: {h}");
assert!(h.contains("SetCATemplates"), "got: {h}");
assert!(!h.contains("TLS"), "wrong branch: {h}");
}
#[test]
fn pkinit_error_66_maps_to_kb5014754() {
let h = fix_hint("KDC rejected PKINIT AS-REQ: unhandled error_code 66 ''").unwrap();
assert!(h.contains("KB5014754"), "got: {h}");
assert!(h.contains("SID"), "got: {h}");
}
#[test]
fn genuine_tls_error_still_classifies() {
let h =
fix_hint("tls handshake: certificate verify failed: self-signed certificate").unwrap();
assert!(h.contains("TLS verification failed"), "got: {h}");
}
#[test]
fn plaintext_bind_refusal_names_actual_remedy_not_tls() {
let h = fix_hint(
"refusing to send an authenticated LDAP simple_bind over plaintext \
\"ldap://armbusinessbank.local:389\": switch to `ldaps://`, pass `--gssapi`, \
or set `allow_plaintext_bind = true` on the LdapConfig if this is a lab DC \
without an LDAPS certificate.",
)
.unwrap();
assert!(h.contains("integrity-checked bind"), "got: {h}");
assert!(h.contains("--gssapi"), "got: {h}");
assert!(h.contains("--allow-plaintext-ldap"), "got: {h}");
assert!(!h.starts_with("TLS verification"), "wrong branch: {h}");
}
#[test]
fn tcp_reset_is_not_unreachable() {
let h = fix_hint(
"ldap connect: I/O error: Connection reset by peer (os error 104): \
Connection reset by peer (os error 104)",
)
.unwrap();
assert!(h.contains("TCP reset"), "got: {h}");
assert!(h.contains("Schannel") || h.contains("signing"), "got: {h}");
assert!(!h.contains("unreachable"), "wrong branch: {h}");
}
#[test]
fn connection_refused_still_maps_to_unreachable() {
let h = fix_hint("Connection refused (os error 111)").unwrap();
assert!(h.contains("unreachable"), "got: {h}");
}
#[test]
fn ldap_search_lookup_miss_is_not_dns() {
let h = fix_hint("failed to lookup sAMAccountName=svc_missing");
assert!(
h.map(|s| !s.contains("DNS resolution failed"))
.unwrap_or(true),
"LDAP lookup miss must not misclassify as DNS: {h:?}"
);
}
#[test]
fn dns_getaddrinfo_still_classifies() {
let h = fix_hint(
"io error: failed to lookup address information: nodename nor servname provided",
)
.unwrap();
assert!(h.contains("DNS resolution failed"), "got: {h}");
}
#[test]
fn smb_signing_error_not_routed_to_ldap_signing_hint() {
let smb_msg = "smb signing verification failed on RESPONSE packet";
assert_ne!(
classify_bind(smb_msg),
BindVerdict::StrongerAuthRequired,
"SMB signing errors must not be routed to LDAP StrongerAuthRequired"
);
}
#[test]
fn genuine_ldap_signing_still_classifies() {
assert_eq!(
classify_bind("ldap bind refused: signing/sealing required"),
BindVerdict::StrongerAuthRequired
);
assert_eq!(
classify_bind("simple_bind rejected because signing is required"),
BindVerdict::StrongerAuthRequired
);
}
#[test]
fn kdc_numeric_error_codes_are_translated() {
let cases = [
(6, "KDC_ERR_C_PRINCIPAL_UNKNOWN"),
(7, "KDC_ERR_S_PRINCIPAL_UNKNOWN"),
(14, "KDC_ERR_ETYPE_NOSUPP"),
(18, "KDC_ERR_CLIENT_REVOKED"),
(23, "KDC_ERR_KEY_EXPIRED"),
(24, "KDC_ERR_PREAUTH_FAILED"),
(25, "KDC_ERR_PREAUTH_REQUIRED"),
(68, "KDC_ERR_WRONG_REALM"),
];
for (code, name) in cases {
let msg = format!("AS-REQ rejected, KDC error {code}");
let h = fix_hint(&msg).unwrap_or_else(|| panic!("no hint for KDC error {code}"));
assert!(
h.contains(name),
"KDC error {code} hint should name {name} — got: {h}"
);
}
}
}