pyLDAPmonitor.py
1 #!/usr/bin/env python3 2 # -*- coding: utf-8 -*- 3 # File name : pyLDAPmonitor.py 4 # Author : Podalirius (@podalirius_) 5 # Date created : 3 Jan 2022 6 7 8 import argparse 9 import os 10 import sys 11 import ssl 12 import random 13 import ldap3 14 from sectools.windows.ldap import raw_ldap_query, init_ldap_session 15 from sectools.windows.crypto import nt_hash, parse_lm_nt_hashes 16 from ldap3.protocol.formatters.formatters import format_sid 17 import time 18 import datetime 19 import re 20 from binascii import unhexlify 21 22 23 ### Data utils 24 25 def dict_get_paths(d): 26 paths = [] 27 for key in d.keys(): 28 if type(d[key]) == dict: 29 paths = [[key] + p for p in dict_get_paths(d[key])] 30 else: 31 paths.append([key]) 32 return paths 33 34 35 def dict_path_access(d, path): 36 for key in path: 37 if key in d.keys(): 38 d = d[key] 39 else: 40 return None 41 return d 42 43 ### Logger 44 45 class Logger(object): 46 def __init__(self, debug=False, logfile=None, nocolors=False): 47 super(Logger, self).__init__() 48 self.__debug = debug 49 self.__nocolors = nocolors 50 self.logfile = logfile 51 # 52 if self.logfile is not None: 53 if os.path.exists(self.logfile): 54 k = 1 55 while os.path.exists(self.logfile+(".%d"%k)): 56 k += 1 57 self.logfile = self.logfile + (".%d" % k) 58 open(self.logfile, "w").close() 59 60 def print(self, message=""): 61 nocolor_message = re.sub("\x1b[\[]([0-9;]+)m", "", message) 62 if self.__nocolors: 63 print(nocolor_message) 64 else: 65 print(message) 66 if self.logfile is not None: 67 f = open(self.logfile, "a") 68 f.write(nocolor_message + "\n") 69 f.close() 70 71 def info(self, message): 72 nocolor_message = re.sub("\x1b[\[]([0-9;]+)m", "", message) 73 if self.__nocolors: 74 print("[info] %s" % nocolor_message) 75 else: 76 print("[info] %s" % message) 77 if self.logfile is not None: 78 f = open(self.logfile, "a") 79 f.write(nocolor_message + "\n") 80 f.close() 81 82 def debug(self, message): 83 if self.__debug == True: 84 nocolor_message = re.sub("\x1b[\[]([0-9;]+)m", "", message) 85 if self.__nocolors: 86 print("[debug] %s" % nocolor_message) 87 else: 88 print("[debug] %s" % message) 89 if self.logfile is not None: 90 f = open(self.logfile, "a") 91 f.write("[debug] %s" % nocolor_message + "\n") 92 f.close() 93 94 def error(self, message): 95 nocolor_message = re.sub("\x1b[\[]([0-9;]+)m", "", message) 96 if self.__nocolors: 97 print("[error] %s" % nocolor_message) 98 else: 99 print("[error] %s" % message) 100 if self.logfile is not None: 101 f = open(self.logfile, "a") 102 f.write("[error] %s" % nocolor_message + "\n") 103 f.close() 104 105 ### LDAPConsole 106 107 class LDAPConsole(object): 108 def __init__(self, ldap_server, ldap_session, target_dn, logger, page_size=1000): 109 super(LDAPConsole, self).__init__() 110 self.ldap_server = ldap_server 111 self.ldap_session = ldap_session 112 self.delegate_from = None 113 self.target_dn = target_dn 114 self.logger = logger 115 self.page_size = page_size 116 self.__results = {} 117 self.logger.debug("Using dn: %s" % self.target_dn) 118 119 def query(self, query, attributes=['*'], notify=False): 120 # controls 121 # https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/3c5e87db-4728-4f29-b164-01dd7d7391ea 122 LDAP_PAGED_RESULT_OID_STRING = "1.2.840.113556.1.4.319" 123 # https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/f14f3610-ee22-4d07-8a24-1bf1466cba5f 124 LDAP_SERVER_NOTIFICATION_OID = "1.2.840.113556.1.4.528" 125 results = {} 126 try: 127 # https://ldap3.readthedocs.io/en/latest/searches.html#the-search-operation 128 paged_response = True 129 paged_cookie = None 130 while paged_response == True: 131 self.ldap_session.search( 132 self.target_dn, query, attributes=attributes, 133 size_limit=0, paged_size=self.page_size, paged_cookie=paged_cookie 134 ) 135 # 136 if "controls" in self.ldap_session.result.keys(): 137 if LDAP_PAGED_RESULT_OID_STRING in self.ldap_session.result["controls"].keys(): 138 next_cookie = self.ldap_session.result["controls"][LDAP_PAGED_RESULT_OID_STRING]["value"]["cookie"] 139 if len(next_cookie) == 0: 140 paged_response = False 141 else: 142 paged_response = True 143 paged_cookie = next_cookie 144 else: 145 paged_response = False 146 else: 147 paged_response = False 148 # 149 for entry in self.ldap_session.response: 150 if entry['type'] != 'searchResEntry': 151 continue 152 results[entry['dn']] = entry["attributes"] 153 except ldap3.core.exceptions.LDAPInvalidFilterError as e: 154 print("Invalid Filter. (ldap3.core.exceptions.LDAPInvalidFilterError)") 155 except Exception as e: 156 raise e 157 return results 158 159 160 161 def diff(last1_query_results, last2_query_results, logger, ignore_user_logon=False): 162 ignored_keys = ["dnsRecord", "replUpToDateVector", "repsFrom"] 163 if ignore_user_logon: 164 ignored_keys.append("lastlogon") 165 ignored_keys.append("logoncount") 166 dateprompt = "\x1b[0m[\x1b[96m%s\x1b[0m]" % datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") 167 common_keys = [] 168 for key in last2_query_results.keys(): 169 if key in last1_query_results.keys(): 170 common_keys.append(key) 171 else: 172 logger.print("%s \x1b[91m'%s' was deleted.\x1b[0m" % (dateprompt, key)) 173 for key in last1_query_results.keys(): 174 if key not in last2_query_results.keys() and key not in ignored_keys: 175 logger.print("%s \x1b[92m'%s' was added.\x1b[0m" % (dateprompt, key)) 176 # 177 for _dn in common_keys: 178 paths_l2 = dict_get_paths(last2_query_results[_dn]) 179 paths_l1 = dict_get_paths(last1_query_results[_dn]) 180 # 181 attrs_diff = [] 182 for p in paths_l1: 183 if p[-1].lower() not in ignored_keys: 184 value_before = dict_path_access(last2_query_results[_dn], p) 185 value_after = dict_path_access(last1_query_results[_dn], p) 186 if value_after != value_before: 187 attrs_diff.append((p, value_after, value_before)) 188 # 189 if len(attrs_diff) != 0: 190 # Print DN 191 logger.print("%s \x1b[94m%s\x1b[0m" % (dateprompt, _dn)) 192 for _ad in attrs_diff: 193 path, value_after, value_before = _ad 194 attribute_path = "─>".join(["\"\x1b[93m%s\x1b[0m\"" % attr for attr in path]) 195 if any([ik in path for ik in ignored_keys]): 196 continue 197 if type(value_before) == list: 198 value_before = [ 199 v.strftime("%Y-%m-%d %H:%M:%S") 200 if isinstance(v, datetime.datetime) 201 else v 202 for v in value_before 203 ] 204 if type(value_after) == list: 205 value_after = [ 206 v.strftime("%Y-%m-%d %H:%M:%S") 207 if isinstance(v, datetime.datetime) 208 else v 209 for v in value_after 210 ] 211 if value_after is not None and value_before is not None: 212 logger.print(" | Attribute %s changed from '\x1b[96m%s\x1b[0m' to '\x1b[96m%s\x1b[0m'" % (attribute_path, value_before, value_after)) 213 elif value_after is None and value_before is not None: 214 logger.print(" | Attribute %s = '\x1b[96m%s\x1b[0m' was deleted." % (attribute_path, value_before)) 215 elif value_after is not None and value_before is None: 216 logger.print(" | Attribute %s = '\x1b[96m%s\x1b[0m' was created." % (attribute_path, value_after)) 217 218 219 def parse_args(): 220 parser = argparse.ArgumentParser(add_help=True, description='Monitor LDAP changes live!') 221 parser.add_argument('--use-ldaps', action='store_true', help='Use LDAPS instead of LDAP') 222 parser.add_argument("--debug", dest="debug", action="store_true", default=False, help="Debug mode.") 223 parser.add_argument("--no-colors", dest="no_colors", action="store_true", default=False, help="No colors mode.") 224 parser.add_argument("-l", "--logfile", dest="logfile", type=str, default=None, help="Log file to save output to.") 225 parser.add_argument("-s", "--page-size", dest="page_size", type=int, default=1000, help="Page size.") 226 parser.add_argument("-S", "--search-base", dest="search_base", type=str, default=None, help="Search base.") 227 parser.add_argument("-r", "--randomize-delay", dest="randomize_delay", action="store_true", default=False, help="Randomize delay between two queries, between 1 and 5 seconds.") 228 parser.add_argument("-t", "--time-delay", dest="time_delay", type=int, default=1, help="Delay between two queries in seconds (default: 1).") 229 parser.add_argument("--ignore-user-logon", dest="ignore_user_logon", action="store_true", default=False, help="Ignores user logon events.") 230 # parser.add_argument("-n", "--notify", dest="notify", action="store_true", default=False, help="Uses LDAP_SERVER_NOTIFICATION_OID to get only changed objects. (useful for large domains).") 231 232 authconn = parser.add_argument_group('authentication & connection') 233 authconn.add_argument('--dc-ip', dest="dc_ip", action='store', metavar="ip address", help='IP Address of the domain controller or KDC (Key Distribution Center) for Kerberos. If omitted it will use the domain part (FQDN) specified in the identity parameter') 234 authconn.add_argument('--kdcHost', dest="kdcHost", action='store', metavar="FQDN KDC", help='FQDN of KDC for Kerberos.') 235 authconn.add_argument("-d", "--domain", dest="auth_domain", metavar="DOMAIN", action="store", help="(FQDN) domain to authenticate to") 236 authconn.add_argument("-u", "--user", dest="auth_username", metavar="USER", action="store", help="user to authenticate with") 237 238 secret = parser.add_argument_group() 239 cred = secret.add_mutually_exclusive_group() 240 cred.add_argument('--no-pass', action="store_true", help='don\'t ask for password (useful for -k)') 241 cred.add_argument("-p", "--password", dest="auth_password", metavar="PASSWORD", action="store", help="password to authenticate with") 242 cred.add_argument("-H", "--hashes", dest="auth_hashes", action="store", metavar="[LMHASH:]NTHASH", help='NT/LM hashes, format is LMhash:NThash') 243 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)') 244 secret.add_argument("-k", "--kerberos", dest="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') 245 246 if len(sys.argv) == 1: 247 parser.print_help() 248 sys.exit(1) 249 250 args = parser.parse_args() 251 252 if args.auth_password is None and args.no_pass == False and args.auth_hashes is None: 253 print("[+] No password of hashes provided and --no-pass is '%s'" % args.no_pass) 254 from getpass import getpass 255 if args.auth_domain is not None: 256 args.auth_password = getpass(" | Provide a password for '%s\\%s':" % (args.auth_domain, args.auth_username)) 257 else: 258 args.auth_password = getpass(" | Provide a password for '%s':" % args.auth_username) 259 260 return args 261 262 263 def query_all_naming_contexts(ldap_server, ldap_session, logger, page_size, search_base=None): 264 results = {} 265 if search_base is not None: 266 naming_contexts = [search_base] 267 else: 268 naming_contexts = ldap_server.info.naming_contexts 269 for nc in naming_contexts: 270 lc = LDAPConsole(ldap_server, ldap_session, nc, logger=logger, page_size=page_size) 271 _r = lc.query("(objectClass=*)", attributes=['*']) 272 for key in _r.keys(): 273 if key not in results: 274 results[key] = _r[key] 275 else: 276 print("[debug] key already exists: %s (this shouldn't be possible)" % key) 277 return results 278 279 280 if __name__ == '__main__': 281 args = parse_args() 282 logger = Logger(debug=args.debug, nocolors=args.no_colors, logfile=args.logfile) 283 logger.print("[+]======================================================") 284 logger.print("[+] LDAP live monitor v1.3 @podalirius_ ") 285 logger.print("[+]======================================================") 286 logger.print() 287 288 auth_lm_hash = "" 289 auth_nt_hash = "" 290 if args.auth_hashes is not None: 291 if ":" in args.auth_hashes: 292 auth_lm_hash = args.auth_hashes.split(":")[0] 293 auth_nt_hash = args.auth_hashes.split(":")[1] 294 else: 295 auth_nt_hash = args.auth_hashes 296 297 if args.auth_key is not None: 298 args.use_kerberos = True 299 300 if args.use_kerberos is True and args.kdcHost is None: 301 print("[!] Specify KDC's Hostname of FQDN using the argument --kdcHost") 302 exit() 303 304 try: 305 logger.print("[>] Trying to connect to %s ..." % args.dc_ip) 306 ldap_server, ldap_session = init_ldap_session( 307 auth_domain=args.auth_domain, 308 auth_dc_ip=args.dc_ip, 309 auth_username=args.auth_username, 310 auth_password=args.auth_password, 311 auth_lm_hash=auth_lm_hash, 312 auth_nt_hash=auth_nt_hash, 313 auth_key=args.auth_key, 314 use_kerberos=args.use_kerberos, 315 kdcHost=args.kdcHost, 316 use_ldaps=args.use_ldaps 317 ) 318 319 logger.debug("Authentication successful!") 320 321 last2_query_results = query_all_naming_contexts(ldap_server, ldap_session, logger, args.page_size, args.search_base) 322 last1_query_results = last2_query_results 323 324 logger.print("[>] Listening for LDAP changes ...") 325 running = True 326 while running: 327 if args.randomize_delay == True: 328 delay = random.randint(1000, 5000) / 1000 329 else: 330 delay = args.time_delay 331 logger.debug("Waiting %s seconds" % str(delay)) 332 time.sleep(delay) 333 # 334 last2_query_results = last1_query_results 335 last1_query_results = query_all_naming_contexts(ldap_server, ldap_session, logger, args.page_size) 336 # 337 diff(last1_query_results, last2_query_results, logger=logger, ignore_user_logon=args.ignore_user_logon) 338 339 except Exception as e: 340 raise e