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: