1+ import ssl
2+ import ldap3
3+ import re
4+ from io import BytesIO
5+
6+
7+ class NXCModule :
8+ """
9+ Module to retrieve privileges assigned via Group Policy Objects (GPOs) by parsing GptTmpl.inf files
10+ and resolving SIDs using LDAP.
11+ """
12+
13+ name = "gpp_privileges"
14+ description = "Extracts privileges assigned via GPOs and resolves SIDs via LDAP."
15+ supported_protocols = ["smb" ]
16+ opsec_safe = True
17+ multiple_hosts = True
18+
19+ WELL_KNOWN_SIDS = {
20+ "S-1-0" : "Null Authority" ,
21+ "S-1-0-0" : "Nobody" ,
22+ "S-1-1" : "World Authority" ,
23+ "S-1-1-0" : "Everyone" ,
24+ "S-1-2" : "Local Authority" ,
25+ "S-1-2-0" : "Local" ,
26+ "S-1-2-1" : "Console Logon" ,
27+ "S-1-3" : "Creator Authority" ,
28+ "S-1-3-0" : "Creator Owner" ,
29+ "S-1-3-1" : "Creator Group" ,
30+ "S-1-3-2" : "Creator Owner Server" ,
31+ "S-1-3-3" : "Creator Group Server" ,
32+ "S-1-3-4" : "Owner Rights" ,
33+ "S-1-5-80-0" : "All Services" ,
34+ "S-1-4" : "Non-unique Authority" ,
35+ "S-1-5" : "NT Authority" ,
36+ "S-1-5-1" : "Dialup" ,
37+ "S-1-5-2" : "Network" ,
38+ "S-1-5-3" : "Batch" ,
39+ "S-1-5-4" : "Interactive" ,
40+ "S-1-5-6" : "Service" ,
41+ "S-1-5-7" : "Anonymous" ,
42+ "S-1-5-8" : "Proxy" ,
43+ "S-1-5-9" : "Enterprise Domain Controllers" ,
44+ "S-1-5-10" : "Principal Self" ,
45+ "S-1-5-11" : "Authenticated Users" ,
46+ "S-1-5-12" : "Restricted Code" ,
47+ "S-1-5-13" : "Terminal Server Users" ,
48+ "S-1-5-14" : "Remote Interactive Logon" ,
49+ "S-1-5-15" : "This Organization" ,
50+ "S-1-5-17" : "This Organization" ,
51+ "S-1-5-18" : "Local System" ,
52+ "S-1-5-19" : "NT Authority" ,
53+ "S-1-5-20" : "NT Authority" ,
54+ "S-1-5-32-544" : "Administrators" ,
55+ "S-1-5-32-545" : "Users" ,
56+ "S-1-5-32-546" : "Guests" ,
57+ "S-1-5-32-547" : "Power Users" ,
58+ "S-1-5-32-548" : "Account Operators" ,
59+ "S-1-5-32-549" : "Server Operators" ,
60+ "S-1-5-32-550" : "Print Operators" ,
61+ "S-1-5-32-551" : "Backup Operators" ,
62+ "S-1-5-32-552" : "Replicators" ,
63+ "S-1-5-64-10" : "NTLM Authentication" ,
64+ "S-1-5-64-14" : "SChannel Authentication" ,
65+ "S-1-5-64-21" : "Digest Authority" ,
66+ "S-1-5-80" : "NT Service" ,
67+ "S-1-5-83-0" : "NT VIRTUAL MACHINE\\ Virtual Machines" ,
68+ "S-1-16-0" : "Untrusted Mandatory Level" ,
69+ "S-1-16-4096" : "Low Mandatory Level" ,
70+ "S-1-16-8192" : "Medium Mandatory Level" ,
71+ "S-1-16-8448" : "Medium Plus Mandatory Level" ,
72+ "S-1-16-12288" : "High Mandatory Level" ,
73+ "S-1-16-16384" : "System Mandatory Level" ,
74+ "S-1-16-20480" : "Protected Process Mandatory Level" ,
75+ "S-1-16-28672" : "Secure Process Mandatory Level" ,
76+ "S-1-5-32-554" : "BUILTIN\\ Pre-Windows 2000 Compatible Access" ,
77+ "S-1-5-32-555" : "BUILTIN\\ Remote Desktop Users" ,
78+ "S-1-5-32-557" : "BUILTIN\\ Incoming Forest Trust Builders" ,
79+ "S-1-5-32-556" : "BUILTIN\\ Network Configuration Operators" ,
80+ "S-1-5-32-558" : "BUILTIN\\ Performance Monitor Users" ,
81+ "S-1-5-32-559" : "BUILTIN\\ Performance Log Users" ,
82+ "S-1-5-32-560" : "BUILTIN\\ Windows Authorization Access Group" ,
83+ "S-1-5-32-561" : "BUILTIN\\ Terminal Server License Servers" ,
84+ "S-1-5-32-562" : "BUILTIN\\ Distributed COM Users" ,
85+ "S-1-5-32-569" : "BUILTIN\\ Cryptographic Operators" ,
86+ "S-1-5-32-573" : "BUILTIN\\ Event Log Readers" ,
87+ "S-1-5-32-574" : "BUILTIN\\ Certificate Service DCOM Access" ,
88+ "S-1-5-32-575" : "BUILTIN\\ RDS Remote Access Servers" ,
89+ "S-1-5-32-576" : "BUILTIN\\ RDS Endpoint Servers" ,
90+ "S-1-5-32-577" : "BUILTIN\\ RDS Management Servers" ,
91+ "S-1-5-32-578" : "BUILTIN\\ Hyper-V Administrators" ,
92+ "S-1-5-32-579" : "BUILTIN\\ Access Control Assistance Operators" ,
93+ "S-1-5-32-580" : "BUILTIN\\ Remote Management Users" ,
94+ }
95+
96+
97+ def options (self , context , module_options ):
98+ """ """
99+ pass
100+
101+ def options (self , context , module_options ):
102+ """
103+ Define module options.
104+ - no_ldap: If set to True, disables LDAP queries for resolving SIDs.
105+ """
106+ self .no_ldap = module_options .get ("NO_LDAP" , False )
107+
108+ def on_login (self , context , connection ):
109+ shares = connection .shares ()
110+ for share in shares :
111+ if share ["name" ] == "SYSVOL" and "READ" in share ["access" ]:
112+ context .log .info ("Found SYSVOL share" )
113+ context .log .display ("Searching for GptTmpl.inf files" )
114+
115+ paths = connection .spider ("SYSVOL" , pattern = ["GptTmpl.inf" ])
116+
117+ if not paths :
118+ context .log .warning ("No GptTmpl.inf files found in SYSVOL." )
119+ return
120+
121+ for path in paths :
122+ if "6AC1786C-016F-11D2-945F-00C04fB984F9" in path : # Default Domain Policy
123+ context .log .success (f"Found Default Domain Policy GptTmpl.inf: { path } " )
124+ else :
125+ context .log .info (f"Found GptTmpl.inf: { path } " )
126+
127+ buf = BytesIO ()
128+ connection .conn .getFile ("SYSVOL" , path , buf .write )
129+
130+ try :
131+ content = buf .getvalue ().decode ("utf-16le" )
132+ except UnicodeDecodeError as e :
133+ context .log .error (f"Failed to decode { path } as UTF-16LE: { e } " )
134+ continue
135+
136+ privileges = self .extract_privileges (content )
137+ if privileges :
138+ ldap_connection = None
139+ if not self .no_ldap :
140+ ldap_connection = self .initialize_ldap_connection (context , connection )
141+
142+ context .log .success (f"Privileges extracted from { path } :" )
143+ for privilege , sids in privileges .items ():
144+ resolved_sids = [
145+ self .resolve_sid (context , sid , ldap_connection ) for sid in sids
146+ ]
147+ context .log .highlight (f"{ privilege } : { ', ' .join (resolved_sids )} " )
148+
149+ if ldap_connection :
150+ ldap_connection .unbind ()
151+
152+ def extract_privileges (self , content ):
153+ """Parses the content of GptTmpl.inf to extract privilege rights."""
154+ privileges = {}
155+ in_priv_section = False
156+
157+ for line in content .splitlines ():
158+ if line .strip () == "[Privilege Rights]" :
159+ in_priv_section = True
160+ continue
161+ if in_priv_section and line .strip () == "" :
162+ break
163+ if in_priv_section :
164+ match = re .match (r"^(.*?)\s*=\s*(.*)$" , line )
165+ if match :
166+ privilege , sids = match .groups ()
167+ privileges [privilege ] = [sid .strip ("*" ) for sid in sids .split ("," )]
168+
169+ return privileges
170+
171+ def initialize_ldap_connection (self , context , connection ):
172+ """
173+ Initializes an LDAP connection using LDAP3 with LDAPS first, then falls back to plaintext LDAP if LDAPS fails.
174+ Attempts to retrieve the base DN from the Root DSE or derive it from the domain name.
175+ """
176+ tls = ldap3 .Tls (validate = ssl .CERT_NONE , version = ssl .PROTOCOL_TLSv1_2 , ciphers = "ALL:@SECLEVEL=0" )
177+ base_dn = None
178+
179+ try :
180+ ldap_server = ldap3 .Server (connection .host , use_ssl = True , port = 636 , tls = tls )
181+ ldap_connection = ldap3 .Connection (
182+ ldap_server ,
183+ user = f"{ connection .domain } \\ { connection .username } " ,
184+ password = connection .password ,
185+ authentication = ldap3 .NTLM ,
186+ raise_exceptions = True ,
187+ )
188+ ldap_connection .bind ()
189+ context .log .success ("Connected to LDAP over SSL (LDAPS)." )
190+
191+ try :
192+ ldap_connection .search (
193+ search_base = "" ,
194+ search_filter = "(objectClass=*)" ,
195+ search_scope = ldap3 .BASE ,
196+ attributes = ["defaultNamingContext" ],
197+ )
198+ if ldap_connection .entries :
199+ base_dn = ldap_connection .entries [0 ]["defaultNamingContext" ].value
200+ context .log .success (f"Retrieved base DN over LDAPS: { base_dn } " )
201+ else :
202+ context .log .warning ("defaultNamingContext not found in Root DSE. Falling back to domain name derivation." )
203+ except Exception as e :
204+ context .log .warning (f"Failed to query Root DSE for defaultNamingContext over LDAPS: { e } " )
205+
206+ if not base_dn :
207+ domain_parts = connection .domain .split ("." )
208+ base_dn = "," .join ([f"dc={ part } " for part in domain_parts ])
209+ context .log .info (f"Derived base DN: { base_dn } " )
210+
211+ ldap_connection .base_dn = base_dn
212+ return ldap_connection
213+
214+ except Exception as ldaps_error :
215+ context .log .warning (f"LDAPS connection failed: { ldaps_error } " )
216+ context .log .info ("Falling back to plain LDAP..." )
217+
218+ try :
219+ ldap_server = ldap3 .Server (connection .host , use_ssl = False , port = 389 )
220+ ldap_connection = ldap3 .Connection (
221+ ldap_server ,
222+ user = f"{ connection .domain } \\ { connection .username } " ,
223+ password = connection .password ,
224+ authentication = ldap3 .NTLM ,
225+ raise_exceptions = True ,
226+ )
227+ ldap_connection .bind ()
228+ context .log .info ("Connected to LDAP successfully (plaintext)." )
229+
230+ try :
231+ ldap_connection .search (
232+ search_base = "" ,
233+ search_filter = "(objectClass=*)" ,
234+ search_scope = ldap3 .BASE ,
235+ attributes = ["defaultNamingContext" ],
236+ )
237+ if ldap_connection .entries :
238+ base_dn = ldap_connection .entries [0 ]["defaultNamingContext" ].value
239+ context .log .success (f"Retrieved base DN over plain LDAP: { base_dn } " )
240+ else :
241+ context .log .warning ("defaultNamingContext not found in Root DSE. Falling back to domain name derivation." )
242+ except Exception as e :
243+ context .log .warning (f"Failed to query Root DSE for defaultNamingContext over plain LDAP: { e } " )
244+
245+ if not base_dn :
246+ domain_parts = connection .domain .split ("." )
247+ base_dn = "," .join ([f"dc={ part } " for part in domain_parts ])
248+ context .log .info (f"Derived base DN: { base_dn } " )
249+
250+ ldap_connection .base_dn = base_dn
251+ return ldap_connection
252+
253+ except Exception as ldap_error :
254+ context .log .error (f"Failed to connect to LDAP: { ldap_error } " )
255+
256+ return None
257+
258+
259+
260+
261+ def resolve_sid (self , context , sid , ldap_connection ):
262+ """
263+ Resolves a SID to a human-readable name using well-known mappings or LDAP queries.
264+ """
265+ if sid in self .WELL_KNOWN_SIDS :
266+ return self .WELL_KNOWN_SIDS [sid ]
267+
268+ if ldap_connection and ldap_connection .bound :
269+ try :
270+ base_dn = getattr (ldap_connection , "base_dn" , None )
271+ if not base_dn :
272+ context .log .warning (f"No base DN found for LDAP connection. Cannot resolve SID { sid } ." )
273+ return sid
274+
275+ search_filter = f"(objectSid={ ldap3 .utils .conv .escape_filter_chars (sid )} )"
276+ ldap_connection .search (
277+ search_base = base_dn ,
278+ search_filter = search_filter ,
279+ attributes = ["sAMAccountName" ],
280+ )
281+
282+ if ldap_connection .entries :
283+ entry = ldap_connection .entries [0 ]
284+ return f"{ entry ['sAMAccountName' ]} "
285+ else :
286+ context .log .warning (f"SID { sid } not found in LDAP. Returning raw SID." )
287+
288+ except Exception as e :
289+ context .log .error (f"Failed to resolve SID { sid } via LDAP: { e } " )
290+ else :
291+ context .log .warning (f"LDAP connection not established or unbound. Returning raw SID: { sid } " )
292+
293+ return sid
294+
295+
0 commit comments