Skip to content

Commit ec54abe

Browse files
committed
refactor isdc
1 parent ff19840 commit ec54abe

1 file changed

Lines changed: 118 additions & 58 deletions

File tree

nxc/protocols/smb.py

Lines changed: 118 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
from impacket.dcerpc.v5.samr import SID_NAME_USE
3232
from impacket.dcerpc.v5.dtypes import MAXIMUM_ALLOWED
3333
from impacket.krb5.ccache import CCache
34-
from impacket.krb5.kerberosv5 import SessionKeyDecryptionError, getKerberosTGT, getKerberosTGS
34+
from impacket.krb5.kerberosv5 import SessionKeyDecryptionError, getKerberosTGT, getKerberosTGS, KerberosError
3535
from impacket.krb5.types import KerberosException, Principal
3636
from impacket.krb5 import constants
3737
from 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

Comments
 (0)