From 5f9ebcefe018e68ed570e00ae9fa61929ad3df7d Mon Sep 17 00:00:00 2001 From: PvUL00 Date: Wed, 1 Apr 2026 18:10:45 +0200 Subject: [PATCH 1/3] Add USER option to maq module to enumerate per-user machine join count Adds a USER option that queries how many computers a given user has already joined to the domain, compared against the MachineAccountQuota. Detection covers both SAMR domain joins (ms-DS-CreatorSID) and direct LDAP creation such as addcomputer.py (nTSecurityDescriptor owner SID). --- nxc/modules/maq.py | 112 +++++++++++++++++++++++++++++++++++++++-- tests/e2e_commands.txt | 1 + 2 files changed, 109 insertions(+), 4 deletions(-) diff --git a/nxc/modules/maq.py b/nxc/modules/maq.py index 1ae4d04a02..15764b2dca 100644 --- a/nxc/modules/maq.py +++ b/nxc/modules/maq.py @@ -1,7 +1,35 @@ +import struct +from impacket.ldap import ldapasn1 as ldapasn1_impacket from nxc.helpers.misc import CATEGORY from nxc.parsers.ldap_results import parse_result_attributes +def _sid_to_str(sid): + try: + revision = int(sid[0]) + sub_authorities = int(sid[1]) + identifier_authority = int.from_bytes(sid[2:8], byteorder="big") + if identifier_authority >= 2**32: + identifier_authority = hex(identifier_authority) + sub_authority = "-" + "-".join( + [str(int.from_bytes(sid[8 + (i * 4): 12 + (i * 4)], byteorder="little")) for i in range(sub_authorities)] + ) + return "S-" + str(revision) + "-" + str(identifier_authority) + sub_authority + except Exception: + return sid + + +def _owner_sid_from_sd(sd_bytes): + """Extract the owner SID from a Windows security descriptor binary blob.""" + try: + offset_owner = struct.unpack("= len(sd_bytes): + return None + return _sid_to_str(sd_bytes[offset_owner:]) + except Exception: + return None + + class NXCModule: """ Module by Shutdown and Podalirius @@ -15,14 +43,18 @@ class NXCModule: Podalirius: @podalirius_ """ - def options(self, context, module_options): - """No options available""" - name = "maq" description = "Retrieves the MachineAccountQuota domain-level attribute" supported_protocols = ["ldap"] category = CATEGORY.ENUMERATION + def options(self, context, module_options): + """ + USER Username to check machine-join count for (default: authenticated user). + Omit to show MAQ only; supply any value to also enumerate joined machines. + """ + self.user = module_options.get("USER", None) + def on_login(self, context, connection): context.log.display("Getting the MachineAccountQuota") @@ -33,4 +65,76 @@ def on_login(self, context, connection): context.log.fail("No LDAP entries returned.") return - context.log.highlight(f"MachineAccountQuota: {entries[0]['ms-DS-MachineAccountQuota']}") + maq = int(entries[0]["ms-DS-MachineAccountQuota"]) + context.log.highlight(f"MachineAccountQuota: {maq}") + + if self.user is None: + return + + target_user = self.user if self.user else connection.username + if not target_user: + context.log.fail("Could not determine target username.") + return + + self._enum_user_machines(context, connection, target_user, maq) + + def _enum_user_machines(self, context, connection, username, maq): + context.log.display(f"Resolving SID for user: {username}") + + user_resp = connection.search(f"(sAMAccountName={username})", ["objectSid"]) + user_entries = parse_result_attributes(user_resp) + if not user_entries or "objectSid" not in user_entries[0]: + context.log.fail(f"Could not resolve SID for user: {username}") + return + + user_sid = user_entries[0]["objectSid"] + context.log.debug(f"User SID: {user_sid}") + + context.log.display("Querying computers...") + + # SD flags control: OWNER_SECURITY_INFORMATION = 1 + # Needed to retrieve nTSecurityDescriptor owner for LDAP-created accounts (addcomputer.py) + sd_control = ldapasn1_impacket.Control() + sd_control["controlType"] = "1.2.840.113556.1.4.801" + sd_control["criticality"] = False + sd_control["controlValue"] = b"\x30\x03\x02\x01\x01" + + paged_control = ldapasn1_impacket.SimplePagedResultsControl(criticality=True, size=1000) + + comp_resp = connection.ldap_connection.search( + searchBase=connection.baseDN, + searchFilter="(objectCategory=computer)", + attributes=["name", "ms-DS-CreatorSID", "nTSecurityDescriptor"], + sizeLimit=0, + searchControls=[paged_control, sd_control], + ) + computers = parse_result_attributes(comp_resp) + + user_computers = [] + for comp in computers: + matched = False + + # Method 1: ms-DS-CreatorSID — set by DC for SAMR domain joins + raw_sid = comp.get("ms-DS-CreatorSID") + if raw_sid is not None: + creator_sid = _sid_to_str(raw_sid) if isinstance(raw_sid, bytes) else raw_sid + matched = creator_sid == user_sid + + # Method 2: nTSecurityDescriptor owner — set for direct LDAP creation (addcomputer.py) + if not matched: + sd = comp.get("nTSecurityDescriptor") + if sd is not None and isinstance(sd, bytes): + matched = _owner_sid_from_sd(sd) == user_sid + + if matched: + user_computers.append(comp.get("name", "Unknown")) + + count = len(user_computers) + remaining = maq - count + + context.log.highlight(f"Machines joined by '{username}': {count}/{maq} (remaining quota: {remaining})") + if remaining <= 0: + context.log.fail(f"'{username}' has reached the MachineAccountQuota — cannot join additional machines!") + + for name in user_computers: + context.log.highlight(f" Computer: {name}") diff --git a/tests/e2e_commands.txt b/tests/e2e_commands.txt index c8c06dcdd9..caa34f01c0 100644 --- a/tests/e2e_commands.txt +++ b/tests/e2e_commands.txt @@ -229,6 +229,7 @@ netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M get-net netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M groupmembership -o USER=LOGIN_USERNAME netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M laps netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M maq +netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M maq -o USER=LOGIN_USERNAME netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M subnets netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M user-desc netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M whoami From 659aa5bf850db2bfd569fad321c4a013abbaa8c0 Mon Sep 17 00:00:00 2001 From: PvUL00 Date: Sun, 19 Apr 2026 16:12:17 +0200 Subject: [PATCH 2/3] Address review feedback on maq module --- nxc/modules/maq.py | 60 ++++++++++------------------------------------ 1 file changed, 13 insertions(+), 47 deletions(-) diff --git a/nxc/modules/maq.py b/nxc/modules/maq.py index 15764b2dca..fb42e90309 100644 --- a/nxc/modules/maq.py +++ b/nxc/modules/maq.py @@ -1,33 +1,7 @@ -import struct from impacket.ldap import ldapasn1 as ldapasn1_impacket +from impacket.ldap import ldaptypes from nxc.helpers.misc import CATEGORY -from nxc.parsers.ldap_results import parse_result_attributes - - -def _sid_to_str(sid): - try: - revision = int(sid[0]) - sub_authorities = int(sid[1]) - identifier_authority = int.from_bytes(sid[2:8], byteorder="big") - if identifier_authority >= 2**32: - identifier_authority = hex(identifier_authority) - sub_authority = "-" + "-".join( - [str(int.from_bytes(sid[8 + (i * 4): 12 + (i * 4)], byteorder="little")) for i in range(sub_authorities)] - ) - return "S-" + str(revision) + "-" + str(identifier_authority) + sub_authority - except Exception: - return sid - - -def _owner_sid_from_sd(sd_bytes): - """Extract the owner SID from a Windows security descriptor binary blob.""" - try: - offset_owner = struct.unpack("= len(sd_bytes): - return None - return _sid_to_str(sd_bytes[offset_owner:]) - except Exception: - return None +from nxc.parsers.ldap_results import parse_result_attributes, sid_to_str class NXCModule: @@ -50,8 +24,7 @@ class NXCModule: def options(self, context, module_options): """ - USER Username to check machine-join count for (default: authenticated user). - Omit to show MAQ only; supply any value to also enumerate joined machines. + USER Username to enumerate machine-join count for (optional) """ self.user = module_options.get("USER", None) @@ -65,25 +38,17 @@ def on_login(self, context, connection): context.log.fail("No LDAP entries returned.") return - maq = int(entries[0]["ms-DS-MachineAccountQuota"]) - context.log.highlight(f"MachineAccountQuota: {maq}") - - if self.user is None: - return - - target_user = self.user if self.user else connection.username - if not target_user: - context.log.fail("Could not determine target username.") - return + context.log.highlight(f"MachineAccountQuota: {entries[0]['ms-DS-MachineAccountQuota']}") - self._enum_user_machines(context, connection, target_user, maq) + if self.user: + self._enum_user_machines(context, connection, self.user, int(entries[0]["ms-DS-MachineAccountQuota"])) def _enum_user_machines(self, context, connection, username, maq): context.log.display(f"Resolving SID for user: {username}") user_resp = connection.search(f"(sAMAccountName={username})", ["objectSid"]) user_entries = parse_result_attributes(user_resp) - if not user_entries or "objectSid" not in user_entries[0]: + if not user_entries: context.log.fail(f"Could not resolve SID for user: {username}") return @@ -117,14 +82,15 @@ def _enum_user_machines(self, context, connection, username, maq): # Method 1: ms-DS-CreatorSID — set by DC for SAMR domain joins raw_sid = comp.get("ms-DS-CreatorSID") if raw_sid is not None: - creator_sid = _sid_to_str(raw_sid) if isinstance(raw_sid, bytes) else raw_sid - matched = creator_sid == user_sid + matched = sid_to_str(raw_sid) == user_sid # Method 2: nTSecurityDescriptor owner — set for direct LDAP creation (addcomputer.py) if not matched: - sd = comp.get("nTSecurityDescriptor") - if sd is not None and isinstance(sd, bytes): - matched = _owner_sid_from_sd(sd) == user_sid + raw_sd = comp.get("nTSecurityDescriptor") + if raw_sd is not None: + sd = ldaptypes.SR_SECURITY_DESCRIPTOR() + sd.fromString(raw_sd) + matched = sd["OwnerSid"].formatCanonical() == user_sid if matched: user_computers.append(comp.get("name", "Unknown")) From fc0f68e311da047e94d796fb33a9966f4b16ec42 Mon Sep 17 00:00:00 2001 From: PvUL00 Date: Sun, 19 Apr 2026 23:15:39 +0200 Subject: [PATCH 3/3] Address review feedback on parser --- nxc/modules/maq.py | 8 ++++---- nxc/parsers/ldap_results.py | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/nxc/modules/maq.py b/nxc/modules/maq.py index fb42e90309..ce554ca5dd 100644 --- a/nxc/modules/maq.py +++ b/nxc/modules/maq.py @@ -1,7 +1,7 @@ from impacket.ldap import ldapasn1 as ldapasn1_impacket from impacket.ldap import ldaptypes from nxc.helpers.misc import CATEGORY -from nxc.parsers.ldap_results import parse_result_attributes, sid_to_str +from nxc.parsers.ldap_results import parse_result_attributes class NXCModule: @@ -80,9 +80,9 @@ def _enum_user_machines(self, context, connection, username, maq): matched = False # Method 1: ms-DS-CreatorSID — set by DC for SAMR domain joins - raw_sid = comp.get("ms-DS-CreatorSID") - if raw_sid is not None: - matched = sid_to_str(raw_sid) == user_sid + creator_sid = comp.get("ms-DS-CreatorSID") + if creator_sid is not None: + matched = creator_sid == user_sid # Method 2: nTSecurityDescriptor owner — set for direct LDAP creation (addcomputer.py) if not matched: diff --git a/nxc/parsers/ldap_results.py b/nxc/parsers/ldap_results.py index 36f673dca6..5fa8bb0546 100644 --- a/nxc/parsers/ldap_results.py +++ b/nxc/parsers/ldap_results.py @@ -17,6 +17,8 @@ def parse_result_attributes(ldap_response): val_decoded = UUID(bytes=val.__bytes__()) elif str(attribute["type"]) == "objectSid": val_decoded = sid_to_str(val.__bytes__()) + elif str(attribute["type"]) == "ms-DS-CreatorSID": + val_decoded = sid_to_str(val.__bytes__()) elif str(attribute["type"]) == "dNSProperty": val_decoded = val.__bytes__() else: