├── 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 | 
2 |
3 |
4 | The ldap2json script allows you to extract the whole LDAP content of a Windows domain into a JSON file.
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | 
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 | 
33 |
34 | ## Debug mode
35 |
36 | 
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 | 
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 |
--------------------------------------------------------------------------------