3131from impacket .dcerpc .v5 .samr import SID_NAME_USE
3232from impacket .dcerpc .v5 .dtypes import MAXIMUM_ALLOWED
3333from impacket .krb5 .ccache import CCache
34- from impacket .krb5 .kerberosv5 import SessionKeyDecryptionError , getKerberosTGT , getKerberosTGS
34+ from impacket .krb5 .kerberosv5 import SessionKeyDecryptionError , getKerberosTGT , getKerberosTGS , KerberosError
3535from impacket .krb5 .types import KerberosException , Principal
3636from impacket .krb5 import constants
3737from impacket .dcerpc .v5 .dtypes import NULL
@@ -200,13 +200,11 @@ def enum_host_info(self):
200200 self .no_ntlm = True
201201 self .logger .debug ("NTLM not supported" )
202202
203- if check_guest_account and not self .no_ntlm :
204- try :
205- self .conn .login ("Guest" , "" )
206- self .logger .debug ("Guest authentication successful" )
207- self .is_guest = True
208- except Exception :
209- self .is_guest = False
203+ # The Kerberos/MSRPC DC probes reach out to ports 88/135; only allow them
204+ # when the DC answer is actually consumed, so a plain scan (especially
205+ # over a pivot) stays on the SMB session it already holds. Tier 1 (SMB)
206+ # runs regardless, so DCs are still flagged whenever a null session works.
207+ need_dc = bool (self .args .generate_hosts_file or self .args .generate_krb5_file or self .args .generate_tgt )
210208
211209 # self.domain is the attribute we authenticate with
212210 # self.targetDomain is the attribute which gets displayed as host domain
@@ -222,9 +220,10 @@ def enum_host_info(self):
222220 self .targetDomain = self .conn .getServerDNSDomainName ()
223221 if not self .targetDomain : # Not sure if that can even happen but now we are safe
224222 self .targetDomain = self .hostname
223+ self .is_host_dc (allow_network_probes = need_dc ) # SMB (Tier 1) suffices when NTLM is up
225224 else :
226225 try :
227- self .is_host_dc ()
226+ self .is_host_dc (allow_network_probes = need_dc ) # no NTLM: needs 88/135 if the DC answer is wanted
228227 # If we know the host is a DC we can still get the hostname over LDAP if NTLM is not available
229228 if self .isdc and detect_if_ip (self .host ):
230229 self .hostname , self .domain = LDAPResolution (self .host ).get_resolution ()
@@ -290,6 +289,15 @@ def enum_host_info(self):
290289
291290 self .os_arch = self .get_os_arch ()
292291
292+ # moved at the end because it can cause issues with some DCs if we try to login as guest before checking if dc or not
293+ if check_guest_account and not self .no_ntlm :
294+ try :
295+ self .conn .login ("Guest" , "" )
296+ self .logger .debug ("Guest authentication successful" )
297+ self .is_guest = True
298+ except Exception :
299+ self .is_guest = False
300+
293301 try :
294302 # DCs seem to want us to logoff first, windows workstations sometimes reset the connection
295303 self .conn .logoff ()
@@ -321,13 +329,12 @@ def print_host_info(self):
321329 signing = colored (f"signing:{ self .signing } " , host_info_colors [0 ], attrs = ["bold" ]) if self .signing else colored (f"signing:{ self .signing } " , host_info_colors [1 ], attrs = ["bold" ])
322330 smbv1 = colored (f"SMBv1:{ self .smbv1 } " , host_info_colors [2 ], attrs = ["bold" ]) if self .smbv1 else colored (f"SMBv1:{ self .smbv1 } " , host_info_colors [3 ], attrs = ["bold" ])
323331 ntlm = colored (f" (NTLM:{ not self .no_ntlm } )" , host_info_colors [2 ], attrs = ["bold" ]) if self .no_ntlm else ""
324- null_auth = colored (f" (Null Auth:{ self .null_auth } )" , host_info_colors [2 ], attrs = ["bold" ]) if self .null_auth else ""
325332 guest = colored (f" (Guest Auth:{ self .is_guest } )" , host_info_colors [1 ], attrs = ["bold" ]) if self .is_guest else ""
326- self .logger .display (f"{ self .server_os } { f' x{ self .os_arch } ' if self .os_arch else '' } (name:{ self .hostname } ) (domain:{ self .targetDomain } ) ({ signing } ) ({ smbv1 } ){ ntlm } { null_auth } { guest } " )
333+ isdc = colored (f" (DC:{ self .isdc } )" , host_info_colors [3 ], attrs = ["bold" ]) if self .isdc else ""
334+ null_auth = colored (f" (Null Auth:{ self .null_auth } )" , host_info_colors [2 ], attrs = ["bold" ]) if self .null_auth and not self .isdc else ""
335+ self .logger .display (f"{ self .server_os } { f' x{ self .os_arch } ' if self .os_arch else '' } (name:{ self .hostname } ) (domain:{ self .targetDomain } ) ({ signing } ) ({ smbv1 } ){ ntlm } { null_auth } { guest } { isdc } " )
327336
328337 if self .args .generate_hosts_file or self .args .generate_krb5_file :
329- if self .isdc is None :
330- self .is_host_dc ()
331338 if self .args .generate_hosts_file :
332339 with open (self .args .generate_hosts_file , "a+" ) as host_file :
333340 dc_part = f" { self .targetDomain } " if self .isdc else ""
@@ -843,57 +850,110 @@ def generate_st(self):
843850 except Exception as e :
844851 self .logger .fail (f"Failed to get ST: { e } " )
845852
846- def check_dc_ports (self , timeout = 1 ):
847- """Check multiple DC-specific ports in case first check fails"""
848- import socket
849- dc_ports = [88 , 389 , 636 , 3268 , 9389 ] # Kerberos, LDAP, LDAPS, Global Catalog, ADWS
850- open_ports = 0
851-
852- for port in dc_ports :
853- try :
854- sock = socket .socket (socket .AF_INET , socket .SOCK_STREAM )
855- sock .settimeout (timeout )
856- result = sock .connect_ex ((self .host , port ))
857- if result == 0 :
858- self .logger .debug (f"Port { port } is open on { self .host } " )
859- open_ports += 1
860- sock .close ()
861- except Exception :
862- pass
863- # If 3 or more DC ports are open, likely a DC
864- return open_ports >= 3
865-
866- def is_host_dc (self ):
853+ def is_host_dc (self , allow_network_probes = False ):
867854 if self .isdc is not None :
868855 return self .isdc
869856
870- from impacket .dcerpc .v5 import nrpc , epm
857+ probes = [self ._is_dc_via_smb ]
858+ if allow_network_probes :
859+ probes += [self ._is_dc_via_rpc , self ._is_dc_via_kerberos ]
860+
861+ for probe in probes :
862+ result = probe ()
863+ if result is not None :
864+ self .isdc = result
865+ return self .isdc
866+ # inconclusive if all probes return None
867+ if allow_network_probes :
868+ self .isdc = False
869+ return self .isdc
870+
871+ def _is_dc_via_smb (self ):
872+ """Tier 1: SYSVOL is published only by DCs. One TREE_CONNECT over the
873+ session we already hold answers without enumerating every share (unlike
874+ listShares). ACCESS_DENIED still proves the share exists, hence a DC.
875+
876+ Meant to run while the null session is still fresh. When there is no
877+ session, the reason is decisive: a DC with NTLM enabled always accepts
878+ the null bind, so "NTLM on + no null session" means this is NOT a DC.
879+ Only "NTLM disabled" is inconclusive here (a real DC can refuse the null
880+ bind then), so we hand that case off to the Kerberos/RPC tiers.
881+ """
882+ if not self .null_auth :
883+ if not self .no_ntlm :
884+ self .logger .debug ("NTLM enabled but no null session: host is not a DC" )
885+ return False
886+ self .logger .debug ("No null session (NTLM disabled): deferring to Kerberos/RPC" )
887+ return None
888+ try :
889+ tid = self .conn .connectTree ("SYSVOL" )
890+ self .conn .disconnectTree (tid )
891+ self .logger .debug ("SYSVOL reachable over SMB: host is a DC" )
892+ return True
893+ except SessionError as e :
894+ if "STATUS_ACCESS_DENIED" in str (e ):
895+ self .logger .debug ("SYSVOL exists (access denied): host is a DC" )
896+ return True
897+ if "STATUS_BAD_NETWORK_NAME" in str (e ):
898+ self .logger .debug ("No SYSVOL share: host is not a DC" )
899+ return False
900+ self .logger .debug (f"SMB DC check inconclusive: { e } " )
901+ return None
902+ except Exception as e :
903+ self .logger .debug (f"SMB DC check unavailable: { e } " )
904+ return None
905+
906+ def _is_dc_via_kerberos (self ):
907+ """Tier 2: only a KDC answers a Kerberos AS-REQ, and in AD the KDC runs
908+ only on DCs. Pre-auth, no credentials - the fallback for NTLM-disabled
909+ hosts where no SMB session exists.
910+
911+ The realm need not be correct: a live KDC answers a wrong realm with
912+ KDC_ERR_WRONG_REALM, which proves it is a KDC just as well as
913+ PRINCIPAL_UNKNOWN would. So we use the known domain if we happen to have
914+ one (a PRINCIPAL_UNKNOWN reply is marginally quieter) and otherwise a
915+ placeholder - we never need to actually know the domain.
916+ """
917+ # targetDomain is not set yet when this runs on the no-NTLM path (it is
918+ # produced later by the isdc-dependent LDAP resolution), so read it
919+ # defensively and fall back to a placeholder - the realm need not be real.
920+ realm = (self .domain or self .args .domain or "NXCPROBE" ).upper ()
921+ user = Principal ("nxc_dc_probe" , type = constants .PrincipalNameType .NT_PRINCIPAL .value )
922+ try :
923+ # A bogus principal with no pre-auth data cannot obtain a ticket, so
924+ # a live KDC always answers with a KRB-ERROR (PRINCIPAL_UNKNOWN /
925+ # WRONG_REALM / PREAUTH_REQUIRED) -> KerberosError. That reply is the
926+ # proof: a KDC is listening, i.e. this host is a DC. Only a
927+ # transport-level failure (connection refused / timeout) means no KDC.
928+ getKerberosTGT (user , "" , realm , "" , "" , kdcHost = self .host )
929+ except KerberosError as e :
930+ self .logger .debug (f"KDC replied with an error ({ e } ): host is a DC" )
931+ except Exception as e :
932+ self .logger .debug (f"No KDC response (port 88 filtered or not a DC): { e } " )
933+ return None
934+ return True
871935
872- self .logger .debug ("Performing authentication attempts..." )
936+ def _is_dc_via_rpc (self ):
937+ """Tier 3: unauthenticated Netlogon endpoint lookup on 135. NTLM-agnostic
938+ and needs no session, but heavier and noisier than the SMB/Kerberos
939+ probes above, so it only runs when both of those were inconclusive.
940+ """
941+ from impacket .dcerpc .v5 import nrpc , epm
873942
874- # First check if port 135 is open
875- if self ._is_port_open (135 ):
876- self .logger .debug ("Port 135 is open, attempting MSRPC connection..." )
877- try :
878- epm .hept_map (self .host , nrpc .MSRPC_UUID_NRPC , protocol = "ncacn_ip_tcp" )
879- self .isdc = True
880- return True
881- except DCERPCException :
882- self .logger .debug ("Error while connecting to host: DCERPCException, which means this is probably not a DC!" )
883- except TimeoutError :
884- self .logger .debug ("Timeout while connecting to host: likely not a DC or host is unreachable." )
885- except Exception as e :
886- self .logger .debug (f"Error while connecting to host: { e } " )
887- self .isdc = False
943+ if not self ._is_port_open (135 ):
944+ self .logger .debug ("Port 135 closed and no higher-tier signal: treating as not a DC" )
888945 return False
889- else :
890- self .logger .debug ("Port 135 is closed, skipping MSRPC check..." )
891- # Fallback to checking DC ports
892- if self .check_dc_ports ():
893- self .logger .debug ("Host appears to be a DC (multiple DC ports open)" )
894- self .isdc = True
895- return True
896- self .isdc = False
946+
947+ self .logger .debug ("Port 135 is open, attempting Netlogon MSRPC lookup..." )
948+ try :
949+ epm .hept_map (self .host , nrpc .MSRPC_UUID_NRPC , protocol = "ncacn_ip_tcp" )
950+ return True
951+ except DCERPCException :
952+ self .logger .debug ("DCERPCException on Netlogon lookup: probably not a DC" )
953+ except TimeoutError :
954+ self .logger .debug ("Timeout on Netlogon lookup: likely not a DC or unreachable" )
955+ except Exception as e :
956+ self .logger .debug (f"Error on Netlogon lookup: { e } " )
897957 return False
898958
899959 def _is_port_open (self , port , timeout = 1 ):
0 commit comments