Skip to content

Commit 2f62843

Browse files
authored
Merge pull request Pennyw0rth#877 from Pennyw0rth/passpool
2 parents 6e7edbd + ae8fc46 commit 2f62843

5 files changed

Lines changed: 167 additions & 59 deletions

File tree

nxc/helpers/misc.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from termcolor import colored
88
from ipaddress import ip_address
99
from nxc.logger import nxc_logger
10+
from time import strftime, gmtime
1011

1112

1213
def identify_target_file(target_file):
@@ -149,6 +150,82 @@ def detect_if_ip(target):
149150
return False
150151

151152

153+
def d2b(a):
154+
"""
155+
Function used to convert password property flags from decimal to binary
156+
format for easier interpretation of individual flag bits.
157+
"""
158+
tbin = []
159+
while a:
160+
tbin.append(a % 2)
161+
a //= 2
162+
163+
t2bin = tbin[::-1]
164+
if len(t2bin) != 8:
165+
for _x in range(6 - len(t2bin)):
166+
t2bin.insert(0, 0)
167+
return "".join([str(g) for g in t2bin])
168+
169+
170+
def convert(low, high, lockout=False):
171+
"""
172+
Convert Windows FILETIME (64-bit) values to human-readable time strings.
173+
174+
Windows stores time intervals as 64-bit values representing 100-nanosecond
175+
intervals since January 1, 1601. This function converts these values to
176+
readable format like "30 days 5 hours 15 minutes".
177+
178+
Args:
179+
low (int): Low 32 bits of the FILETIME value
180+
high (int): High 32 bits of the FILETIME value
181+
lockout (bool): If True, treats the value as a lockout duration (simpler conversion)
182+
183+
Returns:
184+
str: Human-readable time string (e.g., "42 days 5 hours 30 minutes") or
185+
special values like "Not Set", "None", or "[-] Invalid TIME"
186+
"""
187+
time = ""
188+
tmp = 0
189+
190+
if (low == 0 and high == -0x8000_0000) or (low == 0 and high == -0x8000_0000_0000_0000):
191+
return "Not Set"
192+
if low == 0 and high == 0:
193+
return "None"
194+
195+
if not lockout:
196+
if low != 0:
197+
high = abs(high + 1)
198+
else:
199+
high = abs(high)
200+
low = abs(low)
201+
202+
tmp = low + (high << 32) # convert to 64bit int
203+
tmp *= 1e-7 # convert to seconds
204+
else:
205+
tmp = abs(high) * (1e-7)
206+
207+
try:
208+
minutes = int(strftime("%M", gmtime(tmp)))
209+
hours = int(strftime("%H", gmtime(tmp)))
210+
days = int(strftime("%j", gmtime(tmp))) - 1
211+
except ValueError:
212+
return "[-] Invalid TIME"
213+
214+
if days > 1:
215+
time += f"{days} days "
216+
elif days == 1:
217+
time += f"{days} day "
218+
if hours > 1:
219+
time += f"{hours} hours "
220+
elif hours == 1:
221+
time += f"{hours} hour "
222+
if minutes > 1:
223+
time += f"{minutes} minutes "
224+
elif minutes == 1:
225+
time += f"{minutes} minute "
226+
return time
227+
228+
152229
def display_modules(args, modules):
153230
for category, color in {CATEGORY.ENUMERATION: "green", CATEGORY.CREDENTIAL_DUMPING: "cyan", CATEGORY.PRIVILEGE_ESCALATION: "magenta"}.items():
154231
# Add category filter for module listing

nxc/protocols/ldap.py

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,13 @@
3838
from nxc.config import process_secret, host_info_colors
3939
from nxc.connection import connection
4040
from nxc.helpers.bloodhound import add_user_bh
41+
from nxc.helpers.misc import get_bloodhound_info, convert, d2b
4142
from nxc.logger import NXCAdapter, nxc_logger
4243
from nxc.protocols.ldap.bloodhound import BloodHound
4344
from nxc.protocols.ldap.gmsa import MSDS_MANAGEDPASSWORD_BLOB
4445
from nxc.protocols.ldap.kerberos import KerberosAttacks
4546
from nxc.parsers.ldap_results import parse_result_attributes
4647
from nxc.helpers.ntlm_parser import parse_challenge
47-
from nxc.helpers.misc import get_bloodhound_info
4848
from nxc.paths import CONFIG_PATH, NXC_PATH
4949

5050
ldap_error_status = {
@@ -1481,6 +1481,92 @@ def pso_mins(ldap_time):
14811481
self.logger.highlight(f"\t{policyApplies}")
14821482
self.logger.highlight("")
14831483

1484+
def pass_pol(self):
1485+
search_filter = "(objectClass=domainDNS)"
1486+
attributes = [
1487+
"minPwdLength",
1488+
"pwdHistoryLength",
1489+
"maxPwdAge",
1490+
"minPwdAge",
1491+
"lockoutThreshold",
1492+
"lockoutDuration",
1493+
"lockOutObservationWindow",
1494+
"forceLogoff",
1495+
"pwdProperties"
1496+
]
1497+
1498+
resp = self.search(search_filter, attributes, sizeLimit=0, baseDN=self.baseDN)
1499+
resp_parsed = parse_result_attributes(resp)
1500+
1501+
if not resp_parsed:
1502+
self.logger.fail("No domain password policy found!")
1503+
return
1504+
1505+
for policy in resp_parsed:
1506+
def ldap_to_filetime(ldap_time):
1507+
"""Convert LDAP time to FILETIME format for convert function"""
1508+
if not ldap_time or ldap_time == "0":
1509+
return 0, 0
1510+
1511+
time_int = int(ldap_time)
1512+
if time_int < 0:
1513+
time_int = abs(time_int)
1514+
1515+
low = time_int & 0xFFFFFFFF
1516+
high = (time_int >> 32) & 0xFFFFFFFF
1517+
if ldap_time.startswith("-") or int(ldap_time) < 0:
1518+
high = -high
1519+
1520+
return low, high
1521+
1522+
min_pass_len = policy.get("minPwdLength", "None")
1523+
pass_hist_len = policy.get("pwdHistoryLength", "None")
1524+
max_pwd_age_low, max_pwd_age_high = ldap_to_filetime(policy.get("maxPwdAge", "0"))
1525+
max_pass_age = convert(max_pwd_age_low, max_pwd_age_high)
1526+
min_pwd_age_low, min_pwd_age_high = ldap_to_filetime(policy.get("minPwdAge", "0"))
1527+
min_pass_age = convert(min_pwd_age_low, min_pwd_age_high)
1528+
accnt_lock_thres = policy.get("lockoutThreshold", "None")
1529+
lockout_duration_val = policy.get("lockoutDuration", "0")
1530+
lock_accnt_dur = convert(0, int(lockout_duration_val) if lockout_duration_val != "0" else 0, lockout=True)
1531+
lockout_obs_val = policy.get("lockOutObservationWindow", "0")
1532+
rst_accnt_lock_counter = convert(0, int(lockout_obs_val) if lockout_obs_val != "0" else 0, lockout=True)
1533+
force_logoff_low, force_logoff_high = ldap_to_filetime(policy.get("forceLogoff", "0"))
1534+
force_logoff_time = convert(force_logoff_low, force_logoff_high)
1535+
1536+
# Convert password properties using existing d2b function
1537+
pwd_properties = policy.get("pwdProperties", "0")
1538+
pass_prop = d2b(int(pwd_properties)) if pwd_properties != "0" else "000000"
1539+
1540+
# Use the same formatting and constants as SMB passpol
1541+
PASSCOMPLEX = {
1542+
5: "Domain Password Complex:",
1543+
4: "Domain Password No Anon Change:",
1544+
3: "Domain Password No Clear Change:",
1545+
2: "Domain Password Lockout Admins:",
1546+
1: "Domain Password Store Cleartext:",
1547+
0: "Domain Refuse Password Change:",
1548+
}
1549+
1550+
# Pretty print using same format as SMB
1551+
self.logger.success(f"Dumping password info for domain: {self.domain}")
1552+
self.logger.highlight(f"Minimum password length: {min_pass_len}")
1553+
self.logger.highlight(f"Password history length: {pass_hist_len}")
1554+
self.logger.highlight(f"Maximum password age: {max_pass_age}")
1555+
self.logger.highlight("")
1556+
self.logger.highlight(f"Password Complexity Flags: {pass_prop or 'None'}")
1557+
1558+
for i, a in enumerate(pass_prop):
1559+
self.logger.highlight(f"\t{PASSCOMPLEX[i]} {a!s}")
1560+
1561+
self.logger.highlight("")
1562+
self.logger.highlight(f"Minimum password age: {min_pass_age}")
1563+
self.logger.highlight(f"Reset Account Lockout Counter: {rst_accnt_lock_counter}")
1564+
self.logger.highlight(f"Locked Account Duration: {lock_accnt_dur}")
1565+
self.logger.highlight(f"Account Lockout Threshold: {accnt_lock_thres}")
1566+
self.logger.highlight(f"Forced Log off Time: {force_logoff_time}")
1567+
1568+
break # Only process first policy result
1569+
14841570
def bloodhound(self):
14851571
# Check which version is desired
14861572
use_bhce = self.config.getboolean("BloodHound-CE", "bhce_enabled", fallback=False)

nxc/protocols/ldap/proto_args.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ def proto_args(parser, parents):
3030
vgroup.add_argument("--get-sid", action="store_true", help="Get domain sid")
3131
vgroup.add_argument("--active-users", nargs="*", help="Get Active Domain Users Accounts")
3232
vgroup.add_argument("--pso", action="store_true", help="Get Fine Grained Password Policy/PSOs")
33+
vgroup.add_argument("--pass-pol", action="store_true", help="Dump password policy")
3334

3435
ggroup = ldap_parser.add_argument_group("Retrieve gmsa on the remote DC", "Options to play with gmsa")
3536
ggroup.add_argument("--gmsa", action="store_true", help="Enumerate GMSA passwords")

nxc/protocols/smb/passpol.py

Lines changed: 1 addition & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -2,64 +2,8 @@
22

33
from impacket.dcerpc.v5.rpcrt import DCERPC_v5
44
from impacket.dcerpc.v5 import transport, samr
5-
from time import strftime, gmtime
65
from nxc.logger import nxc_logger
7-
8-
9-
def d2b(a):
10-
tbin = []
11-
while a:
12-
tbin.append(a % 2)
13-
a //= 2
14-
15-
t2bin = tbin[::-1]
16-
if len(t2bin) != 8:
17-
for _x in range(6 - len(t2bin)):
18-
t2bin.insert(0, 0)
19-
return "".join([str(g) for g in t2bin])
20-
21-
22-
def convert(low, high, lockout=False):
23-
time = ""
24-
tmp = 0
25-
26-
if (low == 0 and high == -0x8000_0000) or (low == 0 and high == -0x8000_0000_0000_0000):
27-
return "Not Set"
28-
if low == 0 and high == 0:
29-
return "None"
30-
31-
if not lockout:
32-
if low != 0:
33-
high = abs(high + 1)
34-
else:
35-
high = abs(high)
36-
low = abs(low)
37-
38-
tmp = low + (high << 32) # convert to 64bit int
39-
tmp *= 1e-7 # convert to seconds
40-
else:
41-
tmp = abs(high) * (1e-7)
42-
43-
try:
44-
minutes = int(strftime("%M", gmtime(tmp)))
45-
hours = int(strftime("%H", gmtime(tmp)))
46-
days = int(strftime("%j", gmtime(tmp))) - 1
47-
except ValueError:
48-
return "[-] Invalid TIME"
49-
50-
if days > 1:
51-
time += f"{days} days "
52-
elif days == 1:
53-
time += f"{days} day "
54-
if hours > 1:
55-
time += f"{hours} hours "
56-
elif hours == 1:
57-
time += f"{hours} hour "
58-
if minutes > 1:
59-
time += f"{minutes} minutes "
60-
elif minutes == 1:
61-
time += f"{minutes} minute "
62-
return time
6+
from nxc.helpers.misc import convert, d2b
637

648

659
class PassPolDump:
@@ -233,7 +177,6 @@ def pretty_print(self):
233177
nxc_logger.debug(f"{domain['Name']}")
234178

235179
self.logger.success(f"Dumping password info for domain: {self.__domains[0]['Name']}")
236-
237180
self.logger.highlight(f"Minimum password length: {self.__min_pass_len}")
238181
self.logger.highlight(f"Password history length: {self.__pass_hist_len}")
239182
self.logger.highlight(f"Maximum password age: {self.__max_pass_age}")

tests/e2e_commands.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --trusted-
203203
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --admin-count
204204
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --gmsa
205205
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --pso
206+
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --pass-pol
206207
##### LDAP Modules
207208
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -L
208209
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M adcs

0 commit comments

Comments
 (0)