|
| 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 | + def options(self, context, module_options): |
| 97 | + """NO_LDAP If set to True, disables LDAP queries for resolving SIDs.""" |
| 98 | + self.no_ldap = module_options.get("NO_LDAP", False) |
| 99 | + |
| 100 | + def on_login(self, context, connection): |
| 101 | + try: |
| 102 | + connection.conn.listPath("SYSVOL", "*") |
| 103 | + except Exception as e: |
| 104 | + context.log.fail(f"Failed to list shares: {e}") |
| 105 | + return |
| 106 | + |
| 107 | + context.log.display("Searching for GptTmpl.inf files") |
| 108 | + paths = connection.spider("SYSVOL", pattern=["GptTmpl.inf"]) |
| 109 | + |
| 110 | + if not paths: |
| 111 | + context.log.warning("No GptTmpl.inf files found in SYSVOL.") |
| 112 | + return |
| 113 | + |
| 114 | + for path in paths: |
| 115 | + if "6AC1786C-016F-11D2-945F-00C04fB984F9" in path: # Default Domain Policy |
| 116 | + context.log.success(f"Found Default Domain Policy GptTmpl.inf: {path}") |
| 117 | + else: |
| 118 | + context.log.info(f"Found GptTmpl.inf: {path}") |
| 119 | + |
| 120 | + buf = BytesIO() |
| 121 | + connection.conn.getFile("SYSVOL", path, buf.write) |
| 122 | + |
| 123 | + try: |
| 124 | + content = buf.getvalue().decode("utf-16le") |
| 125 | + except UnicodeDecodeError as e: |
| 126 | + context.log.error(f"Failed to decode {path} as UTF-16LE: {e}") |
| 127 | + continue |
| 128 | + |
| 129 | + privileges = self.extract_privileges(content) |
| 130 | + if privileges: |
| 131 | + ldap_connection = None |
| 132 | + if not self.no_ldap: |
| 133 | + ldap_connection = self.initialize_ldap_connection(context, connection) |
| 134 | + |
| 135 | + context.log.success(f"Privileges extracted from {path}:") |
| 136 | + for privilege, sids in privileges.items(): |
| 137 | + resolved_sids = [self.resolve_sid(context, sid, ldap_connection) for sid in sids] |
| 138 | + context.log.highlight(f"{privilege}: {', '.join(resolved_sids)}") |
| 139 | + |
| 140 | + if ldap_connection: |
| 141 | + ldap_connection.unbind() |
| 142 | + |
| 143 | + def extract_privileges(self, content): |
| 144 | + """Parses the content of GptTmpl.inf to extract privilege rights.""" |
| 145 | + privileges = {} |
| 146 | + in_priv_section = False |
| 147 | + |
| 148 | + for line in content.splitlines(): |
| 149 | + if line.strip() == "[Privilege Rights]": |
| 150 | + in_priv_section = True |
| 151 | + continue |
| 152 | + if in_priv_section and line.strip() == "": |
| 153 | + break |
| 154 | + if in_priv_section: |
| 155 | + match = re.match(r"^(.*?)\s*=\s*(.*)$", line) |
| 156 | + if match: |
| 157 | + privilege, sids = match.groups() |
| 158 | + privileges[privilege] = [sid.strip("*") for sid in sids.split(",")] |
| 159 | + |
| 160 | + return privileges |
| 161 | + |
| 162 | + def initialize_ldap_connection(self, context, connection): |
| 163 | + """ |
| 164 | + Initializes an LDAP connection using LDAP3 with LDAPS first, then falls back to plaintext LDAP if LDAPS fails. |
| 165 | + Attempts to retrieve the base DN from the Root DSE or derive it from the domain name. |
| 166 | + """ |
| 167 | + tls = ldap3.Tls(validate=ssl.CERT_NONE, version=ssl.PROTOCOL_TLSv1_2, ciphers="ALL:@SECLEVEL=0") |
| 168 | + base_dn = None |
| 169 | + |
| 170 | + try: |
| 171 | + ldap_server = ldap3.Server(connection.host, use_ssl=True, port=636, tls=tls, get_info=ldap3.ALL) |
| 172 | + ldap_connection = ldap3.Connection( |
| 173 | + ldap_server, |
| 174 | + user=f"{connection.domain}\\{connection.username}", |
| 175 | + password=connection.password, |
| 176 | + authentication=ldap3.NTLM, |
| 177 | + raise_exceptions=True, |
| 178 | + ) |
| 179 | + ldap_connection.bind() |
| 180 | + context.log.success("Connected to LDAP over SSL (LDAPS).") |
| 181 | + |
| 182 | + try: |
| 183 | + base_dn = ldap_server.info.other.get("defaultNamingContext", [None])[0] |
| 184 | + except Exception as e: |
| 185 | + context.log.warning(f"Failed to query Root DSE for defaultNamingContext over plaintext LDAP: {e}") |
| 186 | + |
| 187 | + if not base_dn: |
| 188 | + domain_parts = connection.domain.split(".") |
| 189 | + base_dn = ",".join([f"dc={part}" for part in domain_parts]) |
| 190 | + context.log.info(f"Derived base DN: {base_dn}") |
| 191 | + |
| 192 | + ldap_connection.base_dn = base_dn |
| 193 | + return ldap_connection |
| 194 | + |
| 195 | + except Exception as ldaps_error: |
| 196 | + context.log.warning(f"LDAPS connection failed: {ldaps_error}") |
| 197 | + context.log.info("Falling back to plain LDAP...") |
| 198 | + |
| 199 | + try: |
| 200 | + ldap_server = ldap3.Server(connection.host, use_ssl=False, port=389, get_info=ldap3.ALL) |
| 201 | + ldap_connection = ldap3.Connection( |
| 202 | + ldap_server, |
| 203 | + user=f"{connection.domain}\\{connection.username}", |
| 204 | + password=connection.password, |
| 205 | + authentication=ldap3.NTLM, |
| 206 | + raise_exceptions=True, |
| 207 | + ) |
| 208 | + ldap_connection.bind() |
| 209 | + context.log.info("Connected to LDAP successfully (plaintext).") |
| 210 | + |
| 211 | + try: |
| 212 | + base_dn = ldap_server.info.other.get("defaultNamingContext", [None])[0] |
| 213 | + except Exception as e: |
| 214 | + context.log.warning(f"Failed to query Root DSE for defaultNamingContext over plaintext LDAP: {e}") |
| 215 | + |
| 216 | + if not base_dn: |
| 217 | + domain_parts = connection.domain.split(".") |
| 218 | + base_dn = ",".join([f"dc={part}" for part in domain_parts]) |
| 219 | + context.log.info(f"Derived base DN: {base_dn}") |
| 220 | + |
| 221 | + ldap_connection.base_dn = base_dn |
| 222 | + return ldap_connection |
| 223 | + |
| 224 | + except Exception as ldap_error: |
| 225 | + context.log.error(f"Failed to connect to LDAP: {ldap_error}") |
| 226 | + |
| 227 | + return None |
| 228 | + |
| 229 | + def resolve_sid(self, context, sid, ldap_connection): |
| 230 | + """Resolves a SID to a human-readable name using well-known mappings or LDAP queries.""" |
| 231 | + if sid in self.WELL_KNOWN_SIDS: |
| 232 | + return self.WELL_KNOWN_SIDS[sid] |
| 233 | + |
| 234 | + if ldap_connection and ldap_connection.bound: |
| 235 | + try: |
| 236 | + base_dn = getattr(ldap_connection, "base_dn", None) |
| 237 | + if not base_dn: |
| 238 | + context.log.warning(f"No base DN found for LDAP connection. Cannot resolve SID {sid}.") |
| 239 | + return sid |
| 240 | + |
| 241 | + search_filter = f"(objectSid={ldap3.utils.conv.escape_filter_chars(sid)})" |
| 242 | + ldap_connection.search( |
| 243 | + search_base=base_dn, |
| 244 | + search_filter=search_filter, |
| 245 | + attributes=["sAMAccountName"], |
| 246 | + ) |
| 247 | + |
| 248 | + if ldap_connection.entries: |
| 249 | + entry = ldap_connection.entries[0] |
| 250 | + return f"{entry['sAMAccountName']}" |
| 251 | + else: |
| 252 | + context.log.warning(f"SID {sid} not found in LDAP. Returning raw SID.") |
| 253 | + |
| 254 | + except Exception as e: |
| 255 | + context.log.error(f"Failed to resolve SID {sid} via LDAP: {e}") |
| 256 | + else: |
| 257 | + context.log.warning(f"LDAP connection not established or unbound. Returning raw SID: {sid}") |
| 258 | + |
| 259 | + return sid |
0 commit comments