From f0cbbdb123e79a7477251322efc41ff7257c5e51 Mon Sep 17 00:00:00 2001 From: pavvka Date: Sun, 11 Jan 2026 10:42:58 -0500 Subject: [PATCH 1/3] Add delegation enumeration module. --- nxc/modules/enum_delegation.py | 162 +++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100755 nxc/modules/enum_delegation.py diff --git a/nxc/modules/enum_delegation.py b/nxc/modules/enum_delegation.py new file mode 100755 index 0000000000..8ac04e6dde --- /dev/null +++ b/nxc/modules/enum_delegation.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from impacket.dcerpc.v5.samr import ( + UF_TRUSTED_FOR_DELEGATION, + UF_TRUSTED_TO_AUTHENTICATE_FOR_DELEGATION +) +from impacket.ldap import ldapasn1, ldaptypes + +class NXCModule: + """ + Enumerates all Active Directory delegation types: + - Unconstrained Delegation + - Constrained Delegation (with / without protocol transition) + - Resource-Based Constrained Delegation (RBCD) + + NOTE: The current version of the RBCD search only checks for msDS-AllowedToActOnBehalfOfOtherIdentity attribute. + It does NOT enumerate ACLs that could allow configuring RBCD. + + To find principals that can ENABLE RBCD via ACLs, use this BloodHound query: + + MATCH (n:User) + MATCH p=allShortestPaths((n)-[r:WriteAccountRestrictions|GenericAll|GenericWrite|Owns|WriteDacl]->(m:Computer)) + WHERE n.owned + RETURN p + + Module by @pavel-usatenko + + Resources: + - Inspired by HTB Academy: Kerberos Attacks + - https://github.com/fortra/impacket/blob/master/examples/findDelegation.py + - https://www.r-tec.net/r-tec-blog-resource-based-constrained-delegation.html + + """ + + name = "enum_delegation" + description = "Enumerate Unconstrained, Constrained and Resource-Based Constrained Delegation" + supported_protocols = ["ldap"] + opsec_safe = True + multiple_hosts = False + + def sid_to_name(self, ldap_conn, base_dn, sid): + try: + res = ldap_conn.search( + searchBase=base_dn, + searchFilter=f"(objectSid={sid})", + attributes=["sAMAccountName"] + ) + for item in res: + if isinstance(item, ldapasn1.SearchResultEntry): + for attr in item["attributes"]: + if str(attr["type"]) == "sAMAccountName": + return str(attr["vals"][0]) + except Exception: + pass + return sid # fallback + + def options(self, context, module_options): + pass + + def on_login(self, context, connection): + ldap_conn = connection.ldap_connection + base_dn = connection.baseDN + + context.log.highlight( + "NOTE:\nRBCD enumeration only checks the " + "msDS-AllowedToActOnBehalfOfOtherIdentity attribute.\n" + "It does NOT enumerate ACLs that could allow configuring RBCD.\n\n" + "To find principals that can ENABLE RBCD via ACLs, use this BloodHound query:\n\n" + "MATCH (n:User) " + "MATCH p=allShortestPaths((n)-[r:WriteAccountRestrictions|GenericAll|GenericWrite|Owns|WriteDacl]->(m:Computer)) " + "WHERE n.owned " + "RETURN p" + ) + + search_filter = ( + "(&(|" + "(userAccountControl:1.2.840.113556.1.4.803:=524288)" + "(userAccountControl:1.2.840.113556.1.4.803:=16777216)" + "(msDS-AllowedToDelegateTo=*)" + "(msDS-AllowedToActOnBehalfOfOtherIdentity=*)" + ")" + "(!(userAccountControl:1.2.840.113556.1.4.803:=2)))" + ) + + attributes = [ + "sAMAccountName", + "userAccountControl", + "objectCategory", + "msDS-AllowedToDelegateTo", + "msDS-AllowedToActOnBehalfOfOtherIdentity", + ] + + context.log.info("Enumerating delegation settings via LDAP") + + try: + resp = ldap_conn.search( + searchBase=base_dn, + searchFilter=search_filter, + attributes=attributes, + sizeLimit=999 + ) + except Exception as e: + context.log.error(f"LDAP search failed: {e}") + return + + found = False + + for item in resp: + if not isinstance(item, ldapasn1.SearchResultEntry): + continue + + sam = None + uac = 0 + obj_type = "Unknown" + delegation = None + rights = [] + + for attr in item["attributes"]: + name = str(attr["type"]) + + if name == "sAMAccountName": + sam = str(attr["vals"][0]) + + elif name == "objectCategory": + obj_type = str(attr["vals"][0]).split("=")[1].split(",")[0] + + elif name == "userAccountControl": + uac = int(attr["vals"][0]) + + if uac & UF_TRUSTED_FOR_DELEGATION: + delegation = "Unconstrained" + rights.append("N/A") + + elif uac & UF_TRUSTED_TO_AUTHENTICATE_FOR_DELEGATION: + delegation = "Constrained (Protocol Transition)" + + elif name == "msDS-AllowedToDelegateTo": + if delegation is None: + delegation = "Constrained (No Protocol Transition)" + for val in attr["vals"]: + rights.append(str(val)) + + elif name == "msDS-AllowedToActOnBehalfOfOtherIdentity": + sd = ldaptypes.SR_SECURITY_DESCRIPTOR(data=bytes(attr["vals"][0])) + for ace in sd["Dacl"].aces: + sid = ace["Ace"]["Sid"].formatCanonical() + name = self.sid_to_name(ldap_conn, base_dn, sid) + context.log.highlight( + f"[RBCD] {sam} ({obj_type}) ← {name}" + ) + found = True + + if delegation and sam: + for r in rights: + context.log.highlight( + f"[{delegation}] {sam} ({obj_type}) → {r}" + ) + found = True + + if not found: + context.log.info("No delegation entries found") From d809ea1f7be42db3084919855539c1058d5c615a Mon Sep 17 00:00:00 2001 From: pavvka Date: Sun, 11 Jan 2026 11:32:02 -0500 Subject: [PATCH 2/3] Add e2e test. --- tests/e2e_commands.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/e2e_commands.txt b/tests/e2e_commands.txt index a574c906bc..414aa0c7af 100644 --- a/tests/e2e_commands.txt +++ b/tests/e2e_commands.txt @@ -233,6 +233,7 @@ netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M user-de netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M whoami netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M dump-computers netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M raisechild +netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M enum_delegation ##### WINRM netexec winrm TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS # need an extra space after this command due to regex netexec winrm TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -X ipconfig @@ -319,4 +320,4 @@ netexec ftp TARGET_HOST -u TEST_USER_FILE -p TEST_PASSWORD_FILE netexec nfs TARGET_HOST -u "" -p "" --shares netexec nfs TARGET_HOST -u "" -p "" --enum-shares netexec nfs TARGET_HOST -u "" -p "" --get-file /NFStest/test/test.txt ../test.txt -netexec nfs TARGET_HOST -u "" -p "" --put-file ../test.txt /NFStest/test \ No newline at end of file +netexec nfs TARGET_HOST -u "" -p "" --put-file ../test.txt /NFStest/test From d9fd7645c077446574b65999b3ae08a377e74d3b Mon Sep 17 00:00:00 2001 From: pavvka Date: Sun, 11 Jan 2026 11:40:10 -0500 Subject: [PATCH 3/3] Add comments. --- nxc/modules/enum_delegation.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/nxc/modules/enum_delegation.py b/nxc/modules/enum_delegation.py index 8ac04e6dde..9b26bee247 100755 --- a/nxc/modules/enum_delegation.py +++ b/nxc/modules/enum_delegation.py @@ -72,6 +72,12 @@ def on_login(self, context, connection): "WHERE n.owned " "RETURN p" ) + + # LDAP filter covering: + # - Unconstrained delegation + # - Constrained delegation (with and without protocol transition) + # - Resource-Based Constrained Delegation (RBCD) + # Disabled accounts are explicitly excluded search_filter = ( "(&(|" @@ -83,6 +89,7 @@ def on_login(self, context, connection): "(!(userAccountControl:1.2.840.113556.1.4.803:=2)))" ) + # Only attributes required for delegation detection are requested attributes = [ "sAMAccountName", "userAccountControl", @@ -123,9 +130,11 @@ def on_login(self, context, connection): sam = str(attr["vals"][0]) elif name == "objectCategory": + # Extract object type (User / Computer) from DN-style value obj_type = str(attr["vals"][0]).split("=")[1].split(",")[0] elif name == "userAccountControl": + # Delegation flags are stored as UAC bitmasks uac = int(attr["vals"][0]) if uac & UF_TRUSTED_FOR_DELEGATION: @@ -136,12 +145,14 @@ def on_login(self, context, connection): delegation = "Constrained (Protocol Transition)" elif name == "msDS-AllowedToDelegateTo": + # Constrained delegation SPNs if delegation is None: delegation = "Constrained (No Protocol Transition)" for val in attr["vals"]: rights.append(str(val)) elif name == "msDS-AllowedToActOnBehalfOfOtherIdentity": + # RBCD is stored as a security descriptor containing allowed SIDs sd = ldaptypes.SR_SECURITY_DESCRIPTOR(data=bytes(attr["vals"][0])) for ace in sd["Dacl"].aces: sid = ace["Ace"]["Sid"].formatCanonical() @@ -151,6 +162,7 @@ def on_login(self, context, connection): ) found = True + # Output classic delegation results if delegation and sam: for r in rights: context.log.highlight(