Codebase list python-ldapdomaindump / 78a2ddf
New upstream version 0.8.7 Sophie Brun 5 years ago
13 changed file(s) with 1291 addition(s) and 0 deletion(s). Raw diff Collapse all Expand all
0 # http://editorconfig.org
1 root = true
2
3 [*]
4 indent_style = space
5 indent_size = 4
6 end_of_line = crlf
7 charset = utf-8
8 trim_trailing_whitespace = true
9 insert_final_newline = true
10
11 [*.md]
12 trim_trailing_whitespace = false
0 build/
1 dist/
2 ldapdomaindump.egg-info
3 domainlookup.py
4 *.pyc
0 The MIT License (MIT)
1
2 Copyright (c) 2016 Dirk-jan
3
4 Permission is hereby granted, free of charge, to any person obtaining a copy
5 of this software and associated documentation files (the "Software"), to deal
6 in the Software without restriction, including without limitation the rights
7 to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8 copies of the Software, and to permit persons to whom the Software is
9 furnished to do so, subject to the following conditions:
10
11 The above copyright notice and this permission notice shall be included in all
12 copies or substantial portions of the Software.
13
14 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20 SOFTWARE.
0 include ldapdomaindump/style.css
0 # LDAPDomainDump
1 Active Directory information dumper via LDAP
2
3 ## Introduction
4 In an Active Directory domain, a lot of interesting information can be retrieved via LDAP by any authenticated user (or machine).
5 This makes LDAP an interesting protocol for gathering information in the recon phase of a pentest of an internal network.
6 A problem is that data from LDAP often is not available in an easy to read format.
7
8 ldapdomaindump is a tool which aims to solve this problem, by collecting and parsing information available via LDAP and outputting it in a human readable HTML format, as well as machine readable json and csv/tsv/greppable files.
9
10 The tool was designed with the following goals in mind:
11 - Easy overview of all users/groups/computers/policies in the domain
12 - Authentication both via username and password, as with NTLM hashes (requires ldap3 >=1.3.1)
13 - Possibility to run the tool with an existing authenticated connection to an LDAP service, allowing for integration with relaying tools such as impackets ntlmrelayx
14
15 The tool outputs several files containing an overview of objects in the domain:
16 - *domain_groups*: List of groups in the domain
17 - *domain_users*: List of users in the domain
18 - *domain_computers*: List of computer accounts in the domain
19 - *domain_policy*: Domain policy such as password requirements and lockout policy
20 - *domain_trusts*: Incoming and outgoing domain trusts, and their properties
21
22 As well as two grouped files:
23 - *domain_users_by_group*: Domain users per group they are member of
24 - *domain_computers_by_os*: Domain computers sorted by Operating System
25
26 ## Dependencies and installation
27 Requires [ldap3](https://github.com/cannatag/ldap3) > 2.0 and [dnspython](https://github.com/rthalley/dnspython)
28
29 Both can be installed with `pip install ldap3 dnspython`
30
31 The ldapdomaindump package can be installed with `python setup.py install` from the git source, or for the latest release with `pip install ldapdomaindump`.
32
33 ## Usage
34 There are 3 ways to use the tool:
35 - With just the source, run `python ldapdomaindump.py`
36 - After installing, by running `python -m ldapdomaindump`
37 - After installing, by running `ldapdomaindump`
38
39 Help can be obtained with the -h switch:
40 ```
41 usage: ldapdomaindump.py [-h] [-u USERNAME] [-p PASSWORD] [-at {NTLM,SIMPLE}]
42 [-o DIRECTORY] [--no-html] [--no-json] [--no-grep]
43 [--grouped-json] [-d DELIMITER] [-r] [-n DNS_SERVER]
44 [-m]
45 HOSTNAME
46
47 Domain information dumper via LDAP. Dumps users/computers/groups and
48 OS/membership information to HTML/JSON/greppable output.
49
50 Required options:
51 HOSTNAME Hostname/ip or ldap://host:port connection string to
52 connect to (use ldaps:// to use SSL)
53
54 Main options:
55 -h, --help show this help message and exit
56 -u USERNAME, --user USERNAME
57 DOMAIN\username for authentication, leave empty for
58 anonymous authentication
59 -p PASSWORD, --password PASSWORD
60 Password or LM:NTLM hash, will prompt if not specified
61 -at {NTLM,SIMPLE}, --authtype {NTLM,SIMPLE}
62 Authentication type (NTLM or SIMPLE, default: NTLM)
63
64 Output options:
65 -o DIRECTORY, --outdir DIRECTORY
66 Directory in which the dump will be saved (default:
67 current)
68 --no-html Disable HTML output
69 --no-json Disable JSON output
70 --no-grep Disable Greppable output
71 --grouped-json Also write json files for grouped files (default:
72 disabled)
73 -d DELIMITER, --delimiter DELIMITER
74 Field delimiter for greppable output (default: tab)
75
76 Misc options:
77 -r, --resolve Resolve computer hostnames (might take a while and
78 cause high traffic on large networks)
79 -n DNS_SERVER, --dns-server DNS_SERVER
80 Use custom DNS resolver instead of system DNS (try a
81 domain controller IP)
82 -m, --minimal Only query minimal set of attributes to limit memmory
83 usage
84 ```
85
86 ## Options
87 ### Authentication
88 Most AD servers support NTLM authentication. In the rare case that it does not, use --authtype SIMPLE.
89
90 ### Output formats
91 By default the tool outputs all files in HTML, JSON and tab delimited output (greppable). There are also two grouped files (users_by_group and computers_by_os) for convenience. These do not have a greppable output. JSON output for grouped files is disabled by default since it creates very large files without any data that isn't present in the other files already.
92
93 ### DNS resolving
94 An important option is the *-r* option, which decides if a computers DNSHostName attribute should be resolved to an IPv4 address.
95 While this can be very useful, the DNSHostName attribute is not automatically updated. When the AD Domain uses subdomains for computer hostnames, the DNSHostName will often be incorrect and will not resolve. Also keep in mind that resolving every hostname in the domain might cause a high load on the domain controller.
96
97 ### Minimizing network and memory usage
98 By default ldapdomaindump will try to dump every single attribute it can read to disk in the .json files. In large networks, this uses a lot of memory (since group relationships are currently calculated in memory before being written to disk). To dump only the minimal required attributes (the ones shown by default in the .html and .grep files), use the `--minimal` switch.
99
100 ## Visualizing groups with BloodHound
101 LDAPDomainDump includes a utility that can be used to convert ldapdomaindumps `.json` files to CSV files suitable for BloodHound. The utility is called `ldd2bloodhound` and is added to your path upon installation. Alternatively you can run it with `python -m ldapdomaindump.convert` or with `python ldapdomaindump/convert.py` if you are running it from the source.
102 The conversion tool will take the users/groups/computers/trusts `.json` file and convert those to `group_membership.csv` and `trust.csv` which you can add to BloodHound.
103
104 ## License
105 MIT
0 #!/usr/bin/env python
1 import ldapdomaindump
2 ldapdomaindump.main()
0 #!/usr/bin/env python
1 from ldapdomaindump import convert
2 convert.ldd2bloodhound()
0 ####################
1 #
2 # Copyright (c) 2017 Dirk-jan Mollema
3 #
4 # Permission is hereby granted, free of charge, to any person obtaining a copy
5 # of this software and associated documentation files (the "Software"), to deal
6 # in the Software without restriction, including without limitation the rights
7 # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8 # copies of the Software, and to permit persons to whom the Software is
9 # furnished to do so, subject to the following conditions:
10 #
11 # The above copyright notice and this permission notice shall be included in all
12 # copies or substantial portions of the Software.
13 #
14 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20 # SOFTWARE.
21 #
22 ####################
23
24 import sys, os, re, codecs, json, argparse, getpass, base64
25 # import class and constants
26 from datetime import datetime
27 from urllib import quote_plus
28
29 import ldap3
30 from ldap3 import Server, Connection, SIMPLE, SYNC, ALL, SASL, NTLM
31 from ldap3.core.exceptions import LDAPKeyError, LDAPAttributeError, LDAPCursorError
32 from ldap3.abstract import attribute, attrDef
33 from ldap3.utils import dn
34 from ldap3.protocol.formatters.formatters import format_sid
35
36 # dnspython, for resolving hostnames
37 import dns.resolver
38
39
40 # User account control flags
41 # From: https://blogs.technet.microsoft.com/askpfeplat/2014/01/15/understanding-the-useraccountcontrol-attribute-in-active-directory/
42 uac_flags = {'ACCOUNT_DISABLED':0x00000002,
43 'ACCOUNT_LOCKED':0x00000010,
44 'PASSWD_NOTREQD':0x00000020,
45 'PASSWD_CANT_CHANGE': 0x00000040,
46 'NORMAL_ACCOUNT': 0x00000200,
47 'WORKSTATION_ACCOUNT':0x00001000,
48 'SERVER_TRUST_ACCOUNT': 0x00002000,
49 'DONT_EXPIRE_PASSWD': 0x00010000,
50 'SMARTCARD_REQUIRED': 0x00040000,
51 'TRUSTED_FOR_DELEGATION': 0x00080000,
52 'NOT_DELEGATED': 0x00100000,
53 'USE_DES_KEY_ONLY': 0x00200000,
54 'DONT_REQ_PREAUTH': 0x00400000,
55 'PASSWORD_EXPIRED': 0x00800000,
56 'TRUSTED_TO_AUTH_FOR_DELEGATION': 0x01000000,
57 'PARTIAL_SECRETS_ACCOUNT': 0x04000000
58 }
59
60 # Password policy flags
61 pwd_flags = {'PASSWORD_COMPLEX':0x01,
62 'PASSWORD_NO_ANON_CHANGE': 0x02,
63 'PASSWORD_NO_CLEAR_CHANGE': 0x04,
64 'LOCKOUT_ADMINS': 0x08,
65 'PASSWORD_STORE_CLEARTEXT': 0x10,
66 'REFUSE_PASSWORD_CHANGE': 0x20}
67
68 # Domain trust flags
69 # From: https://msdn.microsoft.com/en-us/library/cc223779.aspx
70 trust_flags = {'NON_TRANSITIVE':0x00000001,
71 'UPLEVEL_ONLY':0x00000002,
72 'QUARANTINED_DOMAIN':0x00000004,
73 'FOREST_TRANSITIVE':0x00000008,
74 'CROSS_ORGANIZATION':0x00000010,
75 'WITHIN_FOREST':0x00000020,
76 'TREAT_AS_EXTERNAL':0x00000040,
77 'USES_RC4_ENCRYPTION':0x00000080,
78 'CROSS_ORGANIZATION_NO_TGT_DELEGATION':0x00000200,
79 'PIM_TRUST':0x00000400}
80
81 # Domain trust direction
82 # From: https://msdn.microsoft.com/en-us/library/cc223768.aspx
83 trust_directions = {'INBOUND':0x01,
84 'OUTBOUND':0x02,
85 'BIDIRECTIONAL':0x03}
86 # Domain trust types
87 trust_type = {'DOWNLEVEL':0x01,
88 'UPLEVEL':0x02,
89 'MIT':0x03}
90
91 # Common attribute pretty translations
92 attr_translations = {'sAMAccountName':'SAM Name',
93 'cn':'CN',
94 'operatingSystem':'Operating System',
95 'operatingSystemServicePack':'Service Pack',
96 'operatingSystemVersion':'OS Version',
97 'userAccountControl':'Flags',
98 'objectSid':'SID',
99 'memberOf':'Member of groups',
100 'primaryGroupId':'Primary group',
101 'dNSHostName':'DNS Hostname',
102 'whenCreated':'Created on',
103 'whenChanged':'Changed on',
104 'IPv4':'IPv4 Address',
105 'lockOutObservationWindow':'Lockout time window',
106 'lockoutDuration':'Lockout Duration',
107 'lockoutThreshold':'Lockout Threshold',
108 'maxPwdAge':'Max password age',
109 'minPwdAge':'Min password age',
110 'minPwdLength':'Min password length'}
111
112 MINIMAL_COMPUTERATTRIBUTES = ['cn', 'sAMAccountName', 'dNSHostName', 'operatingSystem', 'operatingSystemServicePack', 'operatingSystemVersion', 'lastLogon', 'userAccountControl', 'whenCreated', 'objectSid', 'description', 'objectClass']
113 MINIMAL_USERATTRIBUTES = ['cn', 'name', 'sAMAccountName', 'memberOf', 'primaryGroupId', 'whenCreated', 'whenChanged', 'lastLogon', 'userAccountControl', 'pwdLastSet', 'objectSid', 'description', 'objectClass']
114 MINIMAL_GROUPATTRIBUTES = ['cn', 'name', 'sAMAccountName', 'memberOf', 'description', 'whenCreated', 'whenChanged', 'objectSid', 'distinguishedName', 'objectClass']
115
116 #Class containing the default config
117 class domainDumpConfig(object):
118 def __init__(self):
119 #Base path
120 self.basepath = '.'
121
122 #Output files basenames
123 self.groupsfile = 'domain_groups' #Groups
124 self.usersfile = 'domain_users' #User accounts
125 self.computersfile = 'domain_computers' #Computer accounts
126 self.policyfile = 'domain_policy' #General domain attributes
127 self.trustsfile = 'domain_trusts' #Domain trusts attributes
128
129 #Combined files basenames
130 self.users_by_group = 'domain_users_by_group' #Users sorted by group
131 self.computers_by_os = 'domain_computers_by_os' #Computers sorted by OS
132
133 #Output formats
134 self.outputhtml = True
135 self.outputjson = True
136 self.outputgrep = True
137
138 #Output json for groups
139 self.groupedjson = False
140
141 #Default field delimiter for greppable format is a tab
142 self.grepsplitchar = '\t'
143
144 #Other settings
145 self.lookuphostnames = False #Look up hostnames of computers to get their IP address
146 self.dnsserver = '' #Addres of the DNS server to use, if not specified default DNS will be used
147 self.minimal = False #Only query minimal list of attributes
148
149 #Domaindumper main class
150 class domainDumper(object):
151 def __init__(self, server, connection, config, root=None):
152 self.server = server
153 self.connection = connection
154 self.config = config
155 #Unless the root is specified we get it from the server
156 if root is None:
157 self.root = self.getRoot()
158 else:
159 self.root = root
160 self.users = None #Domain users
161 self.groups = None #Domain groups
162 self.computers = None #Domain computers
163 self.policy = None #Domain policy
164 self.groups_dnmap = None #CN map for group IDs to CN
165 self.groups_dict = None #Dictionary of groups by CN
166 self.trusts = None #Domain trusts
167
168 #Get the server root from the default naming context
169 def getRoot(self):
170 return self.server.info.other['defaultNamingContext'][0]
171
172 #Query the groups of the current user
173 def getCurrentUserGroups(self, username, domainsid=None):
174 self.connection.search(self.root, '(&(objectCategory=person)(objectClass=user)(sAMAccountName=%s))' % username, attributes=['cn', 'memberOf', 'primaryGroupId'])
175 try:
176 groups = self.connection.entries[0]['memberOf'].values
177 if domainsid is not None:
178 groups.append(self.getGroupDNfromID(domainsid, self.connection.entries[0]['primaryGroupId'].value))
179 return groups
180 except LDAPKeyError:
181 #No groups, probably just member of the primary group
182 if domainsid is not None:
183 primarygroup = self.getGroupDNfromID(domainsid, self.connection.entries[0]['primaryGroupId'].value)
184 return [primarygroup]
185 else:
186 return []
187 except IndexError:
188 #The username does not exist (might be a computer account)
189 return []
190
191 #Check if the user is part of the Domain Admins or Enterprise Admins group, or any of their subgroups
192 def isDomainAdmin(self, username):
193 domainsid = self.getRootSid()
194 groups = self.getCurrentUserGroups(username, domainsid)
195 #Get DA and EA group DNs
196 dagroupdn = self.getDAGroupDN(domainsid)
197 eagroupdn = self.getEAGroupDN(domainsid)
198 #First, simple checks
199 for group in groups:
200 if 'CN=Administrators' in group or 'CN=Domain Admins' in group or dagroupdn == group:
201 return True
202 #Also for enterprise admins if applicable
203 if 'CN=Enterprise Admins' in group or (eagroupdn is not False and eagroupdn == group):
204 return True
205 #Now, just do a recursive check in both groups and their subgroups using LDAP_MATCHING_RULE_IN_CHAIN
206 self.connection.search(self.root, '(&(objectCategory=person)(objectClass=user)(sAMAccountName=%s)(memberOf:1.2.840.113556.1.4.1941:=%s))' % (username, dagroupdn), attributes=['cn', 'sAMAccountName'])
207 if len(self.connection.entries) > 0:
208 return True
209 self.connection.search(self.root, '(&(objectCategory=person)(objectClass=user)(sAMAccountName=%s)(memberOf:1.2.840.113556.1.4.1941:=%s))' % (username, eagroupdn), attributes=['cn', 'sAMAccountName'])
210 if len(self.connection.entries) > 0:
211 return True
212 #At last, check the users primary group ID
213 return False
214
215 #Get all users
216 def getAllUsers(self):
217 if self.config.minimal:
218 self.connection.extend.standard.paged_search('%s' % (self.root), '(&(objectCategory=person)(objectClass=user))', attributes=MINIMAL_USERATTRIBUTES, paged_size=500, generator=False)
219 else:
220 self.connection.extend.standard.paged_search('%s' % (self.root), '(&(objectCategory=person)(objectClass=user))', attributes=ldap3.ALL_ATTRIBUTES, paged_size=500, generator=False)
221 return self.connection.entries
222
223 #Get all computers in the domain
224 def getAllComputers(self):
225 if self.config.minimal:
226 self.connection.extend.standard.paged_search('%s' % (self.root), '(&(objectClass=computer)(objectClass=user))', attributes=MINIMAL_COMPUTERATTRIBUTES, paged_size=500, generator=False)
227 else:
228 self.connection.extend.standard.paged_search('%s' % (self.root), '(&(objectClass=computer)(objectClass=user))', attributes=ldap3.ALL_ATTRIBUTES, paged_size=500, generator=False)
229 return self.connection.entries
230
231 #Get all user SPNs
232 def getAllUserSpns(self):
233 if self.config.minimal:
234 self.connection.extend.standard.paged_search('%s' % (self.root), '(&(objectCategory=person)(objectClass=user)(servicePrincipalName=*))', attributes=MINIMAL_USERATTRIBUTES, paged_size=500, generator=False)
235 else:
236 self.connection.extend.standard.paged_search('%s' % (self.root), '(&(objectCategory=person)(objectClass=user)(servicePrincipalName=*))', attributes=ldap3.ALL_ATTRIBUTES, paged_size=500, generator=False)
237 return self.connection.entries
238
239 #Get all defined groups
240 def getAllGroups(self):
241 if self.config.minimal:
242 self.connection.extend.standard.paged_search(self.root, '(objectClass=group)', attributes=MINIMAL_GROUPATTRIBUTES, paged_size=500, generator=False)
243 else:
244 self.connection.extend.standard.paged_search(self.root, '(objectClass=group)', attributes=ldap3.ALL_ATTRIBUTES, paged_size=500, generator=False)
245 return self.connection.entries
246
247 #Get the domain policies (such as lockout policy)
248 def getDomainPolicy(self):
249 self.connection.search(self.root, '(objectClass=domain)', attributes=ldap3.ALL_ATTRIBUTES)
250 return self.connection.entries
251
252 #Get domain trusts
253 def getTrusts(self):
254 self.connection.search(self.root, '(objectClass=trustedDomain)', attributes=ldap3.ALL_ATTRIBUTES)
255 return self.connection.entries
256
257 #Get all defined security groups
258 #Syntax from:
259 #https://ldapwiki.willeke.com/wiki/Active%20Directory%20Group%20Related%20Searches
260 def getAllSecurityGroups(self):
261 self.connection.search(self.root, '(groupType:1.2.840.113556.1.4.803:=2147483648)', attributes=ldap3.ALL_ATTRIBUTES)
262 return self.connection.entries
263
264 #Get the SID of the root object
265 def getRootSid(self):
266 self.connection.search(self.root, '(objectClass=domain)', attributes=['objectSid'])
267 try:
268 sid = self.connection.entries[0].objectSid
269 except (LDAPAttributeError, LDAPCursorError, IndexError):
270 return False
271 return sid
272
273 #Get group members recursively using LDAP_MATCHING_RULE_IN_CHAIN (1.2.840.113556.1.4.1941)
274 def getRecursiveGroupmembers(self, groupdn):
275 self.connection.extend.standard.paged_search(self.root, '(&(objectCategory=person)(objectClass=user)(memberOf:1.2.840.113556.1.4.1941:=%s))' % groupdn, attributes=MINIMAL_USERATTRIBUTES, paged_size=500, generator=False)
276 return self.connection.entries
277
278 #Resolve group ID to DN
279 def getGroupDNfromID(self, domainsid, gid):
280 self.connection.search(self.root, '(objectSid=%s-%d)' % (domainsid, gid), attributes=['distinguishedName'])
281 return self.connection.entries[0]['distinguishedName'].value
282
283 #Get Domain Admins group DN
284 def getDAGroupDN(self, domainsid):
285 return self.getGroupDNfromID(domainsid, 512)
286
287 #Get Enterprise Admins group DN
288 def getEAGroupDN(self, domainsid):
289 try:
290 return self.getGroupDNfromID(domainsid, 519)
291 except (LDAPAttributeError, LDAPCursorError, IndexError):
292 #This does not exist, could be in a parent domain
293 return False
294
295
296 #Lookup all computer DNS names to get their IP
297 def lookupComputerDnsNames(self):
298 dnsresolver = dns.resolver.Resolver()
299 dnsresolver.lifetime = 2
300 ipdef = attrDef.AttrDef('ipv4')
301 if self.config.dnsserver != '':
302 dnsresolver.nameservers = [self.config.dnsserver]
303 for computer in self.computers:
304 try:
305 answers = dnsresolver.query(computer.dNSHostName.values[0], 'A')
306 ip = str(answers.response.answer[0][0])
307 except dns.resolver.NXDOMAIN:
308 ip = 'error.NXDOMAIN'
309 except dns.resolver.Timeout:
310 ip = 'error.TIMEOUT'
311 except (LDAPAttributeError, LDAPCursorError):
312 ip = 'error.NOHOSTNAME'
313 #Construct a custom attribute as workaround
314 ipatt = attribute.Attribute(ipdef, computer, None)
315 ipatt.__dict__['_response'] = ip
316 ipatt.__dict__['raw_values'] = [ip]
317 ipatt.__dict__['values'] = [ip]
318 #Add the attribute to the entry's dictionary
319 computer._state.attributes['IPv4'] = ipatt
320
321 #Create a dictionary of all operating systems with the computer accounts that are associated
322 def sortComputersByOS(self, items):
323 osdict = {}
324 for computer in items:
325 try:
326 cos = computer.operatingSystem.value or 'Unknown'
327 except (LDAPAttributeError, LDAPCursorError):
328 cos = 'Unknown'
329 try:
330 osdict[cos].append(computer)
331 except KeyError:
332 #New OS
333 osdict[cos] = [computer]
334 return osdict
335
336 #Map all groups on their ID (taken from their SID) to CNs
337 #This is used for getting the primary group of a user
338 def mapGroupsIdsToDns(self):
339 dnmap = {}
340 for group in self.groups:
341 gid = int(group.objectSid.value.split('-')[-1])
342 dnmap[gid] = group.distinguishedName.values[0]
343 self.groups_dnmap = dnmap
344 return dnmap
345
346 #Create a dictionary where a groups CN returns the full object
347 def createGroupsDictByCn(self):
348 gdict = {grp.cn.values[0]:grp for grp in self.groups}
349 self.groups_dict = gdict
350 return gdict
351
352 #Get CN from DN
353 def getGroupCnFromDn(self, dnin):
354 cn = self.unescapecn(dn.parse_dn(dnin)[0][1])
355 return cn
356
357 #Unescape special DN characters from a CN (only needed if it comes from a DN)
358 def unescapecn(self, cn):
359 for c in ' "#+,;<=>\\\00':
360 cn = cn.replace('\\'+c, c)
361 return cn
362
363 #Sort users by group they belong to
364 def sortUsersByGroup(self, items):
365 groupsdict = {}
366 #Make sure the group CN mapping already exists
367 if self.groups_dnmap is None:
368 self.mapGroupsIdsToDns()
369 for user in items:
370 try:
371 ugroups = [self.getGroupCnFromDn(group) for group in user.memberOf.values]
372 #If the user is only in the default group, its memberOf property wont exist
373 except (LDAPAttributeError, LDAPCursorError):
374 ugroups = []
375 #Add the user default group
376 ugroups.append(self.getGroupCnFromDn(self.groups_dnmap[user.primaryGroupId.value]))
377 for group in ugroups:
378 try:
379 groupsdict[group].append(user)
380 except KeyError:
381 #Group is not yet in dict
382 groupsdict[group] = [user]
383
384 #Append any groups that are members of groups
385 for group in self.groups:
386 try:
387 for parentgroup in group.memberOf.values:
388 try:
389 groupsdict[self.getGroupCnFromDn(parentgroup)].append(group)
390 except KeyError:
391 #Group is not yet in dict
392 groupsdict[self.getGroupCnFromDn(parentgroup)] = [group]
393 #Without subgroups this attribute does not exist
394 except (LDAPAttributeError, LDAPCursorError):
395 pass
396
397 return groupsdict
398
399 #Main function
400 def domainDump(self):
401 self.users = self.getAllUsers()
402 self.computers = self.getAllComputers()
403 self.groups = self.getAllGroups()
404 if self.config.lookuphostnames:
405 self.lookupComputerDnsNames()
406 self.policy = self.getDomainPolicy()
407 self.trusts = self.getTrusts()
408 rw = reportWriter(self.config)
409 rw.generateUsersReport(self)
410 rw.generateGroupsReport(self)
411 rw.generateComputersReport(self)
412 rw.generatePolicyReport(self)
413 rw.generateTrustsReport(self)
414 rw.generateComputersByOsReport(self)
415 rw.generateUsersByGroupReport(self)
416
417 class reportWriter(object):
418 def __init__(self, config):
419 self.config = config
420 self.dd = None
421 if self.config.lookuphostnames:
422 self.computerattributes = ['cn', 'sAMAccountName', 'dNSHostName', 'IPv4', 'operatingSystem', 'operatingSystemServicePack', 'operatingSystemVersion', 'lastLogon', 'userAccountControl', 'whenCreated', 'objectSid', 'description']
423 else:
424 self.computerattributes = ['cn', 'sAMAccountName', 'dNSHostName', 'operatingSystem', 'operatingSystemServicePack', 'operatingSystemVersion', 'lastLogon', 'userAccountControl', 'whenCreated', 'objectSid', 'description']
425 self.userattributes = ['cn', 'name', 'sAMAccountName', 'memberOf', 'primaryGroupId', 'whenCreated', 'whenChanged', 'lastLogon', 'userAccountControl', 'pwdLastSet', 'objectSid', 'description']
426 #In grouped view, don't include the memberOf property to reduce output size
427 self.userattributes_grouped = ['cn', 'name', 'sAMAccountName', 'whenCreated', 'whenChanged', 'lastLogon', 'userAccountControl', 'pwdLastSet', 'objectSid', 'description']
428 self.groupattributes = ['cn', 'sAMAccountName', 'memberOf', 'description', 'whenCreated', 'whenChanged', 'objectSid']
429 self.policyattributes = ['cn', 'lockOutObservationWindow', 'lockoutDuration', 'lockoutThreshold', 'maxPwdAge', 'minPwdAge', 'minPwdLength', 'pwdHistoryLength', 'pwdProperties']
430 self.trustattributes = ['cn', 'flatName', 'securityIdentifier', 'trustAttributes', 'trustDirection', 'trustType']
431
432 #Escape HTML special chars
433 def htmlescape(self, html):
434 return (html.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("'", "&#39;").replace('"', "&quot;"))
435
436 #Unescape special DN characters from a CN (only needed if it comes from a DN)
437 def unescapecn(self, cn):
438 for c in ' "#+,;<=>\\\00':
439 cn = cn.replace('\\'+c, c)
440 return cn
441
442 #Convert password max age (in 100 nanoseconds), to days
443 def nsToDays(self, length):
444 return abs(length) * .0000001 / 86400
445
446 def nsToMinutes(self, length):
447 return abs(length) * .0000001 / 60
448
449 #Parse bitwise flags into a list
450 def parseFlags(self, attr, flags_def):
451 outflags = []
452 if attr is None:
453 return outflags
454 for flag, val in flags_def.items():
455 if attr.value & val:
456 outflags.append(flag)
457 return outflags
458
459 #Parse bitwise trust direction - only one flag applies here, 0x03 overlaps
460 def parseTrustDirection(self, attr, flags_def):
461 outflags = []
462 if attr is None:
463 return outflags
464 for flag, val in flags_def.items():
465 if attr.value == val:
466 outflags.append(flag)
467 return outflags
468
469 #Generate a HTML table from a list of entries, with the specified attributes as column
470 def generateHtmlTable(self, listable, attributes, header='', firstTable=True, specialGroupsFormat=False):
471 of = []
472 #Only if this is the first table it is an actual table, the others are just bodies of the first table
473 #This makes sure that multiple tables have their columns aligned to make it less messy
474 if firstTable:
475 of.append(u'<table>')
476 #Table header
477 if header != '':
478 of.append(u'<thead><tr><td colspan="%d" id="cn_%s">%s</td></tr></thead>' % (len(attributes), self.formatId(header), header))
479 of.append(u'<tbody><tr>')
480 for hdr in attributes:
481 try:
482 #Print alias of this attribute if there is one
483 of.append(u'<th>%s</th>' % self.htmlescape(attr_translations[hdr]))
484 except KeyError:
485 of.append(u'<th>%s</th>' % self.htmlescape(hdr))
486 of.append(u'</tr>\n')
487 for li in listable:
488 #Whether we should format group objects separately
489 if specialGroupsFormat and 'group' in li['objectClass'].values:
490 #Give it an extra class and pass it to the function below to make sure the CN is a link
491 liIsGroup = True
492 of.append(u'<tr class="group">')
493 else:
494 liIsGroup = False
495 of.append(u'<tr>')
496 for att in attributes:
497 try:
498 of.append(u'<td>%s</td>' % self.formatAttribute(li[att], liIsGroup))
499 except (LDAPKeyError, LDAPCursorError):
500 of.append(u'<td>&nbsp;</td>')
501 of.append(u'</tr>\n')
502 of.append(u'</tbody>\n')
503 return u''.join(of)
504
505 #Generate several HTML tables for grouped reports
506 def generateGroupedHtmlTables(self, groups, attributes):
507 first = True
508 for groupname, members in groups.iteritems():
509 yield self.generateHtmlTable(members, attributes, groupname, first, specialGroupsFormat=True)
510 if first:
511 first = False
512
513 #Write generated HTML to file
514 def writeHtmlFile(self, rel_outfile, body, genfunc=None, genargs=None, closeTable=True):
515 if not os.path.exists(self.config.basepath):
516 os.makedirs(self.config.basepath)
517 outfile = os.path.join(self.config.basepath, rel_outfile)
518 with codecs.open(outfile, 'w', 'utf8') as of:
519 of.write('<!DOCTYPE html>\n<html>\n<head><meta charset="UTF-8">')
520 #Include the style
521 try:
522 with open(os.path.join(os.path.dirname(__file__), 'style.css'), 'r') as sf:
523 of.write('<style type="text/css">')
524 of.write(sf.read())
525 of.write('</style>')
526 except IOError:
527 log_warn('style.css not found in package directory, styling will be skipped')
528 of.write('</head><body>')
529 #If the generator is not specified, we should write the HTML blob directly
530 if genfunc is None:
531 of.write(body)
532 else:
533 for tpart in genfunc(*genargs):
534 of.write(tpart)
535 #Does the body contain an open table?
536 if closeTable:
537 of.write('</table>')
538 of.write('</body></html>')
539
540 #Write generated JSON to file
541 def writeJsonFile(self, rel_outfile, jsondata, genfunc=None, genargs=None):
542 if not os.path.exists(self.config.basepath):
543 os.makedirs(self.config.basepath)
544 outfile = os.path.join(self.config.basepath, rel_outfile)
545 with codecs.open(outfile, 'w', 'utf8') as of:
546 #If the generator is not specified, we should write the JSON blob directly
547 if genfunc is None:
548 of.write(jsondata)
549 else:
550 for jpart in genfunc(*genargs):
551 of.write(jpart)
552
553 #Write generated Greppable stuff to file
554 def writeGrepFile(self, rel_outfile, body):
555 if not os.path.exists(self.config.basepath):
556 os.makedirs(self.config.basepath)
557 outfile = os.path.join(self.config.basepath, rel_outfile)
558 with codecs.open(outfile, 'w', 'utf8') as of:
559 of.write(body)
560
561 #Format a value for HTML
562 def formatString(self, value):
563 if type(value) is datetime:
564 try:
565 return value.strftime('%x %X')
566 except ValueError:
567 #Invalid date
568 return u'0'
569 if type(value) is unicode:
570 return value#.encode('utf8')
571 if type(value) is str:
572 return unicode(value, errors='replace')#.encode('utf8')
573 if type(value) is int:
574 return unicode(value)
575 if value is None:
576 return ''
577 #Other type: just return it
578 return value
579
580 #Format an attribute to a human readable format
581 def formatAttribute(self, att, formatCnAsGroup=False):
582 aname = att.key.lower()
583 #User flags
584 if aname == 'useraccountcontrol':
585 return ', '.join(self.parseFlags(att, uac_flags))
586 #List of groups
587 if aname == 'member' or aname == 'memberof' and type(att.values) is list:
588 return self.formatGroupsHtml(att.values)
589 #Primary group
590 if aname == 'primarygroupid':
591 return self.formatGroupsHtml([self.dd.groups_dnmap[att.value]])
592 #Pwd flags
593 if aname == 'pwdproperties':
594 return ', '.join(self.parseFlags(att, pwd_flags))
595 #Domain trust flags
596 if aname == 'trustattributes':
597 return ', '.join(self.parseFlags(att, trust_flags))
598 if aname == 'trustdirection':
599 if att.value == 0:
600 return 'DISABLED'
601 else:
602 return ', '.join(self.parseTrustDirection(att, trust_directions))
603 if aname == 'trusttype':
604 return ', '.join(self.parseFlags(att, trust_type))
605 if aname == 'securityidentifier':
606 return format_sid(att.raw_values[0])
607 if aname == 'minpwdage' or aname == 'maxpwdage':
608 return '%.2f days' % self.nsToDays(att.value)
609 if aname == 'lockoutobservationwindow' or aname == 'lockoutduration':
610 return '%.1f minutes' % self.nsToMinutes(att.value)
611 if aname == 'objectsid':
612 return '<abbr title="%s">%s</abbr>' % (att.value, att.value.split('-')[-1])
613 #Special case where the attribute is a CN and it should be made clear its a group
614 if aname == 'cn' and formatCnAsGroup:
615 return self.formatCnWithGroupLink(att.value)
616 #Other
617 return self.htmlescape(self.formatString(att.value))
618
619
620 def formatCnWithGroupLink(self, cn):
621 return u'Group: <a href="#cn_%s" title="%s">%s</a>' % (self.formatId(cn), self.htmlescape(cn), self.htmlescape(cn))
622
623 #Convert a CN to a valid HTML id by replacing all non-ascii characters with a _
624 def formatId(self, cn):
625 return re.sub(r'[^a-zA-Z0-9_\-]+', '_', cn)
626
627 #Format groups to readable HTML
628 def formatGroupsHtml(self, grouplist):
629 outcache = []
630 for group in grouplist:
631 cn = self.unescapecn(dn.parse_dn(group)[0][1])
632 outcache.append(u'<a href="%s.html#cn_%s" title="%s">%s</a>' % (self.config.users_by_group, quote_plus(self.formatId(cn)), self.htmlescape(group), self.htmlescape(cn)))
633 return ', '.join(outcache)
634
635 #Format groups to readable HTML
636 def formatGroupsGrep(self, grouplist):
637 outcache = []
638 for group in grouplist:
639 cn = self.unescapecn(dn.parse_dn(group)[0][1])
640 outcache.append(cn)
641 return ', '.join(outcache)
642
643 #Format attribute for grepping
644 def formatGrepAttribute(self, att):
645 aname = att.key.lower()
646 #User flags
647 if aname == 'useraccountcontrol':
648 return ', '.join(self.parseFlags(att, uac_flags))
649 #List of groups
650 if aname == 'member' or aname == 'memberof' and type(att.values) is list:
651 return self.formatGroupsGrep(att.values)
652 if aname == 'primarygroupid':
653 return self.formatGroupsGrep([self.dd.groups_dnmap[att.value]])
654 #Domain trust flags
655 if aname == 'trustattributes':
656 return ', '.join(self.parseFlags(att, trust_flags))
657 if aname == 'trustdirection':
658 if att.value == 0:
659 return 'DISABLED'
660 else:
661 return ', '.join(self.parseTrustDirection(att, trust_directions))
662 if aname == 'trusttype':
663 return ', '.join(self.parseFlags(att, trust_type))
664 if aname == 'securityidentifier':
665 return format_sid(att.raw_values[0])
666 #Pwd flags
667 if aname == 'pwdproperties':
668 return ', '.join(self.parseFlags(att, pwd_flags))
669 if aname == 'minpwdage' or aname == 'maxpwdage':
670 return '%.2f days' % self.nsToDays(att.value)
671 if aname == 'lockoutobservationwindow' or aname == 'lockoutduration':
672 return '%.1f minutes' % self.nsToMinutes(att.value)
673 return self.formatString(att.value)
674
675 #Generate grep/awk/cut-able output
676 def generateGrepList(self, entrylist, attributes):
677 hdr = self.config.grepsplitchar.join(attributes)
678 out = [hdr]
679 for entry in entrylist:
680 eo = []
681 for attr in attributes:
682 try:
683 eo.append(self.formatGrepAttribute(entry[attr]) or '')
684 except (LDAPKeyError, LDAPCursorError):
685 eo.append('')
686 out.append(self.config.grepsplitchar.join(eo))
687 return '\n'.join(out)
688
689 #Convert a list of entities to a JSON string
690 #String concatenation is used here since the entities have their own json generate
691 #method and converting the string back to json just to process it would be inefficient
692 def generateJsonList(self, entrylist):
693 out = '[' + ','.join([entry.entry_to_json() for entry in entrylist]) + ']'
694 return out
695
696 #Convert a group key/value pair to json
697 #Same methods as previous function are used
698 def generateJsonGroup(self, group):
699 out = '{%s:%s}' % (json.dumps(group[0]), self.generateJsonList(group[1]))
700 return out
701
702 #Convert a list of group dicts with entry lists to JSON string
703 #Same methods as previous functions are used, except that text is returned
704 #from a generator rather than allocating everything in memory
705 def generateJsonGroupedList(self, groups):
706 #Start of the list
707 yield '['
708 firstGroup = True
709 for group in groups.iteritems():
710 if not firstGroup:
711 #Separate items
712 yield ','
713 else:
714 firstGroup = False
715 yield self.generateJsonGroup(group)
716 yield ']'
717
718 #Generate report of all computers grouped by OS family
719 def generateComputersByOsReport(self, dd):
720 grouped = dd.sortComputersByOS(dd.computers)
721 if self.config.outputhtml:
722 #Use the generator approach to save memory
723 self.writeHtmlFile('%s.html' % self.config.computers_by_os, None, genfunc=self.generateGroupedHtmlTables, genargs=(grouped, self.computerattributes))
724 if self.config.outputjson and self.config.groupedjson:
725 self.writeJsonFile('%s.json' % self.config.computers_by_os, None, genfunc=self.generateJsonGroupedList, genargs=(grouped, ))
726
727 #Generate report of all groups and detailled user info
728 def generateUsersByGroupReport(self, dd):
729 grouped = dd.sortUsersByGroup(dd.users)
730 if self.config.outputhtml:
731 #Use the generator approach to save memory
732 self.writeHtmlFile('%s.html' % self.config.users_by_group, None, genfunc=self.generateGroupedHtmlTables, genargs=(grouped, self.userattributes_grouped))
733 if self.config.outputjson and self.config.groupedjson:
734 self.writeJsonFile('%s.json' % self.config.users_by_group, None, genfunc=self.generateJsonGroupedList, genargs=(grouped, ))
735
736 #Generate report with just a table of all users
737 def generateUsersReport(self, dd):
738 #Copy dd to this object, to be able to reference it
739 self.dd = dd
740 dd.mapGroupsIdsToDns()
741 if self.config.outputhtml:
742 html = self.generateHtmlTable(dd.users, self.userattributes, 'Domain users')
743 self.writeHtmlFile('%s.html' % self.config.usersfile, html)
744 if self.config.outputjson:
745 jsonout = self.generateJsonList(dd.users)
746 self.writeJsonFile('%s.json' % self.config.usersfile, jsonout)
747 if self.config.outputgrep:
748 grepout = self.generateGrepList(dd.users, self.userattributes)
749 self.writeGrepFile('%s.grep' % self.config.usersfile, grepout)
750
751 #Generate report with just a table of all computer accounts
752 def generateComputersReport(self, dd):
753 if self.config.outputhtml:
754 html = self.generateHtmlTable(dd.computers, self.computerattributes, 'Domain computer accounts')
755 self.writeHtmlFile('%s.html' % self.config.computersfile, html)
756 if self.config.outputjson:
757 jsonout = self.generateJsonList(dd.computers)
758 self.writeJsonFile('%s.json' % self.config.computersfile, jsonout)
759 if self.config.outputgrep:
760 grepout = self.generateGrepList(dd.computers, self.computerattributes)
761 self.writeGrepFile('%s.grep' % self.config.computersfile, grepout)
762
763 #Generate report with just a table of all computer accounts
764 def generateGroupsReport(self, dd):
765 if self.config.outputhtml:
766 html = self.generateHtmlTable(dd.groups, self.groupattributes, 'Domain groups')
767 self.writeHtmlFile('%s.html' % self.config.groupsfile, html)
768 if self.config.outputjson:
769 jsonout = self.generateJsonList(dd.groups)
770 self.writeJsonFile('%s.json' % self.config.groupsfile, jsonout)
771 if self.config.outputgrep:
772 grepout = self.generateGrepList(dd.groups, self.groupattributes)
773 self.writeGrepFile('%s.grep' % self.config.groupsfile, grepout)
774
775 #Generate policy report
776 def generatePolicyReport(self, dd):
777 if self.config.outputhtml:
778 html = self.generateHtmlTable(dd.policy, self.policyattributes, 'Domain policy')
779 self.writeHtmlFile('%s.html' % self.config.policyfile, html)
780 if self.config.outputjson:
781 jsonout = self.generateJsonList(dd.policy)
782 self.writeJsonFile('%s.json' % self.config.policyfile, jsonout)
783 if self.config.outputgrep:
784 grepout = self.generateGrepList(dd.policy, self.policyattributes)
785 self.writeGrepFile('%s.grep' % self.config.policyfile, grepout)
786
787 #Generate policy report
788 def generateTrustsReport(self, dd):
789 if self.config.outputhtml:
790 html = self.generateHtmlTable(dd.trusts, self.trustattributes, 'Domain trusts')
791 self.writeHtmlFile('%s.html' % self.config.trustsfile, html)
792 if self.config.outputjson:
793 jsonout = self.generateJsonList(dd.trusts)
794 self.writeJsonFile('%s.json' % self.config.trustsfile, jsonout)
795 if self.config.outputgrep:
796 grepout = self.generateGrepList(dd.trusts, self.trustattributes)
797 self.writeGrepFile('%s.grep' % self.config.trustsfile, grepout)
798
799 #Some quick logging helpers
800 def log_warn(text):
801 print '[!] %s' % text
802 def log_info(text):
803 print '[*] %s' % text
804 def log_success(text):
805 print '[+] %s' % text
806
807 def main():
808 parser = argparse.ArgumentParser(description='Domain information dumper via LDAP. Dumps users/computers/groups and OS/membership information to HTML/JSON/greppable output.')
809 parser._optionals.title = "Main options"
810 parser._positionals.title = "Required options"
811
812 #Main parameters
813 #maingroup = parser.add_argument_group("Main options")
814 parser.add_argument("host", type=str, metavar='HOSTNAME', help="Hostname/ip or ldap://host:port connection string to connect to (use ldaps:// to use SSL)")
815 parser.add_argument("-u", "--user", type=str, metavar='USERNAME', help="DOMAIN\\username for authentication, leave empty for anonymous authentication")
816 parser.add_argument("-p", "--password", type=str, metavar='PASSWORD', help="Password or LM:NTLM hash, will prompt if not specified")
817 parser.add_argument("-at", "--authtype", type=str, choices=['NTLM', 'SIMPLE'], default='NTLM', help="Authentication type (NTLM or SIMPLE, default: NTLM)")
818
819 #Output parameters
820 outputgroup = parser.add_argument_group("Output options")
821 outputgroup.add_argument("-o", "--outdir", type=str, metavar='DIRECTORY', help="Directory in which the dump will be saved (default: current)")
822 outputgroup.add_argument("--no-html", action='store_true', help="Disable HTML output")
823 outputgroup.add_argument("--no-json", action='store_true', help="Disable JSON output")
824 outputgroup.add_argument("--no-grep", action='store_true', help="Disable Greppable output")
825 outputgroup.add_argument("--grouped-json", action='store_true', default=False, help="Also write json files for grouped files (default: disabled)")
826 outputgroup.add_argument("-d", "--delimiter", help="Field delimiter for greppable output (default: tab)")
827
828 #Additional options
829 miscgroup = parser.add_argument_group("Misc options")
830 miscgroup.add_argument("-r", "--resolve", action='store_true', help="Resolve computer hostnames (might take a while and cause high traffic on large networks)")
831 miscgroup.add_argument("-n", "--dns-server", help="Use custom DNS resolver instead of system DNS (try a domain controller IP)")
832 miscgroup.add_argument("-m", "--minimal", action='store_true', default=False, help="Only query minimal set of attributes to limit memmory usage")
833
834 args = parser.parse_args()
835 #Create default config
836 cnf = domainDumpConfig()
837 #Dns lookups?
838 if args.resolve:
839 cnf.lookuphostnames = True
840 #Custom dns server?
841 if args.dns_server is not None:
842 cnf.dnsserver = args.dns_server
843 #Minimal attributes?
844 if args.minimal:
845 cnf.minimal = True
846 #Custom separator?
847 if args.delimiter is not None:
848 cnf.grepsplitchar = args.delimiter
849 #Disable html?
850 if args.no_html:
851 cnf.outputhtml = False
852 #Disable json?
853 if args.no_json:
854 cnf.outputjson = False
855 #Disable grep?
856 if args.no_grep:
857 cnf.outputgrep = False
858 #Custom outdir?
859 if args.outdir is not None:
860 cnf.basepath = args.outdir
861 #Do we really need grouped json files?
862 cnf.groupedjson = args.grouped_json
863
864 #Prompt for password if not set
865 authentication = None
866 if args.user is not None:
867 if args.authtype == 'SIMPLE':
868 authentication = 'SIMPLE'
869 else:
870 authentication = NTLM
871 if not '\\' in args.user:
872 log_warn('Username must include a domain, use: DOMAIN\\username')
873 sys.exit(1)
874 if args.password is None:
875 args.password = getpass.getpass()
876 else:
877 log_info('Connecting as anonymous user, dumping will probably fail. Consider specifying a username/password to login with')
878 # define the server and the connection
879 s = Server(args.host, get_info=ALL)
880 log_info('Connecting to host...')
881 c = Connection(s, user=args.user, password=args.password, authentication=authentication)
882 log_info('Binding to host')
883 # perform the Bind operation
884 if not c.bind():
885 log_warn('Could not bind with specified credentials')
886 log_warn(c.result)
887 sys.exit(1)
888 log_success('Bind OK')
889 log_info('Starting domain dump')
890 #Create domaindumper object
891 dd = domainDumper(s, c, cnf)
892
893 #Do the actual dumping
894 dd.domainDump()
895 log_success('Domain dump finished')
896
897 if __name__ == '__main__':
898 main()
0 #!/usr/bin/env python
1 import ldapdomaindump
2 ldapdomaindump.main()
0 import argparse
1 import os
2 import logging
3 import json
4 import codecs
5 import re
6 from ldapdomaindump import trust_flags, trust_directions
7
8 logging.basicConfig()
9 logger = logging.getLogger('ldd2bloodhound')
10
11 class Utils(object):
12 @staticmethod
13 def ldap_to_domain(ldap):
14 return re.sub(',DC=', '.', ldap[ldap.find('DC='):], flags=re.I)[3:]
15
16 @staticmethod
17 def get_group_object(groupo, domain):
18 return {
19 'dn': groupo['dn'],
20 'sid': groupo['attributes']['objectSid'][0],
21 'type': 'group',
22 'principal': '%s@%s' % (groupo['attributes']['sAMAccountName'][0].upper(), domain.upper()),
23 'memberOf': groupo['attributes']['memberOf'] if 'memberOf' in groupo['attributes'] else []
24 }
25
26 class BloodHoundConverter(object):
27 def __init__(self):
28 # Input files
29 self.computers_files = []
30 self.trust_files = []
31 self.group_files = []
32 self.user_files = []
33
34 # Caches
35 self.groups_by_dn = {}
36 self.groups_by_sid = {}
37 self.domaincache = {}
38
39 # Get domain from sid and dn - use cache if possible
40 def get_domain(self, sid, dn):
41 dsid = sid.rsplit('-', 1)[0]
42 try:
43 return self.domaincache[dsid]
44 except KeyError:
45 self.domaincache[dsid] = Utils.ldap_to_domain(dn)
46 return self.domaincache[dsid]
47
48 def build_mappings(self):
49 # Parse groups, build DN and SID index
50 for file in self.group_files:
51 with codecs.open(file, 'r', 'utf-8') as infile:
52 data = json.load(infile)
53 # data is now a list of groups (objects)
54 for group in data:
55 groupattrs = Utils.get_group_object(group, self.get_domain(group['attributes']['objectSid'][0], group['dn']))
56 self.groups_by_dn[group['dn']] = groupattrs
57 self.groups_by_sid[group['attributes']['objectSid'][0]] = groupattrs
58 return
59
60 def write_users(self):
61 # Read user mapping - write to csv
62 with codecs.open('group_membership.csv', 'w', 'utf-8') as outfile:
63 outfile.write('GroupName,AccountName,AccountType\n')
64 for file in self.user_files:
65 with codecs.open(file, 'r', 'utf-8') as infile:
66 data = json.load(infile)
67 # data is now a list of users (objects)
68 for user in data:
69 self.write_entry_memberships(user, outfile)
70
71 def write_computers(self):
72 # Read computer mapping - write to csv
73 # file is already created here, we just append
74 with codecs.open('group_membership.csv', 'a', 'utf-8') as outfile:
75 for file in self.computers_files:
76 with codecs.open(file, 'r', 'utf-8') as infile:
77 data = json.load(infile)
78 # data is now a list of computers (objects)
79 for computer in data:
80 self.write_entry_memberships(computer, outfile, 'computer')
81
82 def write_groups(self):
83 # Read group mapping - write to csv
84 # file is already created here, we just append
85 with codecs.open('group_membership.csv', 'a', 'utf-8') as outfile:
86 for group in self.groups_by_dn.itervalues():
87 for membergroup in group['memberOf']:
88 try:
89 outfile.write('%s,%s,%s\n' % (self.groups_by_dn[membergroup]['principal'], group['principal'], 'group'))
90 except KeyError:
91 logger.warning('Unknown group %s. Not found in groups cache!', membergroup)
92
93 def write_trusts(self):
94 direction_map = {flag:meaning.capitalize() for meaning, flag in trust_directions.items()}
95 # open output file first
96 with codecs.open('trusts.csv', 'w', 'utf-8') as outfile:
97 outfile.write('SourceDomain,TargetDomain,TrustDirection,TrustType,Transitive\n')
98 for file in self.trust_files:
99 # load the trusts from file
100 with codecs.open(file, 'r', 'utf-8') as infile:
101 data = json.load(infile)
102 # data is now a list of trusts (objects)
103 for trust in data:
104 # process flags similar to BloodHound.py
105 flags = trust['attributes']['trustAttributes'][0]
106 if flags & trust_flags['WITHIN_FOREST']:
107 trustType = 'ParentChild'
108 else:
109 trustType = 'External'
110 if flags & trust_flags['NON_TRANSITIVE']:
111 isTransitive = False
112 else:
113 isTransitive = True
114 out = [
115 Utils.ldap_to_domain(trust['dn']),
116 trust['attributes']['name'][0],
117 direction_map[trust['attributes']['trustDirection'][0]],
118 trustType,
119 str(isTransitive)
120 ]
121 outfile.write(','.join(out) + '\n')
122
123 def write_entry_memberships(self, entry, outfile, entry_type='user'):
124 domain = self.get_domain(entry['attributes']['objectSid'][0], entry['dn'])
125 if entry_type == 'user':
126 principal = '%s@%s' % (entry['attributes']['sAMAccountName'][0].upper(), domain.upper())
127 else:
128 principal = '%s.%s' % (entry['attributes']['sAMAccountName'][0][:-1].upper(), domain.upper())
129 if 'memberOf' in entry['attributes']:
130 for group in entry['attributes']['memberOf']:
131 try:
132 rgroup = self.groups_by_dn[group]
133 outfile.write('%s,%s,%s\n' % (rgroup['principal'], principal, entry_type))
134 except KeyError:
135 logger.warning('Unknown group %s. Not found in groups cache!', group)
136 # Now process primary group id
137 dsid = entry['attributes']['objectSid'][0].rsplit('-', 1)[0]
138 try:
139 rgroup = self.groups_by_sid['%s-%d' % (dsid, entry['attributes']['primaryGroupID'][0])]
140 outfile.write('%s,%s,%s\n' % (rgroup['principal'], principal, entry_type))
141 except KeyError:
142 logger.warning('Unknown rid %d. Not found in groups cache!', entry['attributes']['primaryGroupID'][0])
143
144 def parse_files(self, infiles):
145 filemap = {
146 'domain_users.json': self.user_files,
147 'domain_groups.json': self.group_files,
148 'domain_trusts.json': self.trust_files,
149 'domain_computers.json': self.computers_files,
150 }
151 for file in infiles:
152 # Get the filename
153 filename = file.split(os.sep)[-1]
154 try:
155 filemap[filename.lower()].append(file)
156 except KeyError:
157 logger.debug('Unknown input file: %s', filename)
158 return
159
160 def ldd2bloodhound():
161 parser = argparse.ArgumentParser(description='LDAPDomainDump to BloodHound CSV converter utility. Supports users/computers/trusts conversion.')
162
163 #Main parameters
164 parser.add_argument("files", type=str, nargs='+', metavar='FILENAME', help="The ldapdomaindump json files to load. Required files: domain_users.json and domain_groups.json")
165 parser.add_argument("-d", "--debug", action='store_true', help="Enable debug logger")
166
167 args = parser.parse_args()
168 if args.debug:
169 logger.setLevel(logging.DEBUG)
170
171 converter = BloodHoundConverter()
172 converter.parse_files(args.files)
173 if len(converter.group_files) == 0:
174 logger.error('No domain_groups.json files were specified. Need at least one to perform conversion.')
175 return
176 if len(converter.user_files) == 0:
177 logger.error('No domain_users.json files were specified. Need at least one to perform conversion.')
178 return
179 logger.debug('Mapping groups...')
180 converter.build_mappings()
181 logger.debug('Processing users')
182 converter.write_users()
183 logger.debug('Processing groups')
184 converter.write_groups()
185 logger.debug('Processing computers')
186 converter.write_computers()
187 logger.debug('Processing trusts')
188 converter.write_trusts()
189 print('Done!')
190
191 if __name__ == '__main__':
192 ldd2bloodhound()
0 tbody th {
1 border: 1px solid #000;
2 }
3 tbody td {
4 border: 1px solid #ababab;
5 border-spacing: 0px;
6 padding: 4px;
7 border-collapse: collapse;
8 }
9 body {
10 font-family: verdana;
11 }
12 table {
13 font-size: 13px;
14 border-collapse: collapse;
15 width: 100%;
16 }
17 tbody tr:nth-child(odd) td {
18 background-color: #eee;
19 }
20 tbody tr:hover td {
21 background-color: lightblue;
22 }
23 thead td {
24 font-size: 19px;
25 font-weight: bold;
26 padding: 10px 0px;
27 }
0 #!/usr/bin/env python
1 import ldapdomaindump
2 ldapdomaindump.main()
0 from setuptools import setup
1 setup(name='ldapdomaindump',
2 version='0.8.7',
3 description='Active Directory information dumper via LDAP',
4 author='Dirk-jan Mollema',
5 author_email='[email protected]',
6 url='https://github.com/dirkjanm/ldapdomaindump/',
7 packages=['ldapdomaindump'],
8 install_requires=['dnspython','ldap3>=2.0'],
9 package_data={'ldapdomaindump': ['style.css']},
10 include_package_data=True,
11 scripts=['bin/ldapdomaindump', 'bin/ldd2bloodhound']
12 )