├── tests ├── __init__.py ├── secrets.json ├── unit_test.py ├── test_formatters.py ├── test_authentication.py ├── test_msldap_module.py └── test_functional.py ├── requirements.txt ├── bloodyAD ├── cli_modules │ ├── __init__.py │ ├── msldap.py │ ├── remove.py │ └── set.py ├── formatters │ ├── __init__.py │ ├── common.py │ ├── accesscontrol.py │ ├── cryptography.py │ ├── bloodhound.py │ ├── formatters.py │ ├── dns.py │ └── ldaptypes.py ├── __init__.py ├── asciitree │ ├── util.py │ ├── traversal.py │ ├── drawing.py │ └── __init__.py ├── patch.py ├── exceptions.py ├── md4.py ├── network │ └── config.py ├── main.py └── utils.py ├── bloodyAD.py ├── .gitignore ├── .github ├── FUNDING.yml ├── workflows │ ├── release.yml │ └── update_wiki.yaml └── scripts │ └── update_wiki.py ├── pyproject.toml ├── LICENSE └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | . 2 | -------------------------------------------------------------------------------- /bloodyAD/cli_modules/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bloodyAD/formatters/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bloodyAD.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from bloodyAD import main 3 | 4 | if __name__ == "__main__": 5 | main.main() 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.pem 3 | *.pfx 4 | *.ccache 5 | venv/ 6 | *.egg-info/ 7 | dist/ 8 | build/ 9 | settings.json 10 | -------------------------------------------------------------------------------- /bloodyAD/__init__.py: -------------------------------------------------------------------------------- 1 | from .network.config import Config, ConnectionHandler 2 | 3 | __all__ = [ 4 | "Config", 5 | "ConnectionHandler", 6 | ] 7 | -------------------------------------------------------------------------------- /bloodyAD/asciitree/util.py: -------------------------------------------------------------------------------- 1 | class KeyArgsConstructor(object): 2 | def __init__(self, **kwargs): 3 | for k, v in kwargs.items(): 4 | setattr(self, k, v) 5 | -------------------------------------------------------------------------------- /tests/secrets.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "bloody.corp", 3 | "pdc":{ 4 | "ip": "192.168.100.3", 5 | "hostname": "main.bloody.corp" 6 | }, 7 | "admin_user":{ 8 | "username":"Administrator", 9 | "password":"Password123!" 10 | } 11 | } -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: CravateRouge 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Compile bloodyAD into standalone binary 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | workflow_dispatch: 8 | 9 | jobs: 10 | windows-build: 11 | runs-on: windows-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | ref: main 16 | - uses: actions/setup-python@v5 17 | with: 18 | python-version: '3.9' 19 | - name: Install dependencies 20 | run: | 21 | pip install . 22 | pip install pyinstaller 23 | - name: Compile 24 | run: pyinstaller --hidden-import unicrypto.backends.cryptography --hidden-import bloodyAD.cli_modules.add --hidden-import bloodyAD.cli_modules.get --hidden-import bloodyAD.cli_modules.remove --hidden-import bloodyAD.cli_modules.set --distpath . --onefile bloodyAD.py 25 | - name: Release 26 | uses: softprops/action-gh-release@v1 27 | with: 28 | files: bloodyAD.exe 29 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [tool.hatch.metadata] 6 | allow-direct-references = true 7 | 8 | [project] 9 | name = "bloodyAD" 10 | authors = [ 11 | { name="CravateRouge", email="baptiste@cravaterouge.com" }, 12 | ] 13 | version = "2.5.1" 14 | description = "AD Privesc Swiss Army Knife" 15 | readme = "README.md" 16 | requires-python = ">=3.8" 17 | classifiers = [ 18 | "Programming Language :: Python :: 3", 19 | "License :: OSI Approved :: MIT License", 20 | "Operating System :: OS Independent", 21 | ] 22 | dependencies = [ 23 | "cryptography==44.0.2", 24 | "badldap>=0.7.2", 25 | "winacl==0.1.9", 26 | "asn1crypto==1.5.1", 27 | "kerbad>=0.5.7" 28 | ] 29 | 30 | [project.urls] 31 | "Homepage" = "https://github.com/CravateRouge/bloodyAD" 32 | "Bug Tracker" = "https://github.com/CravateRouge/bloodyAD/issues" 33 | 34 | [project.scripts] 35 | bloodyAD = "bloodyAD.main:main" 36 | bloodyad = "bloodyAD.main:main" 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 CravateRouge 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /bloodyAD/asciitree/traversal.py: -------------------------------------------------------------------------------- 1 | from .util import KeyArgsConstructor 2 | 3 | 4 | class Traversal(KeyArgsConstructor): 5 | """Traversal method. 6 | 7 | Used by the tree rendering functions like :class:`~asciitree.LeftAligned`. 8 | """ 9 | 10 | def get_children(self, node): 11 | """Return a list of children of a node.""" 12 | raise NotImplementedError 13 | 14 | def get_root(self, tree): 15 | """Return a node representing the tree root from the tree.""" 16 | return tree 17 | 18 | def get_text(self, node): 19 | """Return the text associated with a node.""" 20 | return str(node) 21 | 22 | 23 | class DictTraversal(Traversal): 24 | """Traversal suitable for a dictionary. Keys are tree labels, all values 25 | must be dictionaries as well.""" 26 | 27 | def get_children(self, node): 28 | return list(node[1].items()) 29 | 30 | def get_root(self, tree): 31 | return list(tree.items())[0] 32 | 33 | def get_text(self, node): 34 | return node[0] 35 | 36 | 37 | class AttributeTraversal(Traversal): 38 | """Attribute traversal. 39 | 40 | Uses an attribute of a node as its list of children. 41 | """ 42 | 43 | attribute = "children" #: Attribute to use. 44 | 45 | def get_children(self, node): 46 | return getattr(node, self.attribute) 47 | -------------------------------------------------------------------------------- /.github/workflows/update_wiki.yaml: -------------------------------------------------------------------------------- 1 | name: Update Wiki on Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | update-wiki: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout bloodyAD 13 | uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | 17 | - name: Set up Python 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: '3.11' 21 | 22 | - name: Install bloodyAD and dependencies 23 | run: | 24 | pip install . 25 | 26 | - name: Clone Wiki 27 | run: | 28 | git clone https://github.com/CravateRouge/bloodyAD.wiki.git wiki 29 | 30 | - name: Extract and Update Wiki 31 | run: | 32 | python .github/scripts/update_wiki.py \ 33 | --repo-path . \ 34 | --wiki-path wiki \ 35 | --user-guide User-Guide.md 36 | 37 | - name: Commit and Push Wiki Changes 38 | run: | 39 | cd wiki 40 | git config user.name "bloodyAD Bot" 41 | git config user.email "bloodyad-bot@github.com" 42 | git add User-Guide.md 43 | git commit -m "Update User-Guide.md with latest help output" 44 | git push https://CravateRouge:${{ secrets.WIKITOKEN }}@github.com/CravateRouge/bloodyAD.wiki.git HEAD:master -------------------------------------------------------------------------------- /bloodyAD/patch.py: -------------------------------------------------------------------------------- 1 | # import os 2 | # # Waiting for asysocks 0.2.18 3 | # from asysocks.unicomm.common.unissl import UniSSL 4 | # def pfx_to_pem(self, pfx_path, pfx_password): 5 | # #https://gist.github.com/erikbern/756b1d8df2d1487497d29b90e81f8068 6 | # from pathlib import Path 7 | # from cryptography.hazmat.primitives.serialization import Encoding, PrivateFormat, NoEncryption 8 | # from cryptography.hazmat.primitives.serialization.pkcs12 import load_key_and_certificates 9 | 10 | 11 | # ''' Decrypts the .pfx file to be used with requests. ''' 12 | # pfx = Path(pfx_path).read_bytes() 13 | # if isinstance(pfx_password, str): 14 | # pfx_password = pfx_password.encode('utf-8') 15 | # private_key, main_cert, add_certs = load_key_and_certificates(pfx, pfx_password, None) 16 | # suffix = '%s.pem' % os.urandom(4).hex() 17 | # self._UniSSL__keyfilename = 'key_%s' % suffix 18 | # self._UniSSL__certfilename = 'cert_%s' % suffix 19 | # with open(self._UniSSL__keyfilename, 'wb') as f: 20 | # f.write(private_key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())) 21 | # with open(self._UniSSL__certfilename, 'wb') as f: 22 | # f.write(main_cert.public_bytes(Encoding.PEM)) 23 | # if len(add_certs) > 0: 24 | # self._UniSSL__cacertfilename = 'cacert_%s' % suffix 25 | # with open(self._UniSSL__cacertfilename, 'wb') as f: 26 | # for ca in add_certs: 27 | # f.write(ca.public_bytes(Encoding.PEM)) 28 | 29 | # UniSSL.pfx_to_pem = pfx_to_pem -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > :warning: autobloody has been moved to its own [repo](https://github.com/CravateRouge/autobloody) 2 | 3 | # ![bloodyAD logo](https://repository-images.githubusercontent.com/415977068/9b2fed72-35fb-4faa-a8d3-b120cd3c396f) bloodyAD 4 | 5 | `bloodyAD` is an Active Directory privilege escalation swiss army knife 6 | 7 | ## Description 8 | 9 | This tool can perform specific LDAP calls to a domain controller in order to perform AD privesc. 10 | 11 | `bloodyAD` supports authentication using cleartext passwords, pass-the-hash, pass-the-ticket or certificates and binds to LDAP services of a domain controller to perform AD privesc. 12 | 13 | Exchange of sensitive information without LDAPS is supported. 14 | 15 | It is also designed to be used transparently with a SOCKS proxy. 16 | 17 | 18 | Simple usage: 19 | 20 | ```ps1 21 | bloodyAD --host 172.16.1.15 -d bloody.local -u jane.doe -p :70016778cb0524c799ac25b439bd6a31 set password john.doe 'Password123!' 22 | ``` 23 | 24 | See the [wiki](https://github.com/CravateRouge/bloodyAD/wiki) for more. 25 | 26 | ## Support 27 | Like this project? Donations are greatly appreciated :relaxed: [![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/CravateRouge) 28 | 29 | Need personalized support? send us an [email](mailto:contact@cravaterouge.com) or check our website [cravaterouge.com](https://cravaterouge.com/?utm_source=bloodyad_readme) to see all our cybersecurity services. 30 | 31 | ## Acknowledgements 32 | - Thanks to [@skelsec](https://github.com/skelsec) for his amazing libraries especially [MSLDAP](https://github.com/skelsec/msldap) which is now the engine on which bloodyAD is running. 33 | - Thanks to [impacket](https://github.com/fortra/impacket) contributors. [Structures](https://github.com/fortra/impacket/blob/master/impacket/structure.py) and several [LDAP attacks](https://github.com/fortra/impacket/blob/master/impacket/examples/ntlmrelayx/attacks/ldapattack.py) are based on their work. 34 | - Thanks to [@PowerShellMafia](https://github.com/PowerShellMafia) team ([PowerView.ps1](https://github.com/PowerShellMafia/PowerSploit/blob/master/Recon/PowerView.ps1)) and their work on AD which inspired this tool. 35 | - Thanks to [@dirkjanm](https://github.com/dirkjanm) ([adidnsdump.py](https://github.com/dirkjanm/adidnsdump)) and ([@Kevin-Robertson](https://github.com/Kevin-Robertson))([Invoke-DNSUpdate.ps1](https://github.com/Kevin-Robertson/Powermad/blob/master/Invoke-DNSUpdate.ps1)) for their work on AD DNS which inspired DNS functionnalities. 36 | - Thanks to [@p0dalirius](https://github.com/p0dalirius/) and his [pydsinternals](https://github.com/p0dalirius/pydsinternals) module which helped to build the shadow credential attack 37 | -------------------------------------------------------------------------------- /bloodyAD/exceptions.py: -------------------------------------------------------------------------------- 1 | import logging, sys 2 | 3 | LOG = logging.getLogger('bloodyAD') 4 | 5 | def enableCliLogger(level="DEBUG"): 6 | # If we want to get the logs of every library we used (and which properly defined their loggers) 7 | if level == "TRACE": 8 | # logging.basicConfig( 9 | # level=logging.DEBUG, 10 | # #format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' 11 | # ) 12 | logging.getLogger().setLevel(logging.DEBUG) 13 | level = "DEBUG" 14 | logging_level = getattr(logging, level) 15 | LOG.propagate = False 16 | LOG.setLevel(logging_level) 17 | handler = logging.StreamHandler(sys.stdout) 18 | class SymbolFormatter(logging.Formatter): 19 | LEVEL_SYMBOLS = { 20 | logging.DEBUG: '[*]', 21 | logging.INFO: '[+]', 22 | logging.WARNING: '[!]', 23 | logging.ERROR: '[-]', 24 | logging.CRITICAL: '[X]', 25 | } 26 | def format(self, record): 27 | symbol = self.LEVEL_SYMBOLS.get(record.levelno, '[?]') 28 | return f"{symbol} {record.getMessage()}" 29 | handler.setFormatter(SymbolFormatter()) 30 | handler.setLevel(logging_level) 31 | LOG.addHandler(handler) 32 | 33 | class BloodyError(Exception): 34 | pass 35 | 36 | 37 | class LDAPError(BloodyError): 38 | pass 39 | 40 | 41 | class ResultError(LDAPError): 42 | def __init__(self, result): 43 | self.result = result 44 | 45 | if self.result["result"] == 50: 46 | self.message = ( 47 | "Could not modify object, the server reports insufficient rights: " 48 | + self.result["message"] 49 | ) 50 | elif self.result["result"] == 19: 51 | self.message = ( 52 | "Could not modify object, the server reports a constrained" 53 | " violation: " + self.result["message"] 54 | ) 55 | else: 56 | self.message = "The server returned an error: " + self.result["message"] 57 | 58 | super().__init__(self.message) 59 | 60 | 61 | class NoResultError(LDAPError): 62 | def __init__(self, search_base, ldap_filter): 63 | self.filter = ldap_filter 64 | self.base = search_base 65 | self.message = f"No object found in {self.base} with filter: {self.filter}" 66 | super().__init__(self.message) 67 | 68 | 69 | class TooManyResultsError(LDAPError): 70 | def __init__(self, search_base, ldap_filter, entries): 71 | self.filter = ldap_filter 72 | self.base = search_base 73 | self.limit = 10 74 | self.entries = list(entries) 75 | 76 | if len(self.entries) <= self.limit: 77 | self.results = "\n".join(entry["dn"] for entry in entries) 78 | self.message = ( 79 | f"{len(self.entries)} objects found in {self.base} with" 80 | f" filter: {ldap_filter}\n" 81 | ) 82 | self.message += "\tPlease put the full target DN\n" 83 | self.message += f"\tResult of query: \n{self.results}" 84 | else: 85 | self.message = ( 86 | f"\tMore than {self.limit} entries in {self.base} match {self.filter}" 87 | ) 88 | self.message += "\tPlease put the full target DN" 89 | 90 | super().__init__(self.message) 91 | -------------------------------------------------------------------------------- /bloodyAD/asciitree/drawing.py: -------------------------------------------------------------------------------- 1 | from .util import KeyArgsConstructor 2 | 3 | BOX_LIGHT = { 4 | "UP_AND_RIGHT": "\u2514", 5 | "HORIZONTAL": "\u2500", 6 | "VERTICAL": "\u2502", 7 | "VERTICAL_AND_RIGHT": "\u251c", 8 | } #: Unicode box-drawing glyphs, light style 9 | 10 | 11 | BOX_HEAVY = { 12 | "UP_AND_RIGHT": "\u2517", 13 | "HORIZONTAL": "\u2501", 14 | "VERTICAL": "\u2503", 15 | "VERTICAL_AND_RIGHT": "\u2523", 16 | } #: Unicode box-drawing glyphs, heavy style 17 | 18 | 19 | BOX_DOUBLE = { 20 | "UP_AND_RIGHT": "\u255a", 21 | "HORIZONTAL": "\u2550", 22 | "VERTICAL": "\u2551", 23 | "VERTICAL_AND_RIGHT": "\u2560", 24 | } #: Unicode box-drawing glyphs, double-line style 25 | 26 | 27 | BOX_ASCII = { 28 | "UP_AND_RIGHT": "+", 29 | "HORIZONTAL": "-", 30 | "VERTICAL": "|", 31 | "VERTICAL_AND_RIGHT": "+", 32 | } #: Unicode box-drawing glyphs, using only ascii ``|+-`` characters. 33 | 34 | 35 | BOX_BLANK = { 36 | "UP_AND_RIGHT": " ", 37 | "HORIZONTAL": " ", 38 | "VERTICAL": " ", 39 | "VERTICAL_AND_RIGHT": " ", 40 | } #: Unicode box-drawing glyphs, using only spaces. 41 | 42 | 43 | class Style(KeyArgsConstructor): 44 | """Rendering style for trees.""" 45 | 46 | label_format = "{}" #: Format for labels. 47 | 48 | def node_label(self, text): 49 | """Render a node text into a label.""" 50 | return self.label_format.format(text) 51 | 52 | def child_head(self, label): 53 | """Render a node label into final output.""" 54 | return label 55 | 56 | def child_tail(self, line): 57 | """Render a node line that is not a label into final output.""" 58 | return line 59 | 60 | def last_child_head(self, label): 61 | """Like :func:`~asciitree.drawing.Style.child_head` but only called 62 | for the last child.""" 63 | return label 64 | 65 | def last_child_tail(self, line): 66 | """Like :func:`~asciitree.drawing.Style.child_tail` but only called 67 | for the last child.""" 68 | return line 69 | 70 | 71 | class BoxStyle(Style): 72 | """A rendering style that uses box draw characters and a common layout.""" 73 | 74 | gfx = BOX_ASCII #: Glyhps to use. 75 | label_space = 1 #: Space between glyphs and label. 76 | horiz_len = 2 #: Length of horizontal lines 77 | indent = 1 #: Indent for subtrees 78 | 79 | def child_head(self, label): 80 | return ( 81 | " " * self.indent 82 | + self.gfx["VERTICAL_AND_RIGHT"] 83 | + self.gfx["HORIZONTAL"] * self.horiz_len 84 | + " " * self.label_space 85 | + label 86 | ) 87 | 88 | def child_tail(self, line): 89 | return " " * self.indent + self.gfx["VERTICAL"] + " " * self.horiz_len + line 90 | 91 | def last_child_head(self, label): 92 | return ( 93 | " " * self.indent 94 | + self.gfx["UP_AND_RIGHT"] 95 | + self.gfx["HORIZONTAL"] * self.horiz_len 96 | + " " * self.label_space 97 | + label 98 | ) 99 | 100 | def last_child_tail(self, line): 101 | return ( 102 | " " * self.indent 103 | + " " * len(self.gfx["VERTICAL"]) 104 | + " " * self.horiz_len 105 | + line 106 | ) 107 | -------------------------------------------------------------------------------- /bloodyAD/formatters/common.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | 3 | 4 | class DNBinary: 5 | """ 6 | Object(DN-Binary) - adschema 7 | """ 8 | 9 | def __init__(self, data=None): 10 | if not data: 11 | return 12 | data = data.decode("utf-8").split(":") 13 | if len(data) != 4 or data[0] != "B": 14 | raise TypeError("can't convert str to DN-Binary") 15 | self.count = int(data[1]) 16 | self.binary_value = data[2] 17 | 18 | self.value = binascii.unhexlify(self.binary_value) 19 | self.dn = data[3] 20 | 21 | def fromCanonical(self, value, dn): 22 | self.value = value 23 | self.dn = dn 24 | self.binary_value = binascii.hexlify(value).decode() 25 | self.count = len(self.binary_value) 26 | 27 | def __str__(self): 28 | return f"B:{self.count}:{self.binary_value}:{self.dn}" 29 | 30 | 31 | # see https://social.technet.microsoft.com/wiki/contents/articles/37395.active-directory-schema-versions.aspx 32 | SCHEMA_VERSION = { 33 | "13": "Windows 2000 Server", 34 | "30": "Windows Server 2003", 35 | "31": "Windows Server 2003 R2", 36 | "44": "Windows Server 2008", 37 | "47": "Windows Server 2008 R2", 38 | "56": "Windows Server 2012", 39 | "69": "Windows Server 2012 R2", 40 | "87": "Windows Server 2016", 41 | "88": "Windows Server 2019/2022", 42 | "91": "Windows Server 2025", 43 | } 44 | 45 | # https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/d7422d35-448a-451a-8846-6a7def0044df?redirectedfrom=MSDN 46 | FUNCTIONAL_LEVEL = { 47 | "0": "DS_BEHAVIOR_WIN2000", 48 | "1": "DS_BEHAVIOR_WIN2003_WITH_MIXED_DOMAINS", 49 | "2": "DS_BEHAVIOR_WIN2003", 50 | "3": "DS_BEHAVIOR_WIN2008", 51 | "4": "DS_BEHAVIOR_WIN2008R2", 52 | "5": "DS_BEHAVIOR_WIN2012", 53 | "6": "DS_BEHAVIOR_WIN2012R2", 54 | "7": "DS_BEHAVIOR_WIN2016", 55 | "10": "DS_BEHAVIOR_WIN2025", 56 | } 57 | 58 | # [MS-ADTS] - 6.1.1.4 Well-Known Objects 59 | WELLKNOWN_GUID = { 60 | "AA312825768811D1ADED00C04FD8D5CD": "GUID_COMPUTERS_CONTAINER_W", 61 | "18E2EA80684F11D2B9AA00C04F79F805": "GUID_DELETED_OBJECTS_CONTAINER_W", 62 | "A361B2FFFFD211D1AA4B00C04FD7D83A": "GUID_DOMAIN_CONTROLLERS_CONTAINER_W", 63 | "22B70C67D56E4EFB91E9300FCA3DC1AA": "GUID_FOREIGNSECURITYPRINCIPALS_CONTAINER_W", 64 | "2FBAC1870ADE11D297C400C04FD8D5CD": "GUID_INFRASTRUCTURE_CONTAINER_W", 65 | "AB8153B7768811D1ADED00C04FD8D5CD": "GUID_LOSTANDFOUND_CONTAINER_W", 66 | "F4BE92A4C777485E878E9421D53087DB": "GUID_MICROSOFT_PROGRAM_DATA_CONTAINER_W", 67 | "6227F0AF1FC2410D8E3BB10615BB5B0F": "GUID_NTDS_QUOTAS_CONTAINER_W", 68 | "09460C08AE1E4A4EA0F64AEE7DAA1E5A": "GUID_PROGRAM_DATA_CONTAINER_W", 69 | "AB1D30F3768811D1ADED00C04FD8D5CD": "GUID_SYSTEMS_CONTAINER_W", 70 | "A9D1CA15768811D1ADED00C04FD8D5CD": "GUID_USERS_CONTAINER_W", 71 | "1EB93889E40C45DF9F0C64D23BBB6237": "GUID_MANAGED_SERVICE_ACCOUNTS_CONTAINER_W", 72 | } 73 | 74 | # [MS-ADTS] 6.1.6.7.12 trustDirection 75 | TRUST_DIRECTION = {"DISABLED": 0, "INBOUND": 1, "OUTBOUND": 2, "BIDIRECTIONAL": 3} 76 | 77 | # [MS-ADTS] 6.1.6.7.15 trustType 78 | TRUST_TYPE = {"LOCAL_WINDOWS": 1, "AD": 2, "NON_WINDOWS": 3, "AZURE": 5} 79 | 80 | # [MS-ADTS] 6.1.6.7.9 trustAttributes 81 | TRUST_ATTRIBUTES = { 82 | "NON_TRANSITIVE": 0x1, 83 | "UPLEVEL_ONLY": 0x2, 84 | "QUARANTINED_DOMAIN": 0x4, 85 | "FOREST_TRANSITIVE": 0x8, 86 | "CROSS_ORGANIZATION": 0x10, 87 | "WITHIN_FOREST": 0x20, 88 | "TREAT_AS_EXTERNAL": 0x40, 89 | "USES_RC4_ENCRYPTION": 0x80, 90 | "CROSS_ORGANIZATION_NO_TGT_DELEGATION": 0x200, 91 | "CROSS_ORGANIZATION_ENABLE_TGT_DELEGATION": 0x800, 92 | "PIM_TRUST": 0x400, 93 | } 94 | -------------------------------------------------------------------------------- /bloodyAD/md4.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright © 2019 James Seo (github.com/kangtastic). 4 | # 5 | # This file is released under the WTFPL, version 2 (wtfpl.net). 6 | # 7 | # md4.py: An implementation of the MD4 hash algorithm in pure Python 3. 8 | # 9 | # Description: Zounds! Yet another rendition of pseudocode from RFC1320! 10 | # Bonus points for the algorithm literally being from 1992. 11 | # 12 | # Usage: Why would anybody use this? This is self-rolled crypto, and 13 | # self-rolled *obsolete* crypto at that. DO NOT USE if you need 14 | # something "performant" or "secure". :P 15 | # 16 | # Anyway, from the command line: 17 | # 18 | # $ ./md4.py [messages] 19 | # 20 | # where [messages] are some strings to be hashed. 21 | # 22 | # In Python, use similarly to hashlib (not that it even has MD4): 23 | # 24 | # from .md4 import MD4 25 | # 26 | # digest = MD4("BEES").hexdigest() 27 | # 28 | # print(digest) # "501af1ef4b68495b5b7e37b15b4cda68" 29 | import struct 30 | 31 | 32 | class MD4: 33 | """An implementation of the MD4 hash algorithm.""" 34 | 35 | width = 32 36 | mask = 0xFFFFFFFF 37 | 38 | # Unlike, say, SHA-1, MD4 uses little-endian. Fascinating! 39 | h = [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476] 40 | 41 | def __init__(self, msg=None): 42 | """:param ByteString msg: The message to be hashed.""" 43 | if msg is None: 44 | msg = b"" 45 | 46 | self.msg = msg 47 | 48 | # Pre-processing: Total length is a multiple of 512 bits. 49 | ml = len(msg) * 8 50 | msg += b"\x80" 51 | msg += b"\x00" * (-(len(msg) + 8) % 64) 52 | msg += struct.pack("> (MD4.width - n) 126 | return lbits | rbits 127 | -------------------------------------------------------------------------------- /bloodyAD/formatters/accesscontrol.py: -------------------------------------------------------------------------------- 1 | from bloodyAD.formatters import ldaptypes 2 | import uuid 3 | 4 | 5 | # 2.4.7 SECURITY_INFORMATION 6 | OWNER_SECURITY_INFORMATION = 0x00000001 7 | GROUP_SECURITY_INFORMATION = 0x00000002 8 | DACL_SECURITY_INFORMATION = 0x00000004 9 | SACL_SECURITY_INFORMATION = 0x00000008 10 | LABEL_SECURITY_INFORMATION = 0x00000010 11 | UNPROTECTED_SACL_SECURITY_INFORMATION = 0x10000000 12 | UNPROTECTED_DACL_SECURITY_INFORMATION = 0x20000000 13 | PROTECTED_SACL_SECURITY_INFORMATION = 0x40000000 14 | PROTECTED_DACL_SECURITY_INFORMATION = 0x80000000 15 | ATTRIBUTE_SECURITY_INFORMATION = 0x00000020 16 | SCOPE_SECURITY_INFORMATION = 0x00000040 17 | BACKUP_SECURITY_INFORMATION = 0x00010000 18 | 19 | # https://docs.microsoft.com/en-us/windows/win32/api/iads/ne-iads-ads_rights_enum 20 | ACCESS_FLAGS = { 21 | # Flag constants 22 | "GENERIC_READ": 0x80000000, 23 | "GENERIC_WRITE": 0x40000000, 24 | "GENERIC_EXECUTE": 0x20000000, 25 | "GENERIC_ALL": 0x10000000, 26 | "MAXIMUM_ALLOWED": 0x02000000, 27 | "ACCESS_SYSTEM_SECURITY": 0x01000000, 28 | "SYNCHRONIZE": 0x00100000, 29 | # Not in the spec but equivalent to the flags below it 30 | "FULL_CONTROL": 0x000F01FF, 31 | "WRITE_OWNER": 0x00080000, 32 | "WRITE_DACL": 0x00040000, 33 | "READ_CONTROL": 0x00020000, 34 | "DELETE": 0x00010000, 35 | # ACE type specific mask constants 36 | # Note that while not documented, these also seem valid 37 | # for ACCESS_ALLOWED_ACE types 38 | "ADS_RIGHT_DS_CONTROL_ACCESS": 0x00000100, 39 | "ADS_RIGHT_DS_CREATE_CHILD": 0x00000001, 40 | "ADS_RIGHT_DS_DELETE_CHILD": 0x00000002, 41 | "ADS_RIGHT_DS_READ_PROP": 0x00000010, 42 | "ADS_RIGHT_DS_WRITE_PROP": 0x00000020, 43 | "ADS_RIGHT_DS_SELF": 0x00000008, 44 | } 45 | 46 | # https://docs.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-addauditaccessobjectace 47 | ACE_FLAGS = { 48 | # Flag constants 49 | "CONTAINER_INHERIT_ACE": 0x02, 50 | "FAILED_ACCESS_ACE_FLAG": 0x80, 51 | "INHERIT_ONLY_ACE": 0x08, 52 | "INHERITED_ACE": 0x10, 53 | "NO_PROPAGATE_INHERIT_ACE": 0x04, 54 | "OBJECT_INHERIT_ACE": 0x01, 55 | "SUCCESSFUL_ACCESS_ACE_FLAG": 0x40, 56 | } 57 | 58 | # see https://docs.microsoft.com/en-us/troubleshoot/windows-server/identity/useraccountcontrol-manipulate-account-properties 59 | ACCOUNT_FLAGS = { 60 | "SCRIPT": 0x0001, 61 | "ACCOUNTDISABLE": 0x0002, 62 | "HOMEDIR_REQUIRED": 0x0008, 63 | "LOCKOUT": 0x0010, 64 | "PASSWD_NOTREQD": 0x0020, 65 | "PASSWD_CANT_CHANGE": 0x0040, 66 | "ENCRYPTED_TEXT_PWD_ALLOWED": 0x0080, 67 | "TEMP_DUPLICATE_ACCOUNT": 0x0100, 68 | "NORMAL_ACCOUNT": 0x0200, 69 | "INTERDOMAIN_TRUST_ACCOUNT": 0x0800, 70 | "WORKSTATION_TRUST_ACCOUNT": 0x1000, 71 | "SERVER_TRUST_ACCOUNT": 0x2000, 72 | "DONT_EXPIRE_PASSWORD": 0x10000, 73 | "MNS_LOGON_ACCOUNT": 0x20000, 74 | "SMARTCARD_REQUIRED": 0x40000, 75 | "TRUSTED_FOR_DELEGATION": 0x80000, 76 | "NOT_DELEGATED": 0x100000, 77 | "USE_DES_KEY_ONLY": 0x200000, 78 | "DONT_REQ_PREAUTH": 0x400000, 79 | "PASSWORD_EXPIRED": 0x800000, 80 | "TRUSTED_TO_AUTH_FOR_DELEGATION": 0x1000000, 81 | "PARTIAL_SECRETS_ACCOUNT": 0x04000000, 82 | "USE_AES_KEYS": 0x8000000, 83 | } 84 | 85 | 86 | def createACE(sid, object_type=None, access_mask=ACCESS_FLAGS["FULL_CONTROL"]): 87 | nace = ldaptypes.ACE() 88 | nace["AceFlags"] = ( 89 | ACE_FLAGS["CONTAINER_INHERIT_ACE"] + ACE_FLAGS["OBJECT_INHERIT_ACE"] 90 | ) 91 | 92 | if object_type is None: 93 | acedata = ldaptypes.ACCESS_ALLOWED_ACE() 94 | nace["AceType"] = ldaptypes.ACCESS_ALLOWED_ACE.ACE_TYPE 95 | else: 96 | nace["AceType"] = ldaptypes.ACCESS_ALLOWED_OBJECT_ACE.ACE_TYPE 97 | acedata = ldaptypes.ACCESS_ALLOWED_OBJECT_ACE() 98 | acedata["ObjectType"] = uuid.UUID(object_type).bytes_le 99 | acedata["InheritedObjectType"] = b"" 100 | acedata["Flags"] = ldaptypes.ACCESS_ALLOWED_OBJECT_ACE.ACE_OBJECT_TYPE_PRESENT 101 | 102 | acedata["Mask"] = ldaptypes.ACCESS_MASK() 103 | acedata["Mask"]["Mask"] = access_mask 104 | 105 | if type(sid) is str: 106 | acedata["Sid"] = ldaptypes.LDAP_SID() 107 | acedata["Sid"].fromCanonical(sid) 108 | else: 109 | acedata["Sid"] = sid 110 | 111 | nace["Ace"] = acedata 112 | return nace 113 | 114 | 115 | def createEmptySD(): 116 | sd = ldaptypes.SR_SECURITY_DESCRIPTOR() 117 | sd["Revision"] = b"\x01" 118 | sd["Sbz1"] = b"\x00" 119 | sd["Control"] = 32772 120 | sd["OwnerSid"] = ldaptypes.LDAP_SID() 121 | # BUILTIN\Administrators 122 | sd["OwnerSid"].fromCanonical("S-1-5-32-544") 123 | sd["GroupSid"] = b"" 124 | sd["Sacl"] = b"" 125 | acl = ldaptypes.ACL() 126 | acl["AclRevision"] = 4 127 | acl["Sbz1"] = 0 128 | acl["Sbz2"] = 0 129 | acl.aces = [] 130 | sd["Dacl"] = acl 131 | return sd 132 | -------------------------------------------------------------------------------- /bloodyAD/asciitree/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from .drawing import BoxStyle 5 | from .traversal import DictTraversal 6 | from .util import KeyArgsConstructor 7 | from bloodyAD.formatters import common, formatters 8 | 9 | 10 | # Explore the trust_dict level by level and populate a branch provided in to_explore 11 | def branchFactory(to_explore, explored, trust_dict): 12 | next_explore = {} 13 | for ascii_parent, parent_dict in to_explore.items(): 14 | parent = ascii_parent.rsplit(":")[-1] 15 | if parent in explored: 16 | continue 17 | explored.append(parent) 18 | 19 | for child_name, child in trust_dict.get(parent, {}).items(): 20 | # If it's a root key in trust_dict, it means we have been able to connect to it and explore it 21 | # So there is somewhere an INBOUND parent 22 | # So do not print the node below this parent, it will be printed below one with INBOUND 23 | if not ( 24 | int(child["trustDirection"][0].decode()) 25 | & common.TRUST_DIRECTION["INBOUND"] 26 | ) and (child_name in trust_dict): 27 | continue 28 | # If it's already explored no need to print it again it will only show the opposite trust direction and nothing new 29 | if child_name in explored: 30 | continue 31 | 32 | # Format child in ascii representation 33 | ascii_child = "" 34 | flags = [] 35 | if ( 36 | int(child["trustDirection"][0].decode()) 37 | & common.TRUST_DIRECTION["OUTBOUND"] 38 | ): 39 | ascii_child += "<" 40 | # TODO: trustAttributes not printed all the time, find the root cause and fix it 41 | trustFlags = formatters.formatTrustAttributes(child["trustAttributes"][0]) 42 | if trustFlags: 43 | flags += trustFlags 44 | else: 45 | flags += [child["trustAttributes"][0].decode()] 46 | flags.append(formatters.formatTrustType(child["trustType"][0])) 47 | ascii_child += "|".join(flags) 48 | if ( 49 | int(child["trustDirection"][0].decode()) 50 | & common.TRUST_DIRECTION["INBOUND"] 51 | ): 52 | ascii_child += ">" 53 | ascii_child += ":" + child["trustPartner"][0].decode() 54 | parent_dict[ascii_child] = {} 55 | 56 | next_explore = {**next_explore, **parent_dict} 57 | 58 | if next_explore: 59 | branchFactory(next_explore, explored, trust_dict) 60 | 61 | 62 | class LeftAligned(KeyArgsConstructor): 63 | """Creates a renderer for a left-aligned tree. 64 | 65 | Any attributes of the resulting class instances can be set using 66 | constructor arguments.""" 67 | 68 | draw = BoxStyle() 69 | "The draw style used. See :class:`~asciitree.drawing.Style`." 70 | traverse = DictTraversal() 71 | "Traversal method. See :class:`~asciitree.traversal.Traversal`." 72 | 73 | def render(self, node): 74 | """Renders a node. This function is used internally, as it returns 75 | a list of lines. Use :func:`~asciitree.LeftAligned.__call__` instead. 76 | """ 77 | lines = [] 78 | 79 | children = self.traverse.get_children(node) 80 | lines.append(self.draw.node_label(self.traverse.get_text(node))) 81 | 82 | for n, child in enumerate(children): 83 | child_tree = self.render(child) 84 | 85 | if n == len(children) - 1: 86 | # last child does not get the line drawn 87 | lines.append(self.draw.last_child_head(child_tree.pop(0))) 88 | lines.extend(self.draw.last_child_tail(l) for l in child_tree) 89 | else: 90 | lines.append(self.draw.child_head(child_tree.pop(0))) 91 | lines.extend(self.draw.child_tail(l) for l in child_tree) 92 | 93 | return lines 94 | 95 | def __call__(self, tree): 96 | """Render the tree into string suitable for console output. 97 | 98 | :param tree: A tree.""" 99 | return "\n".join(self.render(self.traverse.get_root(tree))) 100 | 101 | 102 | # legacy support below 103 | 104 | from .drawing import Style 105 | from .traversal import Traversal 106 | 107 | 108 | class LegacyStyle(Style): 109 | def node_label(self, text): 110 | return text 111 | 112 | def child_head(self, label): 113 | return " +--" + label 114 | 115 | def child_tail(self, line): 116 | return " |" + line 117 | 118 | def last_child_head(self, label): 119 | return " +--" + label 120 | 121 | def last_child_tail(self, line): 122 | return " " + line 123 | 124 | 125 | def draw_tree(node, child_iter=lambda n: n.children, text_str=str): 126 | """Support asciitree 0.2 API. 127 | 128 | This function solely exist to not break old code (using asciitree 0.2). 129 | Its use is deprecated.""" 130 | return LeftAligned( 131 | traverse=Traversal(get_text=text_str, get_children=child_iter), 132 | draw=LegacyStyle(), 133 | )(node) 134 | -------------------------------------------------------------------------------- /tests/unit_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import sys 3 | from bloodyAD import asciitree 4 | from bloodyAD.main import amain 5 | import asyncio 6 | 7 | 8 | class UnitTests(unittest.TestCase): 9 | def test_01TreeDisplay(self): 10 | trust_dict = trust_dict = { 11 | "child.bloody.lab": { 12 | "bloody.lab": { 13 | "distinguishedName": ( 14 | "CN=bloody.lab,CN=System,DC=child,DC=bloody,DC=lab" 15 | ), 16 | "trustDirection": [b"3"], 17 | "trustPartner": [b"bloody.lab"], 18 | "trustType": [b"2"], 19 | "trustAttributes": [b"32"], 20 | } 21 | }, 22 | "cousin.corp": { 23 | "bloody.lab": { 24 | "distinguishedName": "CN=bloody.lab,CN=System,DC=cousin,DC=corp", 25 | "trustDirection": [b"3"], 26 | "trustPartner": [b"bloody.lab"], 27 | "trustType": [b"2"], 28 | "trustAttributes": [b"32"], 29 | } 30 | }, 31 | "stranger.lab": { 32 | "bloody.lab": { 33 | "distinguishedName": "CN=bloody.lab,CN=System,DC=stranger,DC=lab", 34 | "trustDirection": [b"3"], 35 | "trustPartner": [b"bloody.lab"], 36 | "trustType": [b"2"], 37 | "trustAttributes": [b"8"], 38 | }, 39 | "cousin.corp": { 40 | "distinguishedName": "CN=cousin.corp,CN=System,DC=bloody,DC=lab", 41 | "trustDirection": [b"1"], 42 | "trustPartner": [b"cousin.corp"], 43 | "trustType": [b"2"], 44 | "trustAttributes": [b"32"], 45 | }, 46 | "business.corp": { 47 | "distinguishedName": "CN=business.corp,CN=System,DC=bloody,DC=lab", 48 | "trustDirection": [b"1"], 49 | "trustPartner": [b"business.corp"], 50 | "trustType": [b"2"], 51 | "trustAttributes": [b"32"], 52 | }, 53 | }, 54 | "bloody.lab": { 55 | "child.bloody.lab": { 56 | "distinguishedName": ( 57 | "CN=child.bloody.lab,CN=System,DC=bloody,DC=lab" 58 | ), 59 | "trustDirection": [b"3"], 60 | "trustPartner": [b"child.bloody.lab"], 61 | "trustType": [b"2"], 62 | "trustAttributes": [b"32"], 63 | }, 64 | "cousin.corp": { 65 | "distinguishedName": "CN=cousin.corp,CN=System,DC=bloody,DC=lab", 66 | "trustDirection": [b"3"], 67 | "trustPartner": [b"cousin.corp"], 68 | "trustType": [b"2"], 69 | "trustAttributes": [b"0"], 70 | }, 71 | "stranger.lab": { 72 | "distinguishedName": "CN=stranger.lab,CN=System,DC=bloody,DC=lab", 73 | "trustDirection": [b"3"], 74 | "trustPartner": [b"stranger.lab"], 75 | "trustType": [b"2"], 76 | "trustAttributes": [b"8"], 77 | }, 78 | }, 79 | } 80 | trust_root_domain = "bloody.lab" 81 | tree = {} 82 | asciitree.branchFactory({":" + trust_root_domain: tree}, [], trust_dict) 83 | tree_printer = asciitree.LeftAligned() 84 | print(tree_printer({trust_root_domain: tree})) 85 | 86 | def test_02CaseInsensitiveCommands(self): 87 | """Test that subcommands are case-insensitive""" 88 | test_cases = [ 89 | # Test lowercase 90 | ["--host", "test.local", "add", "genericall", "--help"], 91 | # Test uppercase 92 | ["--host", "test.local", "add", "GENERICALL", "--help"], 93 | # Test mixed case 94 | ["--host", "test.local", "add", "GenericAll", "--help"], 95 | # Test shadowCredentials variations 96 | ["--host", "test.local", "add", "shadowcredentials", "--help"], 97 | ["--host", "test.local", "add", "SHADOWCREDENTIALS", "--help"], 98 | ["--host", "test.local", "add", "ShadowCredentials", "--help"], 99 | # Test get module 100 | ["--host", "test.local", "get", "object", "--help"], 101 | ["--host", "test.local", "get", "OBJECT", "--help"], 102 | # Test remove module 103 | ["--host", "test.local", "remove", "genericall", "--help"], 104 | ["--host", "test.local", "remove", "SHADOWCREDENTIALS", "--help"], 105 | ] 106 | 107 | for test_args in test_cases: 108 | with self.subTest(args=test_args): 109 | sys.argv = ["bloodyAD.py"] + test_args 110 | try: 111 | # The --help flag will cause SystemExit(0) which is expected 112 | asyncio.run(amain()) 113 | self.fail("Expected SystemExit from --help") 114 | except SystemExit as e: 115 | # --help should exit with code 0 116 | self.assertEqual(e.code, 0, f"Command failed with args: {test_args}") 117 | -------------------------------------------------------------------------------- /bloodyAD/formatters/cryptography.py: -------------------------------------------------------------------------------- 1 | from bloodyAD.formatters.structure import Structure 2 | from bloodyAD import md4 3 | import hashlib 4 | from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat 5 | 6 | 7 | class BCRYPT_RSAKEY_BLOB(Structure): 8 | structure = ( 9 | ("Magic", "" 69 | ) from e 70 | # -k kdc= no provided so we consider the AD DS to also be a KDC 71 | elif not self.kdc and self.dcip: 72 | self.kdc = self.dcip 73 | 74 | # If user domain is different from dc domain, we provide cross realm parameters 75 | else: 76 | if not self.kdc: 77 | self.kdc = self.domain 78 | # If cross realm and no realmc we consider it's the host suffix 79 | if not self.realmc: 80 | self.realmc = self.host.split(".", 1)[1] 81 | # If kdcc hasn't been set we consider the ldap dc provided as kdc 82 | if not self.kdcc and self.dcip: 83 | self.kdcc = self.dcip 84 | elif self.kdcc and not self.dcip: 85 | try: 86 | self.dcip = socket.gethostbyname(self.kdcc) 87 | except socket.gaierror as e: 88 | raise socket.gaierror( 89 | "Can't resolve host provided in -k kdcc=" 90 | ) from e 91 | if not self.dcip: 92 | raise socket.gaierror("host in --host couldn't be resolved, provide one in --dc-ip") 93 | 94 | # Handle case where password is hashes for NTLM auth 95 | if not self.kerberos and self.password and ":" in self.password: 96 | lmhash_maybe, nthash_maybe = self.password.split(":") 97 | try: 98 | int(nthash_maybe, 16) 99 | except ValueError: 100 | self.lmhash, self.nthash = None, None 101 | else: 102 | if len(lmhash_maybe) == 0 and len(nthash_maybe) == 32: 103 | self.nthash = nthash_maybe 104 | self.password = f"{self.lmhash}:{self.nthash}" 105 | elif len(lmhash_maybe) == 32 and len(nthash_maybe) == 32: 106 | self.lmhash = lmhash_maybe 107 | self.nthash = nthash_maybe 108 | self.password = f"{self.lmhash}:{self.nthash}" 109 | else: 110 | self.lmhash, self.nthash = None, None 111 | 112 | # Handle case where certificate is provided 113 | if self.certificate and isinstance(self.certificate, str): 114 | if ":" in self.certificate: 115 | self.key, self.crt = self.certificate.split(":") 116 | else: 117 | self.crt = self.certificate 118 | 119 | class ConnectionHandler: 120 | _ldap = None 121 | 122 | def __init__(self, args=None, config=None): 123 | auth = "" 124 | if args: 125 | scheme = "ldap" 126 | if args.gc: 127 | scheme = "gc" 128 | elif args.secure: 129 | if args.secure == 1: 130 | scheme = "ldaps" 131 | elif args.secure >= 2: 132 | auth = "simple" 133 | 134 | cnf = Config( 135 | domain=args.domain, 136 | username=args.username, 137 | password=args.password, 138 | scheme=scheme, 139 | host=args.host, 140 | krb_args=args.kerberos, 141 | certificate=args.certificate, 142 | dcip=args.dc_ip, 143 | format=args.format, 144 | dns=args.dns, 145 | timeout=args.timeout, 146 | auth=auth 147 | ) 148 | else: 149 | cnf = config 150 | self.conf = cnf 151 | 152 | async def getLdap(self): 153 | if not self._ldap: 154 | self._ldap = await Ldap.create(self) 155 | elif not self._ldap.isactive: 156 | self._ldap = await Ldap.create(self) 157 | return self._ldap 158 | 159 | async def closeLdap(self): 160 | if not self._ldap: 161 | return 162 | await self._ldap.close() 163 | self._ldap = None 164 | 165 | # kwargs takes the same arguments as the Config Class 166 | def copy(self, **kwargs): 167 | # If it's krb creds and the new host hasn't the same REALM as the previous connection we'll have to request a ticket for the new REALM from the previous kdcc if there is one, if not from the previous dc ip if possible 168 | if ( 169 | self.conf.kerberos 170 | and kwargs.get("host") 171 | and self.conf.domain not in kwargs.get("host") 172 | ): 173 | kirbi_tgt = self._ldap._con.auth.selected_authentication_context.kc.ccache.get_all_tgt_kirbis()[ 174 | 0 175 | ] 176 | kwargs["key"] = kirbi_tgt.to_b64() 177 | kwargs["krbformat"] = "kirbi" 178 | kwargs["format"] = "b64" 179 | if self.conf.kdcc: 180 | kwargs["kdc"] = self.conf.kdcc 181 | else: 182 | kwargs["kdc"] = self.conf.dcip 183 | # Reset previous conf params 184 | kwargs["krb_args"] = [] 185 | kwargs["password"] = "" 186 | kwargs["kdcc"] = "" 187 | kwargs["realmc"] = "" 188 | if "dcip" not in kwargs: 189 | kwargs["dcip"] = "" 190 | 191 | newconf = dataclasses.replace(self.conf, **kwargs) 192 | return ConnectionHandler(config=newconf) 193 | -------------------------------------------------------------------------------- /bloodyAD/formatters/bloodhound.py: -------------------------------------------------------------------------------- 1 | from badldap.ldap_objects import ( 2 | MSADUser, MSADMachine, MSADGroup, MSADOU, MSADGPO, 3 | MSADContainer, MSADDMSAUser, MSADGMSAUser, MSADDomainTrust 4 | ) 5 | from badldap.wintypes.asn1.sdflagsrequest import SDFlagsRequestValue 6 | from bloodyAD.formatters import accesscontrol 7 | from badldap.external.bloodhoundpy.resolver import resolve_aces, WELLKNOWN_SIDS 8 | from badldap.external.bloodhoundpy.acls import parse_binary_acl 9 | from badldap.bloodhound import MSLDAPDump2Bloodhound 10 | import zipfile 11 | from bloodyAD.network.ldap import showRecoverable 12 | from bloodyAD.exceptions import LOG 13 | from bloodyAD.utils import global_lazy_adschema 14 | 15 | def create_msldapentry(entry, otype): 16 | """ 17 | Create a badldap ldapentry object from a dictionary entry based on resolved type. 18 | """ 19 | 20 | if otype == 'user': 21 | object_class = entry["attributes"].get('objectClass', []) 22 | if 'msDS-GroupManagedServiceAccount' in object_class: 23 | return MSADGMSAUser.from_ldap(entry) 24 | elif 'msDS-ManagedServiceAccount' in object_class: 25 | return MSADDMSAUser.from_ldap(entry) 26 | else: 27 | return MSADUser.from_ldap(entry) 28 | elif otype == 'computer': 29 | return MSADMachine.from_ldap(entry) 30 | elif otype == 'group': 31 | return MSADGroup.from_ldap(entry) 32 | elif otype == 'gpo': 33 | return MSADGPO.from_ldap(entry) 34 | elif otype == 'ou': 35 | return MSADOU.from_ldap(entry) 36 | elif otype == 'domain': 37 | return MSADDomainTrust.from_ldap(entry) 38 | else: 39 | # container, domain, base, trustaccount, or unknown 40 | return MSADContainer.from_ldap(entry) 41 | 42 | 43 | async def granular_bh(conn, searchbase, ldap_filter, output_path=None): 44 | """ 45 | Generate BloodHound-compatible JSON for queried objects. 46 | """ 47 | ldap = await conn.getLdap() 48 | msbh = MSLDAPDump2Bloodhound(ldap.co_url) 49 | additional_schema = { 50 | 'ms-mcs-admpwd': '79775c0c-d2e0-4b5f-b8e5-0e8e6a0c0e0e', # LAPS password attribute 51 | 'ms-laps-encryptedpassword': 'c835d1d5-f9fb-4c5b-8b8f-8b6f9b6f9b6f', # LAPS encrypted password 52 | 'ms-ds-key-credential-link': '5b47d60f-6090-40b2-9f37-2a4de88f3063', # Key Credential Link 53 | 'service-principal-name': 'f3a64788-5306-11d1-a9c5-0000f80367c1', # SPN attribute 54 | 'user-principal-name': '28630ebf-41d5-11d1-a9c1-0000f80367c1', # UPN attribute 55 | } 56 | msbh.schema.update(additional_schema) 57 | msbh.domainname = ldap.domainname 58 | bh_data = {} 59 | filectr_dict = {} 60 | group_members = {} 61 | adinfo, err = await ldap.get_ad_info() 62 | if err: 63 | raise err 64 | control_flag=( 65 | accesscontrol.OWNER_SECURITY_INFORMATION 66 | + accesscontrol.GROUP_SECURITY_INFORMATION 67 | + accesscontrol.DACL_SECURITY_INFORMATION 68 | ) 69 | req_flags = SDFlagsRequestValue({"Flags": control_flag}) 70 | # First one for recycled and second one for nt security descriptor 71 | controls = showRecoverable() +[("1.2.840.113556.1.4.801", True, req_flags.dump())] 72 | with zipfile.ZipFile(msbh.zipfilepath, 'w', zipfile.ZIP_DEFLATED) as msbh.zipfile: 73 | async for entry, err in ldap.pagedsearch(ldap_filter, attributes=['*'], tree=searchbase, controls=controls): 74 | if err: 75 | raise err 76 | entry_attr = entry['attributes'] 77 | # Deal with known foreign principals 78 | gname = "" 79 | if entry_attr.get('name') in WELLKNOWN_SIDS: 80 | bh_entry = {} 81 | gname, sidtype = WELLKNOWN_SIDS[entry_attr['name']] 82 | obj_type = sidtype.lower() 83 | # bh_entry['type'] = sidtype.capitalize() 84 | # bh_entry['principal'] = '%s@%s' % (gname.upper(), msbh.domainname.upper()) 85 | # bh_entry['ObjectIdentifier'] = '%s-%s' % (msbh.domainname.upper(), entry_attr['objectSid'].upper()) 86 | else: 87 | resolved = msbh.resolve_entry(entry_attr) 88 | obj_type = resolved['type'].lower() 89 | if obj_type == 'trustaccount': 90 | obj_type = 'user' # We will consider Trust accounts are users in BH for now 91 | msldap_entry = create_msldapentry(entry, obj_type) 92 | try: 93 | bh_entry = msldap_entry.to_bh(ldap.domainname, adinfo.objectSid) 94 | except TypeError: 95 | try: 96 | bh_entry = msldap_entry.to_bh(ldap.domainname) 97 | except TypeError: 98 | bh_entry = msldap_entry.to_bh() 99 | if gname: 100 | bh_entry['Properties']['name'] = gname 101 | elif entry_attr.get('isDeleted'): 102 | bh_entry['Properties']['name'] = '%s@%s' % (entry_attr['name'].replace('\n',' ').upper(), ldap.domainname.upper()) 103 | # Parse the ACL 104 | dn_entry, bh_entry, relations = parse_binary_acl( 105 | entry_attr['distinguishedName'], 106 | bh_entry, 107 | obj_type, 108 | entry['attributes']['nTSecurityDescriptor'], 109 | msbh.schema 110 | ) 111 | 112 | # Add to object cache for group membership resolution 113 | msbh.add_ocache(dn_entry, bh_entry['ObjectIdentifier'], bh_entry['Properties']['name'], obj_type) 114 | 115 | bh_entry['Aces'] = resolve_aces(relations, ldap.domainname, adinfo.objectSid, msbh.ocache) 116 | 117 | json_type = obj_type + 's' 118 | bh_type_data = bh_data.get(json_type, msbh.get_json_wrapper(json_type)) 119 | bh_type_data['data'].append(bh_entry) 120 | bh_type_data['meta']['count'] += 1 121 | 122 | # For groups we need post processing to resolve members so we cannot flush now 123 | if json_type == 'groups': 124 | group_members[bh_entry['ObjectIdentifier']] = entry_attr.get('member', []) 125 | elif bh_type_data['meta']['count'] == msbh.MAX_ENTRIES_PER_FILE: 126 | LOG.info('Max entries per file reached for %s, flushing %d entries to zip' % (json_type, bh_type_data['meta']['count'])) 127 | filectr = filectr_dict.get(json_type, 0) 128 | await msbh.write_json_to_zip(json_type, bh_type_data, filectr) 129 | bh_type_data = msbh.get_json_wrapper(json_type) 130 | filectr_dict[json_type] = filectr + 1 131 | bh_data[json_type] = bh_type_data 132 | 133 | for bh_type, bh_type_data in bh_data.items(): 134 | if bh_type_data['meta']['count'] > 0: 135 | # If it's a group we populate members objectId now 136 | if bh_type == 'groups': 137 | await process_members(conn, bh_type_data, group_members, msbh.DNs) 138 | filectr = filectr_dict.get(bh_type, 0) 139 | await msbh.write_json_to_zip(bh_type, bh_type_data, filectr) 140 | LOG.info('Flushing %d %s entries to zip' % (bh_type_data['meta']['count'], bh_type)) 141 | 142 | LOG.info('Bloodhound data saved to %s' % msbh.zipfilepath) 143 | 144 | async def process_members(conn, bh_type_data, group_members, dn_to_sid): 145 | """ 146 | For each group entry in bh_type_data, populate its 'Members' property with resolved ObjectIdentifiers. 147 | Uses dn_to_sid for fast lookup, and LazyAdSchema for unresolved DNs. 148 | """ 149 | # The dn_to_sid gonna be updated by lazy schema and that's the goal 150 | global_lazy_adschema.dn_dict = dn_to_sid 151 | global_lazy_adschema.conn = conn 152 | 153 | # Add all DNs to LazyAdSchema for resolution 154 | for members_DNs in group_members.values(): 155 | for dn in members_DNs: 156 | global_lazy_adschema.adddn(dn) 157 | 158 | # For each group entry, populate Members with ObjectIdentifiers 159 | for entry in bh_type_data['data']: 160 | group_objid = entry.get('ObjectIdentifier') 161 | members_DNs = group_members[group_objid] 162 | members_objids = [] 163 | for dn in members_DNs: 164 | objid = await global_lazy_adschema.getdn(dn.upper()) 165 | # If no obj id found, skip the member 166 | if objid: 167 | if objid.upper() in WELLKNOWN_SIDS: 168 | objid = '%s-%s' % (entry['Properties']['domain'], objid.upper()) 169 | members_objids.append({"ObjectIdentifier": objid}) 170 | entry['Members'] = members_objids -------------------------------------------------------------------------------- /bloodyAD/cli_modules/msldap.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module exposes methods from badldap.examples.msldapclient.MSLDAPClientConsole 3 | as bloodyAD CLI commands. Users can call them like: 4 | bloodyAD msldap 5 | """ 6 | 7 | from badldap.examples.msldapclient import MSLDAPClientConsole 8 | import inspect 9 | 10 | 11 | # Use a class to encapsulate helper functions and avoid exposing them at module level 12 | class _MSLDAPWrapper: 13 | """Internal wrapper class to manage MSLDAPClientConsole method execution""" 14 | 15 | # Interactive methods and methods that should not be exposed 16 | INTERACTIVE_METHODS = { 17 | 'do_login', 18 | 'do_nosig', 19 | 'do_help', 20 | 'do_quit', 21 | 'do_test', # Might be interactive 22 | 'do_plugin', # Might be interactive 23 | 'do_ls', # File system navigation 24 | 'do_cd', # File system navigation 25 | 'do_rm', # File system operation 26 | 'do_nullcb', # Channel binding option 27 | 'do_nocb', # Channel binding option 28 | 'do_bindtree', # Tree binding operation 29 | 'do_cat', # File system operation 30 | } 31 | 32 | # Parameters that should not be exposed 33 | HIDDEN_PARAMETERS = {'show', 'to_print'} 34 | 35 | @staticmethod 36 | def get_msldap_methods(): 37 | """ 38 | Get all non-interactive do_ methods from MSLDAPClientConsole. 39 | Returns a dict mapping method names (without 'do_' prefix) to the method objects. 40 | """ 41 | methods = {} 42 | for attr_name in dir(MSLDAPClientConsole): 43 | if not attr_name.startswith('do_'): 44 | continue 45 | if attr_name in _MSLDAPWrapper.INTERACTIVE_METHODS: 46 | continue 47 | 48 | method = getattr(MSLDAPClientConsole, attr_name) 49 | if not callable(method): 50 | continue 51 | 52 | # Strip 'do_' prefix to get the command name 53 | command_name = attr_name[3:] 54 | methods[command_name] = method 55 | 56 | return methods 57 | 58 | @staticmethod 59 | async def execute_msldap_method(conn, method_name: str, original_method, **kwargs): 60 | """ 61 | Execute an MSLDAPClientConsole method with the given arguments. 62 | 63 | :param conn: ConnectionHandler instance 64 | :param method_name: Name of the method to execute (without 'do_' prefix) 65 | :param original_method: The original method object (to get signature for hidden params) 66 | :param kwargs: Arguments to pass to the method 67 | """ 68 | # Initialize MSLDAPClientConsole without URL (non-interactive mode) 69 | msldapcc = MSLDAPClientConsole() 70 | 71 | # Get the LDAP connection and set it as the connection attribute 72 | ldap = await conn.getLdap() 73 | msldapcc.connection = ldap 74 | 75 | # Also initialize some attributes that might be used by methods 76 | msldapcc.ldapinfo = None 77 | msldapcc.adinfo = None 78 | msldapcc._disable_channel_binding = False 79 | msldapcc._disable_signing = False 80 | msldapcc._null_channel_binding = False 81 | 82 | # Get the method with 'do_' prefix 83 | full_method_name = f'do_{method_name}' 84 | method = getattr(msldapcc, full_method_name) 85 | 86 | # Add hidden parameters with their default values 87 | sig = inspect.signature(original_method) 88 | for param_name, param in sig.parameters.items(): 89 | if param_name in _MSLDAPWrapper.HIDDEN_PARAMETERS and param_name not in kwargs: 90 | if param.default is not None and param.default != inspect.Parameter.empty: 91 | # Use the original default value 92 | kwargs[param_name] = param.default 93 | 94 | # Call the method with provided arguments 95 | # Note: We don't return the result as requested - the method will print its output 96 | await method(**kwargs) 97 | 98 | # Return None to indicate success without returning actual results 99 | return None 100 | 101 | @staticmethod 102 | def create_wrapper_function(method_name, original_method): 103 | """ 104 | Create a wrapper function for an MSLDAPClientConsole method. 105 | This wrapper handles connection setup and method execution. 106 | """ 107 | # Get the method signature 108 | sig = inspect.signature(original_method) 109 | params = list(sig.parameters.values()) 110 | 111 | # Remove 'self' parameter 112 | if params and params[0].name == 'self': 113 | params = params[1:] 114 | 115 | # Filter out hidden parameters (show, to_print) 116 | params = [p for p in params if p.name not in _MSLDAPWrapper.HIDDEN_PARAMETERS] 117 | 118 | # Get docstring and format it for argparse 119 | raw_docstring = inspect.getdoc(original_method) or f"Execute {method_name} from MSLDAPClientConsole" 120 | 121 | # Format docstring as expected by bloodyAD's doc_parser 122 | # First line is the description, then blank line, then :param lines 123 | docstring_lines = [raw_docstring, ""] 124 | for param in params: 125 | docstring_lines.append(f":param {param.name}: {param.name}") 126 | docstring = "\n".join(docstring_lines) 127 | 128 | # Build the function dynamically with proper parameter names 129 | # This is necessary because bloodyAD's main.py extracts parameter names from __code__.co_varnames 130 | param_names = [p.name for p in params] 131 | param_str = ', '.join(param_names) 132 | 133 | # Create function code that calls execute_msldap_method with all parameters as kwargs 134 | func_code = f'''async def wrapper(conn, {param_str}): 135 | kwargs = {{{', '.join(f"'{name}': {name}" for name in param_names)}}} 136 | return await _MSLDAPWrapper.execute_msldap_method(conn, method_name, original_method, **kwargs) 137 | ''' 138 | 139 | # Execute the code to create the function 140 | local_vars = { 141 | '_MSLDAPWrapper': _MSLDAPWrapper, 142 | 'method_name': method_name, 143 | 'original_method': original_method 144 | } 145 | exec(func_code, local_vars) 146 | wrapper = local_vars['wrapper'] 147 | 148 | # Set the wrapper's name and docstring 149 | wrapper.__name__ = method_name 150 | wrapper.__doc__ = docstring 151 | 152 | # Build annotations with proper types 153 | annotations = {'conn': object} 154 | for param in params: 155 | if param.annotation != inspect.Parameter.empty: 156 | annotations[param.name] = param.annotation 157 | else: 158 | # Infer type from default value if available, otherwise default to str 159 | if param.default is not None and param.default != inspect.Parameter.empty: 160 | annotations[param.name] = type(param.default) 161 | else: 162 | annotations[param.name] = str 163 | wrapper.__annotations__ = annotations 164 | 165 | # Build signature with proper defaults 166 | new_params = [inspect.Parameter('conn', inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=object)] 167 | for param in params: 168 | # Preserve the default value and annotation 169 | if param.annotation != inspect.Parameter.empty: 170 | new_param = inspect.Parameter( 171 | param.name, 172 | inspect.Parameter.POSITIONAL_OR_KEYWORD, 173 | default=param.default, 174 | annotation=param.annotation 175 | ) 176 | else: 177 | # Infer type from default value if available, otherwise default to str 178 | if param.default is not None and param.default != inspect.Parameter.empty: 179 | inferred_type = type(param.default) 180 | else: 181 | inferred_type = str 182 | new_param = inspect.Parameter( 183 | param.name, 184 | inspect.Parameter.POSITIONAL_OR_KEYWORD, 185 | default=param.default, 186 | annotation=inferred_type 187 | ) 188 | new_params.append(new_param) 189 | 190 | wrapper.__signature__ = inspect.Signature(new_params) 191 | 192 | return wrapper 193 | 194 | 195 | # Dynamically create all wrapper functions and add them to this module's namespace 196 | def _initialize_module(): 197 | """Initialize module by creating all wrapper functions""" 198 | methods = _MSLDAPWrapper.get_msldap_methods() 199 | for method_name, method_obj in methods.items(): 200 | wrapper_func = _MSLDAPWrapper.create_wrapper_function(method_name, method_obj) 201 | # Add to module namespace 202 | globals()[method_name] = wrapper_func 203 | 204 | _initialize_module() 205 | # Clean up the initialization function from module namespace 206 | del _initialize_module 207 | -------------------------------------------------------------------------------- /bloodyAD/formatters/formatters.py: -------------------------------------------------------------------------------- 1 | from bloodyAD.formatters import ( 2 | accesscontrol, 3 | common, 4 | cryptography, 5 | dns, 6 | ) 7 | from bloodyAD.exceptions import LOG 8 | import base64 9 | from functools import lru_cache 10 | from winacl.dtyp.security_descriptor import SECURITY_DESCRIPTOR 11 | 12 | 13 | def formatAccountControl(userAccountControl): 14 | userAccountControl = int(userAccountControl.decode()) 15 | return [ 16 | key 17 | for key, val in accesscontrol.ACCOUNT_FLAGS.items() 18 | if userAccountControl & val == val 19 | ] 20 | 21 | 22 | def formatTrustDirection(trustDirection): 23 | trustDirection = int(trustDirection.decode()) 24 | for key, val in common.TRUST_DIRECTION.items(): 25 | if trustDirection == val: 26 | return key 27 | return trustDirection 28 | 29 | 30 | def formatTrustAttributes(trustAttributes): 31 | trustAttributes = int(trustAttributes.decode()) 32 | return [ 33 | key 34 | for key, val in common.TRUST_ATTRIBUTES.items() 35 | if trustAttributes & val == val 36 | ] 37 | 38 | 39 | def formatTrustType(trustType): 40 | trustType = int(trustType.decode()) 41 | for key, val in common.TRUST_TYPE.items(): 42 | if trustType == val: 43 | return key 44 | return trustType 45 | 46 | 47 | def formatSD(sd_bytes): 48 | return SECURITY_DESCRIPTOR.from_bytes(sd_bytes).to_sddl() 49 | 50 | 51 | def formatFunctionalLevel(behavior_version): 52 | behavior_version = behavior_version.decode() 53 | return ( 54 | common.FUNCTIONAL_LEVEL[behavior_version] 55 | if behavior_version in common.FUNCTIONAL_LEVEL 56 | else behavior_version 57 | ) 58 | 59 | 60 | def formatSchemaVersion(objectVersion): 61 | objectVersion = objectVersion.decode() 62 | return ( 63 | common.SCHEMA_VERSION[objectVersion] 64 | if objectVersion in common.SCHEMA_VERSION 65 | else objectVersion 66 | ) 67 | 68 | 69 | def formatGMSApass(managedPassword): 70 | gmsa_blob = cryptography.MSDS_MANAGEDPASSWORD_BLOB(managedPassword) 71 | nt_hash = gmsa_blob.toNtHash() 72 | return { 73 | "NT": nt_hash, 74 | "B64ENCODED": base64.b64encode(gmsa_blob["CurrentPassword"]).decode(), 75 | } 76 | 77 | 78 | def formatDnsRecord(dns_record): 79 | return dns.Record(dns_record).toDict() 80 | 81 | 82 | def formatWellKnownObjects(wellKnown_object): 83 | dn_binary = common.DNBinary(wellKnown_object) 84 | if dn_binary.binary_value in common.WELLKNOWN_GUID: 85 | dn_binary.binary_value = common.WELLKNOWN_GUID[dn_binary.binary_value] 86 | return dn_binary 87 | 88 | 89 | def formatKeyCredentialLink(key_dnbinary): 90 | return cryptography.KEYCREDENTIALLINK_BLOB( 91 | common.DNBinary(key_dnbinary).value 92 | ).toDict() 93 | 94 | 95 | from badldap.protocol.typeconversion import ( 96 | LDAP_WELL_KNOWN_ATTRS, 97 | MSLDAP_BUILTIN_ATTRIBUTE_TYPES, 98 | single_guid, 99 | int2timedelta 100 | ) 101 | 102 | 103 | def formatFactory(format_func, origin_format): 104 | def genericFormat(val, encode=False, *args): 105 | if encode: 106 | return origin_format(val, encode, *args) 107 | if not isinstance(val, list): 108 | return format_func(val) 109 | return [format_func(e) for e in val] 110 | # The function name is set to the original function name for encode changes logic 111 | genericFormat.__name__ = origin_format.__name__ 112 | return genericFormat 113 | 114 | @lru_cache 115 | def getFormatters(): 116 | """ 117 | Returns a dictionary mapping attribute names to their formatting functions. 118 | This doesn't modify badldap's global dictionaries, allowing for local formatting. 119 | 120 | Returns both bloodyAD custom formatters and badldap default formatters. 121 | BloodyAD formatters take precedence over badldap formatters. 122 | """ 123 | def make_formatter(format_func): 124 | """Wrapper to handle list/non-list values consistently""" 125 | def wrapper(val): 126 | if isinstance(val, list): 127 | # if len(val) == 1: 128 | # return format_func(val[0]) 129 | # else: 130 | return [format_func(v) for v in val] 131 | else: 132 | return format_func(val) 133 | return wrapper 134 | 135 | def make_list_formatter(format_func): 136 | """Wrapper for formatters that expect the value as a list""" 137 | def wrapper(val): 138 | if isinstance(val, list): 139 | return format_func(val) 140 | else: 141 | return format_func([val]) 142 | return wrapper 143 | 144 | # Start with badldap's default formatters 145 | formatters_map = {} 146 | 147 | # First, import badldap's MSLDAP_BUILTIN_ATTRIBUTE_TYPES formatters (higher priority) 148 | for attr_name, formatter in MSLDAP_BUILTIN_ATTRIBUTE_TYPES.items(): 149 | formatters_map[attr_name] = make_list_formatter(formatter) 150 | 151 | # Then, add LDAP_WELL_KNOWN_ATTRS formatters only if not already present 152 | for attr_name, formatter in LDAP_WELL_KNOWN_ATTRS.items(): 153 | if attr_name not in formatters_map: 154 | formatters_map[attr_name] = make_list_formatter(formatter) 155 | 156 | # Now override with bloodyAD custom formatters (these take highest precedence) 157 | # Security descriptors - expect single bytes value 158 | formatters_map["nTSecurityDescriptor"] = make_formatter(formatSD) 159 | formatters_map["msDS-AllowedToActOnBehalfOfOtherIdentity"] = make_formatter(formatSD) 160 | formatters_map["msDS-GroupMSAMembership"] = make_formatter(formatSD) 161 | 162 | # Passwords and credentials - expect single bytes value 163 | formatters_map["msDS-ManagedPassword"] = make_formatter(formatGMSApass) 164 | 165 | # Account control - expect single bytes value 166 | formatters_map["userAccountControl"] = make_formatter(formatAccountControl) 167 | formatters_map["msDS-User-Account-Control-Computed"] = make_formatter(formatAccountControl) 168 | 169 | # Trust attributes - expect single bytes value 170 | formatters_map["trustDirection"] = make_formatter(formatTrustDirection) 171 | formatters_map["trustAttributes"] = make_formatter(formatTrustAttributes) 172 | formatters_map["trustType"] = make_formatter(formatTrustType) 173 | 174 | # Versions and levels - expect single bytes value 175 | formatters_map["msDS-Behavior-Version"] = make_formatter(formatFunctionalLevel) 176 | formatters_map["objectVersion"] = make_formatter(formatSchemaVersion) 177 | 178 | # DNS and other - expect single bytes value 179 | formatters_map["dnsRecord"] = make_formatter(formatDnsRecord) 180 | formatters_map["msDS-KeyCredentialLink"] = make_formatter(formatKeyCredentialLink) 181 | formatters_map["wellKnownObjects"] = make_formatter(formatWellKnownObjects) 182 | 183 | formatters_map["attributeSecurityGUID"] = make_formatter(single_guid) 184 | formatters_map["msDS-MinimumPasswordAge"] = make_formatter(int2timedelta) 185 | # GUID and time attributes - keep badldap's formatters (already added above) 186 | # attributeSecurityGUID and msDS-MinimumPasswordAge use badldap's single_guid and int2timedelta 187 | 188 | return formatters_map 189 | 190 | 191 | def applyFormatters(attributes): 192 | """ 193 | Apply formatters to attributes dictionary. 194 | 195 | Args: 196 | attributes: Dictionary of attribute names to values 197 | formatters_map: Dictionary of attribute names to formatter functions 198 | 199 | Returns: 200 | Dictionary with formatted attributes 201 | """ 202 | formatted_attrs = {} 203 | formatters_map = getFormatters() 204 | for attr_name, attr_value in attributes.items(): 205 | if attr_name in formatters_map: 206 | formatter = formatters_map[attr_name] 207 | try: 208 | formatted_attrs[attr_name] = formatter(attr_value) 209 | except Exception as e: 210 | # If formatting fails, log the error and keep original value 211 | LOG.debug( 212 | f"Failed to format attribute '{attr_name}': {type(e).__name__}: {e}" 213 | ) 214 | formatted_attrs[attr_name] = attr_value 215 | else: 216 | formatted_attrs[attr_name] = attr_value 217 | 218 | return formatted_attrs 219 | 220 | 221 | from winacl.dtyp.ace import ( 222 | SYSTEM_AUDIT_OBJECT_ACE, 223 | SDDL_ACE_TYPE_MAPS_INV, 224 | aceflags_to_sddl, 225 | accessmask_to_sddl, 226 | ACE_OBJECT_PRESENCE, 227 | ) 228 | 229 | 230 | def to_sddl(self, sd_object_type=None): 231 | # ace_type;ace_flags;rights;object_guid;inherit_object_guid;account_sid;(resource_attribute) 232 | return "(%s;%s;%s;%s;%s;%s)" % ( 233 | SDDL_ACE_TYPE_MAPS_INV[self.AceType], 234 | aceflags_to_sddl(self.AceFlags), 235 | accessmask_to_sddl(self.Mask, self.sd_object_type), 236 | ( 237 | self.ObjectType.to_bytes() 238 | if self.AceFlags & ACE_OBJECT_PRESENCE.ACE_OBJECT_TYPE_PRESENT 239 | else "" 240 | ), 241 | ( 242 | self.InheritedObjectType.to_bytes() 243 | if self.Flags & ACE_OBJECT_PRESENCE.ACE_INHERITED_OBJECT_TYPE_PRESENT 244 | else "" 245 | ), 246 | self.Sid.to_sddl(), 247 | ) 248 | 249 | 250 | setattr(SYSTEM_AUDIT_OBJECT_ACE, "to_sddl", to_sddl) 251 | -------------------------------------------------------------------------------- /bloodyAD/formatters/dns.py: -------------------------------------------------------------------------------- 1 | from bloodyAD.formatters.structure import Structure 2 | import ipaddress 3 | 4 | # Credits to dirkjanm and his tool adidnsdump 5 | """ 6 | DNS_RECORD_TYPE - [MS-DNSP] section 2.2.2.1.1 7 | Prefix DNS_TYPE_ has been removed for implementation purposes and only a subset of constants is implemented 8 | """ 9 | DNS_RECORD_TYPE = { 10 | "A": 0x1, 11 | "AAAA": 0x1C, 12 | "CNAME": 0x5, 13 | "MX": 0xF, 14 | "PTR": 0xC, 15 | "SRV": 0x21, 16 | "TXT": 0x10, 17 | "NS": 0x2, 18 | "SOA": 0x6, 19 | } 20 | 21 | 22 | class Record(Structure): 23 | """ 24 | dnsRecord - [MS-DNSP] section 2.3.2.2 25 | """ 26 | 27 | structure = ( 28 | ("DataLength", "I"), 35 | ("Reserved", "H"), ("nameExchange", ":", DNS_COUNT_NAME)) 199 | 200 | def toDict(self): 201 | return { 202 | "Name": self["nameExchange"].formatCanonical(), 203 | "Preference": self["wPreference"], 204 | } 205 | 206 | def fromCanonical(self, fqdn, preference): 207 | self["wPreference"] = preference 208 | record_name = DNS_COUNT_NAME() 209 | record_name.fromCanonical(fqdn) 210 | self["nameExchange"] = record_name 211 | 212 | 213 | class DNS_RPC_RECORD_SRV(Structure): 214 | """ 215 | DNS_RPC_RECORD_SRV - [MS-DNSP] section 2.2.2.2.4.18 216 | """ 217 | 218 | structure = ( 219 | ("wPriority", ">H"), 220 | ("wWeight", ">H"), 221 | ("wPort", ">H"), 222 | ("nameTarget", ":", DNS_COUNT_NAME), 223 | ) 224 | 225 | def toDict(self): 226 | return { 227 | "Target": self["nameTarget"].formatCanonical(), 228 | "Port": self["wPort"], 229 | "Priority": self["wPriority"], 230 | "Weight": self["wWeight"], 231 | } 232 | 233 | def fromCanonical(self, fqdn, port, priority, weight): 234 | self["wPriority"] = priority 235 | self["wWeight"] = weight 236 | self["wPort"] = port 237 | record_name = DNS_COUNT_NAME() 238 | record_name.fromCanonical(fqdn) 239 | self["nameTarget"] = record_name 240 | 241 | 242 | class DNS_RPC_NAME(Structure): 243 | """ 244 | DNS_RPC_NAME - [MS-DNSP] section 2.2.2.2.1 245 | Used for FQDN in RPC communications and other strings 246 | """ 247 | 248 | structure = (("cchNameLength", "B-dnsName"), ("dnsName", ":")) 249 | 250 | def formatCanonical(self): 251 | return self["dnsName"].decode("utf-8") 252 | 253 | 254 | class DNS_RPC_RECORD_STRING(Structure): 255 | """ 256 | DNS_RPC_RECORD_STRING - [MS-DNSP] section 2.2.2.2.4.6 257 | Used for TXT records 258 | """ 259 | 260 | structure = (("stringData", ":", DNS_RPC_NAME),) 261 | 262 | def formatCanonical(self): 263 | return self["stringData"].formatCanonical() 264 | 265 | def fromCanonical(self, canonical): 266 | data_container = DNS_RPC_NAME() 267 | data_container["dnsName"] = canonical.encode("utf-8") 268 | self["stringData"] = data_container 269 | 270 | 271 | class DNS_RPC_RECORD_SOA(Structure): 272 | """ 273 | DNS_RPC_RECORD_SOA - [MS-DNSP] section 2.2.2.2.4.3 274 | """ 275 | 276 | structure = ( 277 | ("dwSerialNo", ">I"), 278 | ("dwRefresh", ">I"), 279 | ("dwRetry", ">I"), 280 | ("dwExpire", ">I"), 281 | ("dwMinimumTtl", ">I"), 282 | ("namePrimaryServer", ":", DNS_COUNT_NAME), 283 | ("zoneAdminEmail", ":", DNS_COUNT_NAME), 284 | ) 285 | 286 | def toDict(self): 287 | return { 288 | "SerialNo": self["dwSerialNo"], 289 | "Refresh": self["dwRefresh"], 290 | "Retry": self["dwRetry"], 291 | "Expire": self["dwExpire"], 292 | "MinimumTtl": self["dwMinimumTtl"], 293 | "PrimaryServer": self["namePrimaryServer"].formatCanonical(), 294 | "zoneAdminEmail": self["zoneAdminEmail"].formatCanonical(), 295 | } 296 | -------------------------------------------------------------------------------- /bloodyAD/cli_modules/remove.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | from typing import Literal 3 | import badldap 4 | from bloodyAD import utils, ConnectionHandler 5 | from bloodyAD.exceptions import LOG 6 | from bloodyAD.formatters import accesscontrol, common, dns, cryptography 7 | from bloodyAD.exceptions import BloodyError 8 | from bloodyAD.network.ldap import Change 9 | 10 | 11 | async def dcsync(conn: ConnectionHandler, trustee: str): 12 | """ 13 | Remove DCSync right for provided trustee 14 | 15 | :param trustee: sAMAccountName, DN or SID of the trustee 16 | """ 17 | ldap = await conn.getLdap() 18 | new_sd, _ = await utils.getSD(conn, ldap.domainNC) 19 | if "s-1-" in trustee.lower(): 20 | trustee_sid = trustee 21 | else: 22 | entry = None 23 | async for e in ldap.bloodysearch(trustee, attr=["objectSid"]): 24 | entry = e 25 | break 26 | trustee_sid = entry["objectSid"] 27 | access_mask = accesscontrol.ACCESS_FLAGS["ADS_RIGHT_DS_CONTROL_ACCESS"] 28 | utils.delRight(new_sd, trustee_sid, access_mask) 29 | 30 | req_flags = badldap.wintypes.asn1.sdflagsrequest.SDFlagsRequestValue( 31 | {"Flags": accesscontrol.DACL_SECURITY_INFORMATION} 32 | ) 33 | controls = [("1.2.840.113556.1.4.801", True, req_flags.dump())] 34 | 35 | await ldap.bloodymodify( 36 | ldap.domainNC, 37 | {"nTSecurityDescriptor": [(Change.REPLACE.value, new_sd.getData())]}, 38 | controls, 39 | ) 40 | 41 | LOG.info(f"{trustee} can't DCSync anymore") 42 | 43 | 44 | async def dnsRecord( 45 | conn: ConnectionHandler, 46 | name: str, 47 | data: str, 48 | dnstype: Literal["A", "AAAA", "CNAME", "MX", "PTR", "SRV", "TXT"] = "A", 49 | zone: str = "CurrentDomain", 50 | ttl: int = None, 51 | preference: int = None, 52 | port: int = None, 53 | priority: int = None, 54 | weight: int = None, 55 | forest: bool = False, 56 | ): 57 | """ 58 | Remove a DNS record of an AD environment. 59 | 60 | :param name: name of the dnsNode object (hostname) which contains the record 61 | :param data: DNS record data 62 | :param dnstype: DNS record type 63 | :param zone: DNS zone 64 | :param ttl: DNS record TTL 65 | :param preference: DNS MX record preference 66 | :param port: listening port of the service in a DNS SRV record 67 | :param priority: priority of a DNS SRV record against concurrent 68 | :param weight: weight of a DNS SRV record against concurrent 69 | :param forest: if set, will fetch the dns record in forest instead of domain 70 | """ 71 | 72 | ldap = await conn.getLdap() 73 | naming_context = "," + ldap.domainNC 74 | if zone == "CurrentDomain": 75 | zone = "" 76 | for label in naming_context.split(",DC="): 77 | if label: 78 | zone += "." + label 79 | if forest: 80 | zone = "_msdcs" + zone 81 | else: 82 | # Removes first dot 83 | zone = zone[1:] 84 | 85 | # TODO: take into account custom ForestDnsZones and DomainDnsZones partition name ? 86 | if forest: 87 | zone_type = "ForestDnsZones" 88 | else: 89 | zone_type = "DomainDnsZones" 90 | 91 | zone_dn = f",DC={zone},CN=MicrosoftDNS,DC={zone_type}{naming_context}" 92 | record_dn = f"DC={name}{zone_dn}" 93 | 94 | record_to_remove = None 95 | entry = None 96 | async for e in ldap.bloodysearch(record_dn, attr=["dnsRecord"], raw=True): 97 | entry = e 98 | break 99 | dns_list = entry["dnsRecord"] 100 | for raw_record in dns_list: 101 | record = dns.Record(raw_record) 102 | tmp_record = dns.Record() 103 | 104 | if not ttl: 105 | ttl = record["TtlSeconds"] 106 | tmp_record.fromDict( 107 | data, 108 | dnstype, 109 | ttl, 110 | record["Rank"], 111 | record["Serial"], 112 | preference, 113 | port, 114 | priority, 115 | weight, 116 | ) 117 | if tmp_record.getData() == raw_record: 118 | record_to_remove = raw_record 119 | break 120 | 121 | if not record_to_remove: 122 | LOG.warning("Record not found") 123 | return 124 | 125 | if len(dns_list) > 1: 126 | await ldap.bloodymodify( 127 | record_dn, {"dnsRecord": [(Change.DELETE.value, record_to_remove)]} 128 | ) 129 | else: 130 | await ldap.bloodydelete(record_dn) 131 | 132 | LOG.info(f"Given record has been successfully removed from {name}") 133 | 134 | 135 | async def genericAll(conn: ConnectionHandler, target: str, trustee: str): 136 | """ 137 | Remove full control of trustee on target 138 | 139 | :param target: sAMAccountName, DN or SID of the target 140 | :param trustee: sAMAccountName, DN or SID of the trustee 141 | """ 142 | ldap = await conn.getLdap() 143 | new_sd, _ = await utils.getSD(conn, target) 144 | if "s-1-" in trustee.lower(): 145 | trustee_sid = trustee 146 | else: 147 | entry = None 148 | async for e in ldap.bloodysearch(trustee, attr=["objectSid"]): 149 | entry = e 150 | break 151 | trustee_sid = entry["objectSid"] 152 | utils.delRight(new_sd, trustee_sid) 153 | 154 | req_flags = badldap.wintypes.asn1.sdflagsrequest.SDFlagsRequestValue( 155 | {"Flags": accesscontrol.DACL_SECURITY_INFORMATION} 156 | ) 157 | controls = [("1.2.840.113556.1.4.801", True, req_flags.dump())] 158 | 159 | await ldap.bloodymodify( 160 | target, 161 | {"nTSecurityDescriptor": [(Change.REPLACE.value, new_sd.getData())]}, 162 | controls, 163 | ) 164 | 165 | LOG.info(f"{trustee} doesn't have GenericAll on {target} anymore") 166 | 167 | 168 | async def groupMember(conn: ConnectionHandler, group: str, member: str): 169 | """ 170 | Remove member (user, group, computer) from group 171 | 172 | :param group: sAMAccountName, DN or SID of the group 173 | :param member: sAMAccountName, DN or SID of the member 174 | """ 175 | # This is equivalent to classic add member, 176 | # see [MS-ADTS] - 3.1.1.3.1.2.4 Alternative Forms of DNs 177 | # But also has the advantage of being compatible with foreign security principals, 178 | # see [MS-ADTS] - 3.1.1.5.3.3 Processing Specifics 179 | ldap = await conn.getLdap() 180 | if "s-1-" in member.lower(): 181 | # We assume member is an SID 182 | member_transformed = f"" 183 | else: 184 | member_transformed = await ldap.dnResolver(member) 185 | 186 | await ldap.bloodymodify( 187 | group, {"member": [(Change.DELETE.value, member_transformed)]} 188 | ) 189 | LOG.info(f"{member} removed from {group}") 190 | 191 | 192 | async def object(conn: ConnectionHandler, target: str): 193 | """ 194 | Remove object (user, group, computer, organizational unit, etc) 195 | 196 | :param target: sAMAccountName, DN or SID of the target 197 | """ 198 | ldap = await conn.getLdap() 199 | await ldap.bloodydelete(target) 200 | LOG.info(f"{target} has been removed") 201 | 202 | 203 | async def rbcd(conn: ConnectionHandler, target: str, service: str): 204 | """ 205 | Remove Resource Based Constraint Delegation for service on target 206 | 207 | :param target: sAMAccountName, DN or SID of the target 208 | :param service: sAMAccountName, DN or SID of the service account 209 | """ 210 | ldap = await conn.getLdap() 211 | control_flag = 0 212 | new_sd, _ = await utils.getSD( 213 | conn, target, "msDS-AllowedToActOnBehalfOfOtherIdentity", control_flag 214 | ) 215 | if "s-1-" in service.lower(): 216 | service_sid = service 217 | else: 218 | entry = None 219 | async for e in ldap.bloodysearch(service, attr=["objectSid"]): 220 | entry = e 221 | break 222 | service_sid = entry["objectSid"] 223 | access_mask = accesscontrol.ACCESS_FLAGS["ADS_RIGHT_DS_CONTROL_ACCESS"] 224 | utils.delRight(new_sd, service_sid, access_mask) 225 | 226 | attr_values = [] 227 | if len(new_sd["Dacl"].aces) > 0: 228 | attr_values = new_sd.getData() 229 | await ldap.bloodymodify( 230 | target, 231 | { 232 | "msDS-AllowedToActOnBehalfOfOtherIdentity": [ 233 | ( 234 | Change.REPLACE.value, 235 | attr_values, 236 | ) 237 | ] 238 | }, 239 | ) 240 | 241 | LOG.info(f"{service} can't impersonate users on {target} anymore") 242 | 243 | 244 | async def shadowCredentials(conn: ConnectionHandler, target: str, key: str = None): 245 | """ 246 | Remove Key Credentials from target 247 | 248 | :param target: sAMAccountName, DN or SID of the target 249 | :param key: RSA key of Key Credentials to remove from the target, removes all if key not specified 250 | """ 251 | ldap = await conn.getLdap() 252 | entry = None 253 | async for e in ldap.bloodysearch(target, attr=["msDS-KeyCredentialLink"], raw=True): 254 | entry = e 255 | break 256 | keyCreds = entry.get("msDS-KeyCredentialLink", []) 257 | newKeyCreds = [] 258 | isFound = False 259 | for keyCred in keyCreds: 260 | key_raw = common.DNBinary(keyCred).value 261 | key_blob = cryptography.KEYCREDENTIALLINK_BLOB(key_raw) 262 | if key and key_blob.getKeyID() != binascii.unhexlify(key): 263 | newKeyCreds.append(keyCred.decode()) 264 | else: 265 | isFound = True 266 | LOG.debug("Key to delete found") 267 | 268 | if not isFound: 269 | LOG.warning("No key found") 270 | return 271 | 272 | await ldap.bloodymodify( 273 | target, {"msDS-KeyCredentialLink": [(Change.REPLACE.value, newKeyCreds)]} 274 | ) 275 | str_key = key if key else "All keys" 276 | LOG.info(f"{str_key} removed") 277 | 278 | 279 | async def uac(conn: ConnectionHandler, target: str, f: list = None): 280 | """ 281 | Remove property flags altering user/computer object behavior 282 | 283 | :param target: sAMAccountName, DN or SID of the target 284 | :param f: name of property flag to remove, can be called multiple times if multiple flags to remove (e.g -f LOCKOUT -f ACCOUNTDISABLE) 285 | """ 286 | ldap = await conn.getLdap() 287 | uac = 0 288 | for flag in f: 289 | uac |= accesscontrol.ACCOUNT_FLAGS[flag] 290 | 291 | try: 292 | entry = None 293 | async for e in ldap.bloodysearch(target, attr=["userAccountControl"], raw=True): 294 | entry = e 295 | break 296 | old_uac = entry["userAccountControl"][0] 297 | except IndexError as e: 298 | entry = None 299 | async for search_entry in ldap.bloodysearch(target, attr=["allowedAttributes"]): 300 | entry = search_entry 301 | break 302 | for allowed in entry["allowedAttributes"]: 303 | if "userAccountControl" in allowed: 304 | raise BloodyError( 305 | "Current user doesn't have the right to read userAccountControl on" 306 | f" {target}" 307 | ) from e 308 | raise BloodyError(f"{target} doesn't have userAccountControl attribute") from e 309 | 310 | uac = int(old_uac) & ~uac 311 | await ldap.bloodymodify( 312 | target, {"userAccountControl": [(Change.REPLACE.value, uac)]} 313 | ) 314 | 315 | LOG.info(f"{f} property flags removed from {target}'s userAccountControl") 316 | -------------------------------------------------------------------------------- /bloodyAD/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from bloodyAD.patch import * 3 | from bloodyAD import cli_modules, ConnectionHandler, exceptions 4 | import sys, argparse, types, json, asyncio 5 | 6 | # For dynamic argparse 7 | import inspect, pkgutil, importlib 8 | 9 | 10 | async def amain(): 11 | parser = argparse.ArgumentParser(description="AD Privesc Swiss Army Knife") 12 | 13 | parser.add_argument("-d", "--domain", help="Domain used for NTLM authentication") 14 | parser.add_argument( 15 | "-u", "--username", help="Username used for NTLM authentication" 16 | ) 17 | parser.add_argument( 18 | "-p", 19 | "--password", 20 | help=( 21 | "password or LMHASH:NTHASH for NTLM authentication, password or AES/RC4 key for kerberos, password for certificate" 22 | " (Do not specify to trigger integrated windows authentication)" 23 | ), 24 | ) 25 | parser.add_argument( 26 | "-k", 27 | "--kerberos", 28 | nargs="*", 29 | help=( 30 | "Enable Kerberos authentication. If '-p' is provided it will try to query a TGT with it. You can also provide a list of one or more optional keywords as '-k kdc=192.168.100.1 kdcc=192.168.150.1 realmc=foreign.realm.corp =/home/silver/Admin.ccache', being ccache, kirbi or keytab, 'kdc' being the kerberos server for the keyfile provided and 'realmc' and 'kdcc' for cross realm (the realm of the '--host' provided)" 31 | ), 32 | ) 33 | parser.add_argument( 34 | "-f", 35 | "--format", 36 | help="Specify format for '--password' or '-k '", 37 | choices=["b64", "hex", "aes", "rc4", "default"], 38 | default="default", 39 | ) 40 | parser.add_argument( 41 | "-c", 42 | "--certificate", 43 | nargs="?", 44 | help='Schannel authentication or krb pkinit if -k also provided, e.g: "path/to/key:path/to/cert" (Use Windows Certstore with krb if left empty)', 45 | ) 46 | 47 | parser.add_argument( 48 | "-s", 49 | "--secure", 50 | help="Use LDAP/GC over TLS (LDAPS/GCS). Use -ss to remove all encryption/signing (useful for debug).", 51 | action="count", 52 | default=0, 53 | ) 54 | 55 | parser.add_argument( 56 | "-H", 57 | "--host", 58 | help="Hostname or IP of the DC (ex: my.dc.local or 172.16.1.3)", 59 | required=True 60 | ) 61 | parser.add_argument( 62 | "-i", 63 | "--dc-ip", 64 | help="IP of the DC (useful if you provided a --host which can't resolve)", 65 | ) 66 | parser.add_argument( 67 | "--dns", 68 | help="IP of the DNS to resolve AD names (useful for inter-domain functions)", 69 | ) 70 | parser.add_argument( 71 | "-t", 72 | "--timeout", 73 | help="Connection timeout in seconds", 74 | ) 75 | parser.add_argument( 76 | "--gc", 77 | help="Connect to Global Catalog (GC)", 78 | action="store_true", 79 | default=False, 80 | ) 81 | parser.add_argument( 82 | "-v", 83 | "--verbose", 84 | help="Adjust output verbosity", 85 | choices=["QUIET", "INFO", "DEBUG", "TRACE"], 86 | default="INFO", 87 | ) 88 | parser.add_argument( 89 | "--json", 90 | help="Output results in JSON format", 91 | action="store_true", 92 | default=False, 93 | ) 94 | 95 | subparsers = parser.add_subparsers(title="Commands") 96 | submodnames = [] 97 | # Store mapping of lowercase function names to their original case for case-insensitive matching 98 | function_name_map = {} 99 | 100 | # Iterates all submodules in module package and creates one parser per submodule 101 | for importer, submodname, ispkg in pkgutil.iter_modules(cli_modules.__path__): 102 | submodnames.append(submodname) 103 | subparser = subparsers.add_parser( 104 | submodname, help=f"[{submodname.upper()}] function category" 105 | ) 106 | subsubparsers = subparser.add_subparsers(title=f"{submodname} commands") 107 | submodule = importlib.import_module("." + submodname, cli_modules.__name__) 108 | for function_name, function in inspect.getmembers( 109 | submodule, inspect.isfunction 110 | ): 111 | # Store case-insensitive mapping for this submodule's functions 112 | function_name_map[f"{submodname}:{function_name.lower()}"] = function_name 113 | 114 | function_doc, params_doc = doc_parser(inspect.getdoc(function)) 115 | # This formatter class prints default values 116 | subsubparser = subsubparsers.add_parser( 117 | function_name, 118 | help=function_doc, 119 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, 120 | ) 121 | # Gets function signature to extract parameters default values 122 | func_signature = inspect.signature(function) 123 | function_annotations = {pname: pval for pname, pval in function.__annotations__.items() if pname != "conn"} 124 | for param_name, param_value, param_doc in zip( 125 | function_annotations.keys(), 126 | function_annotations.values(), 127 | params_doc, 128 | ): 129 | parser_args = {} 130 | 131 | # Fetches help from param_doc, if param_name doesn't match 132 | # name in param_doc, raises exception 133 | try: 134 | param_doc = param_doc.split(f":param {param_name}: ")[1] 135 | except IndexError as e: 136 | raise IndexError(f"param_name '{param_name}' doesn't match '{param_doc}'") from e 137 | parser_args["help"] = param_doc 138 | 139 | # If parameter has a default value, then it will be an optional argument 140 | param_signature = func_signature.parameters.get(param_name) 141 | if param_signature.default is param_signature.empty: 142 | arg_name = param_name 143 | else: 144 | # If param with one letter only add just one dash 145 | if len(param_name) < 2: 146 | arg_name = f"-{param_name}" 147 | else: 148 | param_name = param_name.replace("_", "-") 149 | arg_name = f"--{param_name}" 150 | parser_args["default"] = param_signature.default 151 | 152 | # If param_type is not a string describing a type it's a literal with a restricted set of values 153 | if "Literal" in str(param_value): 154 | parser_args["choices"] = param_value.__args__ 155 | parser_args["type"] = type(param_value.__args__[0]) 156 | else: 157 | if param_value.__name__ == "bool": 158 | parser_args["action"] = "store_true" 159 | elif param_value.__name__ == "list": 160 | parser_args["action"] = "append" 161 | parser_args["type"] = str 162 | else: 163 | parser_args["type"] = param_value 164 | 165 | subsubparser.add_argument(arg_name, **parser_args) 166 | # If a function name is provided in cli, arg.func will exist with function as value 167 | subsubparser.set_defaults(func=function) 168 | 169 | # Preprocess the input arguments because nargs ? and * can capture subparsers commands if put at the end 170 | # So we always put the --host option at the end 171 | # Also normalize function names to be case-insensitive 172 | input_args = sys.argv[1:] 173 | isHost = False 174 | parsed_args = [] 175 | host_arg = None 176 | current_submodname = None 177 | for arg in input_args: 178 | if arg in ["-H", "--host"]: 179 | isHost = True 180 | elif isHost: 181 | isHost = False 182 | host_arg = arg 183 | elif arg in submodnames: 184 | current_submodname = arg 185 | parsed_args.append("--host") 186 | parsed_args.append(host_arg) 187 | parsed_args.append(arg) 188 | else: 189 | # Check if this could be a function name and normalize it if needed 190 | if current_submodname and not arg.startswith("-"): 191 | normalized_key = f"{current_submodname}:{arg.lower()}" 192 | if normalized_key in function_name_map: 193 | # Replace with the correct case version 194 | parsed_args.append(function_name_map[normalized_key]) 195 | current_submodname = None # Reset after finding function 196 | else: 197 | parsed_args.append(arg) 198 | else: 199 | parsed_args.append(arg) 200 | args = parser.parse_args(parsed_args) 201 | 202 | if "func" not in args: 203 | parser.print_help(sys.stderr) 204 | sys.exit(1) 205 | # Get the list of parameters to provide to the command 206 | param_names = args.func.__code__.co_varnames[1 : args.func.__code__.co_argcount] 207 | params = {param_name: vars(args)[param_name] for param_name in param_names} 208 | 209 | # Configure loggers # 210 | 211 | # Doesn't work when launching new threads in bloodyAD.ldap so we'll use propagate to false below 212 | # # Enable all children loggers in debug mode 213 | # logging.getLogger().setLevel(logging.DEBUG) 214 | # # Make the root logger quiet 215 | # # WARNING: operation below is not thread safe! 216 | # logging.getLogger().handlers = [] 217 | 218 | exceptions.enableCliLogger(level=args.verbose) 219 | # We show badldap logs only if debug is enabled 220 | # import badldap 221 | # if args.verbose == "DEBUG": 222 | # badldap.logger.handlers = [] 223 | # handler = logging.StreamHandler(sys.stdout) 224 | # handler.setLevel(logging.DEBUG) 225 | # formatter = logging.Formatter('[badldap] %(message)s') 226 | # handler.setFormatter(formatter) 227 | # badldap.logger.addHandler(handler) 228 | # badldap.logger.setLevel(logging.DEBUG) 229 | # badldap.logger.propagate = False 230 | 231 | # Launch the command 232 | conn = ConnectionHandler(args=args) 233 | try: 234 | result = args.func(conn, **params) 235 | # Helper functions for output 236 | def print_human_entry(entry): 237 | print() 238 | for attr_name, attr_val in entry.items(): 239 | entry_str = print_entry(attr_name, attr_val) 240 | if not (entry_str is None or entry_str == ""): 241 | print(f"{attr_name}: {entry_str}") 242 | 243 | def print_json_stream(entries): 244 | first = True 245 | print('[', end='') 246 | for entry in entries: 247 | if not first: 248 | print(',', end='') 249 | print(json.dumps(json_serialize_entry(entry), indent=2), end='') 250 | first = False 251 | print(']') 252 | 253 | # Handle async generator, regular generator, coroutine, or other 254 | if hasattr(result, "__aiter__"): 255 | # Async generator 256 | if args.json: 257 | async def json_async_stream(): 258 | first = True 259 | print('[', end='') 260 | async for entry in result: 261 | if not first: 262 | print(',', end='') 263 | print(json.dumps(json_serialize_entry(entry), indent=2), end='') 264 | first = False 265 | print(']') 266 | await json_async_stream() 267 | else: 268 | async for entry in result: 269 | print_human_entry(entry) 270 | else: 271 | # Coroutine or other 272 | output = await result 273 | output_type = type(output) 274 | if not output or output_type == bool: 275 | return 276 | if output_type not in [list, dict, types.GeneratorType]: 277 | if args.json: 278 | print(json.dumps({"result": str(output)}, indent=2)) 279 | else: 280 | print("\n" + str(output)) 281 | return 282 | if args.json: 283 | print_json_stream(output) 284 | else: 285 | for entry in output: 286 | print_human_entry(entry) 287 | 288 | finally: 289 | await conn.closeLdap() 290 | 291 | 292 | # Gets unparsed doc and returns a tuple of two values 293 | # first is function description (starts at the beginning of the string and ends before two newlines) 294 | # second is a list of parameter descriptions 295 | # (other part of the string, one parameter description per line, starting with :param param_name:) 296 | def doc_parser(doc): 297 | doc_parsed = doc.splitlines() 298 | return doc_parsed[0], doc_parsed[2:] 299 | 300 | 301 | def json_serialize_entry(entry): 302 | """ 303 | Convert an entry to JSON-serializable format by converting custom objects to strings 304 | """ 305 | if isinstance(entry, dict): 306 | return {k: json_serialize_entry(v) for k, v in entry.items()} 307 | elif isinstance(entry, (list, set, types.GeneratorType)): 308 | return [json_serialize_entry(v) for v in entry] 309 | else: 310 | # Convert custom objects to string representation 311 | return str(entry) 312 | 313 | 314 | def print_entry(entryname, entry): 315 | if type(entry) in [list, set, types.GeneratorType]: 316 | i = 0 317 | simple_entries = [] 318 | length = len(entry) 319 | i_str = "" 320 | for v in entry: 321 | if length > 1: 322 | i_str = f".{i}" 323 | entry_str = print_entry(f"{entryname}{i_str}", v) 324 | i += 1 325 | if not (entry_str is None or entry_str == ""): 326 | simple_entries.append(entry_str) 327 | if simple_entries: 328 | print(f"{entryname}: {'; '.join([str(v) for v in simple_entries])}") 329 | elif type(entry) is dict: 330 | length = len(entry) 331 | k_str = "" 332 | for k in entry: 333 | if length > 1: 334 | k_str = f".{k}" 335 | entry_str = print_entry(f"{entryname}{k_str}", entry[k]) 336 | if not (entry_str is None or entry_str == ""): 337 | print(f"{entryname}.{k}: {entry_str}") 338 | else: 339 | return entry 340 | 341 | def main(): 342 | asyncio.run(amain()) 343 | 344 | if __name__ == "__main__": 345 | main() 346 | -------------------------------------------------------------------------------- /bloodyAD/cli_modules/set.py: -------------------------------------------------------------------------------- 1 | import badldap 2 | 3 | from bloodyAD import utils 4 | from bloodyAD.exceptions import LOG 5 | from bloodyAD.formatters import accesscontrol 6 | from bloodyAD.network.ldap import Change, Scope, showRecoverable 7 | from badldap.protocol import typeconversion 8 | from badldap.protocol.typeconversion import ( 9 | LDAP_WELL_KNOWN_ATTRS, 10 | MSLDAP_BUILTIN_ATTRIBUTE_TYPES, 11 | MSLDAP_BUILTIN_ATTRIBUTE_TYPES_ENC 12 | ) 13 | from datetime import datetime, timezone, timedelta 14 | import unicodedata, base64 15 | 16 | async def object(conn, target: str, attribute: str, v: list = [], raw: bool = False, b64: bool = False): 17 | """ 18 | Add/Replace/Delete target's attribute 19 | 20 | :param target: sAMAccountName, DN or SID of the target 21 | :param attribute: name of the attribute 22 | :param v: add value if attribute doesn't exist, replace value if attribute exists, delete if no value given, can be called multiple times if multiple values to set (e.g -v HOST/janettePC -v HOST/janettePC.bloody.local) 23 | :param raw: if set, will try to send the values provided as is, without any encoding 24 | :param b64: expect base64 values in -v (available only with --raw) 25 | """ 26 | 27 | if not raw: 28 | # We change some encoding functions because for whatever reason some are marked as 'bytes' but are actually 'sd' so can take sddl string 29 | # but we cannot directly change in badldap because it would break the ones passing directly multi_bytes 30 | MSLDAP_BUILTIN_ATTRIBUTE_TYPES_ENC["msDS-AllowedToActOnBehalfOfOtherIdentity"] = typeconversion.multi_sd 31 | MSLDAP_BUILTIN_ATTRIBUTE_TYPES_ENC["nTSecurityDescriptor"] = typeconversion.single_sd 32 | norm_attr = attribute.lower() 33 | lookup_table = None 34 | # Order is very important cause there are overlapped with different encoding function values 35 | for table in [MSLDAP_BUILTIN_ATTRIBUTE_TYPES_ENC, MSLDAP_BUILTIN_ATTRIBUTE_TYPES, LDAP_WELL_KNOWN_ATTRS]: 36 | for key in table: 37 | if key.lower() == norm_attr: 38 | attribute = key 39 | lookup_table = table 40 | break 41 | if lookup_table: 42 | break 43 | 44 | if lookup_table: 45 | encoding_func = lookup_table[attribute] 46 | str_support = ["utf16le","sid","str","int","guid","sd"] 47 | encoding_type = encoding_func.__name__.split('_')[1] 48 | if encoding_type not in str_support: 49 | LOG.warning(f"Attribute encoding not supported for {attribute} with {encoding_type} attribute type, using raw mode") 50 | raw = True 51 | else: 52 | LOG.warning(f"Attribute encoding not supported for {attribute}, using raw mode") 53 | raw = True 54 | # Converting raw str into raw binary 55 | if raw: 56 | if b64: 57 | v = [base64.b64decode(vstr, validate=True) for vstr in v] 58 | else: 59 | v = [vstr.encode() for vstr in v] 60 | 61 | ldap = await conn.getLdap() 62 | await ldap.bloodymodify( 63 | target, {attribute: [(Change.REPLACE.value, v)]}, encode=(not raw) 64 | ) 65 | LOG.info(f"{target}'s {attribute} has been updated") 66 | 67 | 68 | async def owner(conn, target: str, owner: str): 69 | """ 70 | Changes target ownership with provided owner (WriteOwner permission required) 71 | 72 | :param target: sAMAccountName, DN or SID of the target 73 | :param owner: sAMAccountName, DN or SID of the new owner 74 | """ 75 | ldap = await conn.getLdap() 76 | entry = None 77 | async for e in ldap.bloodysearch(owner, attr=["objectSid"]): 78 | entry = e 79 | break 80 | new_sid = entry["objectSid"] if entry else None 81 | 82 | new_sd, _ = await utils.getSD( 83 | conn, target, "nTSecurityDescriptor", accesscontrol.OWNER_SECURITY_INFORMATION 84 | ) 85 | 86 | old_sid = new_sd["OwnerSid"].formatCanonical() 87 | if old_sid == new_sid: 88 | LOG.warning(f"{old_sid} is already the owner, no modification will be made") 89 | else: 90 | new_sd["OwnerSid"].fromCanonical(new_sid) 91 | 92 | req_flags = badldap.wintypes.asn1.sdflagsrequest.SDFlagsRequestValue( 93 | {"Flags": accesscontrol.OWNER_SECURITY_INFORMATION} 94 | ) 95 | controls = [("1.2.840.113556.1.4.801", True, req_flags.dump())] 96 | 97 | await ldap.bloodymodify( 98 | target, 99 | {"nTSecurityDescriptor": [(Change.REPLACE.value, new_sd.getData())]}, 100 | controls, 101 | ) 102 | 103 | LOG.info(f"Old owner {old_sid} is now replaced by {owner} on {target}") 104 | 105 | 106 | # Full info on what you can do: 107 | # https://learn.microsoft.com/en-us/troubleshoot/windows-server/identity/change-windows-active-directory-user-password 108 | async def password(conn, target: str, newpass: str, oldpass: str = None, stealth: bool = False): 109 | """ 110 | Change password of a user/computer 111 | 112 | :param target: sAMAccountName, DN or SID of the target 113 | :param newpass: new password for the target 114 | :param oldpass: old password of the target, mandatory if you don't have "change password" permission on the target 115 | :param stealth: disable password policy check when password change is denied 116 | """ 117 | encoded_new_password = '"%s"' % newpass 118 | if oldpass is not None: 119 | encoded_old_password = '"%s"' % oldpass 120 | op_list = [ 121 | (Change.DELETE.value, encoded_old_password), 122 | (Change.ADD.value, encoded_new_password), 123 | ] 124 | else: 125 | op_list = [(Change.REPLACE.value, encoded_new_password)] 126 | 127 | ldap = await conn.getLdap() 128 | try: 129 | await ldap.bloodymodify(target, {"unicodePwd": op_list}) 130 | 131 | except badldap.commons.exceptions.LDAPModifyException as e: 132 | if stealth: 133 | raise e 134 | # Let's check if we comply to pwd policy 135 | entry = None 136 | async for search_entry in ldap.bloodysearch( 137 | target, 138 | attr=[ 139 | "msDS-ResultantPSO", 140 | "pwdLastSet", 141 | "displayName", 142 | "sAMAccountName", 143 | "sAMAccountType", 144 | ], 145 | ): 146 | entry = search_entry 147 | break 148 | pwdLastSet = entry.get("pwdLastSet", 0) 149 | pwdPolicy = None 150 | error_str = "" 151 | if "msDS-ResultantPSO" in entry: 152 | tmpPolicy = None 153 | async for p in ldap.bloodysearch( 154 | entry["msDS-ResultantPSO"], 155 | attr=[ 156 | "msDS-MinimumPasswordAge", 157 | "msDS-MinimumPasswordLength", 158 | "msDS-PasswordHistoryLength", 159 | "msDS-PasswordComplexityEnabled", 160 | "name", 161 | ], 162 | ): 163 | tmpPolicy = p 164 | break 165 | pwdPolicy = { 166 | "minPwdAge": tmpPolicy.get("msDS-MinimumPasswordAge", timedelta()), 167 | "minPwdLength": tmpPolicy.get("msDS-MinimumPasswordLength", 0), 168 | "pwdHistoryLength": tmpPolicy.get("msDS-PasswordHistoryLength", 0), 169 | "pwdComplexity": tmpPolicy.get("msDS-PasswordComplexityEnabled", False), 170 | } 171 | # custom password policies are not readable by basic users 172 | if "name" not in tmpPolicy: 173 | error_str = ( 174 | "Password can't be changed. User is subject to the custom password" 175 | f" policy {entry['msDS-ResultantPSO'].split(',')[0]} which may be" 176 | " more restrictive than the default one." 177 | ) 178 | else: 179 | pwdPolicy = None 180 | async for p in ldap.bloodysearch( 181 | ldap.domainNC, 182 | attr=[ 183 | "minPwdAge", 184 | "minPwdLength", 185 | "pwdHistoryLength", 186 | "pwdProperties", 187 | ], 188 | ): 189 | pwdPolicy = p 190 | break 191 | pwdPolicy["pwdComplexity"] = (pwdPolicy["pwdProperties"] & 1) > 0 192 | 193 | # Complexity check 194 | if pwdPolicy.get("pwdComplexity"): 195 | tmp_err = "New password doesn't match the complexity:" 196 | objectName = entry["sAMAccountName"] 197 | objectDisplayName = entry.get("displayName", "") 198 | # Checks on name only apply on users not computers idk why 199 | if ( 200 | entry["sAMAccountType"] == 805306368 201 | and objectName.upper() in newpass.upper() 202 | ): 203 | error_str = ( 204 | f"{tmp_err} newpass must not include the user's name '{objectName}'" 205 | " (case insensitive)." 206 | ) 207 | elif ( 208 | entry["sAMAccountType"] == 805306368 209 | and objectDisplayName 210 | and objectDisplayName.upper() in newpass.upper() 211 | ): 212 | error_str = ( 213 | f"{tmp_err} newpass must not include the user's display name" 214 | f" '{objectDisplayName}' (case insensitive)." 215 | ) 216 | else: 217 | checks = 0 218 | if any(char.isupper() for char in newpass): 219 | checks += 1 220 | if any(char.islower() for char in newpass): 221 | checks += 1 222 | if any(char.isdigit() for char in newpass): 223 | checks += 1 224 | if any(char in '-!"#$%&()*,./:;?@[]^_`{|}~+<=>' for char in newpass): 225 | checks += 1 226 | # Any Unicode character that's categorized as an alphabetic character but isn't uppercase or lowercase 227 | # https://www.unicode.org/reports/tr44/#General_Category_Values 228 | if any( 229 | "L" in unicodedata.category(char) 230 | and unicodedata.category(char) not in ["Ll", "Lu"] 231 | for char in newpass 232 | ): 233 | checks += 1 234 | 235 | if checks < 3: 236 | error_str = ( 237 | f"{tmp_err} The password must contains characters from three of" 238 | " the following categories: Uppercase, Lowercase, Digits," 239 | " Special" 240 | ) 241 | # Pwd length check 242 | if len(newpass) < pwdPolicy.get("minPwdLength", 0): 243 | error_str += ( 244 | "\nNew password should have at least" 245 | f" {pwdPolicy['minPwdLength']} characters and not {len(newpass)}" 246 | ) 247 | # Pwd age check 248 | if pwdLastSet: 249 | pwdAge = datetime.now(timezone.utc) - pwdLastSet 250 | if pwdAge < -pwdPolicy.get("minPwdAge", timedelta()): 251 | error_str += ( 252 | "\nPassword can't be changed before" 253 | f" {pwdPolicy['minPwdAge'] - pwdAge} because of the minimum" 254 | " password age policy." 255 | ) 256 | # No issue has been found, it may be because of the password history 257 | if not error_str: 258 | # If password changed without oldpass, you don't need to respect password history 259 | if not oldpass: 260 | error_str = ( 261 | "Password can't be changed. It may be because the oldpass provided" 262 | " is not valid.\nYou can try to use another password change" 263 | " protocol such as smbpasswd, server error may be more explicit." 264 | ) 265 | else: 266 | 267 | if pwdPolicy.get("pwdHistoryLength", 0) > 0: 268 | if oldpass == newpass: 269 | error_str = "New Password can't be identical to old password." 270 | else: 271 | error_str = ( 272 | "Password can't be changed. It may be because the new" 273 | " password is already in the password history of the" 274 | " target or that the oldpass provided is not valid.\nYou" 275 | " can try to use another password change protocol such as" 276 | " smbpasswd, server error may be more explicit." 277 | ) 278 | else: 279 | error_str = ( 280 | "Password can't be changed. It may be because the oldpass" 281 | " provided is not valid.\nYou can try to use another password" 282 | " change protocol such as smbpasswd, server error may be more" 283 | " explicit." 284 | ) 285 | 286 | # We can't modify the object on the fly so let's do it on the class :D 287 | badldap.commons.exceptions.LDAPModifyException.__str__ = lambda self: error_str 288 | raise e 289 | 290 | LOG.info("Password changed successfully!") 291 | return True 292 | 293 | 294 | async def restore(conn, target: str, newName: str = None, newParent: str = None): 295 | """ 296 | Restore a deleted object 297 | 298 | :param target: DN, sAMAccountName (or name for GPO) or SID of the target (avoid sAMAccountName if there is a duplicate) 299 | :param newName: new name for the restored object (update also sAMAccountName, UPN, SPN...), if not provided will use the last known RDN 300 | :param newParent: new parent for the restored object, if not provided will use the last known parent 301 | """ 302 | if target.lower().startswith("cn=") or target.lower().startswith("dc="): 303 | # double encode needed because of \0A in deleted objects DNs 304 | ldap_filter = f"(distinguishedName={utils.double_encode_controls(target)})" 305 | elif target.lower().startswith("s-1-"): 306 | ldap_filter = f"(objectSid={target})" 307 | elif target.startswith("{"): 308 | ldap_filter = f"(name={target})" 309 | else: 310 | ldap_filter = f"(sAMAccountName={target})" 311 | ldap_filter = f"(&{ldap_filter}(isDeleted=TRUE))" 312 | ldap = await conn.getLdap() 313 | entry = None 314 | async for e in ldap.bloodysearch( 315 | "CN=Deleted Objects,"+ldap.domainNC, ldap_filter, search_scope=Scope.SUBTREE, attr=["msDS-LastKnownRDN","lastKnownParent", "sAMAccountName", "servicePrincipalName", "userPrincipalName", "name", "dNSHostName", "displayName"], controls=showRecoverable() 316 | ): 317 | entry = e 318 | break# LDAP_SERVER_SHOW_DELETED_OID 319 | old_name = entry['name'].splitlines()[0] 320 | new_dn = f"CN={newName if newName else entry.get('msDS-LastKnownRDN',old_name)},{newParent if newParent else entry['lastKnownParent']}" 321 | attributes = {"distinguishedName": [(Change.REPLACE.value, new_dn)],"isDeleted": [(Change.DELETE.value, [])]} 322 | if newName: 323 | # Name will be automatically replaced by new RDN, 324 | # If we force the change we will have error ERROR_DS_CANT_ON_RDN 325 | #attributes["name"] = [(Change.REPLACE.value, newName)] 326 | if entry.get("displayName"): 327 | attributes["displayName"] = [(Change.REPLACE.value, entry["displayName"].replace(entry["name"], newName))] 328 | if entry.get("sAMAccountName"): 329 | attributes["sAMAccountName"] = [(Change.REPLACE.value, newName+'$' if entry["sAMAccountName"][-1] == "$" else newName)] 330 | if entry.get("servicePrincipalName"): 331 | attributes["servicePrincipalName"] = [(Change.REPLACE.value, [v.replace(entry["name"],newName) for v in entry["servicePrincipalName"]])] 332 | if entry.get("userPrincipalName"): 333 | attributes["userPrincipalName"] = [(Change.REPLACE.value, newName + '@' + entry["userPrincipalName"].split('@')[-1])] 334 | if entry.get("dNSHostName"): 335 | attributes["dNSHostName"] = [(Change.REPLACE.value, newName + '.' + entry["dNSHostName"].split('.',1)[-1])] 336 | 337 | try: 338 | await ldap.bloodymodify( 339 | entry["distinguishedName"], attributes, controls=[("1.2.840.113556.1.4.417", True, None)], is_restore=True 340 | ) 341 | except badldap.commons.exceptions.LDAPModifyException as e: 342 | if "userPrincipalName" in str(e.diagnostic_message) and e.resultcode == 19: # 19 is constraintViolation 343 | LOG.error( 344 | "Operation failed, the userPrincipalName is probably already used by another non-deleted object, you have the change the other user UPN first (changing UPN of a deleted object is not allowed)" 345 | ) 346 | return 347 | raise e 348 | 349 | LOG.info(f"{target} has been restored successfully under {new_dn}") 350 | -------------------------------------------------------------------------------- /bloodyAD/formatters/ldaptypes.py: -------------------------------------------------------------------------------- 1 | # Impacket - Collection of Python classes for working with network protocols. 2 | # 3 | # Copyright (C) 2022 Fortra. All rights reserved. 4 | # 5 | # This software is provided under a slightly modified version 6 | # of the Apache Software License. See the accompanying LICENSE file 7 | # for more information. 8 | # 9 | # Description: 10 | # Structures and types used in LDAP 11 | # Contains the Structures for the NT Security Descriptor (non-RPC format) and 12 | # all ACL related structures 13 | # 14 | # Author: 15 | # Dirk-jan Mollema (@_dirkjan) / Fox-IT (https://www.fox-it.com) 16 | # 17 | from bloodyAD.formatters.structure import Structure 18 | from struct import unpack, pack 19 | 20 | # Global constant if the library should recalculate ACE sizes in objects that are decoded/re-encoded. 21 | # This defaults to True, but this causes the ACLs to not match on a binary level 22 | # since Active Directory for some reason sometimes adds null bytes to the end of ACEs. 23 | # This is valid according to the spec (see 2.4.4), but since impacket encodes them more efficiently 24 | # this should be turned off if running unit tests. 25 | RECALC_ACE_SIZE = True 26 | 27 | 28 | # LDAP SID structure - based on SAMR_RPC_SID, except the SubAuthority is LE here 29 | class LDAP_SID_IDENTIFIER_AUTHORITY(Structure): 30 | structure = (("Value", "6s"),) 31 | 32 | 33 | class LDAP_SID(Structure): 34 | structure = ( 35 | ("Revision", " 1: 484 | func[0](*func[1:]) 485 | else: 486 | func[0]() 487 | except Exception as e: 488 | raise e 489 | finally: 490 | cls.tearDownClass() 491 | 492 | def launchBloody(self, creds, args, isErr=True, doPrint=True): 493 | cmd_creds = ["-u", creds["username"], "-p", creds["password"]] 494 | return self.launchProcess(self.bloody_prefix + cmd_creds + args, isErr, doPrint) 495 | 496 | def launchProcess(self, cmd, isErr=True, doPrint=True, ignoreErr=False): 497 | out, err = subprocess.Popen( 498 | cmd, stderr=subprocess.PIPE, stdout=subprocess.PIPE, env=self.env 499 | ).communicate() 500 | out = out.decode() 501 | if not ignoreErr: 502 | self.assertFalse(isErr and err, self.printErr(err.decode(), cmd)) 503 | out += "\n" + err.decode() 504 | if doPrint: 505 | print(out) 506 | return out 507 | 508 | def printErr(self, err, cmd): 509 | err = err.replace("\n", "\n ") 510 | self.err = f"here is the error output ->\n\n {cmd}\n{err}" 511 | return self.err 512 | 513 | 514 | if __name__ == "__main__": 515 | unittest.main(failfast=True) 516 | -------------------------------------------------------------------------------- /bloodyAD/utils.py: -------------------------------------------------------------------------------- 1 | from bloodyAD.exceptions import LOG 2 | from bloodyAD.formatters import ( 3 | ldaptypes, 4 | accesscontrol, 5 | adschema, 6 | ) 7 | from bloodyAD.network.ldap import Scope, phantomRoot, showRecoverable 8 | import types, base64, collections, re 9 | from winacl import dtyp 10 | from winacl.dtyp.security_descriptor import SECURITY_DESCRIPTOR 11 | from badldap.network import reacher 12 | 13 | 14 | def addRight( 15 | sd, 16 | user_sid, 17 | access_mask=accesscontrol.ACCESS_FLAGS["FULL_CONTROL"], 18 | object_type=None, 19 | ): 20 | user_sid = dtyp.sid.SID.from_string(user_sid) 21 | user_aces = [ 22 | ace 23 | for ace in sd["Dacl"].aces 24 | if ace["Ace"]["Sid"].getData() == user_sid.to_bytes() 25 | ] 26 | new_ace = accesscontrol.createACE(user_sid.to_bytes(), object_type, access_mask) 27 | if object_type: 28 | access_denied_type = ldaptypes.ACCESS_DENIED_OBJECT_ACE.ACE_TYPE 29 | else: 30 | access_denied_type = ldaptypes.ACCESS_DENIED_ACE.ACE_TYPE 31 | hasPriv = False 32 | 33 | for ace in user_aces: 34 | new_mask = new_ace["Ace"]["Mask"] 35 | mask = ace["Ace"]["Mask"] 36 | 37 | # Removes Access-Denied ACEs interfering 38 | if ace["AceType"] == access_denied_type and new_mask.hasPriv(mask["Mask"]): 39 | sd["Dacl"].aces.remove(ace) 40 | LOG.debug("An interfering Access-Denied ACE has been removed:") 41 | LOG.debug(ace) 42 | # Adds ACE if not already added 43 | elif ace.hasFlag(new_ace["AceFlags"]) and mask.hasPriv(new_mask["Mask"]): 44 | hasPriv = True 45 | break 46 | 47 | if hasPriv: 48 | LOG.debug("This right already exists") 49 | else: 50 | sd["Dacl"].aces.append(new_ace) 51 | 52 | isAdded = not hasPriv 53 | return isAdded 54 | 55 | 56 | def delRight( 57 | sd, 58 | user_sid, 59 | access_mask=accesscontrol.ACCESS_FLAGS["FULL_CONTROL"], 60 | object_type=None, 61 | ): 62 | isRemoved = False 63 | user_sid = dtyp.sid.SID.from_string(user_sid) 64 | user_aces = [ 65 | ace 66 | for ace in sd["Dacl"].aces 67 | if ace["Ace"]["Sid"].getData() == user_sid.to_bytes() 68 | ] 69 | if object_type: 70 | access_allowed_type = ldaptypes.ACCESS_ALLOWED_OBJECT_ACE.ACE_TYPE 71 | else: 72 | access_allowed_type = ldaptypes.ACCESS_ALLOWED_ACE.ACE_TYPE 73 | 74 | for ace in user_aces: 75 | mask = ace["Ace"]["Mask"] 76 | if ace["AceType"] == access_allowed_type and mask.hasPriv(access_mask): 77 | mask.removePriv(access_mask) 78 | LOG.debug("Privilege Removed") 79 | if mask["Mask"] == 0: 80 | sd["Dacl"].aces.remove(ace) 81 | isRemoved = True 82 | 83 | if not isRemoved: 84 | LOG.debug("No right to remove") 85 | return isRemoved 86 | 87 | 88 | async def getSD( 89 | conn, 90 | object_id, 91 | ldap_attribute="nTSecurityDescriptor", 92 | control_flag=accesscontrol.DACL_SECURITY_INFORMATION, 93 | ): 94 | ldap = await conn.getLdap() 95 | entry = None 96 | async for e in ldap.bloodysearch( 97 | object_id, attr=[ldap_attribute], control_flag=control_flag, raw=True 98 | ): 99 | entry = e 100 | break 101 | sd_data = entry.get(ldap_attribute, []) if entry else [] 102 | if len(sd_data) < 1: 103 | LOG.warning( 104 | "No security descriptor has been returned, a new one will be created" 105 | ) 106 | sd = accesscontrol.createEmptySD() 107 | else: 108 | sd = ldaptypes.SR_SECURITY_DESCRIPTOR(data=sd_data[0]) 109 | 110 | LOG.debug( 111 | "Old Security Descriptor: " 112 | + "\t".join([SECURITY_DESCRIPTOR.from_bytes(sd).to_sddl() for sd in sd_data]) 113 | ) 114 | return sd, sd_data 115 | 116 | 117 | # First elt in grouping order is the first grouping criterium 118 | def groupBy(rows, grouping_order): 119 | try: 120 | grouping_key = grouping_order.pop() 121 | merged = groupBy(rows, grouping_order) 122 | except IndexError: # We exhausted all grouping criteriums 123 | return rows 124 | 125 | new_merge = [] 126 | for row in merged: 127 | isMergeable = False 128 | for new_row in new_merge: 129 | isMergeable = True 130 | for k in row: 131 | if k == grouping_key: 132 | continue 133 | try: 134 | if row[k] != new_row[k]: 135 | isMergeable = False 136 | break 137 | except ( 138 | KeyError 139 | ): # If one of the rows doesn't have the non grouping property, it can't be merged 140 | continue 141 | if isMergeable: 142 | new_row[grouping_key] |= row[grouping_key] 143 | break 144 | if not isMergeable: 145 | new_merge.append(row) 146 | return new_merge 147 | 148 | 149 | ACCESS_RIGHTS = { 150 | "CREATE_CHILD": (0x1,), 151 | "DELETE_CHILD": (0x2,), 152 | "LIST_CHILD": (0x4,), # LIST_CONTENTS 153 | "WRITE_VALIDATED": (0x8,), # WRITE_PROPERTY_EXTENDED 154 | "READ_PROP": (0x10,), 155 | "WRITE_PROP": (0x20,), # Does it contains WRITE VALIDATED? 156 | "DELETE_TREE": (0x40,), 157 | "LIST_OBJECT": (0x80,), 158 | "CONTROL_ACCESS": (0x100,), 159 | "DELETE": (0x10000,), 160 | "READ_SD": (0x20000,), 161 | "WRITE_DACL": (0x40000,), 162 | "WRITE_OWNER": (0x80000,), 163 | "ACCESS_SYSTEM_SECURITY": (0x1000000,), 164 | "SYNCHRONIZE": (0x100000,), 165 | } 166 | ACCESS_RIGHTS["GENERIC_EXECUTE"] = ( 167 | 0x20000000, 168 | ACCESS_RIGHTS["READ_SD"][0] | ACCESS_RIGHTS["LIST_CHILD"][0], 169 | ) 170 | ACCESS_RIGHTS["GENERIC_WRITE"] = ( 171 | 0x40000000, 172 | ACCESS_RIGHTS["READ_SD"][0] 173 | | ACCESS_RIGHTS["WRITE_PROP"][0] 174 | | ACCESS_RIGHTS["WRITE_VALIDATED"][0], 175 | ) 176 | ACCESS_RIGHTS["GENERIC_READ"] = ( 177 | 0x80000000, 178 | ACCESS_RIGHTS["READ_SD"][0] 179 | | ACCESS_RIGHTS["READ_PROP"][0] 180 | | ACCESS_RIGHTS["LIST_CHILD"][0] 181 | | ACCESS_RIGHTS["LIST_OBJECT"][0], 182 | ) 183 | ACCESS_RIGHTS["GENERIC_ALL"] = ( 184 | 0x10000000, 185 | ACCESS_RIGHTS["GENERIC_EXECUTE"][0] 186 | | ACCESS_RIGHTS["GENERIC_WRITE"][0] 187 | | ACCESS_RIGHTS["GENERIC_READ"][0] 188 | | ACCESS_RIGHTS["DELETE"][0] 189 | | ACCESS_RIGHTS["DELETE_TREE"][0] 190 | | ACCESS_RIGHTS["CONTROL_ACCESS"][0] 191 | | ACCESS_RIGHTS["CREATE_CHILD"][0] 192 | | ACCESS_RIGHTS["DELETE_CHILD"][0] 193 | | ACCESS_RIGHTS["WRITE_DACL"][0] 194 | | ACCESS_RIGHTS["WRITE_OWNER"][0], 195 | ACCESS_RIGHTS["READ_SD"][0] 196 | | ACCESS_RIGHTS["READ_PROP"][0] 197 | | ACCESS_RIGHTS["LIST_CHILD"][0] 198 | | ACCESS_RIGHTS["LIST_OBJECT"][0] 199 | | ACCESS_RIGHTS["WRITE_PROP"][0] 200 | | ACCESS_RIGHTS["WRITE_VALIDATED"][0] 201 | | ACCESS_RIGHTS["DELETE"][0] 202 | | ACCESS_RIGHTS["DELETE_TREE"][0] 203 | | ACCESS_RIGHTS["CONTROL_ACCESS"][0] 204 | | ACCESS_RIGHTS["CREATE_CHILD"][0] 205 | | ACCESS_RIGHTS["DELETE_CHILD"][0] 206 | | ACCESS_RIGHTS["WRITE_DACL"][0] 207 | | ACCESS_RIGHTS["WRITE_OWNER"][0], 208 | ) 209 | # Reverse is sorted for mask operations 210 | REVERSE_ACCESS_RIGHTS = dict( 211 | sorted( 212 | [ 213 | (mask, flag) 214 | for flag, masktuple in ACCESS_RIGHTS.items() 215 | for mask in masktuple 216 | ], 217 | reverse=True, 218 | ) 219 | ) 220 | 221 | 222 | class Right: 223 | def __init__(self, mask): 224 | self.mask = mask 225 | 226 | def __str__(self): 227 | flag_list = [] 228 | tmp_mask = self.mask 229 | for key_mask in REVERSE_ACCESS_RIGHTS: 230 | if ( 231 | key_mask & ~tmp_mask 232 | ) > 0: # Means key_mask is including bits not in tmp_mask 233 | continue 234 | remainder = ( 235 | tmp_mask & ~key_mask 236 | ) # We keep a remainder of tmp_mask with complement of key_mask if tmp_mask is bigger than key_mask 237 | flag_list.append(REVERSE_ACCESS_RIGHTS[key_mask]) 238 | tmp_mask = remainder 239 | if remainder == 0: 240 | break 241 | if tmp_mask != 0: # If there is unknown mask 242 | flag_list.append(str(tmp_mask)) 243 | return "|".join(flag_list) 244 | 245 | 246 | class Control: 247 | def __init__(self, control_enum): 248 | self.control_enum = control_enum 249 | 250 | def __str__(self): 251 | flag_str = repr(self.control_enum).split(".")[1].split(":")[0] 252 | flag_str = flag_str.replace("SE_", "") 253 | return flag_str 254 | 255 | 256 | class AceType: 257 | def __init__(self, acetype_enum): 258 | self.acetype_enum = acetype_enum 259 | 260 | def __eq__(self, o): 261 | if not isinstance(o, AceType): 262 | return NotImplemented 263 | return self.acetype_enum == o.acetype_enum 264 | 265 | def __str__(self): 266 | flag_str = repr(self.acetype_enum).split(".")[1].split(":")[0] 267 | flag_str = flag_str.replace("ACCESS_", "") 268 | flag_str = flag_str.replace("SYSTEM_", "") 269 | flag_str = flag_str.replace("_ACE_TYPE", "") 270 | return f"== {flag_str} ==" 271 | 272 | 273 | class AceFlag: 274 | def __init__(self, aceflag_enum): 275 | self.aceflag_enum = aceflag_enum 276 | 277 | def __str__(self): 278 | flag_str = repr(self.aceflag_enum).split(".")[1].split(":")[0] 279 | flag_str = flag_str.replace("_ACE_FLAG", "") 280 | flag_str = flag_str.replace("_ACE", "") 281 | return flag_str 282 | 283 | 284 | class LazyAdSchema: 285 | guids = set() 286 | sids = set() 287 | DNs = set() 288 | # All known guids 289 | guid_dict = { 290 | **adschema.OBJECT_TYPES, 291 | "Self": "Self", 292 | } 293 | # All known sids 294 | sid_dict = dtyp.sid.well_known_sids_sid_name_map 295 | # All known DNs 296 | dn_dict = {} 297 | isResolved = False 298 | 299 | # We resolve every guid/sid/dn in one request to be more efficient 300 | # Put the load on the server instead of the client 301 | # Perfect in case of bad network 302 | async def _resolveAll(self): 303 | if self.isResolved: 304 | return 305 | 306 | async def domResolve(conn, sid_iter, dn_iter): 307 | # WARNING: only 512 filters max per request 308 | filters = [] 309 | buffer_filter = "" 310 | filter_nb = 0 311 | for sid in sid_iter: 312 | if filter_nb > 511: 313 | filters.append(buffer_filter) 314 | buffer_filter = "" 315 | filter_nb = 0 316 | buffer_filter += f"(objectSid={sid})" 317 | filter_nb += 1 318 | for dn in dn_iter: 319 | if filter_nb > 511: 320 | filters.append(buffer_filter) 321 | buffer_filter = "" 322 | filter_nb = 0 323 | buffer_filter += f"(distinguishedName={dn})" 324 | filter_nb += 1 325 | 326 | if conn == self.conn: 327 | for guid in self.guids: 328 | if filter_nb > 511: 329 | filters.append(buffer_filter) 330 | buffer_filter = "" 331 | filter_nb = 0 332 | guid_bin_str = "\\" + "\\".join( 333 | [ 334 | "{:02x}".format(b) 335 | for b in dtyp.guid.GUID().from_string(guid).to_bytes() 336 | ] 337 | ) 338 | buffer_filter += ( 339 | f"(rightsGuid={str(guid)})(schemaIDGUID={guid_bin_str})" 340 | ) 341 | filter_nb += 2 342 | filters.append(buffer_filter) 343 | 344 | # Search in all non application partitions 345 | for ldap_filter in filters: 346 | ldap = await conn.getLdap() 347 | entries = ldap.bloodysearch( 348 | "", 349 | ldap_filter=f"(|{ldap_filter})", 350 | attr=[ 351 | "name", 352 | "sAMAccountName", 353 | "objectSid", 354 | "rightsGuid", 355 | "schemaIDGUID", 356 | ], 357 | search_scope=Scope.SUBTREE, 358 | controls=phantomRoot()+showRecoverable(), 359 | ) 360 | async for entry in entries: 361 | if entry.get("objectSid"): 362 | self.sid_dict[entry["objectSid"]] = ( 363 | entry["sAMAccountName"] 364 | if entry.get("sAMAccountName") 365 | else entry["name"] 366 | ) 367 | self.dn_dict[entry["distinguishedName"].upper()] = entry["objectSid"] 368 | else: 369 | if entry.get("rightsGuid"): 370 | key = entry["rightsGuid"] 371 | elif entry.get("schemaIDGUID"): 372 | key = entry["schemaIDGUID"] 373 | else: 374 | LOG.warning(f"No guid/sid returned for {entry}") 375 | continue 376 | self.guid_dict[key] = entry["name"] 377 | 378 | sidmap = collections.defaultdict(list) 379 | if getattr(self.conn.conf, "transitive", None): 380 | ldap = await self.conn.getLdap() 381 | trustmap = await ldap.getTrustMap() 382 | for sid in self.sids: 383 | for dom_params in trustmap.values(): 384 | if dom_params.get("conn") and sid.startswith(dom_params["domsid"]): 385 | sidmap[dom_params["conn"]].append(sid) 386 | for conn, sidlist in sidmap.items(): 387 | await domResolve(conn, sidlist) 388 | else: 389 | await domResolve(self.conn, self.sids, self.DNs) 390 | 391 | # Cleanup resolved ids from queues 392 | self.isResolved = True 393 | self.guids = set() 394 | self.sids = set() 395 | self.DNs = set() 396 | 397 | def addguid(self, guid): 398 | # Should not add in set to resolve after if it is already resolved 399 | if guid not in self.guid_dict: 400 | self.guids.add(guid) 401 | 402 | def addsid(self, sid): 403 | # Should not add in set to resolve after if it is already resolved 404 | if sid not in self.sid_dict: 405 | self.sids.add(sid) 406 | 407 | def adddn(self, dn): 408 | dn_upper = dn.upper() 409 | if dn_upper not in self.dn_dict: 410 | self.DNs.add(dn_upper) 411 | 412 | # Return name mapped to the guid 413 | async def getguid(self, guid): 414 | try: 415 | return self.guid_dict[guid] 416 | except KeyError: 417 | if not self.isResolved: 418 | await self._resolveAll() 419 | return await self.getguid(guid) 420 | else: 421 | return guid 422 | 423 | # Return name mapped to the sid 424 | async def getsid(self, sid) -> str: 425 | try: 426 | return self.sid_dict[sid] 427 | except KeyError: 428 | if not self.isResolved: 429 | await self._resolveAll() 430 | return await self.getsid(sid) 431 | else: 432 | return sid 433 | 434 | async def getdn(self, dn) -> str: 435 | dn_upper = dn.upper() 436 | try: 437 | return self.dn_dict[dn_upper] 438 | except KeyError: 439 | if not self.isResolved: 440 | await self._resolveAll() 441 | return await self.getdn(dn) 442 | else: 443 | return "" 444 | 445 | global_lazy_adschema = LazyAdSchema() 446 | 447 | 448 | class LazyGuid: 449 | def __init__(self, guid): 450 | self.guid = guid 451 | global_lazy_adschema.addguid(guid) 452 | self._resolved = None 453 | 454 | async def resolve(self): 455 | if self._resolved is None: 456 | self._resolved = await global_lazy_adschema.getguid(self.guid) 457 | return self._resolved 458 | 459 | def __str__(self): 460 | # Return cached resolution if available, otherwise return the raw guid 461 | if self._resolved is not None: 462 | return self._resolved 463 | return self.guid 464 | 465 | 466 | class LazySid: 467 | def __init__(self, sid): 468 | self.sid = sid 469 | global_lazy_adschema.addsid(sid) 470 | self._resolved = None 471 | 472 | async def resolve(self): 473 | if self._resolved is None: 474 | self._resolved = await global_lazy_adschema.getsid(self.sid) 475 | return self._resolved 476 | 477 | def __str__(self): 478 | # Return cached resolution if available, otherwise return the raw sid 479 | if self._resolved is not None: 480 | return self._resolved 481 | return self.sid 482 | 483 | 484 | def aceFactory(k, a): 485 | if k == "Trustee": 486 | return LazySid(a) 487 | elif k == "Right": 488 | return Right(a) 489 | elif k in ("ObjectType", "InheritedObjectType"): 490 | return LazyGuid(a) 491 | elif k == "Flags": 492 | return AceFlag(a) 493 | else: 494 | return a 495 | 496 | 497 | async def renderSD(sddl, conn): 498 | global_lazy_adschema.conn = conn 499 | sd = SECURITY_DESCRIPTOR.from_sddl(sddl) 500 | # We don't print Revision because it's always 1, 501 | # Group isn't used in ADDS 502 | renderedSD = {"Owner": LazySid(str(sd.Owner)), "Control": Control(sd.Control)} 503 | rendered_aces = [] 504 | allAces = [] 505 | if sd.Dacl: 506 | allAces += sd.Dacl.aces 507 | if sd.Sacl: 508 | allAces += sd.Sacl.aces 509 | for ace in allAces: 510 | rendered_ace = { 511 | "Type": AceType(ace.AceType), 512 | "Trustee": set([str(ace.Sid)]), 513 | "Right": ace.Mask, 514 | "ObjectType": set(), 515 | "InheritedObjectType": set(), 516 | "Flags": ace.AceFlags, 517 | } 518 | 519 | if hasattr(ace, "ObjectType") and ace.ObjectType: 520 | object_guid_str = str(ace.ObjectType) 521 | else: 522 | object_guid_str = "Self" 523 | rendered_ace["ObjectType"].add(object_guid_str) 524 | if hasattr(ace, "InheritedObjectType") and ace.InheritedObjectType: 525 | rendered_ace["InheritedObjectType"].add(str(ace.InheritedObjectType)) 526 | 527 | rendered_aces.append(rendered_ace) 528 | 529 | grouped_aces = groupBy( 530 | rendered_aces, 531 | ["ObjectType", "Trustee", "Flags", "InheritedObjectType", "Right"], 532 | ) 533 | typed_aces = [] 534 | lazy_objects = [renderedSD["Owner"]] # Collect all lazy objects for resolution 535 | 536 | for ace in grouped_aces: 537 | typed_ace = {} 538 | for k, v in ace.items(): 539 | if not v: 540 | continue 541 | try: 542 | typed_ace[k] = [] 543 | for a in v: # If it's a set of guids/sids 544 | obj = aceFactory(k, a) 545 | typed_ace[k].append(obj) 546 | if isinstance(obj, (LazyGuid, LazySid)): 547 | lazy_objects.append(obj) 548 | except TypeError: # If it's a mask 549 | typed_ace[k] = aceFactory(k, v) 550 | 551 | typed_aces.append(typed_ace) 552 | 553 | # Resolve all lazy objects 554 | for obj in lazy_objects: 555 | if isinstance(obj, (LazyGuid, LazySid)): 556 | await obj.resolve() 557 | 558 | renderedSD["ACL"] = typed_aces 559 | 560 | return renderedSD 561 | 562 | 563 | async def renderSearchResult(entries: types.AsyncGeneratorType) -> types.AsyncGeneratorType: 564 | """ 565 | Takes entries as an asynchronous generator of dicts ({dn: },{...}...) 566 | Yields each entry as a dict, converting raw bytes to base64 if not decodable in utf-8. 567 | Sorts entry attributes alphabetically (except 'distinguishedName' is first). 568 | This function is now an async generator and must be iterated with 'async for'. 569 | """ 570 | decoded_entry = {} 571 | async for entry in entries: 572 | entry = { 573 | **{"distinguishedName": entry["distinguishedName"]}, 574 | **{k: v for k, v in sorted(entry.items()) if k != "distinguishedName"}, 575 | } 576 | for attr_name, attr_members in entry.items(): 577 | if type(attr_members) in [list, types.GeneratorType]: 578 | decoded_entry[attr_name] = [] 579 | for member in attr_members: 580 | if type(member) is bytes: 581 | try: 582 | decoded = member.decode() 583 | except UnicodeDecodeError: 584 | decoded = base64.b64encode(member).decode() 585 | else: 586 | decoded = member 587 | decoded_entry[attr_name].append(decoded) 588 | else: 589 | if type(attr_members) is bytes: 590 | try: 591 | decoded = attr_members.decode() 592 | except UnicodeDecodeError: 593 | decoded = base64.b64encode(attr_members).decode() 594 | else: 595 | decoded = attr_members 596 | decoded_entry[attr_name] = decoded 597 | yield decoded_entry 598 | decoded_entry = {} 599 | 600 | 601 | async def findCompatibleDC(conn, min_version: int = 0, max_version: int = 1000, scope: str = "DOMAIN"): 602 | """ 603 | Find a list of compatible Domain Controllers in the domain, forest. 604 | 605 | Return a list of hostnames with their IPs of compatible DCs. 606 | """ 607 | if scope not in {"DOMAIN", "FOREST"}: 608 | raise ValueError("Invalid scope. Scope must be 'DOMAIN', 'FOREST'") 609 | 610 | dc_dict = collections.defaultdict(dict) 611 | ldap = await conn.getLdap() 612 | 613 | async for dc_info in ldap.bloodysearch( 614 | "CN=Sites,"+ldap.configNC, 615 | "(|(objectClass=nTDSDSA)(objectClass=server))", 616 | search_scope=Scope.SUBTREE, 617 | attr=["msDS-Behavior-Version", "msDS-HasDomainNCs", "objectClass", "dNSHostName"], 618 | raw=True, 619 | ): 620 | if b"nTDSDSA" in dc_info["objectClass"]: 621 | parent_name = dc_info["distinguishedName"].split(",", 1)[1] 622 | dc_dict[parent_name]["version"] = int(dc_info["msDS-Behavior-Version"][0]) 623 | dc_dict[parent_name]["domainNCs"] = dc_info["msDS-HasDomainNCs"] 624 | elif b"server" in dc_info["objectClass"]: 625 | dc_dict[dc_info["distinguishedName"]]["hostname"] = dc_info["dNSHostName"][0].decode() 626 | 627 | compatible_dcs = [] 628 | for dc_info in dc_dict.values(): 629 | # length check to ensure server's child nTDSDSA is present 630 | if len(dc_info) < 2: 631 | LOG.warning(f"nTDSDSA object missing for server {dc_info.get('hostname')}, excluding from compatible DCs...") 632 | continue 633 | if min_version <= dc_info["version"] <= max_version and (scope == "FOREST" or (scope == "DOMAIN" and ldap.domainNC.encode() in dc_info.get("domainNCs"))): 634 | compatible_dcs.append(dc_info["hostname"]) 635 | return compatible_dcs 636 | 637 | async def connectReachable(conn, srv_names: list, ports: list = [389, 636, 3268, 3269]): 638 | """ 639 | Connect to a reachable server from the provided list. 640 | 641 | Return a connection object or a dict with connection information if protocol not supported. 642 | """ 643 | record_list = [] 644 | new_conn = None 645 | for srv_name in srv_names: 646 | record_list.append({"type": ["A"], "name": srv_name}) 647 | host_params = await reacher.findReachableServer(record_list, conn.conf.dns, conn.conf.dcip, ports) 648 | if host_params: 649 | schemes = {389: "ldap", 636: "ldaps", 3268: "gc", 3269: "gc-ssl"} 650 | if host_params["port"] not in schemes: 651 | LOG.debug(f"Protocol for port {host_params['port']} not supported, please open a connection manually") 652 | return host_params 653 | new_conn = conn.copy( 654 | scheme=schemes[host_params["port"]], 655 | host=host_params["name"], 656 | dcip=host_params["ip"], 657 | ) 658 | else: 659 | LOG.warning( 660 | "No reachable server found, try to provide one manually in --host or a dns server with --dns" 661 | ) 662 | return new_conn 663 | 664 | def double_encode_controls(s): 665 | # Match a backslash followed by two hex digits (e.g., \0A, \2A) 666 | return re.sub(r'\\([0-9A-Fa-f]{2})', r'\\\\\1', s) --------------------------------------------------------------------------------