diff --git a/nxc/modules/check-add-computer.py b/nxc/modules/check-add-computer.py new file mode 100644 index 0000000000..ca2077e982 --- /dev/null +++ b/nxc/modules/check-add-computer.py @@ -0,0 +1,239 @@ +from io import BytesIO +from nxc.helpers.misc import CATEGORY +from nxc.protocols.smb.samrfunc import LSAQuery + + +class NXCModule: + """ + Module to check which users/groups can add workstations to domain + Author : @Blatzy github.com/Blatzy + """ + + name = "check-add-computer" + description = "Checks the 'Add workstations to domain' policy from Default Domain Controllers Policy" + supported_protocols = ["smb"] + category = CATEGORY.ENUMERATION + + def options(self, context, module_options): + """ + Check the SeMachineAccountPrivilege in the Default Domain Controllers Policy. + Displays which users/groups can add workstations to the domain. + Usage: nxc smb $DC-IP -u 'username' -p 'password' -M check-add-computer + """ + + def on_login(self, context, connection): + self.context = context + self.connection = connection + + # Check if SYSVOL share exists (DC verification) + if not self.check_sysvol_exists(): + self.context.log.fail("SYSVOL share not found - This may not be a Domain Controller") + return + + self.context.log.debug("SYSVOL share found - Confirmed Domain Controller") + + # Initialize LSA for SID resolution + try: + self.lsa_query = LSAQuery( + username=connection.username, + password=connection.password, + domain=connection.domain, + remote_name=connection.hostname, + remote_host=connection.host, + lmhash=connection.lmhash, + nthash=connection.nthash, + kerberos=connection.kerberos, + kdcHost=connection.kdcHost, + aesKey=connection.aesKey, + logger=context.log + ) + except Exception as e: + self.context.log.fail(f"Failed to initialize LSA connection: {e}") + self.lsa_query = None + + # Try static path first (most reliable) + # Use targetDomain (DC's domain) not domain (user's auth domain) for trust scenarios + dc_domain = connection.targetDomain + dc_policy_guid = "{6AC1786C-016F-11D2-945F-00C04fB984F9}" + dc_policy_path = f"{dc_domain}\\Policies\\{dc_policy_guid}\\MACHINE\\Microsoft\\Windows NT\\SecEdit\\GptTmpl.inf" + + self.context.log.info("Trying static path for Default Domain Controllers Policy...") + self.context.log.debug(f"Static path: {dc_policy_path}") + + # Test if static path works + try: + buf = BytesIO() + connection.conn.getFile("SYSVOL", dc_policy_path, buf.write) + if buf.getvalue(): + self.context.log.highlight("") + self.context.log.highlight("Found Default Domain Controllers Policy via static path") + self.context.log.highlight("(don't forget to check MAQ : nxc ldap <...> -M MAQ)") + else: + dc_policy_path = None + except Exception as e: + self.context.log.debug(f"Static path failed: {e}") + dc_policy_path = None + + # If static path fails, try spider + if not dc_policy_path: + self.context.log.info("Static path failed, searching with spider...") + try: + paths = connection.spider("SYSVOL", pattern=["GptTmpl.inf"]) + self.context.log.debug(f"Spider found {len(paths) if paths else 0} GptTmpl.inf files") + + if paths: + for path in paths: + self.context.log.debug(f" - {path}") + # Look for Default Domain Controllers Policy GUID + if "6AC1786C-016F-11D2-945F-00C04fB984F9" in path.upper(): + dc_policy_path = path + self.context.log.success(f"Found Default Domain Controllers Policy: {path}") + break + else: + self.context.log.fail("No GptTmpl.inf files found in SYSVOL") + except Exception as e: + self.context.log.fail(f"Failed to search SYSVOL: {e}") + + if not dc_policy_path: + self.context.log.fail("Default Domain Controllers Policy not found") + return + + # Get the policy file content + policy_content = self.get_policy_file(dc_policy_path) + if not policy_content: + self.context.log.fail("Could not retrieve Default Domain Controllers Policy") + return + + # Parse and display SeMachineAccountPrivilege + self.parse_machine_account_privilege(policy_content) + + def check_sysvol_exists(self): + """Check if SYSVOL share exists on the target""" + try: + shares = self.connection.conn.listShares() + for share in shares: + if share["shi1_netname"].rstrip("\x00").upper() == "SYSVOL": + return True + return False + except Exception as e: + self.context.log.debug(f"Error checking for SYSVOL: {e}") + return False + + def get_policy_file(self, policy_path): + """Retrieve GptTmpl.inf content from given path""" + self.context.log.info("Reading policy file...") + self.context.log.debug(f"Policy path: {policy_path}") + + try: + # Use getFile with BytesIO like gpp_privileges.py + buf = BytesIO() + self.connection.conn.getFile("SYSVOL", policy_path, buf.write) + + content = buf.getvalue() + + if not content: + self.context.log.fail("File is empty or could not be read") + return None + + self.context.log.debug(f"Read {len(content)} bytes from policy file") + + # Try different encodings + for encoding in ["utf-16-le", "utf-16", "latin-1", "utf-8"]: + try: + decoded = content.decode(encoding, errors="ignore") + if decoded and len(decoded) > 0: + self.context.log.debug(f"Successfully decoded with {encoding}") + return decoded + except: + continue + + self.context.log.fail("Could not decode policy file with any known encoding") + return None + + except Exception as e: + self.context.log.fail(f"Error reading policy file: {e}") + self.context.log.debug(f"Full error details: {type(e).__name__}: {e!s}") + return None + + def parse_machine_account_privilege(self, content): + """Parse GptTmpl.inf to find SeMachineAccountPrivilege""" + self.context.log.info("Parsing security policy...") + + # Find the [Privilege Rights] section + in_privilege_section = False + machine_account_line = None + + for line in content.split("\n"): + line = line.strip() + + if line.upper() == "[PRIVILEGE RIGHTS]": + in_privilege_section = True + continue + + if in_privilege_section: + # Check if we've moved to another section + if line.startswith("["): + break + + # Look for SeMachineAccountPrivilege + if line.startswith("SeMachineAccountPrivilege"): + machine_account_line = line + break + + if not machine_account_line: + self.context.log.info("SeMachineAccountPrivilege not found in policy") + self.context.log.highlight("=" * 60) + self.context.log.highlight("Default configuration applies:") + self.context.log.highlight(" - Authenticated Users can join computers to the domain") + self.context.log.highlight("=" * 60) + return + + # Parse the line: SeMachineAccountPrivilege = *S-1-5-32-544,*S-1-5-21-...-512 + parts = machine_account_line.split("=", 1) + if len(parts) != 2: + self.context.log.fail("Could not parse SeMachineAccountPrivilege line") + return + + sids = parts[1].strip() + + if not sids: + self.context.log.info("No users/groups explicitly assigned (using default)") + return + + # Split by comma and process each SID + sid_list = [s.strip().lstrip("*") for s in sids.split(",") if s.strip()] + + if not sid_list: + self.context.log.info("No SIDs found in policy") + return + + # Resolve all SIDs at once using LSA + resolved_names = self.resolve_sids(sid_list) + + # Display results + self.context.log.highlight("Users/Groups that can add computers to the domain:") + self.context.log.highlight("=" * 60) + + for sid, name in zip(sid_list, resolved_names, strict=False): + if name and name != "": + self.context.log.highlight(f" - {name} ({sid})") + else: + self.context.log.highlight(f" - UNKNOWN ({sid})") + + self.context.log.highlight("=" * 60) + + def resolve_sids(self, sid_list): + """Resolve a list of SIDs to friendly names using LSA""" + if not self.lsa_query: + self.context.log.debug("LSA not available, cannot resolve SIDs") + return ["UNKNOWN"] * len(sid_list) + + try: + # Use LSAQuery to resolve all SIDs at once + resolved_names = self.lsa_query.lookup_sids(sid_list) + return resolved_names + except Exception as e: + self.context.log.debug(f"Error resolving SIDs via LSA: {e}") + # Fallback to returning UNKNOWN for all SIDs + return [""] * len(sid_list) + diff --git a/tests/e2e_commands.txt b/tests/e2e_commands.txt index c5793dcdbe..0a4806a0d7 100644 --- a/tests/e2e_commands.txt +++ b/tests/e2e_commands.txt @@ -71,6 +71,7 @@ netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M add-comp netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M add-computer -o NAME="BADPC" PASSWORD="Password2" CHANGEPW=True netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M add-computer -o NAME="BADPC" DELETE=True netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M bitlocker +netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M check-add-computer netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M dpapi_hash netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M dpapi_hash -o OUTPUTFILE=hashes.txt netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M drop-library-ms -o SERVER=127.0.0.1 NAME=test