├── .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 | ![](./.github/banner.png) 2 | 3 |

4 | A python tool to generate an Excel file linking the list of cracked accounts and their LDAP attributes. 5 |
6 | GitHub release (latest by date) 7 | 8 | YouTube Channel Subscribers 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 | --------------------------------------------------------------------------------