1#!/usr/bin/env python
2# Copyright (c) 2016 CORE Security Technologies
3#
4# This software is provided under under a slightly modified version
5# of the Apache Software License. See the accompanying LICENSE file
6# for more information.
7#
8# Author:
9#  Alberto Solino (@agsolino)
10#
11# Description:
12#     This script will gather data about the domain's users and their corresponding email addresses. It will also
13#     include some extra information about last logon and last password set attributes.
14#     You can enable or disable the the attributes shown in the final table by changing the values in line 184 and
15#     headers in line 190.
16#     If no entries are returned that means users don't have email addresses specified. If so, you can use the
17#     -all-users parameter.
18#
19# Reference for:
20#     LDAP
21#
22
23
24import argparse
25import logging
26import sys
27from datetime import datetime
28
29from impacket import version
30from impacket.dcerpc.v5.samr import UF_ACCOUNTDISABLE
31from impacket.examples import logger
32from impacket.ldap import ldap, ldapasn1
33from impacket.smbconnection import SMBConnection
34
35
36class GetADUsers:
37    def __init__(self, username, password, domain, cmdLineOptions):
38        self.options = cmdLineOptions
39        self.__username = username
40        self.__password = password
41        self.__domain = domain
42        self.__lmhash = ''
43        self.__nthash = ''
44        self.__aesKey = cmdLineOptions.aesKey
45        self.__doKerberos = cmdLineOptions.k
46        self.__target = None
47        self.__kdcHost = cmdLineOptions.dc_ip
48        self.__requestUser = cmdLineOptions.user
49        self.__all = cmdLineOptions.all
50        if cmdLineOptions.hashes is not None:
51            self.__lmhash, self.__nthash = cmdLineOptions.hashes.split(':')
52
53        # Create the baseDN
54        domainParts = self.__domain.split('.')
55        self.baseDN = ''
56        for i in domainParts:
57            self.baseDN += 'dc=%s,' % i
58        # Remove last ','
59        self.baseDN = self.baseDN[:-1]
60
61        # Let's calculate the header and format
62        self.__header = ["Name", "Email", "PasswordLastSet", "LastLogon"]
63        # Since we won't process all rows at once, this will be fixed lengths
64        self.__colLen = [20, 30, 19, 19]
65        self.__outputFormat = ' '.join(['{%d:%ds} ' % (num, width) for num, width in enumerate(self.__colLen)])
66
67
68
69    def getMachineName(self):
70        if self.__kdcHost is not None:
71            s = SMBConnection(self.__kdcHost, self.__kdcHost)
72        else:
73            s = SMBConnection(self.__domain, self.__domain)
74        try:
75            s.login('', '')
76        except Exception:
77            if s.getServerName() == '':
78                raise('Error while anonymous logging into %s' % self.__domain)
79        else:
80            s.logoff()
81        return s.getServerName()
82
83    @staticmethod
84    def getUnixTime(t):
85        t -= 116444736000000000
86        t /= 10000000
87        return t
88
89    def processRecord(self, item):
90        if isinstance(item, ldapasn1.SearchResultEntry) is not True:
91            return
92        sAMAccountName = ''
93        pwdLastSet = ''
94        mail = ''
95        lastLogon = 'N/A'
96        try:
97            for attribute in item['attributes']:
98                if attribute['type'] == 'sAMAccountName':
99                    if str(attribute['vals'][0]).endswith('$') is False:
100                        # User Account
101                        sAMAccountName = str(attribute['vals'][0])
102                elif attribute['type'] == 'pwdLastSet':
103                    if str(attribute['vals'][0]) == '0':
104                        pwdLastSet = '<never>'
105                    else:
106                        pwdLastSet = str(datetime.fromtimestamp(self.getUnixTime(int(str(attribute['vals'][0])))))
107                elif attribute['type'] == 'lastLogon':
108                    if str(attribute['vals'][0]) == '0':
109                        lastLogon = '<never>'
110                    else:
111                        lastLogon = str(datetime.fromtimestamp(self.getUnixTime(int(str(attribute['vals'][0])))))
112                elif attribute['type'] == 'mail':
113                    mail = str(attribute['vals'][0])
114
115            print(self.__outputFormat.format(*[sAMAccountName, mail, pwdLastSet, lastLogon]))
116        except Exception as e:
117            logging.error('Skipping item, cannot process due to error %s' % str(e))
118            pass
119
120    def run(self):
121        if self.__doKerberos:
122            self.__target = self.getMachineName()
123        else:
124            if self.__kdcHost is not None:
125                self.__target = self.__kdcHost
126            else:
127                self.__target = self.__domain
128
129        # Connect to LDAP
130        try:
131            ldapConnection = ldap.LDAPConnection('ldap://%s'%self.__target, self.baseDN, self.__kdcHost)
132            if self.__doKerberos is not True:
133                ldapConnection.login(self.__username, self.__password, self.__domain, self.__lmhash, self.__nthash)
134            else:
135                ldapConnection.kerberosLogin(self.__username, self.__password, self.__domain, self.__lmhash, self.__nthash,
136                                             self.__aesKey, kdcHost=self.__kdcHost)
137        except ldap.LDAPSessionError as e:
138            if str(e).find('strongerAuthRequired') >= 0:
139                # We need to try SSL
140                ldapConnection = ldap.LDAPConnection('ldaps://%s' % self.__target, self.baseDN, self.__kdcHost)
141                if self.__doKerberos is not True:
142                    ldapConnection.login(self.__username, self.__password, self.__domain, self.__lmhash, self.__nthash)
143                else:
144                    ldapConnection.kerberosLogin(self.__username, self.__password, self.__domain, self.__lmhash, self.__nthash,
145                                                 self.__aesKey, kdcHost=self.__kdcHost)
146            else:
147                raise
148
149        logging.info('Querying %s for information about domain.' % self.__target)
150        # Print header
151        print(self.__outputFormat.format(*self.__header))
152        print('  '.join(['-' * itemLen for itemLen in self.__colLen]))
153
154        # Building the search filter
155        if self.__all:
156            searchFilter = "(&(sAMAccountName=*)(objectCategory=user)"
157        else:
158            searchFilter = "(&(sAMAccountName=*)(mail=*)(!(UserAccountControl:1.2.840.113556.1.4.803:=%d))" % UF_ACCOUNTDISABLE
159
160        if self.__requestUser is not None:
161            searchFilter += '(sAMAccountName:=%s))' % self.__requestUser
162        else:
163            searchFilter += ')'
164
165        try:
166            logging.debug('Search Filter=%s' % searchFilter)
167            sc = ldap.SimplePagedResultsControl(size=100)
168            resp = ldapConnection.search(searchFilter=searchFilter,
169                                         attributes=['sAMAccountName', 'pwdLastSet', 'mail', 'lastLogon'],
170                                         sizeLimit=0, searchControls = [sc], perRecordCallback=self.processRecord)
171        except ldap.LDAPSearchError as e:
172                raise
173
174        ldapConnection.close()
175
176# Process command-line arguments.
177if __name__ == '__main__':
178    # Init the example's logger theme
179    logger.init()
180    print(version.BANNER)
181
182    parser = argparse.ArgumentParser(add_help = True, description = "Queries target domain for users data")
183
184    parser.add_argument('target', action='store', help='domain/username[:password]')
185    parser.add_argument('-user', action='store', metavar='username', help='Requests data for specific user ')
186    parser.add_argument('-all', action='store_true', help='Return all users, including those with no email '
187                                                           'addresses and disabled accounts. When used with -user it '
188                                                          'will return user\'s info even if the account is disabled')
189    parser.add_argument('-debug', action='store_true', help='Turn DEBUG output ON')
190
191    group = parser.add_argument_group('authentication')
192
193    group.add_argument('-hashes', action="store", metavar = "LMHASH:NTHASH", help='NTLM hashes, format is LMHASH:NTHASH')
194    group.add_argument('-no-pass', action="store_true", help='don\'t ask for password (useful for -k)')
195    group.add_argument('-k', action="store_true", help='Use Kerberos authentication. Grabs credentials from ccache file '
196                                                       '(KRB5CCNAME) based on target parameters. If valid credentials '
197                                                       'cannot be found, it will use the ones specified in the command '
198                                                       'line')
199    group.add_argument('-aesKey', action="store", metavar = "hex key", help='AES key to use for Kerberos Authentication '
200                                                                            '(128 or 256 bits)')
201    group.add_argument('-dc-ip', action='store',metavar = "ip address",  help='IP Address of the domain controller. If '
202                                                                              'ommited it use the domain part (FQDN) '
203                                                                              'specified in the target parameter')
204
205    if len(sys.argv)==1:
206        parser.print_help()
207        sys.exit(1)
208
209    options = parser.parse_args()
210
211    if options.debug is True:
212        logging.getLogger().setLevel(logging.DEBUG)
213    else:
214        logging.getLogger().setLevel(logging.INFO)
215
216    import re
217    # This is because I'm lazy with regex
218    # ToDo: We need to change the regex to fullfil domain/username[:password]
219    targetParam = options.target+'@'
220    domain, username, password, address = re.compile('(?:(?:([^/@:]*)/)?([^@:]*)(?::([^@]*))?@)?(.*)').match(targetParam).groups('')
221
222    #In case the password contains '@'
223    if '@' in address:
224        password = password + '@' + address.rpartition('@')[0]
225        address = address.rpartition('@')[2]
226
227    if domain is '':
228        logging.critical('Domain should be specified!')
229        sys.exit(1)
230
231    if password == '' and username != '' and options.hashes is None and options.no_pass is False and options.aesKey is None:
232        from getpass import getpass
233        password = getpass("Password:")
234
235    if options.aesKey is not None:
236        options.k = True
237
238    try:
239        executer = GetADUsers(username, password, domain, options)
240        executer.run()
241    except Exception as e:
242        if logging.getLogger().level == logging.DEBUG:
243            import traceback
244            traceback.print_exc()
245        print (str(e))
246