├── requirements.txt ├── .github ├── banner.png ├── example.png └── FUNDING.yml ├── README.md └── FindUnusualSessions.py /requirements.txt: -------------------------------------------------------------------------------- 1 | impacket 2 | sectools 3 | argparse 4 | -------------------------------------------------------------------------------- /.github/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p0dalirius/FindUnusualSessions/HEAD/.github/banner.png -------------------------------------------------------------------------------- /.github/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p0dalirius/FindUnusualSessions/HEAD/.github/example.png -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: p0dalirius 4 | patreon: Podalirius -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](./.github/banner.png) 2 | 3 |

4 | A tool to remotely detect unusual sessions opened on windows machines using RPC 5 |
6 | GitHub release (latest by date) 7 | 8 | YouTube Channel Subscribers 9 |
10 |

11 | 12 | ## Features 13 | 14 | - [x] Extracts the list of legitimate domains and trusts registered in the LDAP 15 | - [x] Maps the current sessions open on remote machines through RPC 16 | - [x] Highlight suspicious sessions 17 | - [x] Multithreaded connections to enum remote sessions. 18 | - [ ] Export results in JSON with `--export-json `. 19 | - [ ] Export results in XLSX with `--export-xlsx `. 20 | - [ ] Export results in SQLITE3 with `--export-sqlite `. 21 | 22 | ## Demonstration 23 | 24 | ``` 25 | ./FindUnusualSessions.py -au $USER -ad $DOMAIN -ap $PASSWORD -ai $DC_IP 26 | ``` 27 | 28 | ![](./.github/example.png) 29 | 30 | ## Usage 31 | 32 | ``` 33 | $ ./FindUnusualSessions.py -h 34 | FindUnusualSessions v1.1 - by Remi GASCOU (Podalirius) 35 | 36 | usage: FindUnusualSessions.py [-h] [-v] [--debug] [--no-colors] [-L LOGFILE] [-t THREADS] [-ns NAMESERVER] [-tf TARGETS_FILE] [-tt TARGET] [-ad AUTH_DOMAIN] [-ai AUTH_DC_IP] [-au AUTH_USER] [--ldaps] 37 | [--no-ldap] [--subnets] [-tl TARGET_LDAP_QUERY] [--no-pass | -ap AUTH_PASSWORD | -ah AUTH_HASHES | --aes-key hex key] [-k] [--kdcHost AUTH_KDCHOST] [--export-xlsx EXPORT_XLSX] 38 | [--export-json EXPORT_JSON] [--export-sqlite EXPORT_SQLITE] 39 | 40 | options: 41 | -h, --help show this help message and exit 42 | -v, --verbose Verbose mode. (default: False). 43 | --debug Debug mode. (default: False). 44 | --no-colors Disables colored output mode. 45 | -L LOGFILE, --logfile LOGFILE 46 | File to write logs to. 47 | -t THREADS, --threads THREADS 48 | Number of threads (default: 64). 49 | -ns NAMESERVER, --nameserver NAMESERVER 50 | IP of the DNS server to use, instead of the --dc-ip. 51 | 52 | Targets: 53 | -tf TARGETS_FILE, --targets-file TARGETS_FILE 54 | Path to file containing a line by line list of targets. 55 | -tt TARGET, --target TARGET 56 | Target IP, FQDN or CIDR. 57 | -ad AUTH_DOMAIN, --auth-domain AUTH_DOMAIN 58 | Windows domain to authenticate to. 59 | -ai AUTH_DC_IP, --auth-dc-ip AUTH_DC_IP 60 | IP of the domain controller. 61 | -au AUTH_USER, --auth-user AUTH_USER 62 | Username of the domain account. 63 | --ldaps Use LDAPS (default: False) 64 | --no-ldap Do not perform LDAP queries. 65 | --subnets Get all subnets from the domain and use them as targets (default: False) 66 | -tl TARGET_LDAP_QUERY, --target-ldap-query TARGET_LDAP_QUERY 67 | LDAP query to use to extract computers from the domain. 68 | 69 | Credentials: 70 | --no-pass Don't ask for password (useful for -k) 71 | -ap AUTH_PASSWORD, --auth-password AUTH_PASSWORD 72 | Password of the domain account. 73 | -ah AUTH_HASHES, --auth-hashes AUTH_HASHES 74 | LM:NT hashes to pass the hash for this user. 75 | --aes-key hex key AES key to use for Kerberos Authentication (128 or 256 bits) 76 | -k, --kerberos Use Kerberos authentication. Grabs credentials from .ccache file (KRB5CCNAME) based on target parameters. If valid credentials cannot be found, it will use the ones specified in the 77 | command line 78 | --kdcHost AUTH_KDCHOST 79 | IP of the domain controller. 80 | 81 | Output files: 82 | --export-xlsx EXPORT_XLSX 83 | Output XLSX file to store the results in. 84 | --export-json EXPORT_JSON 85 | Output JSON file to store the results in. 86 | --export-sqlite EXPORT_SQLITE 87 | Output SQLITE3 file to store the results in. 88 | 89 | ``` 90 | 91 | ## Contributing 92 | 93 | Pull requests are welcome. Feel free to open an issue if you want to add other features. 94 | -------------------------------------------------------------------------------- /FindUnusualSessions.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # File name : FindUnusualSessions.py 4 | # Author : Podalirius (@podalirius_) 5 | # Date created : 11 April 2025 6 | 7 | 8 | from concurrent.futures import ThreadPoolExecutor 9 | from enum import Enum 10 | from impacket.dcerpc.v5 import transport, srvs 11 | from impacket.dcerpc.v5.ndr import NULL 12 | from impacket.dcerpc.v5.rpcrt import DCERPCException 13 | from impacket.smb3 import SMB3 14 | from impacket.smbconnection import SessionError 15 | from impacket.smb import SMB 16 | from sectools.network.domains import is_fqdn 17 | from sectools.network.ip import is_ipv4_cidr, is_ipv4_addr, is_ipv6_addr, expand_cidr 18 | from sectools.windows.crypto import parse_lm_nt_hashes 19 | from sectools.windows.ldap import get_computers_from_domain, get_subnets, raw_ldap_query, init_ldap_session 20 | import argparse 21 | import datetime 22 | import dns.resolver 23 | import os 24 | import re 25 | import socket 26 | import sys 27 | import threading 28 | import traceback 29 | 30 | 31 | VERSION = "1.1" 32 | 33 | 34 | def getDomainsAndTrusts(auth_domain, auth_username, auth_password, auth_lm_hash, auth_nt_hash, auth_dc_ip, use_ldaps=False): 35 | ldap_server, ldap_session = init_ldap_session( 36 | auth_domain=auth_domain, 37 | auth_dc_ip=auth_dc_ip, 38 | auth_username=auth_username, 39 | auth_password=auth_password, 40 | auth_lm_hash=auth_lm_hash, 41 | auth_nt_hash=auth_nt_hash, 42 | use_ldaps=use_ldaps 43 | ) 44 | 45 | domainsAndtrusts = [] 46 | 47 | ldapresults = list(ldap_session.extend.standard.paged_search( 48 | ldap_server.info.other["rootDomainNamingContext"][0], 49 | "(objectClass=domain)", 50 | attributes=["distinguishedName"] 51 | )) 52 | 53 | # Parse the DN to get the FQDN for all the entries 54 | for entry in ldapresults: 55 | if entry["type"] != "searchResEntry": 56 | continue 57 | dn = entry["attributes"]["distinguishedName"].upper() 58 | domain_parts = dn.split(',') 59 | domain = "" 60 | for part in domain_parts: 61 | if part.startswith("DC="): 62 | domain += part[3:] + "." 63 | domain = domain.rstrip('.') 64 | domainsAndtrusts.append(domain.upper()) 65 | 66 | return domainsAndtrusts 67 | 68 | 69 | def timedeltaStr(t): 70 | hours, rem = divmod(t.total_seconds(), 3600) 71 | minutes, seconds = divmod(rem, 60) 72 | if hours > 0: 73 | return ("%dh%dm%ds" % (hours, minutes, seconds)) 74 | elif minutes > 0: 75 | return ("%dm%ds" % (minutes, seconds)) 76 | elif seconds > 0: 77 | return ("%ds" % (seconds)) 78 | 79 | 80 | def export_xlsx(options, results): 81 | pass 82 | 83 | 84 | def export_sqlite(options, results): 85 | pass 86 | 87 | 88 | def export_json(options, results): 89 | pass 90 | 91 | 92 | def is_port_open(target, port) -> bool: 93 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: 94 | s.settimeout(5) 95 | # Non-existant domains cause a lot of errors, added error handling 96 | try: 97 | return s.connect_ex((target, port)) == 0 98 | except Exception as e: 99 | return False 100 | 101 | 102 | def load_targets(options, logger): 103 | """ 104 | Loads targets from various sources based on the provided options. 105 | 106 | This function populates the 'targets' list with domain computers, specific LDAP query results, and subnetworks of the domain. 107 | It checks the options provided and accordingly loads targets from different sources. If the 'no_ldap' option is set, it skips LDAP-based target loading. 108 | For domain computers, it uses either the default query or a specific LDAP query if provided. For subnetworks, it uses the provided subnets. 109 | """ 110 | 111 | targets = [] 112 | 113 | # Loading targets from domain computers 114 | if not options.no_ldap: 115 | if options.auth_dc_ip is not None and options.auth_user is not None and (options.auth_password is not None or options.auth_hashes is not None) and options.target_ldap_query is None: 116 | logger.debug("Loading targets from computers in the domain '%s'" % options.auth_domain) 117 | targets += get_computers_from_domain( 118 | auth_domain=options.auth_domain, 119 | auth_dc_ip=options.auth_dc_ip, 120 | auth_username=options.auth_user, 121 | auth_password=options.auth_password, 122 | auth_hashes=options.auth_hashes, 123 | auth_key=None, 124 | use_ldaps=options.ldaps, 125 | __print=False 126 | ) 127 | 128 | # Loading targets from domain computers 129 | if not options.no_ldap: 130 | if options.auth_dc_ip is not None and options.auth_user is not None and (options.auth_password is not None or options.auth_hashes is not None) and options.target_ldap_query is not None: 131 | logger.debug("Loading targets from specfic LDAP query '%s'" % options.target_ldap_query) 132 | computers = raw_ldap_query( 133 | auth_domain=options.auth_domain, 134 | auth_dc_ip=options.auth_dc_ip, 135 | auth_username=options.auth_username, 136 | auth_password=options.auth_password, 137 | auth_hashes=options.auth_hashes, 138 | query=options.target_ldap_query, 139 | use_ldaps=options.use_ldaps, 140 | attributes=["dNSHostName"] 141 | ) 142 | for _, computer in computers: 143 | targets.append(computer["dNSHostName"]) 144 | 145 | # Loading targets from subnetworks of the domain 146 | if not options.no_ldap: 147 | if options.subnets and options.auth_dc_ip is not None and options.auth_user is not None and (options.auth_password is not None or options.auth_hashes is not None): 148 | logger.debug("Loading targets from subnetworks of the domain '%s'" % options.auth_domain) 149 | targets += get_subnets( 150 | auth_domain=options.auth_domain, 151 | auth_dc_ip=options.auth_dc_ip, 152 | auth_username=options.auth_user, 153 | auth_password=options.auth_password, 154 | auth_hashes=options.auth_hashes, 155 | auth_key=None, 156 | use_ldaps=options.ldaps, 157 | __print=True 158 | ) 159 | 160 | # Loading targets line by line from a targets file 161 | if options.targets_file is not None: 162 | if os.path.exists(options.targets_file): 163 | logger.debug("Loading targets line by line from targets file '%s'" % options.targets_file) 164 | f = open(options.targets_file, "r") 165 | for line in f.readlines(): 166 | targets.append(line.strip()) 167 | f.close() 168 | else: 169 | logger.error("Could not open targets file '%s'" % options.targets_file) 170 | 171 | # Loading targets from a single --target option 172 | if len(options.target) != 0: 173 | logger.debug("Loading targets from --target options") 174 | for target in options.target: 175 | targets.append(target) 176 | 177 | # Sort uniq on targets list 178 | targets = sorted(list(set(targets))) 179 | 180 | final_targets = [] 181 | # Parsing target to filter IP/DNS/CIDR 182 | for target in targets: 183 | if is_ipv4_cidr(target): 184 | final_targets += [("ip", ip) for ip in expand_cidr(target)] 185 | elif is_ipv4_addr(target): 186 | final_targets.append(("ipv4", target)) 187 | elif is_ipv6_addr(target): 188 | final_targets.append(("ipv6", target)) 189 | elif is_fqdn(target): 190 | final_targets.append(("fqdn", target)) 191 | else: 192 | logger.debug("Target '%s' was not added." % target) 193 | 194 | final_targets = sorted(list(set(final_targets))) 195 | 196 | return final_targets 197 | 198 | 199 | class LogLevel(Enum): 200 | INFO = 0 201 | VERBOSE = 1 202 | DEBUG = 2 203 | 204 | 205 | class Logger(object): 206 | """ 207 | A Logger class that provides logging functionalities with various levels such as INFO, DEBUG, WARNING, ERROR, and CRITICAL. 208 | It supports color-coded output, which can be disabled, and can also log messages to a file. 209 | 210 | Attributes: 211 | __debug (bool): If True, debug level messages will be printed and logged. 212 | __nocolors (bool): If True, disables color-coded output. 213 | logfile (str|None): Path to a file where logs will be written. If None, logging to a file is disabled. 214 | 215 | Methods: 216 | __init__(debug=False, logfile=None, nocolors=False): Initializes the Logger instance. 217 | print(message=""): Prints a message to stdout and logs it to a file if logging is enabled. 218 | info(message): Logs a message at the INFO level. 219 | debug(message): Logs a message at the DEBUG level if debugging is enabled. 220 | error(message): Logs a message at the ERROR level. 221 | """ 222 | 223 | def __init__(self, loglevel, logfile=None, no_colors=False): 224 | super(Logger, self).__init__() 225 | self.no_colors = no_colors 226 | self.loglevel = loglevel 227 | self.logfile = logfile 228 | # 229 | if self.logfile is not None: 230 | if os.path.exists(self.logfile): 231 | k = 1 232 | while os.path.exists(self.logfile + (".%d"%k)): 233 | k += 1 234 | self.logfile = self.logfile + (".%d" % k) 235 | open(self.logfile, "w").close() 236 | self.debug("Writting logs to logfile: '%s'" % self.logfile) 237 | 238 | def print(self, message="", end='\n'): 239 | """ 240 | Prints a message to stdout and logs it to a file if logging is enabled. 241 | 242 | This method prints the provided message to the standard output and also logs it to a file if a log file path is specified during the Logger instance initialization. The message can include color codes for color-coded output, which can be disabled by setting the `nocolors` attribute to True. 243 | 244 | Args: 245 | message (str): The message to be printed and logged. 246 | """ 247 | 248 | nocolor_message = re.sub(r"\x1b[\[]([0-9;]+)m", "", message) 249 | if self.no_colors: 250 | print(nocolor_message, end=end) 251 | else: 252 | print(message, end=end) 253 | self.write_to_logfile(nocolor_message, end=end) 254 | 255 | def dateprint(self, message="", end='\n'): 256 | """ 257 | Prints a message to stdout and logs it to a file if logging is enabled. 258 | 259 | This method prints the provided message to the standard output and also logs it to a file if a log file path is specified during the Logger instance initialization. The message can include color codes for color-coded output, which can be disabled by setting the `nocolors` attribute to True. 260 | 261 | Args: 262 | message (str): The message to be printed and logged. 263 | """ 264 | 265 | date_str = datetime.datetime.now().strftime("[%Y-%m-%d %H:%M:%S]") 266 | nocolor_message = re.sub(r"\x1b[\[]([0-9;]+)m", "", message) 267 | if self.no_colors: 268 | print("%s %s" % (date_str, nocolor_message), end=end) 269 | else: 270 | print("%s %s" % (date_str, message), end=end) 271 | self.write_to_logfile("%s %s" % (date_str, nocolor_message), end=end) 272 | 273 | def info(self, message): 274 | """ 275 | Logs a message at the INFO level. 276 | 277 | This method logs the provided message at the INFO level. The message can include color codes for color-coded output, which can be disabled by setting the `nocolors` attribute to True. The message is also logged to a file if a log file path is specified during the Logger instance initialization. 278 | 279 | Args: 280 | message (str): The message to be logged at the INFO level. 281 | """ 282 | 283 | date_str = datetime.datetime.now().strftime("[%Y-%m-%d %H:%M:%S]") 284 | nocolor_message = re.sub(r"\x1b[\[]([0-9;]+)m", "", message) 285 | if self.no_colors: 286 | print("%s [info] %s" % (date_str, nocolor_message)) 287 | else: 288 | print("%s [\x1b[1;92minfo\x1b[0m] %s" % (date_str, message)) 289 | self.write_to_logfile("%s [info] %s" % (date_str, nocolor_message)) 290 | 291 | def verbose(self, message): 292 | """ 293 | Logs a message at the INFO level. 294 | 295 | This method logs the provided message at the INFO level. The message can include color codes for color-coded output, which can be disabled by setting the `nocolors` attribute to True. The message is also logged to a file if a log file path is specified during the Logger instance initialization. 296 | 297 | Args: 298 | message (str): The message to be logged at the INFO level. 299 | """ 300 | 301 | if self.loglevel.value >= LogLevel.VERBOSE.value: 302 | date_str = datetime.datetime.now().strftime("[%Y-%m-%d %H:%M:%S]") 303 | nocolor_message = re.sub(r"\x1b[\[]([0-9;]+)m", "", message) 304 | if self.no_colors: 305 | print("%s [verbose] %s" % (date_str, nocolor_message)) 306 | else: 307 | print("%s [\x1b[1;92mverbose\x1b[0m] %s" % (date_str, message)) 308 | self.write_to_logfile("%s [verbose] %s" % (date_str, nocolor_message)) 309 | 310 | def debug(self, message): 311 | """ 312 | Logs a message at the DEBUG level if debugging is enabled. 313 | 314 | This method logs the provided message at the DEBUG level if the `debug` attribute is set to True during the Logger instance initialization. The message can include color codes for color-coded output, which can be disabled by setting the `nocolors` attribute to True. 315 | 316 | Args: 317 | message (str): The message to be logged. 318 | """ 319 | 320 | if self.loglevel.value >= LogLevel.DEBUG.value: 321 | date_str = datetime.datetime.now().strftime("[%Y-%m-%d %H:%M:%S]") 322 | nocolor_message = re.sub(r"\x1b[\[]([0-9;]+)m", "", message) 323 | if self.no_colors: 324 | print("%s [debug] %s" % (date_str, nocolor_message)) 325 | else: 326 | print("%s [debug] %s" % (date_str, message)) 327 | self.write_to_logfile("%s [debug] %s" % (date_str, nocolor_message)) 328 | 329 | def warn(self, message): 330 | """ 331 | Logs an error message to the console and the log file. 332 | 333 | This method logs the provided error message to the standard error output and also logs it to a file if a log file path is specified during the Logger instance initialization. The message can include color codes for color-coded output, which can be disabled by setting the `nocolors` attribute to True. 334 | 335 | Args: 336 | message (str): The error message to be logged. 337 | """ 338 | 339 | date_str = datetime.datetime.now().strftime("[%Y-%m-%d %H:%M:%S]") 340 | nocolor_message = re.sub(r"\x1b[\[]([0-9;]+)m", "", message) 341 | if self.no_colors: 342 | print("%s [error] %s" % (date_str, nocolor_message)) 343 | else: 344 | print("%s [\x1b[1;91merror\x1b[0m] %s" % (date_str, message)) 345 | self.write_to_logfile("%s [error] %s" % (date_str, nocolor_message)) 346 | 347 | def write_to_logfile(self, message, end='\n'): 348 | """ 349 | Writes the provided message to the log file specified during Logger instance initialization. 350 | 351 | This method appends the provided message to the log file specified by the `logfile` attribute. If no log file path is specified, this method does nothing. 352 | 353 | Args: 354 | message (str): The message to be written to the log file. 355 | """ 356 | 357 | if self.logfile is not None: 358 | f = open(self.logfile, "a") 359 | nocolor_message = re.sub(r"\x1b[\[]([0-9;]+)m", "", message) 360 | f.write(nocolor_message + end) 361 | f.close() 362 | 363 | 364 | class MicrosoftDNS(object): 365 | """ 366 | Class to interact with Microsoft DNS servers for resolving domain names to IP addresses. 367 | 368 | Attributes: 369 | dnsserver (str): The IP address of the DNS server. 370 | verbose (bool): Flag to enable verbose mode. 371 | auth_domain (str): The authentication domain. 372 | auth_username (str): The authentication username. 373 | auth_password (str): The authentication password. 374 | auth_dc_ip (str): The IP address of the domain controller. 375 | auth_lm_hash (str): The LM hash for authentication. 376 | auth_nt_hash (str): The NT hash for authentication. 377 | """ 378 | 379 | __wildcard_dns_cache = {} 380 | 381 | def __init__(self, dnsserver, auth_domain, auth_username, auth_password, auth_dc_ip, auth_lm_hash, auth_nt_hash, use_ldaps=False, logger=None): 382 | super(MicrosoftDNS, self).__init__() 383 | self.dnsserver = dnsserver 384 | 385 | self.auth_domain = auth_domain 386 | self.auth_username = auth_username 387 | self.auth_password = auth_password 388 | self.auth_dc_ip = auth_dc_ip 389 | self.auth_lm_hash = auth_lm_hash 390 | self.auth_nt_hash = auth_nt_hash 391 | self.use_ldaps = use_ldaps 392 | 393 | self.logger = logger 394 | 395 | def resolve(self, target_name): 396 | """ 397 | Resolves a given target name to its corresponding IP addresses. 398 | 399 | This method attempts to resolve the provided target name to its IP addresses using both A and AAAA record types. It iterates through the DNS answers for each record type, appending the addresses to a list. If no records are found, it logs a debug message indicating the absence of records for the target name. 400 | 401 | Args: 402 | target_name (str): The target name to be resolved to IP addresses. 403 | 404 | Returns: 405 | list: A list of IP addresses corresponding to the target name. 406 | """ 407 | 408 | target_ips = [] 409 | for rdtype in ["A", "AAAA"]: 410 | dns_answer = self.getRecord(value=target_name, rdtype=rdtype) 411 | if dns_answer is not None: 412 | for record in dns_answer: 413 | target_ips.append(record.address) 414 | 415 | if len(target_ips) == 0: 416 | self.logger.debug("[MicrosoftDNS] No records found for %s." % target_name) 417 | 418 | return target_ips 419 | 420 | def reverseLookup(self, target_ip, use_tcp=False): 421 | """ 422 | Performs a reverse DNS lookup for a given IP address. 423 | 424 | This method attempts to perform a reverse DNS lookup for the provided IP address using the PTR record type. It can use either UDP or TCP protocol for the lookup, depending on the specified option. If the lookup is successful, it returns the hostname associated with the IP address. If the lookup fails or no record is found, it logs a debug message indicating the failure or absence of records. 425 | 426 | Args: 427 | target_ip (str): The IP address for which to perform the reverse DNS lookup. 428 | use_tcp (bool, optional): Indicates whether to use TCP protocol for the lookup. Defaults to False. 429 | 430 | Returns: 431 | str: The hostname associated with the IP address if the lookup is successful, otherwise the original IP address. 432 | """ 433 | 434 | result = target_ip 435 | try: 436 | addr = dns.reversename.from_address(target_ip) 437 | except dns.exception.SyntaxError: 438 | return None 439 | else: 440 | try: 441 | answer = str(dns.resolver.resolve(addr, 'PTR', tcp=use_tcp)[0]) 442 | result = answer.rstrip('.') 443 | except (dns.resolver.NXDOMAIN, dns.resolver.Timeout) as e: 444 | pass 445 | except Exception as e: 446 | self.logger.debug("[MicrosoftDNS] DNS lookup failed: %s" % addr) 447 | pass 448 | 449 | return result.upper() 450 | 451 | def getRecord(self, rdtype, value): 452 | """ 453 | Retrieves DNS records for a specified value and record type using UDP and TCP protocols. 454 | 455 | Parameters: 456 | rdtype (str): The type of DNS record to retrieve. 457 | value (str): The value for which the DNS record is to be retrieved. 458 | 459 | Returns: 460 | dns.resolver.Answer: The DNS answer containing the resolved records. 461 | 462 | Raises: 463 | dns.resolver.NXDOMAIN: If the domain does not exist. 464 | dns.resolver.NoAnswer: If the domain exists but does not have the specified record type. 465 | dns.resolver.NoNameservers: If no nameservers are found for the domain. 466 | dns.exception.DNSException: For any other DNS-related exceptions. 467 | """ 468 | 469 | dns_resolver = dns.resolver.Resolver() 470 | dns_resolver.nameservers = [self.dnsserver] 471 | dns_answer = None 472 | 473 | # Try UDP 474 | try: 475 | dns_answer = dns_resolver.resolve(value, rdtype=rdtype, tcp=False) 476 | except dns.resolver.NXDOMAIN: 477 | # the domain does not exist so dns resolutions remain empty 478 | pass 479 | except dns.resolver.NoAnswer as e: 480 | # domains existing but not having AAAA records is common 481 | pass 482 | except dns.resolver.NoNameservers as e: 483 | pass 484 | except dns.exception.DNSException as e: 485 | pass 486 | 487 | if dns_answer is None: 488 | # Try TCP 489 | try: 490 | dns_answer = dns_resolver.resolve(value, rdtype=rdtype, tcp=True) 491 | except dns.resolver.NXDOMAIN: 492 | # the domain does not exist so dns resolutions remain empty 493 | pass 494 | except dns.resolver.NoAnswer as e: 495 | # domains existing but not having AAAA records is common 496 | pass 497 | except dns.resolver.NoNameservers as e: 498 | pass 499 | except dns.exception.DNSException as e: 500 | pass 501 | 502 | return dns_answer 503 | 504 | def checkPresenceOfWildcardDns(self): 505 | """ 506 | Check the presence of wildcard DNS entries in the Microsoft DNS server. 507 | 508 | This function queries the Microsoft DNS server to find wildcard DNS entries in the DomainDnsZones of the specified domain. 509 | It retrieves information about wildcard DNS entries and prints a warning message if any are found. 510 | 511 | Returns: 512 | dict: A dictionary containing information about wildcard DNS entries found in the Microsoft DNS server. 513 | """ 514 | 515 | ldap_server, ldap_session = init_ldap_session( 516 | auth_domain=self.auth_domain, 517 | auth_dc_ip=self.auth_dc_ip, 518 | auth_username=self.auth_username, 519 | auth_password=self.auth_password, 520 | auth_lm_hash=self.auth_lm_hash, 521 | auth_nt_hash=self.auth_nt_hash, 522 | use_ldaps=self.use_ldaps 523 | ) 524 | 525 | target_dn = "CN=MicrosoftDNS,DC=DomainDnsZones," + ldap_server.info.other["rootDomainNamingContext"][0] 526 | 527 | ldapresults = list(ldap_session.extend.standard.paged_search(target_dn, "(&(objectClass=dnsNode)(dc=\\2A))", attributes=["distinguishedName", "dNSTombstoned"])) 528 | 529 | results = {} 530 | for entry in ldapresults: 531 | if entry['type'] != 'searchResEntry': 532 | continue 533 | results[entry['dn']] = entry["attributes"] 534 | 535 | if len(results.keys()) != 0: 536 | self.logger.dateprint("[!] WARNING! Wildcard DNS entries found, dns resolution will not be consistent.") 537 | for dn, data in results.items(): 538 | fqdn = re.sub(','+target_dn+'$', '', dn) 539 | fqdn = '.'.join([dc.split('=')[1] for dc in fqdn.split(',')]) 540 | 541 | ips = self.resolve(fqdn) 542 | 543 | if data["dNSTombstoned"]: 544 | self.logger.dateprint(" | %s ──> %s (set to be removed)" % (dn, ips)) 545 | else: 546 | self.logger.dateprint(" | %s ──> %s" % (dn, ips)) 547 | 548 | # Cache found wildcard dns 549 | for ip in ips: 550 | if fqdn not in self.__wildcard_dns_cache.keys(): 551 | self.__wildcard_dns_cache[fqdn] = {} 552 | if ip not in self.__wildcard_dns_cache[fqdn].keys(): 553 | self.__wildcard_dns_cache[fqdn][ip] = [] 554 | self.__wildcard_dns_cache[fqdn][ip].append(data) 555 | print() 556 | return results 557 | 558 | 559 | class RPCSessionsEnumerator(object): 560 | """ 561 | RPCSessionsEnumerator is a class designed to enumerate RPC sessions on a target system. It facilitates the establishment of a DCE RPC connection to the target system, allowing for the enumeration of RPC sessions. This class is particularly useful for reconnaissance and discovery of RPC services running on a target system. 562 | 563 | Attributes: 564 | options (dict): A dictionary containing options for the enumeration process. 565 | address (str): The IP address of the target system. 566 | hostname (str): The hostname of the target system. 567 | username (str): The username to use for authentication. 568 | domain (str): The domain to use for authentication. 569 | password (str): The password to use for authentication. 570 | lmhash (str): The LM hash of the password to use for authentication. 571 | nthash (str): The NT hash of the password to use for authentication. 572 | aesKey (str): The AES key to use for authentication. 573 | smbconnection (object): The SMB connection object. 574 | """ 575 | 576 | def __init__(self, options, logger, midns, hostname, address, username, domain, password, lmhash='', nthash='', aesKey=None): 577 | self.options = options 578 | self.midns = midns 579 | self.logger = logger 580 | # Target 581 | self.address = address 582 | self.hostname = hostname 583 | # Credentials 584 | self.username = username 585 | self.password = password 586 | self.domain = domain 587 | self.lmhash = lmhash 588 | self.nthash = nthash 589 | self.aesKey = aesKey 590 | 591 | self.smbconnection = None 592 | 593 | def dce_rpc_connect(self, binding, uuid): 594 | """ 595 | Establishes a DCE RPC connection to the target system. 596 | 597 | This method sets up a DCE RPC connection to the target system using the provided binding and UUID. It configures the connection with the target's hostname and address, sets the credentials for authentication, and attempts to connect. If the connection is successful, it returns the DCE RPC object. If the connection fails due to hostname validation or other issues, it logs the error and returns None. 598 | 599 | Parameters: 600 | - binding (str): The binding string for the RPC connection. 601 | - uuid (str): The UUID of the RPC service to connect to. 602 | 603 | Returns: 604 | - dce (DCERPCTransportFactory): The DCE RPC object if the connection is successful, otherwise None. 605 | """ 606 | 607 | try: 608 | self.rpc = transport.DCERPCTransportFactory(binding) 609 | self.rpc.set_connect_timeout(1.0) 610 | 611 | # Set name/host explicitly 612 | self.rpc.setRemoteName(self.hostname) 613 | self.rpc.setRemoteHost(self.address) 614 | 615 | # Else set the required stuff for NTLM 616 | if hasattr(self.rpc, "set_credentials"): 617 | self.rpc.set_credentials( 618 | self.username, 619 | self.password, 620 | domain=self.domain, 621 | lmhash=self.lmhash, 622 | nthash=self.nthash 623 | ) 624 | 625 | dce = self.rpc.get_dce_rpc() 626 | 627 | # Try connecting, catch hostname validation 628 | try: 629 | dce.connect() 630 | except (SMB3.HostnameValidationException, SMB.HostnameValidationException) as exc: 631 | self.logger.info("Ignoring host %s since its hostname does not match: %s" % (self.hostname, str(exc))) 632 | return None 633 | except SessionError as exc: 634 | if ("STATUS_PIPE_NOT_AVAILABLE" in str(exc) or "STATUS_OBJECT_NAME_NOT_FOUND" in str(exc)) and "winreg" in binding.lower(): 635 | # This can happen, silently ignore 636 | return None 637 | if "STATUS_MORE_PROCESSING_REQUIRED" in str(exc): 638 | try: 639 | self.rpc.get_smb_connection().close() 640 | except: 641 | pass 642 | # Try again! 643 | return self.dce_rpc_connect(binding, uuid, False) 644 | # Else, just log it 645 | if self.options.debug: 646 | traceback.print_exc() 647 | self.logger.error("DCE/RPC connection failed: %s" % str(exc)) 648 | return None 649 | 650 | if self.smbconnection is None: 651 | self.smbconnection = self.rpc.get_smb_connection() 652 | # We explicity set the smbconnection back to the rpc object 653 | # this way it won"t be closed when we call disconnect() 654 | self.rpc.set_smb_connection(self.smbconnection) 655 | 656 | dce.bind(uuid) 657 | 658 | except DCERPCException as e: 659 | if self.options.debug: 660 | traceback.print_exc() 661 | self.logger.error("DCE/RPC connection failed: %s" % str(e)) 662 | return None 663 | except KeyboardInterrupt: 664 | raise 665 | except Exception as err: 666 | if self.options.debug: 667 | traceback.print_exc() 668 | self.logger.error("DCE/RPC connection failed: %s" % str(err)) 669 | return None 670 | 671 | return dce 672 | 673 | def rpc_get_sessions(self): 674 | """ 675 | This function retrieves a list of active sessions on the target system. 676 | 677 | It establishes a DCE/RPC connection to the target system using the srvs.MSRPC_UUID_SRVS UUID, which is used for session enumeration. 678 | It then calls the hNetrSessionEnum function to enumerate the active sessions on the target system. 679 | The function filters out the current session of the user running the script and machine accounts. 680 | It computes the session time and idle time for each session and returns a list of sessions with their details. 681 | 682 | Returns: 683 | list: A list of active sessions on the target system, each session represented as a dictionary containing the username, session time, and idle time. 684 | """ 685 | 686 | dce = self.dce_rpc_connect(r"ncacn_np:%s[\PIPE\srvsvc]" % self.address, srvs.MSRPC_UUID_SRVS) 687 | 688 | if dce is None: 689 | return [] 690 | 691 | try: 692 | resp = srvs.hNetrSessionEnum(dce, "\x00", NULL, 10) 693 | except DCERPCException as err: 694 | # impacket.dcerpc.v5.rpcrt.DCERPCException: DCERPC Runtime Error: code: 0x5 - rpc_s_access_denied 695 | if "rpc_s_access_denied" in str(err): 696 | if self.options.debug: 697 | self.logger.debug("Access denied while enumerating Sessions on %s, likely a patched OS" % self.hostname) 698 | self.logger.error("Error: %s" % str(err)) 699 | return [] 700 | else: 701 | raise 702 | except Exception as err: 703 | if str(err).find("Broken pipe") >= 0: 704 | if self.options.debug: 705 | self.logger.error("Error: %s" % str(err)) 706 | return [] 707 | else: 708 | raise 709 | 710 | sessions = [] 711 | for session in resp["InfoStruct"]["SessionInfo"]["Level10"]["Buffer"]: 712 | # Compute sessionTime 713 | sessionTime = datetime.datetime.now() 714 | # I am removing microseconds here to avoid useless remainders 715 | sessionTime -= datetime.timedelta(microseconds=sessionTime.microsecond) 716 | sessionTime -= datetime.timedelta(seconds=session["sesi10_time"]) 717 | 718 | # Parse sessionIdleTime (IDLE since N seconds) 719 | sessionIdleTime = datetime.timedelta(seconds=session["sesi10_idle_time"]) 720 | 721 | # Parse the username of the connection 722 | sessionUsername = session["sesi10_username"][:-1] 723 | # Skip our connection 724 | if sessionUsername == self.username: 725 | continue 726 | # Skip empty usernames 727 | if len(sessionUsername) == 0: 728 | continue 729 | # Skip machine accounts 730 | if sessionUsername[-1] == "$": 731 | continue 732 | 733 | # Parse the source of the connection 734 | sessionSource = session["sesi10_cname"][:-1].upper() 735 | # Strip \\ from IPs 736 | if sessionSource[:2] == "\\\\": 737 | sessionSource = sessionSource[2:] 738 | # Skip empty IPs 739 | if sessionSource == "": 740 | continue 741 | # Skip local connections 742 | if sessionSource in ["127.0.0.1", "[::1]"]: 743 | continue 744 | # IPv6 address 745 | if sessionSource[0] == "[" and sessionSource[-1] == "]": 746 | sessionSource = sessionSource[1:-1] 747 | 748 | if is_ipv4_addr(sessionSource): 749 | machineFqdn = self.midns.reverseLookup(sessionSource) 750 | if machineFqdn is not None: 751 | sessionSource = machineFqdn 752 | 753 | sessions.append({ 754 | "username": sessionUsername, 755 | "source": sessionSource, 756 | "target": self.hostname.upper(), 757 | "sessionTime": sessionTime, 758 | "sessionIdleTime": sessionIdleTime 759 | }) 760 | 761 | dce.disconnect() 762 | 763 | return sessions 764 | 765 | 766 | def worker(options, target, domain, username, password, lmhash, nthash, registered_domains, midns, logger, results, lock): 767 | """ 768 | This function is a worker that processes a target for unusual sessions. It takes in options, target information, domain, username, password, LM and NT hashes, registered domains, and a midns object. It attempts to resolve the target IP, checks if port 445 is open, and if so, enumerates RPC sessions. It then prints out information about any unusual sessions found. 769 | 770 | Parameters: 771 | - options: A dictionary of options for the script. 772 | - target: A tuple containing the type and data of the target. 773 | - domain: The domain to use for authentication. 774 | - username: The username to use for authentication. 775 | - password: The password to use for authentication. 776 | - lmhash: The LM hash to use for authentication. 777 | - nthash: The NT hash to use for authentication. 778 | - registered_domains: A list of registered domains. 779 | - midns: An object for DNS resolution. 780 | - results: A dictionary to store results. 781 | - lock: A lock object for thread-safe printing. 782 | """ 783 | 784 | try: 785 | target_type, target_data = target 786 | 787 | target_ips = None 788 | target_name = "" 789 | if target_type.lower() in ["ip", "ipv4", "ipv6"]: 790 | target_name = target_data 791 | target_ips = [target_data] 792 | 793 | elif target_type.lower() in ["fqdn"]: 794 | target_name = target_data 795 | target_ips = midns.resolve(target_data) 796 | if target_ips is not None: 797 | if options.debug: 798 | lock.acquire() 799 | logger.debug("[+] Resolved '%s' to %s" % (target_name, target_ips)) 800 | lock.release() 801 | 802 | if target_ips is not None: 803 | if len(target_ips) == 0: 804 | if options.debug: 805 | logger.debug("No IP addresses found for %s" % target_name) 806 | return 807 | 808 | if is_port_open(target=target_ips[0], port=445): 809 | rpc_sessions_enumerator = RPCSessionsEnumerator( 810 | options=options, 811 | logger=logger, 812 | midns=midns, 813 | hostname=target_name, 814 | address=target_ips[0], 815 | username=username, 816 | domain=domain, 817 | password=password, 818 | lmhash=lmhash, 819 | nthash=nthash 820 | ) 821 | 822 | sessions = rpc_sessions_enumerator.rpc_get_sessions() 823 | 824 | if len(sessions) != 0: 825 | results["target_name"] = [] 826 | for session in sessions: 827 | if is_ipv4_addr(session["source"]): 828 | if not options.ignore_unknown: 829 | logger.dateprint(f"[\x1b[1;48;2;255;171;0;97m Unknown \x1b[0m] User \x1b[1;93m%s\x1b[0m is logged in on \x1b[1;93m%s\x1b[0m at \x1b[1;94m%s\x1b[0m from \x1b[1;95m%s\x1b[0m, IDLE since \x1b[1;96m%s\x1b[0m" % ( 830 | session["username"], 831 | session["target"], 832 | session["sessionTime"], 833 | session["source"], 834 | timedeltaStr(session["sessionIdleTime"]) 835 | )) 836 | 837 | elif is_fqdn(session["source"]): 838 | source_domain = session["source"].split('.', 1)[-1].upper() 839 | if source_domain not in registered_domains: 840 | logger.dateprint(f"[\x1b[1;48;2;233;61;3;97mExt. Domain\x1b[0m] User \x1b[1;93m%s\x1b[0m is logged in on \x1b[1;93m%s\x1b[0m at \x1b[1;94m%s\x1b[0m from %s, IDLE since \x1b[1;96m%s\x1b[0m" % ( 841 | session["username"], 842 | session["target"], 843 | session["sessionTime"], 844 | "\x1b[1;95m%s\x1b[0m.\x1b[1;48;2;233;61;3;97m%s\x1b[0m" % (session["source"].split('.', 1)[0], session["source"].split('.', 1)[1]), 845 | timedeltaStr(session["sessionIdleTime"]) 846 | )) 847 | else: 848 | if options.verbose or options.debug: 849 | logger.dateprint(f"[\x1b[1;48;2;83;170;51;97m Legit \x1b[0m] User \x1b[1;93m%s\x1b[0m is logged in on \x1b[1;93m%s\x1b[0m at \x1b[1;94m%s\x1b[0m from \x1b[1;95m%s\x1b[0m, IDLE since \x1b[1;96m%s\x1b[0m" % ( 850 | session["username"], 851 | session["target"], 852 | session["sessionTime"], 853 | session["source"], 854 | timedeltaStr(session["sessionIdleTime"]) 855 | )) 856 | else: 857 | if options.verbose or options.debug: 858 | logger.dateprint(f"[\x1b[1;48;2;83;170;51;97m Legit \x1b[0m] User \x1b[1;93m%s\x1b[0m is logged in on \x1b[1;93m%s\x1b[0m at \x1b[1;94m%s\x1b[0m from \x1b[1;95m%s\x1b[0m, IDLE since \x1b[1;96m%s\x1b[0m" % ( 859 | session["username"], 860 | session["target"], 861 | session["sessionTime"], 862 | session["source"], 863 | timedeltaStr(session["sessionIdleTime"]) 864 | )) 865 | results["target_name"].append(session) 866 | else: 867 | logger.debug("Port 445 is closed on %s (%s)" % (target_name, target_ips)) 868 | 869 | except Exception as e: 870 | if options.debug: 871 | traceback.print_exc() 872 | 873 | 874 | def parseArgs(): 875 | print("FindUnusualSessions v%s - by Remi GASCOU (Podalirius)\n" % VERSION) 876 | 877 | parser = argparse.ArgumentParser(add_help=True, description="") 878 | 879 | parser.add_argument("-v", "--verbose", action="store_true", help="Verbose mode. (default: False).") 880 | parser.add_argument("--debug", dest="debug", action="store_true", default=False, help="Debug mode. (default: False).") 881 | parser.add_argument("--no-colors", dest="no_colors", action="store_true", default=False, help="Disables colored output mode.") 882 | parser.add_argument("-L", "--logfile", dest="logfile", metavar="LOGFILE", required=False, default=None, type=str, help="File to write logs to.") 883 | parser.add_argument("-t", "--threads", dest="threads", action="store", type=int, default=64, required=False, help="Number of threads (default: 64).") 884 | parser.add_argument("-ns", "--nameserver", dest="nameserver", default=None, required=False, help="IP of the DNS server to use, instead of the --dc-ip.") 885 | parser.add_argument("-ld", "--legitimate-domains", dest="legitimate_domains", default=[], required=False, action="append", help="Legitimate domains to use for comparison.") 886 | 887 | group_targets_source = parser.add_argument_group("Targets") 888 | group_targets_source.add_argument("-tf", "--targets-file", default=None, type=str, help="Path to file containing a line by line list of targets.") 889 | group_targets_source.add_argument("-tt", "--target", default=[], type=str, action='append', help="Target IP, FQDN or CIDR.") 890 | group_targets_source.add_argument("-ad", "--auth-domain", default="", type=str, help="Windows domain to authenticate to.") 891 | group_targets_source.add_argument("-ai", "--auth-dc-ip", default=None, type=str, help="IP of the domain controller.") 892 | group_targets_source.add_argument("-au", "--auth-user", default=None, type=str, help="Username of the domain account.") 893 | group_targets_source.add_argument("--ldaps", default=False, action="store_true", help="Use LDAPS (default: False)") 894 | group_targets_source.add_argument("--no-ldap", default=False, action="store_true", help="Do not perform LDAP queries.") 895 | group_targets_source.add_argument("--subnets", default=False, action="store_true", help="Get all subnets from the domain and use them as targets (default: False)") 896 | group_targets_source.add_argument("-tl", "--target-ldap-query", dest="target_ldap_query", type=str, default=None, required=False, help="LDAP query to use to extract computers from the domain.") 897 | group_targets_source.add_argument("--ignore-unknown", default=False, action="store_true", help="Ignore unknown sources (default: False)") 898 | 899 | secret = parser.add_argument_group("Credentials") 900 | cred = secret.add_mutually_exclusive_group() 901 | cred.add_argument("--no-pass", default=False, action="store_true", help="Don't ask for password (useful for -k)") 902 | cred.add_argument("-ap", "--auth-password", default=None, type=str, help="Password of the domain account.") 903 | cred.add_argument("-ah", "--auth-hashes", default=None, type=str, help="LM:NT hashes to pass the hash for this user.") 904 | cred.add_argument("--aes-key", dest="auth_key", action="store", metavar="hex key", help="AES key to use for Kerberos Authentication (128 or 256 bits)") 905 | secret.add_argument("-k", "--kerberos", dest="auth_use_kerberos", action="store_true", help="Use Kerberos authentication. Grabs credentials from .ccache file (KRB5CCNAME) based on target parameters. If valid credentials cannot be found, it will use the ones specified in the command line") 906 | secret.add_argument("--kdcHost", dest="auth_kdcHost", default=None, type=str, help="IP of the domain controller.") 907 | 908 | output = parser.add_argument_group("Output files") 909 | output.add_argument("--export-xlsx", dest="export_xlsx", type=str, default=None, required=False, help="Output XLSX file to store the results in.") 910 | output.add_argument("--export-json", dest="export_json", type=str, default=None, required=False, help="Output JSON file to store the results in.") 911 | output.add_argument("--export-sqlite", dest="export_sqlite", type=str, default=None, required=False, help="Output SQLITE3 file to store the results in.") 912 | 913 | if len(sys.argv) == 1: 914 | parser.print_help() 915 | sys.exit(1) 916 | 917 | options = parser.parse_args() 918 | 919 | if options.auth_password is None and options.no_pass == False and options.auth_hashes is None: 920 | print("[+] No password or hashes provided and --no-pass is '%s'" % options.no_pass) 921 | from getpass import getpass 922 | if options.auth_domain is not None: 923 | options.auth_password = getpass(" | Provide a password for '%s\\%s':" % (options.auth_domain, options.auth_user)) 924 | else: 925 | options.auth_password = getpass(" | Provide a password for '%s':" % options.auth_user) 926 | 927 | return options 928 | 929 | 930 | if __name__ == '__main__': 931 | options = parseArgs() 932 | 933 | loglevel = LogLevel.INFO 934 | if options.verbose == True: 935 | loglevel = LogLevel.VERBOSE 936 | if options.debug == True: 937 | loglevel = LogLevel.DEBUG 938 | logger = Logger(loglevel=loglevel, logfile=options.logfile, no_colors=options.no_colors) 939 | 940 | # Parse hashes 941 | if options.auth_hashes is not None: 942 | if ":" not in options.auth_hashes: 943 | options.auth_hashes = ":" + options.auth_hashes 944 | auth_lm_hash, auth_nt_hash = parse_lm_nt_hashes(options.auth_hashes) 945 | 946 | # Use AES Authentication key if available 947 | if options.auth_key is not None: 948 | options.auth_use_kerberos = True 949 | if options.auth_use_kerberos is True and options.auth_kdcHost is None: 950 | logger.warn("Specify KDC's Hostname of FQDN using the argument --kdcHost") 951 | exit() 952 | 953 | try: 954 | if options.auth_dc_ip is not None and options.auth_user is not None and (options.auth_password is not None or options.auth_hashes is not None): 955 | midns = MicrosoftDNS( 956 | dnsserver=options.nameserver, 957 | auth_domain=options.auth_domain, 958 | auth_username=options.auth_user, 959 | auth_password=options.auth_password, 960 | auth_dc_ip=options.auth_dc_ip, 961 | auth_lm_hash=auth_lm_hash, 962 | auth_nt_hash=auth_nt_hash, 963 | use_ldaps=options.ldaps, 964 | logger=logger 965 | ) 966 | midns.checkPresenceOfWildcardDns() 967 | 968 | if options.debug: 969 | logger.debug("[>] Parsing targets ...") 970 | sys.stdout.flush() 971 | 972 | registered_domains = getDomainsAndTrusts( 973 | auth_domain=options.auth_domain, 974 | auth_username=options.auth_user, 975 | auth_password=options.auth_password, 976 | auth_dc_ip=options.auth_dc_ip, 977 | auth_lm_hash=auth_lm_hash, 978 | auth_nt_hash=auth_nt_hash, 979 | use_ldaps=options.ldaps, 980 | ) 981 | if options.legitimate_domains: 982 | registered_domains.extend(options.legitimate_domains) 983 | 984 | if options.debug: 985 | logger.debug("[>] Legitimate domains: %s" % registered_domains) 986 | 987 | logger.info("[>] Loading targets ...") 988 | targets = load_targets(options, logger) 989 | logger.info("[+] Found %d targets." % len(targets)) 990 | 991 | logger.info("[>] Enumerating logged in users on each computer ...") 992 | results = {} 993 | if len(targets) != 0: 994 | # Setup thread lock to properly write in the file 995 | lock = threading.Lock() 996 | # Waits for all the threads to be completed 997 | with ThreadPoolExecutor(max_workers=min(options.threads, len(targets))) as tp: 998 | for target in targets: 999 | tp.submit( 1000 | worker, 1001 | options, 1002 | target, 1003 | options.auth_domain, 1004 | options.auth_user, 1005 | options.auth_password, 1006 | auth_lm_hash, 1007 | auth_nt_hash, 1008 | registered_domains, 1009 | midns, 1010 | logger, 1011 | results, 1012 | lock 1013 | ) 1014 | 1015 | if options.export_json is not None: 1016 | export_json(options, results) 1017 | 1018 | if options.export_xlsx is not None: 1019 | export_xlsx(options, results) 1020 | 1021 | if options.export_sqlite is not None: 1022 | export_sqlite(options, results) 1023 | else: 1024 | logger.warn("No computers parsed from the targets.") 1025 | logger.info("[+] Bye Bye!") 1026 | 1027 | except Exception as e: 1028 | if options.debug: 1029 | traceback.print_exc() 1030 | logger.warn("Error: %s" % str(e)) 1031 | 1032 | --------------------------------------------------------------------------------