1+ #!/usr/bin/env python3
2+ #
3+ # Enumerates which attributes of every AD user are writable by the bound account
4+ # and writes results into Users.csv. Usage:
5+ #
6+ # python3 CheckWritableAttributesADUsers.py DOMAIN/mcontestabile:'XXX' -dc-ip 1.2.3.4
7+ #
8+ # Notes:
9+ # - Uses ldap3. Install with: pip3 install ldap3 colorama
10+ # - The script is conservative: it skips well-known operational or dangerous
11+ # attributes that should not be modified (see SKIP_ATTRS).
12+ # - Only users where attributes can be retrieved are processed.
13+ # - For each attribute the script attempts a MODIFY_REPLACE to "test" and then
14+ # restores the original value(s). Multi-valued attributes are handled.
15+ # - Output CSV (Users.csv) contains two columns: sAMAccountName,WritableAttributes
16+ # where WritableAttributes is a semicolon-separated list of attribute names.
17+ #
18+
19+ import argparse
20+ import csv
21+ import sys
22+ import time
23+ from ldap3 import Server , Connection , ALL , SUBTREE , MODIFY_REPLACE , ALL_ATTRIBUTES
24+ from colorama import Fore , Style , init
25+
26+ init (autoreset = True )
27+
28+ # Attributes we should not attempt to modify (operational, binary, or dangerous)
29+ SKIP_ATTRS = {
30+ 'objectGUID' ,
31+ 'objectSid' ,
32+ 'nTSecurityDescriptor' ,
33+ 'sAMAccountType' ,
34+ 'userAccountControl' ,
35+ 'unicodePwd' , # requires SSL and special modify
36+ 'pwdLastSet' ,
37+ 'badPwdCount' ,
38+ 'badPasswordTime' ,
39+ 'lastLogon' ,
40+ 'lastLogonTimestamp' ,
41+ 'logonCount' ,
42+ 'msDS-UserPasswordExpiryTimeComputed' ,
43+ 'directReports' ,
44+ 'memberOf' ,
45+ 'objectClass' ,
46+ 'whenCreated' ,
47+ 'whenChanged' ,
48+ 'uSNCreated' ,
49+ 'uSNChanged' ,
50+ 'uSNReceived' ,
51+ 'replPropertyMetaData' ,
52+ 'replUpToDateVector' ,
53+ 'msDS-ReplValueMetaData' ,
54+ 'msDS-ReplAttributeMetaData' ,
55+ 'msDS-ConsistencyGuid' ,
56+ }
57+
58+ def parse_identity (identity ):
59+ # DOMAIN/user:password
60+ if '/' not in identity :
61+ raise ValueError ("target must be DOMAIN/user:password" )
62+ domain , rest = identity .split ('/' , 1 )
63+ if ':' in rest :
64+ user , password = rest .split (':' , 1 )
65+ else :
66+ user , password = rest , ''
67+ # Build UPN (assumes domain is DNS-style, e.g. domainame.net)
68+ if '.' in domain :
69+ upn = f"{ user } @{ domain } "
70+ else :
71+ upn = f"{ user } @{ domain .lower ()} .net"
72+ return domain , user , password , upn
73+
74+ def safe_restore (conn , dn , attr , original ):
75+ """Restore attribute original value. original may be None, single value, or list."""
76+ try :
77+ if original is None :
78+ # clear attribute
79+ conn .modify (dn , {attr : [(MODIFY_REPLACE , [])]})
80+ else :
81+ if isinstance (original , (list , tuple )):
82+ conn .modify (dn , {attr : [(MODIFY_REPLACE , list (original ))]})
83+ else :
84+ conn .modify (dn , {attr : [(MODIFY_REPLACE , [original ])]})
85+ return conn .result ['result' ] == 0
86+ except Exception :
87+ return False
88+
89+ def attempt_write_attribute (conn , dn , attr ):
90+ """Attempt to write a safe test value to attr and restore. Return True if writable."""
91+ # small test token; avoid overly long values
92+ test_value = "test"
93+ try :
94+ # perform replace with single-element test value
95+ conn .modify (dn , {attr : [(MODIFY_REPLACE , [test_value ])]})
96+ if conn .result ['result' ] == 0 :
97+ return True
98+ else :
99+ return False
100+ except Exception :
101+ return False
102+
103+ def main ():
104+ parser = argparse .ArgumentParser (description = "Check writable AD attributes for all users" )
105+ parser .add_argument ('target' , help = 'DOMAIN/user:password' )
106+ parser .add_argument ('-dc-ip' , required = True , help = 'Domain controller IP or hostname' )
107+ parser .add_argument ('-out' , default = 'Users.csv' , help = 'Output CSV filename (default: Users.csv)' )
108+ parser .add_argument ('--page-size' , type = int , default = 1000 , help = 'LDAP page size for user enumeration' )
109+ args = parser .parse_args ()
110+
111+ try :
112+ domain , bind_user , bind_password , upn = parse_identity (args .target )
113+ except Exception as e :
114+ print (Fore .RED + f"[-] Error parsing identity: { e } " )
115+ sys .exit (1 )
116+
117+ server = Server (args .dc_ip , get_info = ALL )
118+ try :
119+ conn = Connection (server , user = upn , password = bind_password , auto_bind = True )
120+ except Exception as e :
121+ print (Fore .RED + f"[-] Failed to bind: { e } " )
122+ sys .exit (1 )
123+
124+ try :
125+ base_dn = conn .server .info .other ['defaultNamingContext' ][0 ]
126+ except Exception as e :
127+ print (Fore .RED + f"[-] Unable to determine base DN from server info: { e } " )
128+ sys .exit (1 )
129+
130+ print (Fore .CYAN + f"[*] Bound as { upn } ; base DN: { base_dn } " )
131+ print (Fore .CYAN + "[*] Enumerating users..." )
132+
133+ # LDAP filter for user objects (sAMAccountName present)
134+ user_filter = '(&(objectCategory=person)(objectClass=user)(sAMAccountName=*))'
135+
136+ # Request ALL_ATTRIBUTES so we can iterate attributes
137+ try :
138+ # paged search to retrieve all users
139+ conn .search (search_base = base_dn ,
140+ search_filter = user_filter ,
141+ search_scope = SUBTREE ,
142+ attributes = ALL_ATTRIBUTES ,
143+ paged_size = args .page_size )
144+ except Exception as e :
145+ print (Fore .RED + f"[-] Search failed: { e } " )
146+ conn .unbind ()
147+ sys .exit (1 )
148+
149+ entries = conn .entries
150+ if not entries :
151+ print (Fore .YELLOW + "[-] No users found or insufficient permissions to enumerate users." )
152+ conn .unbind ()
153+ sys .exit (1 )
154+
155+ print (Fore .GREEN + f"[*] Retrieved { len (entries )} user entries (first page). Note: ldap3 may have more pages to iterate manually if required." )
156+
157+ # ldap3's Connection.search with paged_size returns only first page unless we iterate with cookie.
158+ # For robust enumeration, use the generator method below to walk all pages.
159+ all_users = []
160+
161+ # Re-run enumeration using paged search loop to ensure we get all users
162+ cookie = None
163+ try :
164+ while True :
165+ conn .search (search_base = base_dn ,
166+ search_filter = user_filter ,
167+ search_scope = SUBTREE ,
168+ attributes = ALL_ATTRIBUTES ,
169+ paged_size = args .page_size ,
170+ paged_cookie = cookie )
171+ all_users .extend (conn .entries )
172+ cookie = conn .result .get ('controls' , {}).get ('1.2.840.113556.1.4.319' , {}).get ('value' , {}).get ('cookie' , None )
173+ # ldap3 may return cookie as bytes or b''; treat both
174+ if not cookie :
175+ break
176+ except Exception as e :
177+ print (Fore .RED + f"[-] Paged search failed: { e } " )
178+ conn .unbind ()
179+ sys .exit (1 )
180+
181+ print (Fore .GREEN + f"[*] Total users enumerated: { len (all_users )} " )
182+
183+ # Prepare CSV
184+ out_file = args .out
185+ csvfh = open (out_file , 'w' , newline = '' , encoding = 'utf-8' )
186+ csv_writer = csv .writer (csvfh )
187+ csv_writer .writerow (['sAMAccountName' , 'distinguishedName' , 'WritableAttributes' ]) # header
188+
189+ processed = 0
190+ skipped = 0
191+
192+ for entry in all_users :
193+ try :
194+ sam = None
195+ try :
196+ sam = str (entry .sAMAccountName ) if 'sAMAccountName' in entry else None
197+ except Exception :
198+ sam = None
199+
200+ if not sam :
201+ skipped += 1
202+ continue
203+
204+ dn = entry .entry_dn
205+ print (Style .BRIGHT + f"\n [*] Processing user: { sam } DN: { dn } " )
206+
207+ # Retrieve fresh attributes for this user so we have current values
208+ try :
209+ conn .search (search_base = dn , search_filter = '(objectClass=*)' , attributes = ALL_ATTRIBUTES )
210+ if not conn .entries :
211+ print (Fore .YELLOW + f"[-] Could not retrieve attributes for { sam } ; skipping." )
212+ skipped += 1
213+ continue
214+ user_entry = conn .entries [0 ]
215+ except Exception as e :
216+ print (Fore .RED + f"[-] Search for user attributes failed for { sam } : { e } ; skipping." )
217+ skipped += 1
218+ continue
219+
220+ writable_attrs = []
221+
222+ # iterate attributes present in the entry
223+ attrs = list (user_entry .entry_attributes )
224+ for attr in attrs :
225+ if attr in SKIP_ATTRS :
226+ # skip known operational/unsafe attributes
227+ continue
228+
229+ # read original value; could be None, single value, or list
230+ try :
231+ original = user_entry [attr ].value
232+ except Exception :
233+ original = None
234+
235+ # attempt write only if attribute is not empty or is writable candidate
236+ # We'll attempt regardless (except SKIP_ATTRS) but catch failures
237+ try :
238+ success = False
239+ # Attempt write
240+ if attempt_write_attribute (conn , dn , attr ):
241+ # restore original
242+ restored = safe_restore (conn , dn , attr , original )
243+ if not restored :
244+ print (Fore .YELLOW + f"[!] Warning: wrote attr { attr } on { sam } but failed to restore reliably." )
245+ success = True
246+ if success :
247+ writable_attrs .append (attr )
248+ print (Fore .GREEN + f"[+] Writable: { attr } " )
249+ else :
250+ print (Fore .RED + f"[-] Not writable: { attr } " )
251+ except Exception as e :
252+ print (Fore .RED + f"[-] Exception while testing { attr } : { e } " )
253+
254+ if writable_attrs :
255+ csv_writer .writerow ([sam , dn , ';' .join (writable_attrs )])
256+ processed += 1
257+ else :
258+ # Per requirement: "No need to include users which attributes failed to be retrieved."
259+ # If no writable attributes found, still include user with empty list? User requested "no need" so skip
260+ processed += 1
261+
262+ # small sleep to avoid hammering DC too quickly
263+ time .sleep (0.05 )
264+
265+ except KeyboardInterrupt :
266+ print (Fore .YELLOW + "\n [!] Interrupted by user." )
267+ break
268+ except Exception as e :
269+ print (Fore .RED + f"[-] Unexpected error processing an entry: { e } " )
270+ skipped += 1
271+ continue
272+
273+ csvfh .close ()
274+ conn .unbind ()
275+
276+ print ("\n " + Style .BRIGHT + "=== Done ===" )
277+ print (Fore .GREEN + f"Users processed: { processed } " )
278+ print (Fore .YELLOW + f"Users skipped: { skipped } " )
279+ print (Fore .CYAN + f"CSV written to: { out_file } " )
280+
281+ if __name__ == '__main__' :
282+ main ()
0 commit comments