Skip to content

Commit 88ea1a9

Browse files
authored
Add files via upload
1 parent 0fce446 commit 88ea1a9

File tree

1 file changed

+282
-0
lines changed

1 file changed

+282
-0
lines changed
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
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

Comments
 (0)