from pathlib import Path from datetime import datetime from impacket.ldap import ldap, ldapasn1 from impacket.ldap.ldap import LDAPSearchError class NXCModule: """ Get user descriptions stored in Active Directory. Module by Tobias Neitzel (@qtc_de) """ name = "user-desc" description = "Get user descriptions stored in Active Directory" supported_protocols = ["ldap"] opsec_safe = True multiple_hosts = True def __init__(self, context=None, multiple_options=None): self.keywords = None self.search_filter = None self.account_names = None self.context = None self.desc_count = None self.log_file = None def options(self, context, module_options): """ LDAP_FILTER Custom LDAP search filter (fully replaces the default search) DESC_FILTER An additional search filter for descriptions (supports wildcard *) DESC_INVERT An additional search filter for descriptions (shows non matching) USER_FILTER An additional search filter for usernames (supports wildcard *) USER_INVERT An additional search filter for usernames (shows non matching) KEYWORDS Use a custom set of keywords (comma separated) ADD_KEYWORDS Add additional keywords to the default set (comma separated) """ self.log_file = None self.desc_count = 0 self.context = context self.account_names = set() self.keywords = {"pass", "creds", "creden", "key", "secret", "default"} if "LDAP_FILTER" in module_options: self.search_filter = module_options["LDAP_FILTER"] else: self.search_filter = "(&(objectclass=user)" if "DESC_FILTER" in module_options: self.search_filter += f"(description={module_options['DESC_FILTER']})" if "DESC_INVERT" in module_options: self.search_filter += f"(!(description={module_options['DESC_INVERT']}))" if "USER_FILTER" in module_options: self.search_filter += f"(sAMAccountName={module_options['USER_FILTER']})" if "USER_INVERT" in module_options: self.search_filter += f"(!(sAMAccountName={module_options['USER_INVERT']}))" self.search_filter += ")" if "KEYWORDS" in module_options: self.keywords = set(module_options["KEYWORDS"].split(",")) elif "ADD_KEYWORDS" in module_options: add_keywords = set(module_options["ADD_KEYWORDS"].split(",")) self.keywords = self.keywords.union(add_keywords) def on_login(self, context, connection): """ On successful LDAP login we perform a search for all user objects that have a description. Users can specify additional LDAP filters that are applied to the query. """ self.create_log_file(connection.conn.getRemoteHost(), datetime.now().strftime("%Y%m%d_%H%M%S")) context.log.info(f"Starting LDAP search with search filter '{self.search_filter}'") try: sc = ldap.SimplePagedResultsControl() connection.ldapConnection.search( searchFilter=self.search_filter, attributes=["sAMAccountName", "description"], sizeLimit=0, searchControls=[sc], perRecordCallback=self.process_record, ) except LDAPSearchError as e: context.log.fail(f"Obtained unexpected exception: {e!s}") finally: self.delete_log_file() def create_log_file(self, host, time): """Create a log file for dumping user descriptions.""" logfile = f"UserDesc-{host}-{time}.log" logfile = Path.home().joinpath(".nxc").joinpath("logs").joinpath(logfile) self.context.log.info(f"Creating log file '{logfile}'") self.log_file = open(logfile, "w") # noqa: SIM115 self.append_to_log("User:", "Description:") def delete_log_file(self): """Closes the log file.""" try: self.log_file.close() info = f"Saved {self.desc_count} user descriptions to {self.log_file.name}" self.context.log.highlight(info) except AttributeError: pass def append_to_log(self, user, description): """ Append a new entry to the log file. Helper function that is only used to have an unified padding on the user field. """ print(user.ljust(25), description, file=self.log_file) def process_record(self, item): """ Function that is called to process the items obtained by the LDAP search. All items are written to the log file per default. Items that contain one of the keywords configured within this module are also printed to stdout. On large Active Directories there seems to be a problem with duplicate user entries. For some reason the process_record function is called multiple times with the same user entry. Not sure whether this is a fault by this module or by impacket. As a workaround, this function adds each new account name to a set and skips accounts that have already been added. """ if not isinstance(item, ldapasn1.SearchResultEntry): return sAMAccountName = "" description = "" try: for attribute in item["attributes"]: if str(attribute["type"]) == "sAMAccountName": sAMAccountName = attribute["vals"][0].asOctets().decode("utf-8") elif str(attribute["type"]) == "description": description = attribute["vals"][0].asOctets().decode("utf-8") except Exception as e: entry = sAMAccountName or "item" self.context.error(f"Skipping {entry}, cannot process LDAP entry due to error: '{e!s}'") if description and sAMAccountName not in self.account_names: self.desc_count += 1 self.append_to_log(sAMAccountName, description) if self.highlight(description): self.context.log.highlight(f"User: {sAMAccountName} - Description: {description}") self.account_names.add(sAMAccountName) def highlight(self, description): """ Check for interesting entries. Just checks whether certain keywords are contained within the user description. Keywords are configured at the top of this class within the options function. It is tempting to implement more logic here (e.g. catch all strings that are longer than seven characters and contain 3 different character classes). Such functionality is nice when playing CTF in small AD environments. When facing a real AD, such functionality gets annoying, because it generates too much output with 99% of it being false positives. The recommended way when targeting user descriptions is to use the keyword filter to catch low-hanging fruit. More dedicated searches for sensitive information should be done using the logfile. This allows you to refine your search query at any time without having to pull data from AD again. """ return any(keyword.lower() in description.lower() for keyword in self.keywords)