/ python / pyLDAPmonitor.py
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