├── requirements.txt ├── .github ├── banner.png ├── debug.png ├── example.png ├── youtube_preview.png └── FUNDING.yml ├── analysis ├── screenshots │ └── analysis.png ├── README.md └── analysis.py ├── README.md └── ldap2json.py /requirements.txt: -------------------------------------------------------------------------------- 1 | impacket 2 | ldap3 3 | sectools 4 | pycryptodome 5 | -------------------------------------------------------------------------------- /.github/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p0dalirius/ldap2json/HEAD/.github/banner.png -------------------------------------------------------------------------------- /.github/debug.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p0dalirius/ldap2json/HEAD/.github/debug.png -------------------------------------------------------------------------------- /.github/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p0dalirius/ldap2json/HEAD/.github/example.png -------------------------------------------------------------------------------- /.github/youtube_preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p0dalirius/ldap2json/HEAD/.github/youtube_preview.png -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: p0dalirius 4 | patreon: Podalirius -------------------------------------------------------------------------------- /analysis/screenshots/analysis.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p0dalirius/ldap2json/HEAD/analysis/screenshots/analysis.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](./.github/banner.png) 2 | 3 |

4 | The ldap2json script allows you to extract the whole LDAP content of a Windows domain into a JSON file. 5 |
6 | GitHub release (latest by date) 7 | 8 | YouTube Channel Subscribers 9 |
10 |

11 | 12 | 13 | ![](./.github/example.png) 14 | 15 | ## Features 16 | 17 | - [x] Authenticate with password. 18 | - [x] Authenticate with LM:NT hashes. 19 | - [x] Authenticate with kerberos ticket. 20 | - [x] Save LDAP content in json format. 21 | 22 | ## Presentation and demonstration 23 | 24 |

25 | 26 |

27 | 28 | ## LDAP offline analysis tool 29 | 30 | This analysis console offers multiple ways of searching LDAP objects from a JSON file. You can search for objects, property names or property values in the console. 31 | 32 | ![](./analysis/screenshots/analysis.png) 33 | 34 | ## Debug mode 35 | 36 | ![](./.github/debug.png) 37 | 38 | ## Contributing 39 | 40 | Pull requests are welcome. Feel free to open an issue if you want to add other features. 41 | -------------------------------------------------------------------------------- /analysis/README.md: -------------------------------------------------------------------------------- 1 | # LDAP offline analysis tool 2 | 3 | This analysis console offers multiple ways of searching LDAP objects from a JSON file. You can search for objects, property names or property values in the console. 4 | 5 | ![](./screenshots/analysis.png) 6 | 7 | ## Commands 8 | 9 | | Command | Description | 10 | |-----------------------------------------------------------------|--------------------------------------------------------------| 11 | | [searchbase](./#searchbase-command) | Sets the LDAP search base. | 12 | | [object_by_property_name](./#object_by_property_name-command) | Search for an object containing a property by name in LDAP. | 13 | | [object_by_property_value](./#object_by_property_value-command) | Search for an object containing a property by value in LDAP. | 14 | | [object_by_dn](./#object_by_dn-command) | Search for an object by its distinguishedName in LDAP. | 15 | | [help](./#help-command) | Displays this help message. | 16 | | [exit](./#exit-command) | Exits the script. | 17 | 18 | ### searchbase command 19 | 20 | Sets LDAP search base. 21 | 22 | ``` 23 | $ ./analysis.py -f ../example_output.json 24 | [>] Loading ../example_output.json ... done. 25 | []> searchbase DC=LAB,DC=local 26 | [DC=local,DC=LAB]> searchbase CN=Users,DC=LAB,DC=local 27 | [DC=local,DC=LAB,CN=Users]> 28 | ``` 29 | 30 | ### object_by_property_name command 31 | 32 | Search for an object containing a property by name in LDAP. 33 | 34 | ``` 35 | $ ./analysis.py -f ../example_output.json 36 | [>] Loading ../example_output.json ... done. 37 | []> object_by_property_name admincount 38 | [CN=Administrator,CN=Users,DC=LAB,DC=local] => adminCount 39 | - 1 40 | [CN=krbtgt,CN=Users,DC=LAB,DC=local] => adminCount 41 | - 1 42 | [CN=Domain Controllers,CN=Users,DC=LAB,DC=local] => adminCount 43 | - 1 44 | [CN=Domain Admins,CN=Users,DC=LAB,DC=local] => adminCount 45 | - 1 46 | ... 47 | []> 48 | ``` 49 | 50 | ### object_by_property_value command 51 | 52 | Search for an object containing a property by value in LDAP. 53 | 54 | ``` 55 | $ ./analysis.py -f ../example_output.json 56 | [>] Loading ../example_output.json ... done. 57 | []> object_by_property_value 2021-10-17 13:06:46 58 | [CN=user1,CN=Users,DC=LAB,DC=local] => lastLogon 59 | - 2021-10-17 13:06:46 60 | []> 61 | ``` 62 | 63 | ### object_by_dn command 64 | 65 | Search for an object by its distinguishedName in LDAP. 66 | 67 | ``` 68 | $ ./analysis.py -f ../example_output.json 69 | [>] Loading ../example_output.json ... done. 70 | []> object_by_dn CN=krbtgt,CN=Users,DC=LAB,DC=local 71 | { 72 | "objectClass": [], 73 | "cn": "krbtgt", 74 | "description": "Key Distribution Center Service Account", 75 | "distinguishedName": "CN=krbtgt,CN=Users,DC=LAB,DC=local", 76 | "instanceType": 4, 77 | "whenCreated": "2021-10-01 19:46:23", 78 | "whenChanged": "2021-10-01 20:01:34", 79 | "uSNCreated": 12324, 80 | "memberOf": "CN=Denied RODC Password Replication Group,CN=Users,DC=LAB,DC=local", 81 | "uSNChanged": 12782, 82 | "showInAdvancedViewOnly": true, 83 | "name": "krbtgt", 84 | "objectGUID": "{b6ea7e4f-658e-49ab-bb0b-dd11eca300d3}", 85 | "userAccountControl": 514, 86 | "badPwdCount": 0, 87 | "codePage": 0, 88 | "countryCode": 0, 89 | "badPasswordTime": "1601-01-01 00:00:00", 90 | "lastLogoff": "1601-01-01 00:00:00", 91 | "lastLogon": "1601-01-01 00:00:00", 92 | "pwdLastSet": "2021-10-01 19:46:23", 93 | "primaryGroupID": 513, 94 | "objectSid": "S-1-5-21-2088580017-2757022071-1782060386-502", 95 | "adminCount": 1, 96 | "accountExpires": "9999-12-31 23:59:59", 97 | "logonCount": 0, 98 | "sAMAccountName": "krbtgt", 99 | "sAMAccountType": 805306368, 100 | "servicePrincipalName": "kadmin/changepw", 101 | "objectCategory": "CN=Person,CN=Schema,CN=Configuration,DC=LAB,DC=local", 102 | "isCriticalSystemObject": true, 103 | "dSCorePropagationData": [ 104 | "2021-10-01 20:01:34", 105 | "2021-10-01 19:46:24", 106 | "1601-01-01 00:04:16" 107 | ], 108 | "msDS-SupportedEncryptionTypes": 0 109 | } 110 | ``` 111 | 112 | ### help command 113 | 114 | Displays the help message with all the possible commands: 115 | 116 | ``` 117 | - searchbase Sets LDAP search base. 118 | - object_by_property_name Search for an object by property name in LDAP. 119 | - object_by_property_value Search for an object by property value in LDAP. 120 | - object_by_dn Search for an object by DN in LDAP. 121 | - help Displays this help message. 122 | - exit Exits the script. 123 | ``` 124 | 125 | ### exit command 126 | 127 | Exits the analysis console. 128 | -------------------------------------------------------------------------------- /analysis/analysis.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # File name : analysis.py 4 | # Author : Podalirius (@podalirius_) 5 | # Date created : 27 Dec 2022 6 | 7 | import json 8 | import readline 9 | import argparse 10 | import sys 11 | 12 | 13 | class CommandCompleter(object): 14 | def __init__(self): 15 | self.options = { 16 | "searchbase": [], 17 | "object_by_dn": [], 18 | "object_by_property_value": [], 19 | "object_by_property_name": [], 20 | "help": [], 21 | "exit": [] 22 | } 23 | 24 | def complete(self, text, state): 25 | if state == 0: 26 | if len(text) == 0: 27 | self.matches = [s for s in self.options.keys()] 28 | elif len(text) != 0: 29 | 30 | if text.count(' ') == 0: 31 | self.matches = [s for s in self.options.keys() if s and s.startswith(text)] 32 | elif text.count(' ') == 1: 33 | command, remainder = text.split(' ', 1) 34 | if command in self.options.keys(): 35 | self.matches = [command + " " + s for s in self.options[command] if s and s.startswith(remainder)] 36 | else: 37 | pass 38 | else: 39 | self.matches = [] 40 | else: 41 | self.matches = self.options.keys()[:] 42 | try: 43 | return self.matches[state] + " " 44 | except IndexError: 45 | return None 46 | 47 | 48 | readline.set_completer(CommandCompleter().complete) 49 | readline.parse_and_bind('tab: complete') 50 | readline.set_completer_delims('\n') 51 | 52 | 53 | ### Data utils 54 | 55 | 56 | def dict_get_paths(d): 57 | paths = [] 58 | for key in d.keys(): 59 | if type(d[key]) == dict: 60 | paths = [[key] + p for p in dict_get_paths(d[key])] 61 | else: 62 | paths.append([key]) 63 | return paths 64 | 65 | 66 | def dict_path_access(d, path): 67 | for key in path: 68 | if key in d.keys(): 69 | d = d[key] 70 | else: 71 | return None 72 | return d 73 | 74 | 75 | def search_for_property_by_name(d, property, path=[]): 76 | results = [] 77 | for key in d.keys(): 78 | if type(d[key]) == dict: 79 | results += search_for_property_by_name(d[key], property, path=path + [key]) 80 | elif property.lower() == key.lower(): 81 | results.append({ 82 | "path": path, 83 | "property": key, 84 | "value": d[key] 85 | }) 86 | return results 87 | 88 | 89 | def search_for_property_by_value(d, value, path=[]): 90 | results = [] 91 | for key in d.keys(): 92 | if type(d[key]) == dict: 93 | results += search_for_property_by_value(d[key], value, path=path + [key]) 94 | elif str(value) == str(d[key]): 95 | results.append({ 96 | "path": path, 97 | "property": key, 98 | "value": d[key] 99 | }) 100 | return results 101 | 102 | 103 | def parseArgs(): 104 | parser = argparse.ArgumentParser(description="Description message") 105 | parser.add_argument("-f", "--file", default=None, required=True, help='LDAP json file.') 106 | parser.add_argument("-d", "--debug", default=False, action="store_true", help='Debug mode.') 107 | return parser.parse_args() 108 | 109 | def enhanced_print(data, base): 110 | print("[\x1b[93m%s\x1b[0m] => \x1b[94m%s\x1b[0m\n - \x1b[92m%s\x1b[0m" % ( 111 | ','.join(data["path"][::-1] + base[::-1]), 112 | data["property"], 113 | data["value"], 114 | )) 115 | 116 | if __name__ == '__main__': 117 | options = parseArgs() 118 | 119 | print("[>] Loading %s ... " % options.file, end="") 120 | sys.stdout.flush() 121 | f = open(options.file, "r") 122 | ldapdata = json.loads(f.read()) 123 | f.close() 124 | print("done.") 125 | 126 | if options.debug == True: 127 | print("[debug] ") 128 | 129 | base = [] 130 | running = True 131 | while running: 132 | try: 133 | cmd = input("[\x1b[95m%s\x1b[0m]> " % ','.join(base)) 134 | cmd = cmd.strip().split(" ") 135 | 136 | if cmd[0].lower() == "exit": 137 | running = False 138 | 139 | elif cmd[0].lower() == "object_by_dn": 140 | _dn = ' '.join(cmd[1:]).split(',')[::-1] 141 | _data = dict_path_access(ldapdata, _dn) 142 | 143 | if _data is not None: 144 | print(json.dumps(_data, indent=4)) 145 | 146 | elif cmd[0].lower() == "object_by_property_name": 147 | _property_name = ' '.join(cmd[1:]) 148 | 149 | _results = [] 150 | _data = dict_path_access(ldapdata, base) 151 | 152 | if _data is not None: 153 | _results = search_for_property_by_name(_data, _property_name) 154 | 155 | if len(_results) == 0: 156 | print("\x1b[91mNo such property found.\x1b[0m") 157 | else: 158 | for result in _results: 159 | enhanced_print(result, base) 160 | 161 | elif cmd[0].lower() == "object_by_property_value": 162 | _property_value = ' '.join(cmd[1:]) 163 | 164 | _results = [] 165 | _data = dict_path_access(ldapdata, base) 166 | if _data is not None: 167 | _results = search_for_property_by_value(_data, _property_value) 168 | 169 | if len(_results) == 0: 170 | print("\x1b[91mNo property with specified value found.\x1b[0m") 171 | else: 172 | for result in _results: 173 | enhanced_print(result, base) 174 | 175 | elif cmd[0].lower() == "searchbase": 176 | _base = ' '.join(cmd[1:]) 177 | _base = _base.split(',')[::-1] 178 | base = _base 179 | 180 | if options.debug == True: 181 | print("[debug] Changed searchbase to %s" % ','.join(base)) 182 | 183 | elif cmd[0].lower() == "search_for_kerberoastable_users": 184 | _results = [] 185 | _data = dict_path_access(ldapdata, base) 186 | 187 | _results = search_for_property_by_name(_data, "servicePrincipalName") 188 | 189 | for user in _results: 190 | if 'CN=krbtgt' not in user["path"] and 'CN=Users' in user["path"]: 191 | enhanced_print(user, base) 192 | 193 | elif cmd[0].lower() == "search_for_asreproastable_users": 194 | _results = [] 195 | _data = dict_path_access(ldapdata, base) 196 | 197 | _results = search_for_property_by_name(_data, "UserAccountControl") 198 | 199 | for user in _results: 200 | if user["value"] & 0x400000: 201 | enhanced_print(user, base) 202 | 203 | elif cmd[0].lower() == "help": 204 | print(" - %-35s %s " % ("searchbase", "Sets the LDAP search base.")) 205 | print(" - %-35s %s " % ("object_by_property_name", "Search for an object containing a property by name in LDAP.")) 206 | print(" - %-35s %s " % ("object_by_property_value", "Search for an object containing a property by value in LDAP.")) 207 | print(" - %-35s %s " % ("object_by_dn", "Search for an object by its distinguishedName in LDAP.")) 208 | print(" - %-35s %s " % ("search_for_kerberoastable_users", "Search for users accounts linked to at least one service in LDAP.")) 209 | print(" - %-35s %s " % ("search_for_asreproastable_users", "Search for users with DONT_REQ_PREAUTH parameter set to True in LDAP.")) 210 | print(" - %-35s %s " % ("help", "Displays this help message.")) 211 | print(" - %-35s %s " % ("exit", "Exits the script.")) 212 | else: 213 | print("Unknown command. Type 'help' for help.") 214 | except KeyboardInterrupt as e: 215 | print() 216 | running = False 217 | except EOFError as e: 218 | print() 219 | running = False 220 | -------------------------------------------------------------------------------- /ldap2json.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # File name : ldap2json.py 4 | # Author : Podalirius (@podalirius_) 5 | # Date created : 22 Dec 2021 6 | 7 | 8 | from sectools.windows.ldap import init_ldap_session, raw_ldap_query 9 | import argparse 10 | import datetime 11 | import json 12 | import sys 13 | 14 | 15 | def cast_to_dict(cid): 16 | out = {} 17 | for key, value in cid.items(): 18 | if type(value) == bytes: 19 | out[key] = str(value) 20 | elif type(value) == list: 21 | newlist = [] 22 | for element in value: 23 | if type(element) == bytes: 24 | newlist.append(str(element)) 25 | elif type(element) == datetime.datetime: 26 | newlist.append(element.strftime('%Y-%m-%d %T')) 27 | elif type(element) == datetime.timedelta: 28 | # Output format to change 29 | newlist.append(element.seconds) 30 | else: 31 | newlist.append(str(element)) 32 | out[key] = newlist 33 | elif type(value) == datetime.datetime: 34 | out[key] = value.strftime('%Y-%m-%d %T') 35 | elif type(value) == datetime.timedelta: 36 | # Output format to change 37 | out[key] = value.seconds 38 | else: 39 | out[key] = value 40 | return out 41 | 42 | 43 | def bytessize(data): 44 | """ 45 | Function to calculate the size of data in bytes and convert it to a human-readable format. 46 | 47 | Args: 48 | data (bytes): The data for which the size needs to be calculated. 49 | 50 | Returns: 51 | str: The human-readable format of the data size. 52 | """ 53 | l = len(data) 54 | units = ['B','kB','MB','GB','TB','PB'] 55 | for k in range(len(units)): 56 | if l < (1024**(k+1)): 57 | break 58 | return "%4.2f %s" % (round(l/(1024**(k)),2), units[k]) 59 | 60 | 61 | def parseArgs(): 62 | parser = argparse.ArgumentParser(add_help=True, description="The ldap2json script allows you to extract the whole LDAP content of a Windows domain into a JSON file.") 63 | parser.add_argument("--use-ldaps", action="store_true", help="Use LDAPS instead of LDAP.") 64 | parser.add_argument("-q", "--quiet", dest="quiet", action="store_true", default=False, help="Show no information at all.") 65 | parser.add_argument("--debug", dest="debug", action="store_true", default=False, help="Debug mode.") 66 | parser.add_argument("-o", "--outfile", dest="jsonfile", default="ldap.json", help="Output JSON file. (default: ldap.json).") 67 | parser.add_argument("-b", "--base", dest="searchbase", default=None, help="Search base for LDAP query.") 68 | 69 | authconn = parser.add_argument_group("authentication & connection") 70 | 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.") 71 | authconn.add_argument("--kdcHost", dest="kdcHost", action="store", metavar="FQDN KDC", help="FQDN of KDC for Kerberos.") 72 | authconn.add_argument("-d", "--domain", dest="auth_domain", metavar="DOMAIN", action="store", help="(FQDN) domain to authenticate to.") 73 | authconn.add_argument("-u", "--user", dest="auth_username", metavar="USER", action="store", help="user to authenticate with.") 74 | 75 | secret = parser.add_argument_group() 76 | cred = secret.add_mutually_exclusive_group() 77 | cred.add_argument("--no-pass", action="store_true", help="Don't ask for password (useful for -k).") 78 | cred.add_argument("-p", "--password", dest="auth_password", metavar="PASSWORD", action="store", help="Password to authenticate with.") 79 | cred.add_argument("-H", "--hashes", dest="auth_hashes", action="store", metavar="[LMHASH:]NTHASH", help="NT/LM hashes, format is LMhash:NThash.") 80 | 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).") 81 | 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.") 82 | 83 | if len(sys.argv) == 1: 84 | parser.print_help() 85 | sys.exit(1) 86 | 87 | options = parser.parse_args() 88 | 89 | if options.auth_password is None and options.no_pass == False and options.auth_hashes is None: 90 | print("[+] No password of hashes provided and --no-pass is '%s'" % options.no_pass) 91 | from getpass import getpass 92 | if options.auth_domain is not None: 93 | options.auth_password = getpass(" | Provide a password for '%s\\%s':" % (options.auth_domain, options.auth_username)) 94 | else: 95 | options.auth_password = getpass(" | Provide a password for '%s':" % options.auth_username) 96 | 97 | return options 98 | 99 | 100 | if __name__ == '__main__': 101 | options = parseArgs() 102 | 103 | print("[+]======================================================") 104 | print("[+] LDAP2JSON v1.1 @podalirius_ ") 105 | print("[+]======================================================") 106 | print() 107 | 108 | auth_lm_hash = "" 109 | auth_nt_hash = "" 110 | if options.auth_hashes is not None: 111 | if ":" in options.auth_hashes: 112 | auth_lm_hash = options.auth_hashes.split(":")[0] 113 | auth_nt_hash = options.auth_hashes.split(":")[1] 114 | else: 115 | auth_nt_hash = options.auth_hashes 116 | 117 | print("[>] Connecting to remote LDAP host '%s' ... " % options.dc_ip, end="", flush=True) 118 | ldap_server, ldap_session = init_ldap_session( 119 | auth_domain=options.auth_domain, 120 | auth_username=options.auth_username, 121 | auth_password=options.auth_password, 122 | auth_lm_hash=auth_lm_hash, 123 | auth_nt_hash=auth_nt_hash, 124 | auth_key=options.auth_key, 125 | use_kerberos=options.use_kerberos, 126 | kdcHost=options.kdcHost, 127 | use_ldaps=options.use_ldaps, 128 | auth_dc_ip=options.dc_ip 129 | ) 130 | configurationNamingContext = ldap_server.info.other["configurationNamingContext"] 131 | defaultNamingContext = ldap_server.info.other["defaultNamingContext"] 132 | print("done.") 133 | 134 | if options.debug: 135 | print("[>] Authentication successful!") 136 | 137 | print("[>] Extracting all objects from LDAP ...") 138 | 139 | data = {} 140 | for naming_context in ldap_server.info.naming_contexts: 141 | if options.debug: 142 | print("[>] Querying (objectClass=*) to LDAP on %s ..." % naming_context) 143 | 144 | response = raw_ldap_query( 145 | auth_domain=options.auth_domain, 146 | auth_dc_ip=options.dc_ip, 147 | auth_username=options.auth_username, 148 | auth_password=options.auth_password, 149 | auth_hashes=options.auth_hashes, 150 | auth_key=options.auth_key, 151 | searchbase=naming_context, 152 | use_ldaps=options.use_ldaps, 153 | use_kerberos=options.use_kerberos, 154 | kdcHost=options.kdcHost, 155 | query="(objectClass=*)", 156 | attributes=["*"] 157 | ) 158 | 159 | if options.debug: 160 | print(" | LDAP query (objectClass=*) returned %d objects" % len(response)) 161 | 162 | for cn in response: 163 | path = cn.split(',')[::-1] 164 | tmp = data 165 | for key in path[:-1]: 166 | if key in tmp.keys(): 167 | tmp = tmp[key] 168 | else: 169 | tmp[key] = {} 170 | tmp = tmp[key] 171 | if path[-1] in tmp: 172 | tmp[path[-1]].update(cast_to_dict(response[cn])) 173 | else: 174 | tmp[path[-1]] = cast_to_dict(response[cn]) 175 | 176 | json_data = json.dumps(data, indent=4) 177 | if options.debug: 178 | print("[>] JSON data generated.") 179 | 180 | print("[>] Writing json data to %s" % options.jsonfile) 181 | f = open(options.jsonfile, 'w') 182 | f.write(json_data) 183 | f.close() 184 | print("[>] Written %s bytes to %s" % (bytessize(json_data), options.jsonfile)) 185 | --------------------------------------------------------------------------------