diff --git a/.gitignore b/.gitignore index 15d6af0..7895fc5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,9 @@ *.pyc *.json *-jtr-hashes +testing-testing* pyvenv.cfg +__pycache__/* ActiveDirectoryEnum.egg-info/* build/* dist/* diff --git a/MANIFEST.in b/MANIFEST.in index b680976..2d98fda 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,6 @@ exclude ade/__pycache__/* +recursive-include ade/modEnumerator/* +recursive-include ade/connectors/* recursive-include ade/cve_2020_1472/ recursive-include ade/exploits/ include requirements.txt diff --git a/ade/__init__.py b/ade/__init__.py index 407fe85..8ddf6ac 100755 --- a/ade/__init__.py +++ b/ade/__init__.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 from ldap3 import Server, Connection, ALL, ALL_ATTRIBUTES, LEVEL, SUBTREE, ALL_OPERATIONAL_ATTRIBUTES from progressbar import Bar, Percentage, ProgressBar, ETA -from ldap3.core.exceptions import LDAPKeyError +from ldap3.core.exceptions import LDAPKeyError, LDAPBindError, LDAPSocketOpenError from impacket.smbconnection import SessionError from impacket.nmb import NetBIOSTimeout, NetBIOSError from getpass import getpass @@ -22,7 +22,10 @@ from pyasn1.codec.der import decoder, encoder from pyasn1.type.univ import noValue from binascii import hexlify -import datetime, random +import datetime +import random +from .modEnumerator.modEnumerator import ModEnumerator +from .connectors.connectors import Connectors # Thanks SecureAuthCorp for GetUserSPNs.py # For SPN enum @@ -58,6 +61,10 @@ def __init__(self, domainController, ldaps, output, enumsmb, bhout, kpre, spnEnu # At the moment we just want everything self.ldapProps = ["*"] + # Initialize modules + self.connectors = Connectors() + self.enumerator = ModEnumerator() + # Setting lists containing elements we want from the domain controller self.computers = [] @@ -70,6 +77,9 @@ def __init__(self, domainController, ldaps, output, enumsmb, bhout, kpre, spnEnu self.ous = [] self.deletedUsers = [] self.passwd = False + self.passwords = {} + # Holds the values of servers that has been fingerprinted to a particular service + self.namedServers = {} # TODO: Figure a good way to go through the code dryrun if dryrun: @@ -78,16 +88,18 @@ def __init__(self, domainController, ldaps, output, enumsmb, bhout, kpre, spnEnu if domuser is not False: self.runWithCreds() - self.enumDeleted() else: self.runWithoutCreds() - self.enumDeleted() - - self.testExploits() + + self.enumDeleted() + self.enumerate_names() + self.checkForPW() + self.checkOS() + self.write_file() - if not self.CREDS: - print('[ ' + colored('WARN', 'yellow') +' ] Didn\'t find useable info as anonymous user, please gather credentials and run again') - + # Unbind the connection to release the handle + self.conn.unbind() + def runWithCreds(self): self.CREDS = True @@ -96,12 +108,9 @@ def runWithCreds(self): self.bind() self.search() - if self.output: self.write_file() - self.checkForPW() - self.checkOS() if self.searchSysvol: self.checkSYSVOL() @@ -112,9 +121,7 @@ def runWithCreds(self): self.enumKerbPre() if self.spnEnum: - self.enumSPNUsers() - - self.conn.unbind() + self.enumSPNUsers() if self.enumsmb: # Setting variables for further testing and analysis @@ -126,6 +133,8 @@ def runWithCreds(self): # Lets clear variable now self.passwd = None + return + def runWithoutCreds(self): self.CREDS = False @@ -136,18 +145,11 @@ def runWithoutCreds(self): self.bind() self.search() - - if self.output: - self.write_file() - self.checkForPW() - self.checkOS() - self.enumForCreds(self.people) - + return - @contextlib.contextmanager def suppressOutput(self): with open(os.devnull, 'w') as devnull: @@ -179,28 +181,13 @@ def testExploits(self): def bind(self): try: if self.ldaps: - self.dc_conn = Server(self.server, port=636, use_ssl=True, get_info='ALL') - self.conn = Connection(self.dc_conn, user=self.domuser, password=self.passwd) - self.conn.bind() - self.conn.start_tls() - # Validate the login (bind) request - if int(self.conn.result['result']) != 0: - print('\033[1A\r[ ' + colored('ERROR', 'red') +' ] Failed to bind to LDAPS server: {0}'.format(self.conn.result['description'])) - sys.exit(1) - else: - print('\033[1A\r[ ' + colored('OK', 'green') +' ] Bound to LDAPS server: {0}'.format(self.server)) + self.conn = self.connectors.ldap_connector(self.server, True, self.domuser, self.passwd) + print('\033[1A\r[ ' + colored('OK', 'green') +' ] Bound to LDAPS server: {0}'.format(self.server)) else: - self.dc_conn = Server(self.server, get_info=ALL) - self.conn = Connection(self.dc_conn, user=self.domuser, password=self.passwd) - self.conn.bind() - # Validate the login (bind) request - if int(self.conn.result['result']) != 0: - print('\033[1A\r[ ' + colored('ERROR', 'red') +' ] Failed to bind to LDAP server: {0}'.format(self.conn.result['description'])) - sys.exit(1) - else: - print('\033[1A\r[ ' + colored('OK', 'green') +' ] Bound to LDAP server: {0}'.format(self.server)) + self.conn = self.connectors.ldap_connector(self.server, False, self.domuser, self.passwd) + print('\033[1A\r[ ' + colored('OK', 'green') +' ] Bound to LDAP server: {0}'.format(self.server)) # TODO: Catch individual exceptions instead - except Exception: + except (LDAPBindError, LDAPSocketOpenError): if self.ldaps: print('\033[1A\r[ ' + colored('ERROR', 'red') +' ] Failed to bind to LDAPS server: {0}'.format(self.server)) else: @@ -262,31 +249,33 @@ def search(self): for entry in self.conn.entries: self.deletedUsers.append(entry) print('[ ' + colored('OK', 'green') +' ] Got all deleted users') + if len(self.deletedUsers) > 0: + print('[ ' + colored('INFO', 'green') +' ] Searching for juicy info in deleted users') + self.enumForCreds(self.deletedUsers) + + + def enumerate_names(self): + self.namedServers = self.enumerator.enumerate_server_names(self.computers) - ''' Since it sometimes is real that the property 'userPassword:' is set we test for it and dump the passwords ''' def checkForPW(self): - passwords = {} - idx = 0 - for _ in self.people: - user = json.loads(self.people[idx].entry_to_json()) - idx += 1 - if user['attributes'].get('userPassword') is not None: - passwords[user['attributes']['name'][0]] = user['attributes'].get('userPassword') - if len(passwords.keys()) > 0: - with open('{0}-clearpw'.format(self.server), 'w') as f: - json.dump(passwords, f, sort_keys=False) - - if len(passwords.keys()) == 1: - print('[ ' + colored('WARN', 'yellow') +' ] Found {0} clear text password'.format(len(passwords.keys()))) - elif len(passwords.keys()) == 0: - print('[ ' + colored('OK', 'green') +' ] Found {0} clear text password'.format(len(passwords.keys()))) + passwords = self.enumerator.enumerate_for_cleartext_passwords(self.people, self.server) + self.passwords = { **passwords, **self.passwords } + + if len(self.passwords.keys()) > 0: + with open(f'{self.output}-clearpw', 'w') as f: + json.dump(self.passwords, f, sort_keys=False) + + if len(self.passwords.keys()) == 1: + print('[ ' + colored('WARN', 'yellow') +' ] Found {0} clear text password'.format(len(self.passwords.keys()))) + elif len(self.passwords.keys()) == 0: + print('[ ' + colored('OK', 'green') +' ] Found {0} clear text password'.format(len(self.passwords.keys()))) else: - print('[ ' + colored('OK', 'green') +' ] Found {0} clear text passwords'.format(len(passwords.keys()))) + print('[ ' + colored('OK', 'green') +' ] Found {0} clear text passwords'.format(len(self.passwords.keys()))) ''' @@ -296,47 +285,24 @@ def checkForPW(self): enumeration afterwards ''' def checkOS(self): - - os_json = { - # Should perhaps include older version - "Windows XP": [], - "Windows Server 2008": [], - "Windows 7": [], - "Windows Server 2012": [], - "Windows 10": [], - "Windows Server 2016": [], - "Windows Server 2019": [] - } - idx = 0 - for _ in self.computers: - computer = json.loads(self.computers[idx].entry_to_json()) - idx += 1 - - for os_version in os_json.keys(): - try: - if os_version in computer['attributes'].get('operatingSystem'): - os_json[os_version].append(computer['attributes']['dNSHostName']) - except TypeError: - # computer['attributes'].get('operatingSystem') is of NoneType, just continue - continue + os_json = self.enumerator.enumerate_os_version(self.computers) for key, value in os_json.items(): if len(value) == 0: continue - with open('{0}-oldest-OS'.format(self.server), 'w') as f: + with open(f'{self.output}-oldest-OS', 'w') as f: for item in value: f.write('{0}: {1}\n'.format(key, item)) break - print('[ ' + colored('OK', 'green') + ' ] Wrote hosts with oldest OS to {0}-oldest-OS'.format(self.server)) + print('[ ' + colored('OK', 'green') + f' ] Wrote hosts with oldest OS to {self.output}-oldest-OS') def checkSYSVOL(self): print('[ .. ] Searching SYSVOL for cpasswords\r') cpasswords = {} try: - smbconn = smbconnection.SMBConnection('\\\\{0}\\'.format(self.server), self.server, timeout=5) - smbconn.login(self.domuser, self.passwd) + smbconn = self.connectors.smb_connector(self.server, self.domuser, self.passwd) dirs = smbconn.listShares() for share in dirs: if str(share['shi1_netname']).rstrip('\0').lower() == 'sysvol': @@ -457,8 +423,7 @@ def enumSMB(self): try: # Changing default timeout as shares should respond withing 5 seconds if there is a share # and ACLs make it available to self.user with self.passwd - smbconn = smbconnection.SMBConnection('\\\\' + str(dnsname), str(dnsname), timeout=5) - smbconn.login(self.domuser, self.passwd) + smbconn = self.connectors.smb_connector(self.server, self.domuser, self.passwd) dirs = smbconn.listShares() self.smbBrowseable[str(dnsname)] = {} for share in dirs: @@ -494,9 +459,9 @@ def enumSMB(self): availDirs.append(key) if len(self.smbShareCandidates) == 1: - print('[ ' + colored('OK', 'green') + ' ] Searched {0} share and {1} with {2} subdirectories/files is browseable by {3}'.format(len(self.smbShareCandidates), len(self.smbBrowseable.keys()), len(availDirs), self.domuser)) + print('[ ' + colored('OK', 'green') + ' ] Searched {0} share and {1} share with {2} subdirectories/files is browseable by {3}'.format(len(self.smbShareCandidates), len(self.smbBrowseable.keys()), len(availDirs), self.domuser)) else: - print('[ ' + colored('OK', 'green') + ' ] Searched {0} shares and {1} with {2} subdirectories/files are browseable by {3}'.format(len(self.smbShareCandidates), len(self.smbBrowseable.keys()), len(availDirs), self.domuser)) + print('[ ' + colored('OK', 'green') + ' ] Searched {0} shares and {1} shares with {2} subdirectories/file sare browseable by {3}'.format(len(self.smbShareCandidates), len(self.smbBrowseable.keys()), len(availDirs), self.domuser)) if len(self.smbBrowseable.keys()) > 0: with open('{0}-open-smb.json'.format(self.server), 'w') as f: json.dump(self.smbBrowseable, f, indent=4, sort_keys=False) @@ -729,6 +694,7 @@ def enumForCreds(self, ldapdump): if not self.CREDS: self.domuser = usr self.passwd = passwd + self.passwords[usr] = passwd self.runWithCreds() return @@ -796,8 +762,9 @@ def main(args): /*----------------------------------------------------------------------------------------------------------*/ ''')) + parser.add_argument('--dc', type=str, help='Hostname of the Domain Controller') - parser.add_argument('-o', '--out-file', type=str, help='Path to output file. If no path, CWD is assumed (default: None)') + parser.add_argument('-o', '--out-file', type=str, help='Name prefix of output files (default: the name of the dc)') parser.add_argument('-u', '--user', type=str, help='Username of the domain user to query with. The username has to be domain name as `user@domain.org`') parser.add_argument('-s', '--secure', help='Try to estalish connection through LDAPS', action='store_true') parser.add_argument('-smb', '--smb', help='Force enumeration of SMB shares on all computer objects fetched', action='store_true') @@ -857,9 +824,7 @@ def main(args): # Boolean flow control flags - file_to_write = None - if args.out_file: - file_to_write = args.out_file + file_to_write = args.out_file if args.out_file else f'{args.dc}' enumAD = EnumAD(args.dc, args.secure, file_to_write, args.smb, args.bloodhound, args.kerberos_preauth, args.spn, args.sysvol, args.dry_run, args.user) diff --git a/ade/connectors/__init__.py b/ade/connectors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ade/connectors/connectors.py b/ade/connectors/connectors.py new file mode 100644 index 0000000..0099a3c --- /dev/null +++ b/ade/connectors/connectors.py @@ -0,0 +1,99 @@ +# LDAP connection +import ldap3 +from ldap3.core.exceptions import LDAPBindError +# SMB connection +from impacket import smbconnection +from impacket.smbconnection import SessionError +from impacket.nmb import NetBIOSTimeout, NetBIOSError +# RPC connection +from impacket.dcerpc.v5 import transport +# For when impacket releases the feature as a release +#from impacket.http import AUTH_NTLM +from socket import gaierror +# Generic imports +import sys +from termcolor import colored + +class Connectors(): + + + def __init__(self): + pass + + + def ldap_connector(self, server: str, ldaps: bool, domuser: str, passwd: str, level='ALL') -> ldap3.Connection: + '''Returns an ldap3.Connection object that is bound to the supplied domain controller + + Raise LDAPBindError on bind() errors. + + ''' + if ldaps: + dc_conn = ldap3.Server(server, port=636, use_ssl=True, get_info=level) + conn = ldap3.Connection(dc_conn, user=domuser, password=passwd) + conn.bind() + conn.start_tls() + # Validate the login (bind) request + if int(conn.result['result']) != 0: + raise LDAPBindError + else: + dc_conn = ldap3.Server(server, get_info=level) + conn = ldap3.Connection(dc_conn, user=domuser, password=passwd) + conn.bind() + # Validate the login (bind) request + if int(conn.result['result']) != 0: + raise LDAPBindError + + return conn + + + def winrm_connector(self): + pass + + + def rpc_connector(self, server: str, domuser: str, passwd: str): + rpc_protocols = { + 135: 'ncacn_ip_tcp:{}[135]', + 139: 'ncacn_np:{}[\pipe\epmapper]', + 443: 'ncacn_http:[593,RpcProxy={}:443]', + #445: 'ncacn_np:{}[\pipe\epmapper]', + } + dce = False + + for port, protocol in rpc_protocols.items(): + transporter = transport.DCERPCTransportFactory(protocol.format(server)) + + transporter.set_credentials(domuser, passwd, server, '', '') + + if port == 139 or port == 445: + transporter.setRemoteHost(server) + transporter.set_dport(port) + #elif [443] in protocol: + # transporter.set_auth_type(AUTH_NTLM) + try: + dce = transporter.get_dce_rpc() + dce.connect() + # We can break to return since the connection did not raise an error + break + except (NetBIOSError, gaierror): + # Something was off + continue + return dce + + + def smb_connector(self, server: str, domuser: str, passwd: str) -> smbconnection: + try: + smbconn = smbconnection.SMBConnection(f'\\\\{server}\\', server, timeout=5) + smbconn.login(domuser, passwd) + except (SessionError, UnicodeEncodeError, NetBIOSError): + smbconn.close() + return False + return smbconn + + + def ftp_connector(self): + pass + + + def smtp_connector(self): + pass + \ No newline at end of file diff --git a/ade/modEnumerator/__init__.py b/ade/modEnumerator/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ade/modEnumerator/modEnumerator.py b/ade/modEnumerator/modEnumerator.py new file mode 100644 index 0000000..e115281 --- /dev/null +++ b/ade/modEnumerator/modEnumerator.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- + +import json +import ldap3 +from ldap3.core.exceptions import LDAPBindError + +from . .connectors.connectors import Connectors + +class ModEnumerator(): + + def __init__(self): + pass + + + def enumerate_server_names(self, computerobjects: ldap3.Entry) -> dict: + '''Return a dict of key(dNSHostName) and value(fingerprinted servertype) + + ''' + wordlist = { + "mssql": ["mssql", "sqlserver"], + "ftp": ["ftp"], + "smtp": ["exchange", "smtp"], + "ad": ["dc", "domaincontroller", "msol", "domain controller"] + } + results = {} + + for key, value in wordlist.items(): + for fingerprint in value: + for obj in computerobjects: + if fingerprint in str(obj["name"]).lower(): + results[str(obj["dNSHostName"])] = key + elif fingerprint in str(obj["dNSHostName"]).lower(): + results[str(obj["dNSHostName"])] = key + elif fingerprint in str(obj["distinguishedName"]).lower(): + results[str(obj["dNSHostName"])] = key + elif fingerprint in str(obj["dNSHostName"]).lower(): + results[str(obj["dNSHostName"])] = key + + return results + + def enumerate_os_version(self, computerobjects: ldap3.Entry) -> dict: + '''Return a dict of key(os_version) and value(computers with said os) + + ''' + os_json = { + # Should perhaps include older version + "Windows XP": [], + "Windows Server 2008": [], + "Windows 7": [], + "Windows Server 2012": [], + "Windows 10": [], + "Windows Server 2016": [], + "Windows Server 2019": [] + } + idx = 0 + for _ in computerobjects: + computer = json.loads(computerobjects[idx].entry_to_json()) + idx += 1 + + for os_version in os_json.keys(): + try: + if os_version in computer['attributes'].get('operatingSystem')[0]: + if computer['attributes']['dNSHostName'][0] not in os_json[os_version]: + os_json[os_version].append(computer['attributes']['dNSHostName'][0]) + except TypeError: + # computer['attributes'].get('operatingSystem') is of NoneType, just continue + continue + + return os_json + + + def enumerate_for_cleartext_passwords(self, peopleobjects: ldap3.Entry, server: str) -> dict: + '''Return a dict of key(username) and value(password) + + ''' + passwords = {} + + idx = 0 + for _ in peopleobjects: + user = json.loads(peopleobjects[idx].entry_to_json()) + idx += 1 + if user['attributes'].get('userPassword') is not None: + # Attempt login + try: + # First we try encrypted + conn = Connectors().ldap_connector(server=server, ldaps=True, domuser=user['attributes']['name'][0], passwd=user['attributes'].get('userPassword')) + except LDAPBindError: + # Then default to non-encrypted + try: + conn = Connectors().ldap_connector(server=server, ldaps=False, domuser=user['attributes']['name'][0], passwd=user['attributes'].get('userPassword')) + except LDAPBindError: + # No luck + continue + finally: + if int(conn.result['result']) == 0: + # We had a valid login + passwords[user['attributes']['name'][0]] = user['attributes'].get('userPassword') + + return passwords \ No newline at end of file