├── .github
├── banner.png
└── FUNDING.yml
├── README.md
└── CrackedNTDStoXLSX.py
/.github/banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/p0dalirius/CrackedNTDStoXLSX/HEAD/.github/banner.png
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: p0dalirius
4 | patreon: Podalirius
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 |
4 | A python tool to generate an Excel file linking the list of cracked accounts and their LDAP attributes.
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | ## Features
13 |
14 | - [x] Authentications:
15 | - [x] Authenticate with password
16 | - [x] Authenticate with LM:NT hashes (Pass the Hash)
17 | - [x] Authenticate with kerberos ticket (Pass the Ticket)
18 | - [x] Exportable to XLSX format with option `--xlsx`
19 |
20 | ## Demonstration
21 |
22 | This tool takes the output of hashcat
23 |
24 | ```bash
25 | ./hashcat -m 1000 ./lab.local.ntds ./wordlists/rockyou.txt --username --show > cracked_ntds.txt
26 | ```
27 |
28 | Then you can do:
29 |
30 | ```bash
31 | ./CrackedNTDStoXLSX.py -d 'LAB.local' -u 'user' -p 'P@ssw0rd' --dc-ip 10.0.0.101 -n cracked_ntds.txt -x cracked_users.xlsx
32 | ```
33 |
34 | ## Usage
35 |
36 | ```
37 | $ ./CrackedNTDStoXLSX.py
38 | usage: CrackedNTDStoXLSX.py [-h] [--use-ldaps] [-debug] [-a ATTRIBUTES] -x XLSX -n NTDS [--dc-ip ip address] [--kdcHost FQDN KDC] [-d DOMAIN] [-u USER]
39 | [--no-pass | -p PASSWORD | -H [LMHASH:]NTHASH | --aes-key hex key] [-k]
40 |
41 | A python tool to generate an Excel file linking the list of cracked accounts and their LDAP attributes.
42 |
43 | options:
44 | -h, --help show this help message and exit
45 | --use-ldaps Use LDAPS instead of LDAP
46 | -debug Debug mode
47 | -a ATTRIBUTES, --attribute ATTRIBUTES
48 | Attributes to extract.
49 | -x XLSX, --xlsx XLSX Output results to an XLSX file.
50 | -n NTDS, --ntds NTDS Output results to an XLSX file.
51 |
52 | authentication & connection:
53 | --dc-ip ip address 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
54 | --kdcHost FQDN KDC FQDN of KDC for Kerberos.
55 | -d DOMAIN, --domain DOMAIN
56 | (FQDN) domain to authenticate to
57 | -u USER, --user USER user to authenticate with
58 |
59 | --no-pass don't ask for password (useful for -k)
60 | -p PASSWORD, --password PASSWORD
61 | password to authenticate with
62 | -H [LMHASH:]NTHASH, --hashes [LMHASH:]NTHASH
63 | NT/LM hashes, format is LMhash:NThash
64 | --aes-key hex key AES key to use for Kerberos Authentication (128 or 256 bits)
65 | -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
66 | command line
67 | ```
68 |
69 | ## Contributing
70 |
71 | Pull requests are welcome. Feel free to open an issue if you want to add other features.
--------------------------------------------------------------------------------
/CrackedNTDStoXLSX.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 | # File name : CrackedNTDStoXLSX.py
4 | # Author : Podalirius (@podalirius_)
5 | # Date created : 01 March 2023
6 |
7 |
8 | import argparse
9 | from ldap3.protocol.formatters.formatters import format_sid
10 | import ldap3
11 | from sectools.windows.ldap import init_ldap_session
12 | import os
13 | import traceback
14 | import sys
15 | import xlsxwriter
16 | from rich.progress import track
17 |
18 |
19 | VERSION = "1.1"
20 |
21 |
22 | # LDAP controls
23 | # https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/3c5e87db-4728-4f29-b164-01dd7d7391ea
24 | LDAP_PAGED_RESULT_OID_STRING = "1.2.840.113556.1.4.319"
25 | # https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/f14f3610-ee22-4d07-8a24-1bf1466cba5f
26 | LDAP_SERVER_NOTIFICATION_OID = "1.2.840.113556.1.4.528"
27 |
28 | class LDAPSearcher(object):
29 | def __init__(self, ldap_server, ldap_session):
30 | super(LDAPSearcher, self).__init__()
31 | self.ldap_server = ldap_server
32 | self.ldap_session = ldap_session
33 |
34 | def query(self, base_dn, query, attributes=['*'], page_size=1000):
35 | """
36 | Executes an LDAP query with optional notification control.
37 |
38 | This method performs an LDAP search operation based on the provided query and attributes. It supports
39 | pagination to handle large datasets and can optionally enable notification control to receive updates
40 | about changes in the LDAP directory.
41 |
42 | Parameters:
43 | - query (str): The LDAP query string.
44 | - attributes (list of str): A list of attribute names to include in the search results. Defaults to ['*'], which returns all attributes.
45 | - notify (bool): If True, enables the LDAP server notification control to receive updates about changes. Defaults to False.
46 |
47 | Returns:
48 | - dict: A dictionary where each key is a distinguished name (DN) and each value is a dictionary of attributes for that DN.
49 |
50 | Raises:
51 | - ldap3.core.exceptions.LDAPInvalidFilterError: If the provided query string is not a valid LDAP filter.
52 | - Exception: For any other issues encountered during the search operation.
53 | """
54 |
55 | results = {}
56 | try:
57 | # https://ldap3.readthedocs.io/en/latest/searches.html#the-search-operation
58 | paged_response = True
59 | paged_cookie = None
60 | while paged_response == True:
61 | self.ldap_session.search(
62 | base_dn,
63 | query,
64 | attributes=attributes,
65 | size_limit=0,
66 | paged_size=page_size,
67 | paged_cookie=paged_cookie
68 | )
69 | if "controls" in self.ldap_session.result.keys():
70 | if LDAP_PAGED_RESULT_OID_STRING in self.ldap_session.result["controls"].keys():
71 | next_cookie = self.ldap_session.result["controls"][LDAP_PAGED_RESULT_OID_STRING]["value"]["cookie"]
72 | if len(next_cookie) == 0:
73 | paged_response = False
74 | else:
75 | paged_response = True
76 | paged_cookie = next_cookie
77 | else:
78 | paged_response = False
79 | else:
80 | paged_response = False
81 | for entry in self.ldap_session.response:
82 | if entry['type'] != 'searchResEntry':
83 | continue
84 | results[entry['dn']] = entry["attributes"]
85 | except ldap3.core.exceptions.LDAPInvalidFilterError as e:
86 | print("Invalid Filter. (ldap3.core.exceptions.LDAPInvalidFilterError)")
87 | except Exception as e:
88 | raise e
89 | return results
90 |
91 | def query_all_naming_contexts(self, query, attributes=['*'], page_size=1000):
92 | """
93 | Queries all naming contexts on the LDAP server with the given query and attributes.
94 |
95 | This method iterates over all naming contexts retrieved from the LDAP server's information,
96 | performing a paged search for each context using the provided query and attributes. The results
97 | are aggregated and returned as a dictionary where each key is a distinguished name (DN) and
98 | each value is a dictionary of attributes for that DN.
99 |
100 | Parameters:
101 | - query (str): The LDAP query to execute.
102 | - attributes (list of str): A list of attribute names to retrieve for each entry. Defaults to ['*'] which fetches all attributes.
103 |
104 | Returns:
105 | - dict: A dictionary where each key is a DN and each value is a dictionary of attributes for that DN.
106 | """
107 |
108 | results = {}
109 | try:
110 | for naming_context in self.ldap_server.info.naming_contexts:
111 | paged_response = True
112 | paged_cookie = None
113 | while paged_response == True:
114 | self.ldap_session.search(
115 | naming_context,
116 | query,
117 | attributes=attributes,
118 | size_limit=0,
119 | paged_size=self.page_size,
120 | paged_cookie=paged_cookie
121 | )
122 | if "controls" in self.ldap_session.result.keys():
123 | if LDAP_PAGED_RESULT_OID_STRING in self.ldap_session.result["controls"].keys():
124 | next_cookie = self.ldap_session.result["controls"][LDAP_PAGED_RESULT_OID_STRING]["value"]["cookie"]
125 | if len(next_cookie) == 0:
126 | paged_response = False
127 | else:
128 | paged_response = True
129 | paged_cookie = next_cookie
130 | else:
131 | paged_response = False
132 | else:
133 | paged_response = False
134 | for entry in self.ldap_session.response:
135 | if entry['type'] != 'searchResEntry':
136 | continue
137 | results[entry['dn']] = entry["attributes"]
138 | except ldap3.core.exceptions.LDAPInvalidFilterError as e:
139 | print("Invalid Filter. (ldap3.core.exceptions.LDAPInvalidFilterError)")
140 | except Exception as e:
141 | raise e
142 | return results
143 |
144 |
145 | def parse_args():
146 | default_attributes = ["accountExpires", "company", "department", "description", "displayName", "distinguishedName", "lastLogon", "lastLogonTimestamp", "memberOf", "whenChanged", "whenCreated"]
147 |
148 | parser = argparse.ArgumentParser(add_help=True, description='A python tool to generate an Excel file linking the list of cracked accounts and their LDAP attributes.')
149 | parser.add_argument('--use-ldaps', action='store_true', help='Use LDAPS instead of LDAP')
150 | parser.add_argument("-debug", dest="debug", action="store_true", default=False, help="Debug mode")
151 |
152 | parser.add_argument("-a", "--attribute", dest="attributes", default=default_attributes, action="append", type=str, help="Attributes to extract.")
153 |
154 | parser.add_argument("-x", "--xlsx", dest="xlsx", default=None, type=str, required=True, help="Output results to an XLSX file.")
155 | parser.add_argument("-n", "--ntds", dest="ntds", default=None, type=str, required=True, help="Output results to an XLSX file.")
156 |
157 | authconn = parser.add_argument_group('authentication & connection')
158 | authconn.add_argument('--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')
159 | authconn.add_argument('--kdcHost', dest="kdcHost", action='store', metavar="FQDN KDC", help='FQDN of KDC for Kerberos.')
160 | authconn.add_argument("-d", "--domain", dest="auth_domain", metavar="DOMAIN", action="store", help="(FQDN) domain to authenticate to")
161 | authconn.add_argument("-u", "--user", dest="auth_username", metavar="USER", action="store", help="user to authenticate with")
162 |
163 | secret = parser.add_argument_group()
164 | cred = secret.add_mutually_exclusive_group()
165 | cred.add_argument('--no-pass', action="store_true", help='don\'t ask for password (useful for -k)')
166 | cred.add_argument("-p", "--password", dest="auth_password", metavar="PASSWORD", action="store", help="password to authenticate with")
167 | cred.add_argument("-H", "--hashes", dest="auth_hashes", action="store", metavar="[LMHASH:]NTHASH", help='NT/LM hashes, format is LMhash:NThash')
168 | 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)')
169 | 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')
170 |
171 | if len(sys.argv) == 1:
172 | parser.print_help()
173 | sys.exit(1)
174 |
175 | args = parser.parse_args()
176 |
177 | return args
178 |
179 |
180 | def export_xlsx(options, results):
181 | # Prepare file path
182 | basepath = os.path.dirname(options.xlsx)
183 | filename = os.path.basename(options.xlsx)
184 | if basepath not in [".", ""]:
185 | if not os.path.exists(basepath):
186 | os.makedirs(basepath)
187 | path_to_file = basepath + os.path.sep + filename
188 | else:
189 | path_to_file = filename
190 |
191 | # Create Excel workbook
192 | # https://xlsxwriter.readthedocs.io/workbook.html#Workbook
193 | workbook_options = {
194 | 'constant_memory': True,
195 | 'in_memory': True,
196 | 'strings_to_formulas': False,
197 | 'remove_timezone': True
198 | }
199 | workbook = xlsxwriter.Workbook(filename=path_to_file, options=workbook_options)
200 | worksheet = workbook.add_worksheet()
201 |
202 | # Prepare attributes
203 | if '*' in options.attributes:
204 | attributes = []
205 | options.attributes.remove('*')
206 | attributes += options.attributes
207 | for entry, ldapresults in results:
208 | for dn in ldapresults.keys():
209 | attributes = sorted(list(set(attributes + list(ldapresults[dn].keys()))))
210 | else:
211 | attributes = options.attributes
212 |
213 | # Format colmun headers
214 | header_format = workbook.add_format({'bold': 1})
215 | header_format.set_pattern(1)
216 | header_format.set_bg_color('green')
217 |
218 | header_fields = ["domain", "username", "nthash", "password"]
219 | header_fields = header_fields + attributes
220 | for k in range(len(header_fields)):
221 | worksheet.set_column(k, k + 1, len(header_fields[k]) + 3)
222 | worksheet.set_row(row=0, height=40, cell_format=header_format)
223 | worksheet.write_row(row=0, col=0, data=header_fields)
224 |
225 | row_id = 1
226 | for entry, ldapresults in results:
227 | data = [entry["domain"], entry["username"], entry["nthash"], entry["password"]]
228 | if len(ldapresults.keys()) != 1:
229 | if len(ldapresults.keys()) == 0:
230 | worksheet.write_row(row=row_id, col=0, data=data)
231 | else:
232 | print("Error for entry:", entry)
233 | print(list(ldapresults.keys()))
234 | else:
235 | for dn in ldapresults.keys():
236 | for attr in attributes:
237 | if attr in ldapresults[dn].keys():
238 | value = ldapresults[dn][attr]
239 | if type(value) == str:
240 | data.append(value)
241 | elif type(value) == bytes:
242 | data.append(str(value))
243 | elif type(value) == list:
244 | data.append('\n'.join([str(l) for l in value]))
245 | else:
246 | data.append(str(value))
247 | else:
248 | data.append("")
249 | worksheet.write_row(row=row_id, col=0, data=data)
250 | row_id += 1
251 |
252 | worksheet.autofilter(
253 | first_row=0,
254 | first_col=0,
255 | last_row=row_id,
256 | last_col=(len(header_fields)-1)
257 | )
258 | workbook.close()
259 |
260 | print("[>] Written '%s'" % path_to_file)
261 |
262 | if __name__ == '__main__':
263 | options = parse_args()
264 |
265 | print("CrackedNTDStoXLSX.py v%s - by Remi GASCOU (Podalirius)\n" % VERSION)
266 |
267 | # Parse hashes
268 | auth_lm_hash = ""
269 | auth_nt_hash = ""
270 | if options.auth_hashes is not None:
271 | if ":" in options.auth_hashes:
272 | auth_lm_hash = options.auth_hashes.split(":")[0]
273 | auth_nt_hash = options.auth_hashes.split(":")[1]
274 | else:
275 | auth_nt_hash = options.auth_hashes
276 |
277 | # Use AES Authentication key if available
278 | if options.auth_key is not None:
279 | options.use_kerberos = True
280 | if options.use_kerberos is True and options.kdcHost is None:
281 | print("[!] Specify KDC's Hostname of FQDN using the argument --kdcHost")
282 | exit()
283 |
284 | # Try to authenticate with specified credentials
285 | try:
286 | print("[>] Try to authenticate as '%s\\%s' on %s ... " % (options.auth_domain, options.auth_username, options.dc_ip))
287 | ldap_server, ldap_session = init_ldap_session(
288 | auth_domain=options.auth_domain,
289 | auth_dc_ip=options.dc_ip,
290 | auth_username=options.auth_username,
291 | auth_password=options.auth_password,
292 | auth_lm_hash=auth_lm_hash,
293 | auth_nt_hash=auth_nt_hash,
294 | auth_key=options.auth_key,
295 | use_kerberos=options.use_kerberos,
296 | kdcHost=options.kdcHost,
297 | use_ldaps=options.use_ldaps
298 | )
299 | print("[+] Authentication successful!\n")
300 |
301 | search_base = ldap_server.info.other["defaultNamingContext"][0]
302 | ls = LDAPSearcher(ldap_server=ldap_server, ldap_session=ldap_session)
303 |
304 | f = open(options.ntds, "r")
305 | entries = []
306 | for line in f.readlines():
307 | cracked_identity, cracked_nthash, cracked_password = line.strip('\n').split(':',2)
308 | if '\\' in cracked_identity:
309 | cracked_domain = cracked_identity.split('\\',1)[0]
310 | cracked_username = cracked_identity.split('\\',1)[1]
311 | else:
312 | cracked_domain = ""
313 | cracked_username = cracked_identity
314 | entries.append({
315 | "domain": cracked_domain,
316 | "username": cracked_username,
317 | "nthash": cracked_nthash,
318 | "password": cracked_password
319 | })
320 | f.close()
321 |
322 | results = []
323 | for entry in track(entries, description="Matching cracked users with the LDAP ..."):
324 | ldap_query = '(sAMAccountName=%s)' % entry["username"]
325 | ldap_results = ls.query(base_dn=search_base, query=ldap_query, attributes=options.attributes)
326 | results.append((entry, ldap_results))
327 |
328 | export_xlsx(options, results)
329 |
330 | except Exception as e:
331 | if options.debug:
332 | traceback.print_exc()
333 | print("[!] Error: %s" % str(e))
334 |
--------------------------------------------------------------------------------